Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39724e6e73 | |||
| 288d30698a | |||
| a9dc056506 | |||
| 9ef58b5724 | |||
| c9067179ff | |||
| 5e3b5e83a4 | |||
| d4e62b1fb1 | |||
| 7471d9aece | |||
| f0c0d0f487 | |||
| e4e1890da3 | |||
| 470ebac20e | |||
| f284e14630 | |||
| bff955e45b | |||
| 6f7ab6dbc6 | |||
| 498b593b28 | |||
| c57af012b1 | |||
| 86d79e550b | |||
| 07d66ac666 | |||
| 42d2b5bf1c | |||
| d7c2a9cfad | |||
| 8da99d6861 | |||
| 6f20c662eb | |||
| 48d6b642e0 |
2
build.sh
2
build.sh
@@ -4,7 +4,7 @@ cd "$(dirname "$0")/source_clean"
|
|||||||
|
|
||||||
VERSION=$(cat ../VERSION)
|
VERSION=$(cat ../VERSION)
|
||||||
echo "🔨 Building CloudSearch v${VERSION}..."
|
echo "🔨 Building CloudSearch v${VERSION}..."
|
||||||
|
cp ../VERSION ./VERSION
|
||||||
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
|
docker build -t cloudsearch-app:v${VERSION} -t cloudsearch-app:latest .
|
||||||
|
|
||||||
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
|
echo "✅ Built: cloudsearch-app:v${VERSION} + cloudsearch-app:latest"
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ services:
|
|||||||
- CORS_ORIGIN=${CORS_ORIGIN}
|
- CORS_ORIGIN=${CORS_ORIGIN}
|
||||||
|
|
||||||
# ── 数据库 & 缓存 ──
|
# ── 数据库 & 缓存 ──
|
||||||
|
- DATA_DIR=/data
|
||||||
- DB_PATH=${DB_PATH:-/data/database.sqlite}
|
- DB_PATH=${DB_PATH:-/data/database.sqlite}
|
||||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.4.0
|
0.4.13
|
||||||
|
|||||||
11
source_clean/build.sh
Executable file
11
source_clean/build.sh
Executable 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"
|
||||||
128
source_clean/deploy.sh
Executable file
128
source_clean/deploy.sh
Executable file
@@ -0,0 +1,128 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# CloudSearch 一键部署
|
||||||
|
# curl -sS https://gitea.timxx.cn/admin/CloudSearch/raw/branch/master/source_clean/deploy.sh | bash
|
||||||
|
#
|
||||||
|
# 环境变量(可选):
|
||||||
|
# CORS_ORIGIN - 域名 (默认自动检测公网IP)
|
||||||
|
# ADMIN_PASSWORD - 管理员密码 (留空自动生成)
|
||||||
|
# JWT_SECRET - JWT密钥 (留空自动生成)
|
||||||
|
# DEPLOY_DIR - 部署目录 (默认 /opt/cloudsearch)
|
||||||
|
|
||||||
|
REPO_URL="https://gitea.timxx.cn/admin/CloudSearch.git"
|
||||||
|
DEPLOY_DIR="${DEPLOY_DIR:-/opt/cloudsearch}"
|
||||||
|
|
||||||
|
info() { echo "[INFO] $*"; }
|
||||||
|
warn() { echo "[WARN] $*"; }
|
||||||
|
err() { echo "[ERROR] $*"; }
|
||||||
|
|
||||||
|
command -v docker &>/dev/null || { err "Docker未安装"; exit 1; }
|
||||||
|
|
||||||
|
# -- CORS_ORIGIN --
|
||||||
|
if [ -z "$CORS_ORIGIN" ]; then
|
||||||
|
PUBLIC_IP=$(curl -s --connect-timeout 3 ifconfig.me 2>/dev/null || curl -s --connect-timeout 3 ip.sb 2>/dev/null || echo "")
|
||||||
|
if [ -n "$PUBLIC_IP" ]; then
|
||||||
|
CORS_ORIGIN="http://${PUBLIC_IP}:9527"
|
||||||
|
else
|
||||||
|
CORS_ORIGIN="http://localhost:9527"
|
||||||
|
fi
|
||||||
|
warn "CORS_ORIGIN=$CORS_ORIGIN (自动检测)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 拉取仓库 --
|
||||||
|
info "拉取仓库..."
|
||||||
|
if [ -d "$DEPLOY_DIR/.git" ]; then
|
||||||
|
cd "$DEPLOY_DIR" && git pull --ff-only origin master 2>/dev/null || true
|
||||||
|
else
|
||||||
|
rm -rf "$DEPLOY_DIR"
|
||||||
|
git clone --depth 1 "$REPO_URL" "$DEPLOY_DIR"
|
||||||
|
fi
|
||||||
|
cd "$DEPLOY_DIR/source_clean"
|
||||||
|
|
||||||
|
# -- 切换镜像模式 --
|
||||||
|
if grep -q '^ build:' docker-compose.yml 2>/dev/null; then
|
||||||
|
info "切换镜像模式..."
|
||||||
|
sed -i 's/^ build:/ # build:/' docker-compose.yml
|
||||||
|
sed -i 's/^ context:/ # context:/' docker-compose.yml
|
||||||
|
sed -i 's/^ dockerfile:/ # dockerfile:/' docker-compose.yml
|
||||||
|
sed -i 's|^ # image: gitea.timxx.cn/admin/cloudsearch:latest| image: gitea.timxx.cn/admin/cloudsearch:latest|' docker-compose.yml
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 检测 Redis --
|
||||||
|
info "检测Redis..."
|
||||||
|
EXISTING_REDIS=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i redis | grep -v CloudSearch | head -1)
|
||||||
|
if [ -n "$EXISTING_REDIS" ]; then
|
||||||
|
REDIS_URL="redis://${EXISTING_REDIS}:6379"
|
||||||
|
info "复用已有Redis: $EXISTING_REDIS"
|
||||||
|
else
|
||||||
|
REDIS_URL="redis://redis:6379"
|
||||||
|
info "使用Compose自带Redis"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- 生成密钥 --
|
||||||
|
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||||
|
ADMIN_PASSWORD="${ADMIN_PASSWORD:-$(openssl rand -base64 12 | tr -d '=+/' | head -c 16)}"
|
||||||
|
|
||||||
|
# -- 生成 .env --
|
||||||
|
cat > .env <<ENVEOF
|
||||||
|
CORS_ORIGIN=${CORS_ORIGIN}
|
||||||
|
JWT_SECRET=${JWT_SECRET}
|
||||||
|
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
REDIS_URL=${REDIS_URL}
|
||||||
|
LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
info "管理员: admin / ${ADMIN_PASSWORD}"
|
||||||
|
|
||||||
|
# -- 部署 --
|
||||||
|
info "拉取镜像..."
|
||||||
|
docker compose pull app 2>/dev/null || true
|
||||||
|
|
||||||
|
info "停止旧服务..."
|
||||||
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
|
||||||
|
info "启动服务..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# -- 等待就绪 --
|
||||||
|
info "等待服务就绪..."
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
if curl -s -o /dev/null -w '%{http_code}' http://localhost:9527/health 2>/dev/null | grep -q '200'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# -- 强制写入管理员密码 --
|
||||||
|
info "同步管理员密码..."
|
||||||
|
sleep 3
|
||||||
|
docker exec -e ADMIN_PASSWORD="$ADMIN_PASSWORD" CloudSearch_App node -e '
|
||||||
|
var bcrypt = require("bcryptjs");
|
||||||
|
var Database = require("better-sqlite3");
|
||||||
|
var db = new Database("/data/database.sqlite");
|
||||||
|
var pw = process.env.ADMIN_PASSWORD || ""; var hash = bcrypt.hashSync(pw, 10);
|
||||||
|
var existing = db.prepare("SELECT id FROM admins WHERE username = ?").get("admin");
|
||||||
|
if (existing) {
|
||||||
|
db.prepare("UPDATE admins SET password_hash = ? WHERE username = ?").run(hash, "admin");
|
||||||
|
} else {
|
||||||
|
db.prepare("INSERT INTO admins (username, password_hash) VALUES (?, ?)").run("admin", hash);
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
' 2>/dev/null && info "密码已同步" || warn "密码同步失败,请稍后重试"
|
||||||
|
|
||||||
|
# -- 验证 --
|
||||||
|
if docker compose ps 2>/dev/null | grep -q 'Up'; then
|
||||||
|
echo ""
|
||||||
|
echo "=============================================="
|
||||||
|
echo " CloudSearch 部署完成"
|
||||||
|
echo "=============================================="
|
||||||
|
docker compose ps
|
||||||
|
echo ""
|
||||||
|
echo " 管理后台: ${CORS_ORIGIN}/admin/login"
|
||||||
|
echo " 用户名: admin"
|
||||||
|
echo " 密码: ${ADMIN_PASSWORD}"
|
||||||
|
else
|
||||||
|
err "启动失败: docker compose logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
42
source_clean/docker-compose.yml
Normal file
42
source_clean/docker-compose.yml
Normal 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
|
||||||
@@ -35,9 +35,9 @@ function decryptCookie(encrypted: string): string {
|
|||||||
|
|
||||||
function extractCookieUid(cookie: string): string {
|
function extractCookieUid(cookie: string): string {
|
||||||
if (!cookie) return '';
|
if (!cookie) return '';
|
||||||
let m = cookie.match(/__uid=([a-zA-Z0-9+/=_-]+)/);
|
let m = cookie.match(/__uid=([^;]+)/);
|
||||||
if (m) return m[1];
|
if (m) return m[1];
|
||||||
m = cookie.match(/b-user-id=([a-zA-Z0-9-]+)/);
|
m = cookie.match(/b-user-id=([^;]+)/);
|
||||||
if (m) return m[1];
|
if (m) return m[1];
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,8 @@ export async function testCloudConnection(id: number): Promise<{
|
|||||||
return { success: false, message: 'Cookie not configured' };
|
return { success: false, message: 'Cookie not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = decryptCookie(config.cookie);
|
// config.cookie is already decrypted by getCloudConfigById
|
||||||
|
const cookie = config.cookie;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let valid = false;
|
let valid = false;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
@@ -23,6 +27,50 @@ export interface Config {
|
|||||||
dbPath: string;
|
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 = {
|
const config: Config = {
|
||||||
port: parseInt(process.env.PORT || '9527', 10),
|
port: parseInt(process.env.PORT || '9527', 10),
|
||||||
nodeEnv: process.env.NODE_ENV || 'development',
|
nodeEnv: process.env.NODE_ENV || 'development',
|
||||||
@@ -30,9 +78,9 @@ const config: Config = {
|
|||||||
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
|
pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888',
|
||||||
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
|
pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '',
|
||||||
videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001',
|
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',
|
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: {
|
validation: {
|
||||||
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
|
concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10),
|
||||||
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),
|
timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10),
|
||||||
|
|||||||
@@ -42,14 +42,7 @@ export function validateConfig(): ValidationError[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cookie Encryption ───
|
// ─── Cookie Encryption ───
|
||||||
if (!process.env.COOKIE_ENCRYPTION_KEY) {
|
// Key is auto-generated and persisted to encryption.key if COOKIE_ENCRYPTION_KEY is not set
|
||||||
errors.push({
|
|
||||||
key: 'COOKIE_ENCRYPTION_KEY',
|
|
||||||
message: '未设置网盘 Cookie 加密密钥!Cookie 将以明文存储。生产环境强烈建议设置。\n' +
|
|
||||||
'生成: openssl rand -hex 32',
|
|
||||||
severity: 'warn',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Port conflict check (best-effort) ───
|
// ─── Port conflict check (best-effort) ───
|
||||||
if (config.port < 1024 && (process as any).getuid?.() !== 0) {
|
if (config.port < 1024 && (process as any).getuid?.() !== 0) {
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ export function getDb(): Database.Database {
|
|||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
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);
|
runMigrations(db);
|
||||||
seedAdmin(db);
|
seedAdmin(db);
|
||||||
|
|
||||||
@@ -46,6 +39,8 @@ function runMigrations(db: Database.Database): void {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
cloud_type TEXT NOT NULL,
|
cloud_type TEXT NOT NULL,
|
||||||
cookie TEXT,
|
cookie TEXT,
|
||||||
|
cloud_type_uid TEXT DEFAULT NULL,
|
||||||
|
cookie_uid TEXT DEFAULT NULL,
|
||||||
nickname TEXT,
|
nickname TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
storage_used TEXT,
|
storage_used TEXT,
|
||||||
@@ -115,6 +110,13 @@ function runMigrations(db: Database.Database): void {
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS push_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account TEXT NOT NULL UNIQUE,
|
||||||
|
notify_config TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS content_cache (
|
CREATE TABLE IF NOT EXISTS content_cache (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -131,6 +133,14 @@ function runMigrations(db: Database.Database): void {
|
|||||||
migrateSaveRecords(db);
|
migrateSaveRecords(db);
|
||||||
migrateContentCache(db);
|
migrateContentCache(db);
|
||||||
migrateCloudConfigs(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);
|
cleanupOldSaveRecords(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +157,8 @@ function migrateSaveRecords(db: Database.Database): void {
|
|||||||
{ col: 'request_url', def: 'TEXT' },
|
{ col: 'request_url', def: 'TEXT' },
|
||||||
{ col: 'ip_location', def: 'TEXT' },
|
{ col: 'ip_location', def: 'TEXT' },
|
||||||
{ col: 'original_folder_name', def: 'TEXT' },
|
{ col: 'original_folder_name', def: 'TEXT' },
|
||||||
|
{ col: 'config_id', def: 'INTEGER' },
|
||||||
|
{ col: 'promotion_account', def: 'TEXT' },
|
||||||
];
|
];
|
||||||
for (const { col, def } of newCols) {
|
for (const { col, def } of newCols) {
|
||||||
try {
|
try {
|
||||||
@@ -203,7 +215,9 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
cloud_type TEXT NOT NULL,
|
cloud_type TEXT NOT NULL,
|
||||||
cookie TEXT,
|
cookie TEXT,
|
||||||
|
cloud_type_uid TEXT DEFAULT NULL,
|
||||||
nickname TEXT,
|
nickname TEXT,
|
||||||
|
cookie_uid TEXT DEFAULT NULL,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
storage_used TEXT,
|
storage_used TEXT,
|
||||||
storage_total TEXT,
|
storage_total TEXT,
|
||||||
@@ -216,8 +230,8 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
);
|
);
|
||||||
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, cloud_type_uid, cookie_uid, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at)
|
||||||
SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
SELECT id, cloud_type, cookie, cloud_type_uid, cookie_uid, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs;
|
||||||
DROP TABLE cloud_configs;
|
DROP TABLE cloud_configs;
|
||||||
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs;
|
||||||
`);
|
`);
|
||||||
@@ -243,6 +257,7 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
if (!hasPromotionAccount) {
|
if (!hasPromotionAccount) {
|
||||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL");
|
||||||
console.log('[DB] cloud_configs migration: promotion_account column added');
|
console.log('[DB] cloud_configs migration: promotion_account column added');
|
||||||
|
}
|
||||||
|
|
||||||
// v0.3.5: notify_config for per-cloud push notification settings
|
// v0.3.5: notify_config for per-cloud push notification settings
|
||||||
const hasNotifyConfig = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get();
|
const hasNotifyConfig = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get();
|
||||||
@@ -250,6 +265,13 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL");
|
db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL");
|
||||||
console.log('[DB] cloud_configs migration: notify_config column added');
|
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 +301,19 @@ function seedSystemConfigs(db: Database.Database): void {
|
|||||||
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
{ key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' },
|
||||||
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
{ key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' },
|
||||||
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
{ key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' },
|
||||||
{ key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' },
|
{ key: 'link_invalid_keywords', value: '', description: '链接失效关键词(一行一条,命中的链接判定为失效)' },
|
||||||
{ key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' },
|
{ key: 'cloud_type_quark_enabled', value: 'true', description: '夸克网盘' },
|
||||||
{ key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' },
|
{ key: 'cloud_type_baidu_enabled', value: 'true', description: '百度网盘' },
|
||||||
{ key: 'cloud_enabled_115', value: 'true', description: '115 网盘' },
|
{ key: 'cloud_type_aliyun_enabled', value: 'true', description: '阿里云盘' },
|
||||||
{ key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' },
|
{ key: 'cloud_type_115_enabled', value: 'true', description: '115 网盘' },
|
||||||
{ key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' },
|
{ key: 'cloud_type_tianyi_enabled', value: 'true', description: '天翼云盘' },
|
||||||
{ key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' },
|
{ key: 'cloud_type_123pan_enabled', value: 'true', description: '123 云盘' },
|
||||||
{ key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' },
|
{ key: 'cloud_type_uc_enabled', value: 'true', description: 'UC 网盘' },
|
||||||
{ key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' },
|
{ key: 'cloud_type_xunlei_enabled', value: 'true', description: '迅雷网盘' },
|
||||||
{ key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' },
|
{ key: 'cloud_type_pikpak_enabled', value: 'true', description: 'PikPak 网盘' },
|
||||||
{ key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' },
|
{ key: 'cloud_type_magnet_enabled', value: 'true', description: '磁力链接' },
|
||||||
{ key: 'cloud_enabled_others', value: 'false', 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_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' },
|
||||||
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
{ key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' },
|
||||||
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
{ key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' },
|
||||||
@@ -299,6 +322,7 @@ function seedSystemConfigs(db: Database.Database): void {
|
|||||||
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
{ key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' },
|
||||||
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
{ key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' },
|
||||||
{ key: 'ip_geo_api_url', value: '', description: 'IP 归属地查询接口({ip} 会被替换为实际IP,留空则禁用)' },
|
{ key: 'ip_geo_api_url', value: '', description: 'IP 归属地查询接口({ip} 会被替换为实际IP,留空则禁用)' },
|
||||||
|
{ key: 'ip_geo_api_id', value: '', description: 'IP 归属地 API ID(apihz.cn 接口)' },
|
||||||
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
{ key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' },
|
||||||
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
{ key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' },
|
||||||
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
{ key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' },
|
||||||
@@ -318,6 +342,16 @@ function seedSystemConfigs(db: Database.Database): void {
|
|||||||
{ key: 'search_all_channels', value: 'false', description: '使用所有频道参与搜索(包含未启用频道)' },
|
{ key: 'search_all_channels', value: 'false', description: '使用所有频道参与搜索(包含未启用频道)' },
|
||||||
{ key: 'ip_geo_provider', value: 'apihz', description: 'IP 归属地查询接口提供商' },
|
{ key: 'ip_geo_provider', value: 'apihz', description: 'IP 归属地查询接口提供商' },
|
||||||
{ key: 'auto_update_enabled', value: 'false', description: '自动更新镜像(预留,暂未实现)' },
|
{ 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(
|
const insert = db.prepare(
|
||||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||||
|
|||||||
@@ -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 */
|
/** 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 {
|
try {
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
if (!data.cloud_type) {
|
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
|
// Normalize is_active: frontend sends boolean, SQLite needs 0/1
|
||||||
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0;
|
||||||
const saved = saveCloudConfig(data);
|
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);
|
res.json(saved);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message || 'Failed to save cloud config' });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'tmdb': {
|
case 'tmdb': {
|
||||||
const tmdbToken = token || getSystemConfig('tmdb_api_key') || '';
|
const tmdbToken = token || getSystemConfig('tmdb_api_token') || '';
|
||||||
if (!tmdbToken) {
|
if (!tmdbToken) {
|
||||||
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
res.json({ ok: false, info: 'TMDB API Key not configured' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* Production MUST set COOKIE_ENCRYPTION_KEY!
|
* Production MUST set COOKIE_ENCRYPTION_KEY!
|
||||||
*/
|
*/
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
const ALGORITHM = 'aes-256-gcm';
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
const IV_LENGTH = 12; // 96-bit nonce for 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();
|
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||||
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
|
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
|
||||||
} else {
|
} else {
|
||||||
// Default stable key (not ephemeral) — data survives container restart
|
// Auto-generate a random key and persist to file
|
||||||
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
|
const keyFile = path.join(process.env.DATA_DIR || '/app/data', 'encryption.key');
|
||||||
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
|
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;
|
return ENCRYPTION_KEY;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user