import { TMDB_API_HOST, EP as TE } from '../content/tmdb-api'; import { Router, Request, Response } from 'express'; // Native fetch available in Node 20+ import fs from "fs"; import { execSync } from 'child_process'; import { adminLimiter, loginLimiter } from '../middleware/rate-limit'; import { getSaveRecords } from '../cloud/cloud.service'; import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service'; import { dailyCheckIn, skipCheckin, getCheckinSummary, getDrivesForCheckin } from '../cloud/checkin.service'; import { getAllCloudTypes } from '../cloud/cloud-types.service'; import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; import { getAllPushUsers, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service'; import { getAllNotifierParams, testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getGlobalNotifyConfig } from '../cloud/notification.service'; import { getStats } from '../admin/stats.service'; import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; import { getDb } from '../database/database'; import config from '../config'; import { reconnectRedis, testRedisConnection } from '../middleware/cache'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { BaiduDriver } from '../cloud/drivers/baidu.driver'; import { testProxyConnection } from '../utils/proxy-agent'; const router = Router(); // ═══════════════════════════════════════ // Public routes (no auth required) // ═══════════════════════════════════════ /** * POST /api/admin/login * Admin login */ router.post('/admin/login', loginLimiter, (req: Request, res: Response) => { try { const { username, password } = req.body; if (!username || !password) { res.status(400).json({ error: 'Username and password are required' }); return; } const token = login(username, password); if (!token) { res.status(401).json({ error: 'Invalid credentials' }); return; } res.json({ token }); } catch (err: any) { console.error('[Login] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/admin/cloud-types * List all cloud types (public, read-only). */ router.get('/admin/cloud-types', (_req: Request, res: Response) => { try { const types = getAllCloudTypes(); res.json({ types }); } catch (err: any) { res.status(500).json({ error: err.message || 'Internal server error' }); } }); // ═══════════════════════════════════════ // QR Login routes (no auth — user not logged in yet) // MUST be before authMiddleware! // ═══════════════════════════════════════ // ===== 夸克扫码登录 ===== router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => { try { const result = await startQrLogin(); res.json({ ok: true, ...result }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => { try { const sessionId = req.params.sessionId as string; const result = await getQrLoginStatus(sessionId); res.json({ ok: true, ...result }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => { try { const sessionId = req.params.sessionId as string; await cancelQrLogin(sessionId); res.json({ ok: true }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); // ===== 百度扫码登录 ===== router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => { try { const result = await BaiduDriver.startQrLogin(); res.json({ ok: true, ...result }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => { try { const sessionId = req.params.sessionId as string; const result: any = await BaiduDriver.getQrLoginStatus(sessionId); // Map to frontend-expected format (frontend reads data.cookie) res.json({ ok: true, status: result.status, cookie: result.cookie || result.access_token || "", nickname: result.nickname || "", storage_used: result.storage_used || "", storage_total: result.storage_total || "", }); } catch (err: any) { res.status(500).json({ ok: false, error: err.message }); } }); router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => { try { BaiduDriver.cancelQrLogin(req.params.sessionId as string); } catch {} res.json({ ok: true }); }); // ═══════════════════════════════════════ // Auth wall — all routes below require JWT // ═══════════════════════════════════════ router.use('/admin', authMiddleware); // ═══════════════════════════════════════ // Cloud Configs CRUD // ═══════════════════════════════════════ /** GET /api/admin/cloud-configs — list all cloud configs */ router.get('/admin/cloud-configs', (_req: Request, res: Response) => { try { const configs = getCloudConfigs(); res.json(configs); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); } }); /** POST /api/admin/cloud-configs — create or smart-replace a cloud config */ router.post('/admin/cloud-configs', async (req: Request, res: Response) => { try { const data = req.body; if (!data.cloud_type) { res.status(400).json({ error: 'cloud_type is required' }); return; } // Normalize is_active: frontend sends boolean, SQLite needs 0/1 if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0; const saved = saveCloudConfig(data); // Auto-validate if cookie was provided (best-effort, non-blocking) if (data.cookie && saved.id) { try { const result = await testCloudConnectionWithCookie(data.cloud_type, data.cookie); if (result.success) { const updateData: any = { id: saved.id, cloud_type: data.cloud_type }; if (result.nickname) updateData.nickname = result.nickname; if (result.storage_used) updateData.storage_used = result.storage_used; if (result.storage_total) updateData.storage_total = result.storage_total; saveCloudConfig(updateData); Object.assign(saved, { nickname: result.nickname, storage_used: result.storage_used, storage_total: result.storage_total }); } } catch (_) { // Auto-validation is best-effort, don't fail the save } } res.json(saved); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to save cloud config' }); } }); /** PUT /api/admin/cloud-configs/:id — update an existing cloud config */ router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const existing = getCloudConfigById(id); if (!existing) { res.status(404).json({ error: 'Cloud config not found' }); return; } const saved = saveCloudConfig({ ...req.body, id }); res.json(saved); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to update cloud config' }); } }); /** DELETE /api/admin/cloud-configs/:id */ router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const ok = deleteCloudConfig(id); if (!ok) { res.status(404).json({ error: 'Cloud config not found' }); return; } res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to delete cloud config' }); } }); /** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */ router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => { try { const type = req.params.type as string; const { cookie, id } = req.body; // If cookie is provided directly, test with it (for new configs not yet saved) if (cookie) { const result = await testCloudConnectionWithCookie(type, cookie); res.json(result); return; } // Otherwise test by config id if (id) { const result = await testCloudConnection(parseInt(id)); res.json(result); return; } res.status(400).json({ success: false, message: 'Provide either cookie or id' }); } catch (err: any) { res.status(500).json({ success: false, message: err.message || 'Connection test failed' }); } }); // ═══════════════════════════════════════ // Daily Check-in // ═══════════════════════════════════════ /** POST /api/admin/cloud-configs/:id/checkin */ router.post('/admin/cloud-configs/:id/checkin', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const result = await dailyCheckIn(id); res.json(result); } catch (err: any) { res.status(500).json({ success: false, message: err.message || 'Check-in failed' }); } }); /** POST /api/admin/cloud-configs/:id/skip-checkin */ router.post('/admin/cloud-configs/:id/skip-checkin', async (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const ok = skipCheckin(id); res.json({ success: ok }); } catch (err: any) { res.status(500).json({ success: false, message: err.message || 'Skip check-in failed' }); } }); /** POST /api/admin/cloud-configs/checkin-all */ router.post('/admin/cloud-configs/checkin-all', async (_req: Request, res: Response) => { try { const drives = getDrivesForCheckin(); const results: { id: number; nickname: string; success: boolean; message: string }[] = []; let total = 0; for (const drive of drives) { total++; try { const result = await dailyCheckIn(drive.id); results.push({ id: drive.id, nickname: drive.nickname || '', success: result.success, message: result.message }); } catch (err: any) { results.push({ id: drive.id, nickname: drive.nickname || '', success: false, message: err.message || 'Check-in failed' }); } } res.json({ total, results }); } catch (err: any) { res.status(500).json({ error: err.message || 'Check-in all failed' }); } }); /** GET /api/admin/cloud-configs/checkin-summary */ router.get('/admin/cloud-configs/checkin-summary', (_req: Request, res: Response) => { try { const summary = getCheckinSummary(); res.json(summary); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get check-in summary' }); } }); // ═══════════════════════════════════════ // Stats // ═══════════════════════════════════════ /** GET /api/admin/stats */ router.get('/admin/stats', (req: Request, res: Response) => { try { const days = req.query.days ? parseInt(req.query.days as string) : 7; const stats = getStats(days); res.json(stats); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get stats' }); } }); // ═══════════════════════════════════════ // Save Records (转存日志) // ═══════════════════════════════════════ /** GET /api/admin/save-records */ router.get('/admin/save-records', (req: Request, res: Response) => { try { const page = parseInt(req.query.page as string) || 1; const pageSize = parseInt(req.query.pageSize as string) || 20; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; const status = req.query.status as string | undefined; const sourceType = req.query.sourceType as string | undefined; const keyword = req.query.keyword as string | undefined; const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword); res.json(result); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get save records' }); } }); // ═══════════════════════════════════════ // System Configs // ═══════════════════════════════════════ /** GET /api/admin/system-configs */ router.get('/admin/system-configs', (_req: Request, res: Response) => { try { const configs = getAllSystemConfigs(); res.json(configs); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get system configs' }); } }); /** PUT /api/admin/system-configs — batch update */ router.put('/admin/system-configs', (req: Request, res: Response) => { try { const { entries } = req.body; if (!entries || !Array.isArray(entries)) { res.status(400).json({ error: 'entries array is required' }); return; } updateSystemConfigs(entries); res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to update system configs' }); } }); // ═══════════════════════════════════════ // Cloud Types Toggle // ═══════════════════════════════════════ /** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */ router.put('/admin/cloud-types', (req: Request, res: Response) => { try { const { type, enabled } = req.body; if (!type) { res.status(400).json({ error: 'type is required' }); return; } const db = getDb(); db.prepare( `INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` ).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`); res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to toggle cloud type' }); } }); // ═══════════════════════════════════════ // Change Password // ═══════════════════════════════════════ /** POST /api/admin/change-password */ router.post('/admin/change-password', (req: Request, res: Response) => { try { const { oldPassword, newPassword } = req.body; if (!oldPassword || !newPassword) { res.status(400).json({ error: 'Both old and new passwords are required' }); return; } // Get username from JWT const authHeader = req.headers.authorization || ''; const token = authHeader.replace('Bearer ', ''); const payload = verifyToken(token); if (!payload) { res.status(401).json({ error: 'Invalid token' }); return; } const result = changePassword(payload.username, oldPassword, newPassword); res.json(result); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to change password' }); } }); // ═══════════════════════════════════════ // DB Status // ═══════════════════════════════════════ /** GET /api/admin/db-status */ router.get('/admin/db-status', async (_req: Request, res: Response) => { try { const dbFile = getSystemConfig('db_path') || config.dbPath || ''; let dbSize = 'N/A'; if (dbFile) { try { const stats = fs.statSync(dbFile); dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB'; } catch {} } const db = getDb(); const counts = { save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0, search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0, system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0, cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0, content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0, }; // Redis status let redis_status = 'disconnected'; let redis_url = getSystemConfig('redis_url') || ''; try { const testResult = await testRedisConnection(redis_url); redis_status = testResult.ok ? '已连接' : '未连接'; } catch { redis_status = '错误'; } res.json({ db_size: dbSize, db_path: dbFile, ...counts, redis_status, redis_url, }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get DB status' }); } }); // ═══════════════════════════════════════ // Test Redis Connection // ═══════════════════════════════════════ /** POST /api/admin/test-redis */ router.post('/admin/test-redis', async (req: Request, res: Response) => { try { const { url } = req.body; if (!url) { res.status(400).json({ ok: false, info: 'Redis URL is required' }); return; } const result = await testRedisConnection(url); res.json(result); } catch (err: any) { res.status(500).json({ ok: false, info: err.message || 'Redis test failed' }); } }); // ═══════════════════════════════════════ // Test External Service // ═══════════════════════════════════════ /** POST /api/admin/test-external-service */ router.post('/admin/test-external-service', async (req: Request, res: Response) => { try { const { type, url, token } = req.body; const start = Date.now(); switch (type) { case 'pansou': { const pansouUrl = url || getSystemConfig('pansou_url') || ''; if (!pansouUrl) { res.json({ ok: false, info: 'PanSou URL not configured' }); return; } const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) }); const data: any = await response.json(); const latency = Date.now() - start; res.json({ ok: response.ok && data?.status === 'ok', latency, info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败', }); break; } case 'video_parser': { const parserUrl = url || getSystemConfig('video_parser_url') || ''; if (!parserUrl) { res.json({ ok: false, info: 'Video Parser URL not configured' }); return; } const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) }); const latency = Date.now() - start; res.json({ ok: response.ok, latency, info: response.ok ? '连接成功' : `HTTP ${response.status}`, }); break; } case 'tmdb': { const tmdbToken = token || getSystemConfig('tmdb_api_token') || ''; if (!tmdbToken) { res.json({ ok: false, info: 'TMDB API Key not configured' }); return; } const response = await fetch(TMDB_API_HOST + TE.CONFIGURATION, { headers: { Authorization: `Bearer ${tmdbToken}` }, signal: AbortSignal.timeout(8000), }); const latency = Date.now() - start; res.json({ ok: response.ok, latency, info: response.ok ? '连接成功' : `HTTP ${response.status}`, }); break; } case 'proxy': { const proxyUrl = url || getSystemConfig('proxy_url') || ''; if (!proxyUrl) { res.json({ ok: false, info: 'Proxy URL not configured' }); return; } const result = await testProxyConnection(proxyUrl); res.json(result); break; } case 'ip_geo': { const apiId = url || getSystemConfig('ip_geo_api_id') || ''; const apiKey = getSystemConfig('ip_geo_api_key') || ''; if (!apiId || !apiKey) { res.json({ ok: false, info: '请先配置 IP 归属地 API ID 和 Key' }); return; } const testUrl = `https://cn.apihz.cn/api/ip/chaapi.php?id=${encodeURIComponent(apiId)}&key=${encodeURIComponent(apiKey)}&ip=8.8.8.8&td=0`; const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) }); const data: any = await response.json(); const latency = Date.now() - start; const valid = data?.code === 200; res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' }); break; } default: res.json({ ok: false, info: `Unknown service type: ${type}` }); } } catch (err: any) { res.status(500).json({ ok: false, info: err.message || 'External service test failed' }); } }); // ═══════════════════════════════════════ // Pansou Info & Update // ═══════════════════════════════════════ /** GET /api/admin/pansou-info — pansou health + version + update check */ router.get('/admin/pansou-info', async (_req: Request, res: Response) => { try { const baseUrl = getSystemConfig('pansou_url') || ''; if (!baseUrl) { res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' }); return; } // Fetch PanSou health const healthUrl = baseUrl + '/api/health'; const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) }); const healthData: any = await response.json(); const channelCount = healthData.channels_count || 0; const pluginCount = healthData.plugin_count || 0; // Derive disk count from channel names const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease']; const drives = new Set(); for (const ch of (healthData.channels || [])) { for (const kw of driveKeywords) { if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; } } } const diskCount = drives.size || 5; // Get local version from docker label let version = ''; let hasUpdate = false; let latestVersion = ''; try { const created = execSync( `docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`, { timeout: 5000, encoding: 'utf8' } ).trim(); version = created ? created.slice(0, 10) : ''; // Check update cache const cacheFile = '/tmp/pansou-update-cache.json'; let cache: any = null; try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {} const threeDays = 3 * 24 * 3600 * 1000; if (!cache || (Date.now() - cache.checkedAt) > threeDays) { // Check GHCR for latest version try { const tokenRes = await fetch( 'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io' ); const ghcrToken = (await tokenRes.json() as any).token; const manifestRes = await fetch( 'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest', { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } } ); const manifestList: any = await manifestRes.json(); const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux'); if (amd64) { const blobRes = await fetch( `https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`, { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } } ); const blobData: any = await blobRes.json(); const cfgDigest = blobData.config?.digest; if (cfgDigest) { const cfgRes = await fetch( `https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`, { headers: { Authorization: `Bearer ${ghcrToken}` } } ); const cfgData: any = await cfgRes.json(); const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created']; if (remoteCreated) { latestVersion = remoteCreated.slice(0, 10); if (version && latestVersion !== version) hasUpdate = true; } } } } catch {} fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion })); } else { hasUpdate = cache.hasUpdate; latestVersion = cache.latestVersion; } } catch {} res.json({ status: response.ok ? 'connected' : 'disconnected', channelCount, pluginCount, diskCount, version, hasUpdate, latestVersion, }); } catch (err: any) { res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message }); } }); /** POST /api/admin/update-pansou — pull latest pansou image + recreate container */ router.post('/admin/update-pansou', async (_req: Request, res: Response) => { try { execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 }); execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 }); try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {} res.json({ success: true, message: 'PanSou 更新成功' }); } catch (err: any) { res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' }); } }); // ======================== Notification / Push Users ======================== /** GET /api/admin/cloud-configs/:id/notify */ router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const settings = getConfigNotifySettingsJSON(id); res.json(settings); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to get notification settings' }); } }); /** PUT /api/admin/cloud-configs/:id/notify */ router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const settings = req.body; saveConfigNotifySettings(id, settings); res.json({ success: true, message: 'Push config saved' }); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to save notification settings' }); } }); /** POST /api/admin/notify/test */ router.post('/admin/notify/test', async (req: Request, res: Response) => { try { const { channelType, account, configId, params } = req.body; const ctx = account || (configId ? String(configId) : undefined); const result = await testChannel(channelType, ctx, params); res.json(result); } catch (err: any) { res.json({ success: false, message: err.message || 'Test send failed' }); } }); /** GET /api/admin/notify/providers */ router.get('/admin/notify/providers', (_req: Request, res: Response) => { try { const providers = getAllNotifierParams(); res.json(providers); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get providers' }); } }); /** GET /api/admin/notify/global-config */ router.get('/admin/notify/global-config', (_req, res) => { try { const cfg = getGlobalNotifyConfig(); res.json(cfg); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get global config' }); } }); /** PUT /api/admin/notify/global-config */ router.put('/admin/notify/global-config', (req, res) => { try { const cfg = req.body; if (!cfg || typeof cfg !== 'object') { res.status(400).json({ error: 'Invalid config object' }); return; } updateSystemConfig('global_notify_config', JSON.stringify(cfg)); res.json({ success: true }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to save global config' }); } }); /** GET /api/admin/push-users */ router.get('/admin/push-users', (_req: Request, res: Response) => { try { const users = getAllPushUsers(); const parsed = users.map(u => ({ ...u, notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(), })); res.json(parsed); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to list push users' }); } }); /** POST /api/admin/push-users */ router.post('/admin/push-users', (req: Request, res: Response) => { try { const { account, notify_config } = req.body; if (!account) return res.status(400).json({ error: 'account is required' }); const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const user = upsertPushUser(account, configStr); res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to save push user' }); } }); /** PUT /api/admin/push-users/:id */ router.put('/admin/push-users/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const { account, notify_config } = req.body; if (!account) return res.status(400).json({ error: 'account is required' }); const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {}); const user = updatePushUser(id, account, configStr); res.json({ ...user, notify_config: JSON.parse(user!.notify_config) }); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to update push user' }); } }); /** DELETE /api/admin/push-users/:id */ router.delete('/admin/push-users/:id', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const ok = deletePushUser(id); if (ok) res.json({ success: true }); else res.status(404).json({ error: 'Push user not found' }); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to delete push user' }); } }); // ═══════════════════════════════════════════════ // Daily Report // ═══════════════════════════════════════════════ /** GET /api/admin/daily-report/config */ router.get('/admin/daily-report/config', (_req, res) => { try { const { getDailyReportConfig } = require('../services/daily-report.service'); const cfg = getDailyReportConfig(); res.json(cfg); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to get daily report config' }); } }); /** PUT /api/admin/daily-report/config */ router.put('/admin/daily-report/config', (req, res) => { try { const { saveDailyReportConfig } = require('../services/daily-report.service'); saveDailyReportConfig(req.body); const { getDailyReportConfig } = require('../services/daily-report.service'); res.json(getDailyReportConfig()); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to save daily report config' }); } }); /** GET /api/admin/daily-report/preview */ router.get('/admin/daily-report/preview', (req, res) => { try { const { previewDailyReport, generateDailyReport } = require('../services/daily-report.service'); const date = req.query.date as string || undefined; const content = previewDailyReport(date); const report = generateDailyReport(date); res.json({ content, report }); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to preview daily report' }); } }); /** POST /api/admin/daily-report/test — send a test report immediately */ router.post('/admin/daily-report/test', async (_req, res) => { try { const { sendTestDailyReport } = require('../services/daily-report.service'); const result = await sendTestDailyReport(); res.json(result); } catch (err: any) { res.status(500).json({ error: err.message || 'Failed to send test report' }); } }); /** GET /api/admin/daily-report/last-run */ router.get('/admin/daily-report/last-run', (_req, res) => { try { const { getSystemConfig } = require('../admin/system-config.service'); const raw = getSystemConfig('daily_report_last_run') || '{}'; let data: any = {}; try { data = JSON.parse(raw); } catch {} res.json(data); } catch (err: any) { res.status(500).json({ error: err.message }); } }); export default router;