19 Commits

Author SHA1 Message Date
c9067179ff fix: save_records表补全config_id/promotion_account列(修复转存500) 2026-05-19 04:52:08 +08:00
5e3b5e83a4 fix(deploy): 不再硬编码CORS_ORIGIN + 强制覆盖管理员密码(解决401) 2026-05-19 04:44:19 +08:00
d4e62b1fb1 feat: 一键部署脚本 deploy.sh (curl | bash 方式) 2026-05-19 04:32:19 +08:00
7471d9aece v0.4.8: docker-compose添加DATA_DIR=/data确保secrets.json路径一致 2026-05-19 03:34:23 +08:00
f0c0d0f487 v0.4.8: 同步根VERSION文件 2026-05-19 02:29:12 +08:00
e4e1890da3 v0.4.8: 补播link_invalid_keywords种子键 2026-05-19 02:28:49 +08:00
470ebac20e v0.4.7: 修复网盘类型开关key不匹配+补全10个缺失种子配置键 2026-05-19 02:02:48 +08:00
f284e14630 v0.4.6: 保存网盘配置后自动验证+获取昵称空间 2026-05-19 00:36:42 +08:00
bff955e45b v0.4.5: 修复双重解密/配置key/版本号同步/buil+ssh脚本 2026-05-18 20:25:40 +08:00
6f7ab6dbc6 feat: deploy.sh三重Redis密码检测(参数/conf/cli) + URL编码特殊字符 2026-05-18 18:16:33 +08:00
498b593b28 fix: 添加缺失的cloud_type_uid列(CREATE TABLE和迁移) 2026-05-18 18:00:55 +08:00
c57af012b1 feat: 自动生成JWT_SECRET和ADMIN_PASSWORD并持久化到secrets.json 2026-05-18 16:48:04 +08:00
86d79e550b fix: docker-compose.yml 镜像版本改为 latest 2026-05-18 16:35:53 +08:00
07d66ac666 fix: 数据库初始化顺序(索引移到migrateCloudConfigs后) + deploy.sh自动检测Redis密码 2026-05-18 06:14:06 +08:00
42d2b5bf1c deploy.sh: 自动下载docker-compose.yml,真正一键部署 2026-05-18 06:03:49 +08:00
d7c2a9cfad 智能部署脚本:自动检测Redis,有则跳过无则创建 2026-05-18 06:03:09 +08:00
8da99d6861 docker-compose: fill real credentials 2026-05-18 06:00:14 +08:00
6f20c662eb simplify docker-compose.yml 2026-05-18 05:57:16 +08:00
48d6b642e0 add docker-compose.yml for one-click deployment 2026-05-18 05:28:23 +08:00
13 changed files with 199 additions and 38 deletions

View File

@@ -1 +1 @@
0.4.0
0.4.9

View File

@@ -4,7 +4,7 @@ cd "$(dirname "$0")/source_clean"
VERSION=$(cat ../VERSION)
echo "🔨 Building CloudSearch v${VERSION}..."
cp ../VERSION ./VERSION
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"

View File

@@ -70,6 +70,7 @@ services:
- CORS_ORIGIN=${CORS_ORIGIN}
# ── 数据库 & 缓存 ──
- DATA_DIR=/data
- DB_PATH=${DB_PATH:-/data/database.sqlite}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}

View File

@@ -1 +1 @@
0.4.0
0.4.9

11
source_clean/build.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -e
cd "$(dirname "$0")/source_clean"
VERSION=$(cat ../VERSION)
echo "🔨 Building CloudSearch v${VERSION}..."
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
echo " Run: docker-compose up -d app"

BIN
source_clean/deploy.sh Executable file

Binary file not shown.

View File

@@ -0,0 +1,42 @@
name: cloudsearch
services:
app:
image: gitea.timxx.cn/admin/cloudsearch:latest
container_name: CloudSearch_App
restart: unless-stopped
ports: ["9527:9527"]
environment:
REDIS_URL: ${REDIS_URL}
PANSOU_URL: http://pansou:8888
CORS_ORIGIN: ${CORS_ORIGIN:-https://zy.hk.timxx.cn}
JWT_SECRET: ${JWT_SECRET:-cloudsearch-jwt-secret-2024}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-0nL5kLhMIJ1121PYmQb25A}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes: ["cloudsearch-data:/app/data"]
depends_on: [pansou]
networks: [cloudsearch-net]
pansou:
image: ghcr.io/fish2018/pansou-web:latest
container_name: CloudSearch_PanSou
restart: unless-stopped
networks:
cloudsearch-net: { aliases: [pansou] }
# Redis — 仅当系统没有现成 Redis 时才启动
redis:
image: redis:7-alpine
container_name: CloudSearch_Redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes: ["redis-data:/data"]
networks: [cloudsearch-net]
profiles: [full]
volumes:
cloudsearch-data:
redis-data:
networks:
cloudsearch-net:
driver: bridge

