chore: initial commit - CloudSearch v0.0.2

This commit is contained in:
2026-05-15 05:50:50 +08:00
commit d83225d736
102 changed files with 37926 additions and 0 deletions

View File

@@ -0,0 +1,615 @@
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';
// 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' });
}
});
/** 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;