chore: initial commit - CloudSearch v0.0.2
This commit is contained in:
615
packages/backend/src/routes/admin.routes.ts
Normal file
615
packages/backend/src/routes/admin.routes.ts
Normal 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;
|
||||
87
packages/backend/src/routes/cleanup.routes.ts
Normal file
87
packages/backend/src/routes/cleanup.routes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { runFullCleanup, emptyAllTrash } from '../cloud/cleanup.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Cleanup & Storage Management ============
|
||||
|
||||
/**
|
||||
* POST /api/admin/cleanup/run
|
||||
* Manually trigger a cleanup cycle:
|
||||
* - Trash old date folders from cloud drives
|
||||
* - Delete old save_records
|
||||
* - Empty recycle bin
|
||||
*/
|
||||
router.post('/admin/cleanup/run', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = await runFullCleanup();
|
||||
res.json({
|
||||
success: stats.errors.length === 0,
|
||||
files_trashed: stats.filesTrashed,
|
||||
logs_deleted: stats.logsDeleted,
|
||||
trash_emptied: stats.trashEmptied,
|
||||
errors: stats.errors,
|
||||
message: stats.errors.length === 0
|
||||
? `✅ 清理完成:移入回收站 ${stats.filesTrashed} 个文件夹,删除 ${stats.logsDeleted} 条日志,清空回收站${stats.trashEmptied ? '✓' : '-'}`
|
||||
: `清理完成,但有 ${stats.errors.length} 个错误`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/cleanup/empty-trash
|
||||
* Empty recycle bin for all cloud drives (permanently delete, frees space).
|
||||
*/
|
||||
router.post('/admin/cleanup/empty-trash', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await emptyAllTrash();
|
||||
res.json({
|
||||
success: result.errors.length === 0,
|
||||
emptied: result.emptied,
|
||||
errors: result.errors,
|
||||
message: result.emptied
|
||||
? '✅ 回收站已清空,存储空间已释放'
|
||||
: (result.errors.length > 0 ? `清空回收站部分失败:${result.errors.join('; ')}` : '没有可清空的网盘'),
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Extract genre tags from search result titles.
|
||||
*/
|
||||
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (keyword) tags.push(keyword);
|
||||
|
||||
const genreKeywords: Record<string, string> = {
|
||||
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||
'剧场版': '剧场版', '年番': '年番',
|
||||
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const r of results) {
|
||||
const title = (r.title || r.note || '') as string;
|
||||
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||
if (title.includes(key) && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
tags.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
||||
export default router;
|
||||
14
packages/backend/src/routes/index.ts
Executable file
14
packages/backend/src/routes/index.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import searchRoutes from './search.routes';
|
||||
import adminRoutes from './admin.routes';
|
||||
import uploadRoutes from './upload.routes';
|
||||
import cleanupRoutes from './cleanup.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(searchRoutes);
|
||||
router.use(adminRoutes);
|
||||
router.use(uploadRoutes);
|
||||
router.use(cleanupRoutes);
|
||||
|
||||
export default router;
|
||||
630
packages/backend/src/routes/search.routes.ts
Normal file
630
packages/backend/src/routes/search.routes.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
// Native fetch available in Node 20+
|
||||
import { searchLimiter, saveLimiter } from '../middleware/rate-limit';
|
||||
import { detectIntent } from '../intent/intent.service';
|
||||
import { search, applyTitleFilter } from '../search/search.service';
|
||||
import { getRankings, getHotKeywords, getCategorizedRankings } from '../search/rankings.service';
|
||||
import { parseVideo } from '../video/video.service';
|
||||
import { saveFromShare } from '../cloud/cloud.service';
|
||||
import { getEnabledCloudTypeSet } from '../cloud/cloud-types.service';
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
import { verifyToken } from '../admin/auth.service';
|
||||
import { LinkValidator } from '../validation/link-validator.service';
|
||||
import { getContentInfo } from '../content/content.service';
|
||||
import { detectCloudType } from '../config/cloud-labels';
|
||||
import { CLOUD_LABELS, CLOUD_COLORS } from '../config/cloud-labels';
|
||||
import { getDb } from '../database/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Search & Query ============
|
||||
|
||||
/**
|
||||
* POST /api/query
|
||||
* Intent recognition + execution
|
||||
*/
|
||||
router.post('/query', searchLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { input, q } = req.body;
|
||||
const query = input || q;
|
||||
if (!query || typeof query !== 'string') {
|
||||
res.status(400).json({ error: 'Input is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const intent = detectIntent(query);
|
||||
const ip = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
switch (intent.type) {
|
||||
case 'SEARCH': {
|
||||
const result = await search(intent.cleanInput, 1, ip);
|
||||
|
||||
// Pass through: use all results, group by cloud type
|
||||
const allResults = result.results || [];
|
||||
|
||||
// Transform to frontend-friendly format
|
||||
let formatted = (allResults || []).map((item: any, idx: number) => ({
|
||||
id: `search_${idx}`,
|
||||
title: filterTitle(item.title || item.content || ''),
|
||||
description: item.content || '',
|
||||
share_url: item.url || '',
|
||||
cloud_type: detectCloudType(item.url || ''),
|
||||
file_size: '',
|
||||
update_time: item.datetime || '',
|
||||
source: item.source || '',
|
||||
file_id: '',
|
||||
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||
password: item.password || '',
|
||||
}));
|
||||
|
||||
// Filter out expired/invalid links
|
||||
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||
|
||||
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||
// Skip filter if search_all_channels is enabled
|
||||
const searchAllChannels = getSystemConfig('search_all_channels') === 'true';
|
||||
if (!searchAllChannels) {
|
||||
const enabledSet = getEnabledCloudTypeSet();
|
||||
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||
}
|
||||
|
||||
const contentQuery = intent.cleanInput || query;
|
||||
const contentInfo = await getContentInfo(contentQuery).catch(() => null);
|
||||
const extractedTags = extractTagsFromResults(formatted, contentQuery);
|
||||
const linkValidationEnabled = getSystemConfig('link_validation_enabled') !== 'false';
|
||||
|
||||
// Set up streaming response (NDJSON)
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// 0. Send searching signal immediately so frontend shows feedback
|
||||
res.write(JSON.stringify({ type: 'searching' }) + '\n');
|
||||
|
||||
// 0.5 Query local DB for previously saved resources matching keyword
|
||||
const savedResults = getSavedResources(intent.cleanInput);
|
||||
if (savedResults.length > 0) {
|
||||
res.write(JSON.stringify({
|
||||
type: 'saved',
|
||||
results: savedResults,
|
||||
total: savedResults.length,
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
// 1. Send stats immediately
|
||||
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||
const siteLogo = getSystemConfig('site_logo') || '';
|
||||
const siteNameInStats = getSystemConfig('site_name') || 'CloudSearch';
|
||||
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||
const statsPayload = {
|
||||
type: 'stats',
|
||||
total: formatted.length,
|
||||
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||
content_info: contentInfo,
|
||||
content_tags: extractedTags,
|
||||
link_validation: linkValidationEnabled,
|
||||
fallback_image: fallbackImage,
|
||||
site_logo: siteLogo,
|
||||
site_name: siteNameInStats,
|
||||
site_disclaimer: siteDisclaimer,
|
||||
site_marquee: siteMarquee,
|
||||
};
|
||||
res.write(JSON.stringify(statsPayload) + '\n');
|
||||
|
||||
// 2. Validate links — per-type grouping, newest-first, per-type cap from config
|
||||
if (linkValidationEnabled) {
|
||||
const validator = new LinkValidator();
|
||||
const resultLimit = parseInt(getSystemConfig('search_result_limit') || '10', 10);
|
||||
const MAX_VALID_PER_TYPE = Math.min(100, Math.max(1, resultLimit)); // configurable, 1-100
|
||||
const MAX_TOTAL_VALID = MAX_VALID_PER_TYPE * 6; // up to 6 cloud types
|
||||
const pool = validator['pool']; // concurrency: 10
|
||||
|
||||
// Group formatted results by cloud_type, then sort each group by time desc
|
||||
const byType: Record<string, any[]> = {};
|
||||
for (const item of formatted) {
|
||||
const ct = item.cloud_type || 'others';
|
||||
if (!byType[ct]) byType[ct] = [];
|
||||
byType[ct].push(item);
|
||||
}
|
||||
|
||||
// Sort each group by update_time descending (newest first)
|
||||
for (const ct of Object.keys(byType)) {
|
||||
byType[ct].sort((a: any, b: any) => {
|
||||
const ta = a.update_time || '';
|
||||
const tb = b.update_time || '';
|
||||
if (!ta && !tb) return 0;
|
||||
if (!ta) return 1;
|
||||
if (!tb) return -1;
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
}
|
||||
|
||||
// Build a round-robin validation queue: interleave items from each type
|
||||
// to give fair priority across all cloud types
|
||||
const typeOrder = ['quark', 'baidu', 'aliyun', '115', 'tianyi', '123pan', 'uc', 'xunlei', 'pikpak', 'magnet', 'ed2k', 'others'];
|
||||
const sortedTypes = typeOrder.filter(ct => byType[ct] && byType[ct].length > 0);
|
||||
// Sort by total count descending so types with more results get more validation slots
|
||||
sortedTypes.sort((a, b) => (byType[b]?.length || 0) - (byType[a]?.length || 0));
|
||||
|
||||
const validationQueue: { item: any; type: string }[] = [];
|
||||
const maxLen = Math.max(...sortedTypes.map(ct => byType[ct].length), 0);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
for (const ct of sortedTypes) {
|
||||
if (i < byType[ct].length) {
|
||||
validationQueue.push({ item: byType[ct][i], type: ct });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validResults: any[] = [];
|
||||
const perTypeValid: Record<string, number> = {};
|
||||
let totalValid = 0;
|
||||
let totalInvalid = 0;
|
||||
let totalChecked = 0;
|
||||
const unknownItemIds: number[] = []; // IDs that got 'unknown' from PanSou
|
||||
|
||||
// Pass 1: PanSou-only validation
|
||||
const tasks = validationQueue.map(({ item, type }) => pool.run(async () => {
|
||||
// Stop if we've hit overall cap or per-type cap
|
||||
if (totalValid >= MAX_TOTAL_VALID) return;
|
||||
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) return;
|
||||
|
||||
totalChecked++;
|
||||
try {
|
||||
const vr = await validator.validate(item.share_url, item.cloud_type);
|
||||
// 'unknown' = PanSou couldn't determine → treat as valid for now
|
||||
if (vr.status === 'valid' || vr.status === 'unknown') {
|
||||
if (vr.status === 'unknown') {
|
||||
unknownItemIds.push(item.id);
|
||||
}
|
||||
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||
validResults.push(item);
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message }) + '\n');
|
||||
}
|
||||
} else {
|
||||
totalInvalid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message }) + '\n');
|
||||
}
|
||||
} catch {
|
||||
if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) {
|
||||
validResults.push(item);
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
}
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true }) + '\n');
|
||||
}
|
||||
}));
|
||||
await Promise.all(tasks);
|
||||
|
||||
// Pass 2: If PanSou didn't provide enough valid results, validate
|
||||
// uncertain items with local fallback (external API calls)
|
||||
if (totalValid < MAX_TOTAL_VALID && unknownItemIds.length > 0) {
|
||||
const unknownItems = validationQueue.filter(({ item }) => unknownItemIds.includes(item.id));
|
||||
for (const { item, type } of unknownItems) {
|
||||
if (totalValid >= MAX_TOTAL_VALID) break;
|
||||
if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) break;
|
||||
try {
|
||||
const vr = await validator.validateWithLocalFallback(item.share_url, item.cloud_type);
|
||||
if (vr.status === 'valid') {
|
||||
// Already in validResults from pass 1, just count it again
|
||||
perTypeValid[type] = (perTypeValid[type] || 0) + 1;
|
||||
totalValid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message + ' (本地确认)' }) + '\n');
|
||||
} else if (vr.status === 'invalid') {
|
||||
// Remove from validResults — was previously included as unknown
|
||||
const idx = validResults.findIndex(r => r.id === item.id);
|
||||
if (idx >= 0) {
|
||||
validResults.splice(idx, 1);
|
||||
perTypeValid[type] = Math.max(0, (perTypeValid[type] || 1) - 1);
|
||||
totalValid--;
|
||||
}
|
||||
totalInvalid++;
|
||||
res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message + ' (本地确认失效)' }) + '\n');
|
||||
}
|
||||
} catch {
|
||||
// Keep as-is (already treated as valid from pass 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skippedCount = validationQueue.length - totalChecked;
|
||||
|
||||
res.write(JSON.stringify({
|
||||
type: 'complete',
|
||||
results: validResults,
|
||||
channels: groupResultsByChannel(validResults, (item: any) => item.cloud_type),
|
||||
total: validResults.length,
|
||||
filtered: totalInvalid,
|
||||
per_type: perTypeValid,
|
||||
skipped: skippedCount,
|
||||
}) + '\n');
|
||||
} else {
|
||||
// No validation - just send all results
|
||||
res.write(JSON.stringify({
|
||||
type: 'complete',
|
||||
results: formatted,
|
||||
channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type),
|
||||
total: formatted.length,
|
||||
filtered: 0,
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
case 'VIDEO_PARSE': {
|
||||
const videoInfo = await parseVideo(intent.cleanInput);
|
||||
res.json({ intent: intent.type, platform: intent.platform, data: videoInfo });
|
||||
break;
|
||||
}
|
||||
case 'CLOUD_SAVE': {
|
||||
const result = await saveFromShare(intent.cleanInput, intent.platform || '', undefined, req.ip);
|
||||
res.json({ intent: intent.type, platform: intent.platform, ...result });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: 'Unknown intent type' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Query] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/search
|
||||
* Search with optional link validation filtering
|
||||
*/
|
||||
router.get('/search', searchLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const keyword = (req.query.q || req.query.kw) as string;
|
||||
const page = parseInt(req.query.page as string || '1', 10);
|
||||
const ip = req.ip || req.socket.remoteAddress || '';
|
||||
|
||||
if (!keyword) {
|
||||
res.status(400).json({ error: 'Query parameter "q" is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await search(keyword, page, ip);
|
||||
|
||||
// Pass through: use all results
|
||||
const allResults = result.results || [];
|
||||
|
||||
// Transform to frontend format
|
||||
let formatted = (allResults || []).map((item: any) => ({
|
||||
id: item.id || '',
|
||||
title: filterTitle(item.title || item.content || ''),
|
||||
description: item.content || item.snippet || '',
|
||||
share_url: item.url || '',
|
||||
cloud_type: detectCloudType(item.url || ''),
|
||||
file_size: '',
|
||||
source: item.source || '',
|
||||
datetime: item.datetime || '',
|
||||
cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '',
|
||||
password: item.password || '',
|
||||
}));
|
||||
|
||||
// Filter out expired/invalid links
|
||||
const expiredCount = formatted.filter(r => r.share_url && isExpiredShareLink(r.share_url)).length;
|
||||
formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url));
|
||||
|
||||
// Filter by enabled cloud types (admin-configurable per-type toggle)
|
||||
const enabledSet = getEnabledCloudTypeSet();
|
||||
formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type));
|
||||
|
||||
// Return results immediately without blocking validation
|
||||
const channels = groupResultsByChannel(formatted, (item: any) =>
|
||||
detectCloudType(item.url || '')
|
||||
);
|
||||
|
||||
res.json({
|
||||
results: formatted,
|
||||
channels,
|
||||
total: formatted.length,
|
||||
filtered: expiredCount,
|
||||
link_validation: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Search] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Load title filter rules from DB and apply to a title.
|
||||
*/
|
||||
function filterTitle(title: string): string {
|
||||
const rules = getSystemConfig('title_filter_rules') || '';
|
||||
return applyTitleFilter(title, rules);
|
||||
}
|
||||
|
||||
// detectCloudType is imported from config/cloud-labels
|
||||
|
||||
// 检测失效的分享链接(支持多种模式)
|
||||
function isExpiredShareLink(url: string): boolean {
|
||||
if (!url) return false;
|
||||
|
||||
// 空链接/纯片段(无实际链接内容)
|
||||
if (url.startsWith('#') || url.length < 10) return true;
|
||||
|
||||
// PanSou 有时返回残缺链接如 "/s/xxx" 或只有 "#/list/share"
|
||||
if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('http')) return true;
|
||||
|
||||
// 夸克链接格式校验
|
||||
if (url.includes('pan.quark.cn')) {
|
||||
const baseUrl = url.split('#')[0]; // 去掉 hash 路由片段
|
||||
// 有效格式必须是 pan.quark.cn/s/xxxxxx
|
||||
if (!/pan\.quark\.cn\/s\/[a-zA-Z0-9]+/.test(baseUrl)) return true;
|
||||
}
|
||||
|
||||
// 百度网盘常见失效格式
|
||||
if (url.includes('pan.baidu.com') && /share\/init\?surl=$/.test(url)) return true;
|
||||
|
||||
// 阿里云盘失效格式(短到异常的链接)
|
||||
if (url.includes('aliyundrive.com') && url.length < 30) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group search results into channels by cloud type.
|
||||
* Each channel: { cloud_type, label, color, count, items }
|
||||
*/
|
||||
|
||||
function groupResultsByChannel(results: any[], getCloudType?: (item: any) => string): any[] {
|
||||
const groups: Record<string, any[]> = {};
|
||||
const order: Record<string, number> = {
|
||||
quark: 1, baidu: 2, aliyun: 3, '115': 4,
|
||||
tianyi: 5, '123pan': 6, uc: 7, xunlei: 8,
|
||||
pikpak: 9, magnet: 10, ed2k: 11, others: 12,
|
||||
};
|
||||
for (const item of results) {
|
||||
const ct = getCloudType ? getCloudType(item) : (item.source || detectCloudType(item.url || '') || 'others');
|
||||
if (!groups[ct]) groups[ct] = [];
|
||||
groups[ct].push(item);
|
||||
}
|
||||
return Object.entries(groups)
|
||||
.sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99))
|
||||
.map(([cloud_type, items]) => ({
|
||||
cloud_type,
|
||||
label: (CLOUD_LABELS as any)[cloud_type] || cloud_type,
|
||||
color: (CLOUD_COLORS as any)[cloud_type] || '#95a5a6',
|
||||
count: items.length,
|
||||
items,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Video ============
|
||||
// ============ Video ============
|
||||
|
||||
/**
|
||||
* POST /api/video/parse
|
||||
* Parse a video URL
|
||||
*/
|
||||
router.post('/video/parse', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const videoInfo = await parseVideo(url);
|
||||
res.json(videoInfo);
|
||||
} catch (err: any) {
|
||||
console.error('[Video] Parse error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to parse video' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Cloud Save ============
|
||||
// ============ Cloud Save ============
|
||||
|
||||
/**
|
||||
* POST /api/save
|
||||
* Save a share link to a specific cloud
|
||||
*/
|
||||
router.post('/save', saveLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Support both formats:
|
||||
// 1. Backend-style: { url, cloudType }
|
||||
// 2. Frontend-style: { source: { share_url }, target_cloud }
|
||||
const url = req.body.url || req.body.source?.share_url || req.body.source?.url;
|
||||
const cloudType = req.body.cloudType || req.body.target_cloud || (req.body.source as any)?.cloud_type;
|
||||
const sourceTitle = req.body.source_title || req.body.source?.title || req.body.title;
|
||||
if (!url || !cloudType) {
|
||||
res.status(400).json({ error: 'URL and cloudType/cloud_type are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||
const result = await saveFromShare(url, cloudType, sourceTitle, ip);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('[Save] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to save to cloud' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/video/save-to-cloud
|
||||
* Save a video to cloud
|
||||
*/
|
||||
router.post('/video/save-to-cloud', saveLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { videoUrl, cloudType, title } = req.body;
|
||||
if (!videoUrl || !cloudType) {
|
||||
res.status(400).json({ error: 'videoUrl and cloudType are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || '';
|
||||
const result = await saveFromShare(videoUrl, cloudType, title, ip);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
console.error('[Video] Save-to-cloud error:', err);
|
||||
res.status(500).json({ error: err.message || 'Failed to save video to cloud' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Rankings ============
|
||||
// ============ Rankings ============
|
||||
|
||||
/**
|
||||
* GET /api/rankings
|
||||
* Get search rankings
|
||||
*/
|
||||
router.get('/rankings', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rankings = await getRankings();
|
||||
res.json(rankings);
|
||||
} catch (err: any) {
|
||||
console.error('[Rankings] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rankings/hot
|
||||
* Get hot keywords
|
||||
*/
|
||||
router.get('/rankings/hot', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const keywords = await getHotKeywords();
|
||||
res.json(keywords);
|
||||
} catch (err: any) {
|
||||
console.error('[Hot] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rankings/categorized
|
||||
* Get categorized rankings (hot + newest per category), cached for 12h
|
||||
*/
|
||||
router.get('/rankings/categorized', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await getCategorizedRankings();
|
||||
res.json(data);
|
||||
} catch (err: any) {
|
||||
console.error('[Categorized] Error:', err);
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/site-config
|
||||
* Public site configuration (no auth required).
|
||||
*/
|
||||
router.get('/site-config', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const siteLogo = getSystemConfig('site_logo') || '';
|
||||
const siteName = getSystemConfig('site_name') || 'CloudSearch';
|
||||
const fallbackImage = getSystemConfig('search_fallback_image') || '';
|
||||
const siteDisclaimer = getSystemConfig('site_disclaimer') || '';
|
||||
const siteMarquee = getSystemConfig('site_marquee') || '';
|
||||
res.json({ site_logo: siteLogo, site_name: siteName, search_fallback_image: fallbackImage, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/me
|
||||
* Get current user info from token (public, no auth middleware).
|
||||
*/
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.json({ loggedIn: false });
|
||||
return;
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifyToken(token);
|
||||
if (!payload) {
|
||||
res.json({ loggedIn: false });
|
||||
return;
|
||||
}
|
||||
res.json({ loggedIn: true, id: payload.id, username: payload.username });
|
||||
} catch (err: any) {
|
||||
res.json({ loggedIn: false });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Admin ============
|
||||
|
||||
|
||||
/**
|
||||
* Extract genre tags from search result titles.
|
||||
*/
|
||||
function extractTagsFromResults(results: any[], keyword: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (keyword) tags.push(keyword);
|
||||
|
||||
const genreKeywords: Record<string, string> = {
|
||||
'动画': '动画', '动漫': '动画', '国漫': '国漫',
|
||||
'剧场版': '剧场版', '年番': '年番',
|
||||
'动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻',
|
||||
'仙侠': '仙侠', '古装': '古装', '爱情': '爱情',
|
||||
'科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑',
|
||||
'恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情',
|
||||
'冒险': '冒险', '战争': '战争', '武侠': '武侠',
|
||||
'纪录': '纪录片', '真人': '真人秀', '短片': '短片',
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const r of results) {
|
||||
const title = (r.title || r.note || '') as string;
|
||||
for (const [key, val] of Object.entries(genreKeywords)) {
|
||||
if (title.includes(key) && !seen.has(val)) {
|
||||
seen.add(val);
|
||||
tags.push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query DB for previously saved resources that match the keyword.
|
||||
* Returns formatted results for immediate streaming before external API call.
|
||||
*/
|
||||
function getSavedResources(keyword: string): any[] {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT source_url, source_title, target_cloud, share_url, created_at
|
||||
FROM save_records
|
||||
WHERE status = 'success'
|
||||
AND (source_title LIKE ? OR source_url LIKE ?)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`).all(`%${keyword}%`, `%${keyword}%`) as any[];
|
||||
|
||||
return rows.map((row: any, idx: number) => ({
|
||||
id: `saved_${idx}`,
|
||||
title: row.source_title || row.source_url || '',
|
||||
description: '',
|
||||
share_url: row.share_url || row.source_url || '',
|
||||
cloud_type: detectCloudType(row.share_url || row.source_url || ''),
|
||||
file_size: '',
|
||||
update_time: row.created_at || '',
|
||||
source: 'local',
|
||||
file_id: '',
|
||||
cover: '',
|
||||
password: '',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[SavedResources] DB query error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
125
packages/backend/src/routes/upload.routes.ts
Normal file
125
packages/backend/src/routes/upload.routes.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import sharp from 'sharp';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authMiddleware } from '../admin/auth.service';
|
||||
import { updateSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============ Upload ============
|
||||
|
||||
/**
|
||||
* POST /api/admin/upload-fallback-image
|
||||
* Upload a fallback cover image for search results without covers.
|
||||
* Recommended: 320×180 JPEG/PNG (16:9), max 2MB.
|
||||
*/
|
||||
const uploadDir = path.resolve('/app/uploads/fallback');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fallbackStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, _file, cb) => {
|
||||
const ext = '.jpg';
|
||||
cb(null, `fallback_cover_tmp${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: fallbackStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('仅支持图片文件(JPEG/PNG)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.post('/admin/upload-fallback-image', authMiddleware, upload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: '请选择要上传的图片' });
|
||||
return;
|
||||
}
|
||||
// 压缩:最大宽度320px,JPEG quality 80
|
||||
const outPath = path.resolve(uploadDir, 'fallback_cover.jpg');
|
||||
await sharp(req.file.path)
|
||||
.resize(320, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
.toFile(outPath);
|
||||
// 删除原始上传文件(如果路径不同)
|
||||
if (req.file.path !== outPath) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
}
|
||||
const url = `/api/uploads/fallback/fallback_cover.jpg`;
|
||||
updateSystemConfig('search_fallback_image', url);
|
||||
const stat = fs.statSync(outPath);
|
||||
res.json({ success: true, url, message: `✅ 兜底图已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/upload-logo
|
||||
* Upload a site logo image displayed on search page (home link) and homepage.
|
||||
* Recommended: 320×60 or similar wide/banner ratio, JPEG/PNG/WebP, max 2MB.
|
||||
*/
|
||||
const logoUploadDir = path.resolve('/app/uploads/logo');
|
||||
if (!fs.existsSync(logoUploadDir)) {
|
||||
fs.mkdirSync(logoUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const logoStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, logoUploadDir),
|
||||
filename: (_req, _file, cb) => {
|
||||
cb(null, `site_logo_tmp.png`);
|
||||
},
|
||||
});
|
||||
|
||||
const logoUpload = multer({
|
||||
storage: logoStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('仅支持图片文件(JPEG/PNG/WebP)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
router.post('/admin/upload-logo', authMiddleware, logoUpload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: '请选择要上传的图片' });
|
||||
return;
|
||||
}
|
||||
// 压缩:最大宽度640px,PNG格式
|
||||
const outPath = path.resolve(logoUploadDir, 'site_logo.png');
|
||||
await sharp(req.file.path)
|
||||
.resize(640, undefined, { fit: 'inside', withoutEnlargement: true })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outPath);
|
||||
if (req.file.path !== outPath) {
|
||||
fs.unlink(req.file.path, () => {});
|
||||
}
|
||||
const url = `/api/uploads/logo/site_logo.png`;
|
||||
updateSystemConfig('site_logo', url);
|
||||
const stat = fs.statSync(outPath);
|
||||
res.json({ success: true, url, message: `✅ 站点图标已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || '上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service';
|
||||
|
||||
// ===== 夸克扫码登录 (不需要 auth,用户未登录时也需要能用) =====
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user