View File

@@ -222,7 +222,8 @@ export async function testCloudConnection(id: number): Promise<{
return { success: false, message: 'Cookie not configured' };
}
const cookie = decryptCookie(config.cookie);
// config.cookie is already decrypted by getCloudConfigById
const cookie = config.cookie;
try {
let valid = false;

View File

@@ -1,3 +1,7 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
export interface Config {
port: number;
nodeEnv: string;
@@ -23,6 +27,50 @@ export interface Config {
dbPath: string;
}
const DEFAULT_JWT_SECRETS = ['', 'cloudsearch-jwt-secret-dev', 'cloudsearch-jwt-secret-2024', 'your-super-secret-jwt-key-change-me'];
const DEFAULT_PASSWORDS = ['', 'admin123', 'admin', 'password', '123456', '0nL5kLhMIJ1121PYmQb25A'];
function loadOrGenerateSecret(key: string, envKey: string, defaultVal: string, isDefault: (v: string) => boolean, byteLen: number): string {
const envVal = process.env[envKey];
// If env var is set and NOT a known default → use it directly
if (envVal && !isDefault(envVal)) {
return envVal;
}
// Try to load from persisted secrets file
const secretsPath = path.join(process.env.DATA_DIR || '/app/data', 'secrets.json');
try {
if (fs.existsSync(secretsPath)) {
const secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
if (secrets[key]) return secrets[key];
}
} catch (_) {}
// If env var is set but it's a default → still use it (admin set it explicitly, maybe for dev)
if (envVal) return envVal;
// No env var, no persisted file → auto-generate
const generated = byteLen === 16
? crypto.randomBytes(8).toString('hex') // 16-char hex for passwords
: crypto.randomBytes(byteLen).toString('hex'); // 64-char hex for JWT
try {
const dir = path.dirname(secretsPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
let existing: Record<string, string> = {};
if (fs.existsSync(secretsPath)) {
try { existing = JSON.parse(fs.readFileSync(secretsPath, 'utf8')); } catch (_) {}
}
existing[key] = generated;
fs.writeFileSync(secretsPath, JSON.stringify(existing, null, 2));
console.log(`[Config] Auto-generated ${envKey} → saved to secrets.json`);
} catch (e: any) {
console.log(`[Config] Could not persist ${envKey}:`, e.message);
}
return generated;
}
const config: Config = {
port: parseInt(process.env.PORT || '9527', 10),
nodeEnv: process.env.NODE_ENV || 'development',
@@ -30,9 +78,9 @@ const config: Config = {
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001',
jwtSecret: process.env.JWT_SECRET || 'cloudsearch-jwt-secret-dev',
jwtSecret: loadOrGenerateSecret('jwt_secret', 'JWT_SECRET', 'cloudsearch-jwt-secret-dev', (v) => DEFAULT_JWT_SECRETS.includes(v), 32),
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'admin123',
adminPassword: loadOrGenerateSecret('admin_password', 'ADMIN_PASSWORD', 'admin123', (v) => DEFAULT_PASSWORDS.includes(v), 16),
validation: {
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),

View File

@@ -42,14 +42,7 @@ export function validateConfig(): ValidationError[] {
}
// ─── Cookie Encryption ───
if (!process.env.COOKIE_ENCRYPTION_KEY) {
errors.push({
key: 'COOKIE_ENCRYPTION_KEY',
message: '未设置网盘 Cookie 加密密钥Cookie 将以明文存储。生产环境强烈建议设置。\n' +
'生成: openssl rand -hex 32',
severity: 'warn',
});
}
// Key is auto-generated and persisted to encryption.key if COOKIE_ENCRYPTION_KEY is not set
// ─── Port conflict check (best-effort) ───
if (config.port < 1024 && (process as any).getuid?.() !== 0) {

View File

@@ -19,13 +19,6 @@ export function getDb(): Database.Database {
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Performance indexes (IF NOT EXISTS ensures idempotent)
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cc_type_active ON cloud_configs(cloud_type, is_active);
CREATE INDEX IF NOT EXISTS idx_cc_uid ON cloud_configs(cookie_uid);
CREATE INDEX IF NOT EXISTS idx_cc_verification ON cloud_configs(verification_status);
`);
runMigrations(db);
seedAdmin(db);
@@ -46,6 +39,7 @@ function runMigrations(db: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT,
cloud_type TEXT NOT NULL,
cookie TEXT,
cloud_type_uid TEXT DEFAULT NULL,
nickname TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
storage_used TEXT,
@@ -131,6 +125,14 @@ function runMigrations(db: Database.Database): void {
migrateSaveRecords(db);
migrateContentCache(db);
migrateCloudConfigs(db);
// Performance indexes on cloud_configs (after all columns exist)
db.exec(`
CREATE INDEX IF NOT EXISTS idx_cc_type_active ON cloud_configs(cloud_type, is_active);
CREATE INDEX IF NOT EXISTS idx_cc_uid ON cloud_configs(cookie_uid);
CREATE INDEX IF NOT EXISTS idx_cc_verification ON cloud_configs(verification_status);
`);
cleanupOldSaveRecords(db);
}
@@ -147,6 +149,8 @@ function migrateSaveRecords(db: Database.Database): void {
{ col: 'request_url', def: 'TEXT' },
{ col: 'ip_location', def: 'TEXT' },
{ col: 'original_folder_name', def: 'TEXT' },
{ col: 'config_id', def: 'INTEGER' },
{ col: 'promotion_account', def: 'TEXT' },
];
for (const { col, def } of newCols) {
try {
@@ -203,6 +207,7 @@ function migrateCloudConfigs(db: Database.Database): void {
id INTEGER PRIMARY KEY AUTOINCREMENT,
cloud_type TEXT NOT NULL,
cookie TEXT,
cloud_type_uid TEXT DEFAULT NULL,
nickname TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
storage_used TEXT,
@@ -250,6 +255,13 @@ function migrateCloudConfigs(db: Database.Database): void {
db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL");
console.log('[DB] cloud_configs migration: notify_config column added');
}
// Migration 6: Add cloud_type_uid column
const hasCloudTypeUid = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get();
if (!hasCloudTypeUid) {
db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL");
console.log('[DB] cloud_configs migration: cloud_type_uid column added');
}
}
}
@@ -279,18 +291,19 @@ function seedSystemConfigs(db: Database.Database): void {
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关true/false' },
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
{ key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' },
{ key: 'link_invalid_keywords', value: '', description: '链接失效关键词(一行一条,命中的链接判定为失效)' },
{ key: 'cloud_type_quark_enabled', value: 'true', description: '夸克网盘' },
{ key: 'cloud_type_baidu_enabled', value: 'true', description: '百度网盘' },
{ key: 'cloud_type_aliyun_enabled', value: 'true', description: '阿里云盘' },
{ key: 'cloud_type_115_enabled', value: 'true', description: '115 网盘' },
{ key: 'cloud_type_tianyi_enabled', value: 'true', description: '天翼云盘' },
{ key: 'cloud_type_123pan_enabled', value: 'true', description: '123 云盘' },
{ key: 'cloud_type_uc_enabled', value: 'true', description: 'UC 网盘' },
{ key: 'cloud_type_xunlei_enabled', value: 'true', description: '迅雷网盘' },
{ key: 'cloud_type_pikpak_enabled', value: 'true', description: 'PikPak 网盘' },
{ key: 'cloud_type_magnet_enabled', value: 'true', description: '磁力链接' },
{ key: 'cloud_type_ed2k_enabled', value: 'true', description: '电驴链接' },
{ key: 'cloud_type_others_enabled', value: 'false', description: '其他类型(默认关闭)' },
{ key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL留空使用渐变色' },
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL留空使用默认图标/文字)' },
@@ -299,6 +312,7 @@ function seedSystemConfigs(db: Database.Database): void {
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch所有资源仅供学习交流请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
{ key: 'ip_geo_api_url', value: '', description: 'IP 归属地查询接口({ip} 会被替换为实际IP留空则禁用' },
{ key: 'ip_geo_api_id', value: '', description: 'IP 归属地 API IDapihz.cn 接口)' },
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key留空使用默认' },
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/' },
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC' },
@@ -318,6 +332,16 @@ function seedSystemConfigs(db: Database.Database): void {
{ key: 'search_all_channels', value: 'false', description: '使用所有频道参与搜索(包含未启用频道)' },
{ key: 'ip_geo_provider', value: 'apihz', description: 'IP 归属地查询接口提供商' },
{ key: 'auto_update_enabled', value: 'false', description: '自动更新镜像(预留,暂未实现)' },
{ key: 'cleanup_auto_refresh_storage', value: 'false', description: '自动刷新网盘空间信息(每天检查一次)' },
{ key: 'cleanup_verify_enabled', value: 'false', description: '启用转存后自动验证链接有效性' },
{ key: 'cleanup_verify_interval', value: '3600', description: '自动验证间隔(秒)' },
{ key: 'cleanup_whitelist_dirs', value: '', description: '清理文件白名单目录(逗号分隔,保留不删)' },
{ key: 'proxy_url', value: '', description: 'HTTP 代理地址(用于搜索请求代理)' },
{ key: 'quark_ad_keywords', value: '', description: '夸克广告文件关键词(逗号分隔)' },
{ key: 'quark_sus_extensions', value: '', description: '夸克可疑文件后缀(逗号分隔)' },
{ key: 'quark_warning_folder_names', value: '', description: '夸克警示文件夹名称(逗号分隔)' },
{ key: 'storage_refresh_interval', value: '86400', description: '空间信息刷新间隔默认24小时' },
];
const insert = db.prepare(
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'

View File

@@ -154,7 +154,7 @@ router.get('/admin/cloud-configs', (_req: Request, res: Response) => {
});
/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */
router.post('/admin/cloud-configs', (req: Request, res: Response) => {
router.post('/admin/cloud-configs', async (req: Request, res: Response) => {
try {
const data = req.body;
if (!data.cloud_type) {
@@ -164,6 +164,24 @@ router.post('/admin/cloud-configs', (req: Request, res: Response) => {
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
const saved = saveCloudConfig(data);
// Auto-validate if cookie was provided (best-effort, non-blocking)
if (data.cookie && saved.id) {
try {
const result = await testCloudConnectionWithCookie(data.cloud_type, data.cookie);
if (result.success) {
const updateData: any = { id: saved.id, cloud_type: data.cloud_type };
if (result.nickname) updateData.nickname = result.nickname;
if (result.storage_used) updateData.storage_used = result.storage_used;
if (result.storage_total) updateData.storage_total = result.storage_total;
saveCloudConfig(updateData);
Object.assign(saved, { nickname: result.nickname, storage_used: result.storage_used, storage_total: result.storage_total });
}
} catch (_) {
// Auto-validation is best-effort, don't fail the save
}
}
res.json(saved);
} catch (err: any) {
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
@@ -508,7 +526,7 @@ router.post('/admin/test-external-service', async (req: Request, res: Response)
break;
}
case 'tmdb': {
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
const tmdbToken = token || getSystemConfig('tmdb_api_token') || '';
if (!tmdbToken) {
res.json({ ok: false, info: 'TMDB API Key not configured' });
return;

View File

@@ -6,6 +6,8 @@
* Production MUST set COOKIE_ENCRYPTION_KEY!
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // 96-bit nonce for GCM
@@ -25,9 +27,30 @@ function getKey(): Buffer {
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
} else {
// Default stable key (not ephemeral) — data survives container restart
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
// Auto-generate a random key and persist to file
const keyFile = path.join(process.env.DATA_DIR || '/app/data', 'encryption.key');
try {
if (fs.existsSync(keyFile)) {
const savedKey = fs.readFileSync(keyFile, 'utf8').trim();
if (savedKey.length >= 32) {
ENCRYPTION_KEY = crypto.createHash('sha256').update(savedKey).digest();
console.log('[Crypto] Cookie encryption enabled (loaded from encryption.key)');
return ENCRYPTION_KEY;
}
}
} catch (_) { /* file read failed, will generate new key */ }
const autoKey = crypto.randomBytes(32).toString('hex');
try {
const dir = path.dirname(keyFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(keyFile, autoKey);
console.log('[Crypto] Auto-generated encryption key saved to encryption.key');
} catch (e: any) {
console.log('[Crypto] Could not persist key, using ephemeral:', e.message);
}
ENCRYPTION_KEY = crypto.createHash('sha256').update(autoKey).digest();
console.log('[Crypto] Cookie encryption enabled (auto-generated key)');
}
return ENCRYPTION_KEY;
}