887 lines
34 KiB
TypeScript
887 lines
34 KiB
TypeScript
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<string>();
|
|
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;
|
|
|