- 数据库 migration: cloud_configs 加 is_primary 字段 - 后端: togglePrimary API (PUT /admin/cloud-configs/:id/primary) - 后端: getAndValidateCredential 优先选 is_primary 账号 - 前端: CloudConfig.vue 转存启用后加「默认账号」开关列 - 前端: api/index.ts 加 setPrimary 方法
630 lines
24 KiB
TypeScript
630 lines
24 KiB
TypeScript
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<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 更新失败' });
|
|
}
|
|
});
|
|
|
|
export default router;
|