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, togglePrimary } from '../cloud/credential.service'; // Note: check-in routes were removed (sign-in feature removed) import { getAllCloudTypes } from '../cloud/cloud-types.service'; import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; import { getStats } from '../admin/stats.service'; import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; import { testProxyConnection } from '../utils/proxy-agent'; import { getDb } from '../database/database'; import { reconnectRedis, testRedisConnection } from '../middleware/cache'; import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; import { BaiduDriver } from '../cloud/drivers/baidu.driver'; 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', (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; // Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1 if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0; const saved = saveCloudConfig(data); 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' }); } }); /** * PUT /api/admin/cloud-configs/:id/primary — toggle primary status (max 2 per type) */ router.put('/admin/cloud-configs/:id/primary', (req: Request, res: Response) => { try { const id = parseInt(req.params.id as string); const { primary } = req.body; const config = togglePrimary(id, !!primary); res.json(config); } catch (err: any) { res.status(400).json({ error: err.message || 'Failed to toggle primary status' }); } }); /** 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' }); } }); // ═══════════════════════════════════════ // 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') || ''; 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 ? 'connected' : 'disconnected'; } catch { redis_status = 'error'; } 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_key') || ''; if (!tmdbToken) { res.json({ ok: false, info: 'TMDB API Key not configured' }); return; } const response = await fetch('https://api.themoviedb.org/3/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('search_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 geoUrl = url || getSystemConfig('ip_geo_api_url') || ''; if (!geoUrl) { res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' }); return; } const testUrl = geoUrl.replace('{ip}', '8.8.8.8'); const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) }); const data: any = await response.json(); const latency = Date.now() - start; const valid = !!(data?.country || data?.region || data?.city || data?.countryCode); 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 更新失败' }); } }); export default router;