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;

View 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;

View 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;

View 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;

View 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;
}
// 压缩最大宽度320pxJPEG 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;
}
// 压缩最大宽度640pxPNG格式
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;