diff --git a/.env.example b/.env.example index 9d7b00e..9b528da 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Copy this file to `.env` in the project root. It is loaded automatically by: +# - docker compose (variable substitution for compose.yaml) +# - npm start / npm run dev (via node --env-file-if-exists) PORT=3210 PUID=1000 PGID=1000 @@ -5,3 +8,26 @@ EASYX_DATA_DIR=/data EASYX_MEDIA_DIR=/media EASYX_EXTERNAL_PLUGINS_DIR=/plugins EASYX_LOG_LEVEL=info + +# --- 单核 / 低内存部署建议(见 docs/optimization-plan.md) --- +# 字幕 Worker 默认在镜像里是开启的;1GB 内存机器务必关闭(torch 不可用) +EASYX_EMBEDDED_SUBTITLE_WORKER=false +# 集成浏览器登录(Chromium 链)仅在显式开启时可用;1GB 内存机器保持关闭以免 OOM +EASYX_ENABLE_BROWSER_LOGIN=false + +# 下载与录制调优(覆盖默认值) +# maxConcurrentDownloads=1 +# recordingPreset=source +# downloadStallTimeoutSeconds=600 +# downloadRetryAttempts=5 +# downloadRetryBaseSeconds=30 + +# --- 应用登录(单账户门禁,见 docs/login-implementation-plan.md) --- +# 会话签名密钥:建议设一个 >=16 字符的随机串。不设置时每次重启会生成临时密钥(已登录会话失效)。 +# EASYX_SESSION_SECRET=change-me-to-a-long-random-string +# 初始管理员密码:首次启动时若数据库尚无密码,可用此变量注入(>=8 字符);否则自动生成随机密码并打印到日志。 +# EASYX_ADMIN_PASSWORD= +# Cookie 强制 Secure 属性(即使反向代理没透 X-Forwarded-Proto 也可强制走 HTTPS 的 Cookie)。 +# EASYX_COOKIE_SECURE=false +# 可选 TLS 反代(docker compose --profile tls)使用的站点域名,见 deploy/Caddyfile。 +# EASYX_DOMAIN=easyx.example.com diff --git a/.gitignore b/.gitignore index 23d6221..1e07d82 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,14 @@ coverage/ plugins-external/ __pycache__/ *.pyc + +# 本地分析与部署文档(含安全评估,不随公开仓库发布) +docs/architecture-analysis.md +docs/download-analysis.md +docs/download-stability-improvements.md +docs/login-implementation-plan.md +docs/login-implementation-report.md +docs/optimization-implementation-report.md +docs/optimization-plan.md +docs/optimization-review.md +docs/upstream-merge-2026-09-08.md diff --git a/compose.yaml b/compose.yaml index 6af8538..1d24428 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,13 @@ services: EASYX_EXTERNAL_PLUGINS_DIR: /plugins EASYX_SCAN_INTERVAL_MINUTES: ${EASYX_SCAN_INTERVAL_MINUTES:-10} EASYX_WHISPER_MODEL: ${EASYX_WHISPER_MODEL:-small} + # Values come from the .env file next to this compose file (compose reads it + # automatically for variable substitution). + EASYX_EMBEDDED_SUBTITLE_WORKER: ${EASYX_EMBEDDED_SUBTITLE_WORKER:-false} + EASYX_ENABLE_BROWSER_LOGIN: ${EASYX_ENABLE_BROWSER_LOGIN:-false} + EASYX_SESSION_SECRET: ${EASYX_SESSION_SECRET:-} + EASYX_ADMIN_PASSWORD: ${EASYX_ADMIN_PASSWORD:-} + EASYX_COOKIE_SECURE: ${EASYX_COOKIE_SECURE:-false} volumes: - ./data:/data - ./media:/media @@ -30,3 +37,19 @@ services: restart: unless-stopped environment: LOG_LEVEL: info + + # Optional TLS reverse proxy. Start with: docker compose --profile tls up -d + # Edit deploy/Caddyfile.example first (set your domain), or mount your own. + caddy: + image: caddy:2-alpine + profiles: ["tls"] + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + EASYX_DOMAIN: ${EASYX_DOMAIN:-localhost} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - ./deploy/caddy-data:/data + - ./deploy/caddy-config:/config diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..25d5492 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,23 @@ +# TLS reverse proxy for OpenEasyX (terminates HTTPS, issues certificates +# automatically via Let's Encrypt, and forwards the protocol so the app sets +# the `Secure` cookie flag). +# +# Setup: +# 1. Replace easyx.example.com with your DNS name (it must resolve to this host). +# 2. Open UDP/TCP 80 + 443 on your firewall/router. +# 3. docker compose --profile tls up -d +# 4. Put your real secret into .env: EASYX_SESSION_SECRET= +# +# Local/LAN-only testing without a domain: replace the site block with +# :8443 { reverse_proxy open-easyx:3210 } +# and browse https://:8443 (self-signed certificate). + +{$EASYX_DOMAIN} { + encode zstd gzip + + reverse_proxy open-easyx:3210 { + # Required: lets the app detect HTTPS and mark the session cookie Secure. + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-For {remote_host} + } +} diff --git a/package-lock.json b/package-lock.json index a386e9a..7766742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1027,9 +1027,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1144,9 +1141,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1161,9 +1155,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1178,9 +1169,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1195,9 +1183,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1212,9 +1197,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1229,9 +1211,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1246,9 +1225,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1263,9 +1239,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1280,9 +1253,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1297,9 +1267,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1314,9 +1281,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1331,9 +1295,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1348,9 +1309,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index c7fa5f4..586c8f1 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,9 @@ "packages/*" ], "scripts": { - "dev": "concurrently -n api,web -c cyan,magenta \"tsx watch server/index.ts\" \"vite\"", + "dev": "concurrently -n api,web -c cyan,magenta \"tsx watch --env-file-if-exists=.env server/index.ts\" \"vite\"", "build": "tsc --noEmit && vite build", - "start": "tsx server/index.ts", + "start": "tsx --env-file-if-exists=.env server/index.ts", "test": "vitest run", "test:watch": "vitest", "check": "npm run test && npm run build" diff --git a/server/auth.test.ts b/server/auth.test.ts new file mode 100644 index 0000000..79f7a3d --- /dev/null +++ b/server/auth.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { createHmac } from "node:crypto"; +import { AuthService } from "./auth.js"; +import type { Database } from "./database.js"; + +function stubDb(initialHash = ""): Database { + const store: Record = { admin_password_hash: initialHash }; + return { + getSettings: () => store, + updateSettings: (values: Record) => { Object.assign(store, values); return store; }, + } as unknown as Database; +} + +const SECRET = "test-secret-1234567890"; + +describe("AuthService", () => { + it("hashes and verifies a correct password", async () => { + const auth = new AuthService(stubDb(), SECRET); + const hash = await auth.hashPassword("hunter2!"); + expect(hash.startsWith("scrypt$")).toBe(true); + expect(await auth.verifyPassword("hunter2!", hash)).toBe(true); + expect(await auth.verifyPassword("wrong", hash)).toBe(false); + }); + + it("rejects malformed or missing stored hashes", async () => { + const auth = new AuthService(stubDb(), SECRET); + expect(await auth.verifyPassword("anything", "not-a-hash")).toBe(false); + expect(await auth.verifyPassword("anything")).toBe(false); + }); + + it("signs and verifies a session, rejects tampering", () => { + const auth = new AuthService(stubDb(), SECRET); + const token = auth.createSession(); + expect(auth.verifySession(token)).toBe(true); + const tampered = `${token.slice(0, -2)}${token.endsWith("A") ? "B" : "A"}`; + expect(auth.verifySession(tampered)).toBe(false); + expect(auth.verifySession("garbage")).toBe(false); + expect(auth.verifySession(undefined)).toBe(false); + }); + + it("invalidates sessions when the secret changes", () => { + const a = new AuthService(stubDb(), "secret-one-1234567890"); + const b = new AuthService(stubDb(), "secret-two-1234567890"); + expect(b.verifySession(a.createSession())).toBe(false); + }); + + it("rejects expired sessions", () => { + const auth = new AuthService(stubDb(), SECRET); + const payload = Buffer.from(JSON.stringify({ uid: "admin", iat: 1, exp: Date.now() - 1000 })).toString("base64url"); + const sig = createHmac("sha256", SECRET).update(payload).digest("base64url"); + expect(auth.verifySession(`${payload}.${sig}`)).toBe(false); + // a freshly minted token is still valid + expect(auth.verifySession(auth.createSession())).toBe(true); + }); + + it("rate limits after the failure threshold", () => { + const auth = new AuthService(stubDb(), SECRET); + const ip = "1.2.3.4"; + expect(auth.checkRateLimit(ip).allowed).toBe(true); + for (let i = 0; i < 5; i++) auth.recordFailure(ip); + const blocked = auth.checkRateLimit(ip); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeGreaterThan(0); + auth.resetFailures(ip); + expect(auth.checkRateLimit(ip).allowed).toBe(true); + }); + + it("bootstraps a password when none is configured", async () => { + const db = stubDb(); + const auth = new AuthService(db, SECRET); + await auth.bootstrap(); + const stored = String((db.getSettings() as Record)["admin_password_hash"] ?? ""); + expect(stored.startsWith("scrypt$")).toBe(true); + }); + + it("changes password only after verifying the current one", async () => { + const db = stubDb(); + const auth = new AuthService(db, SECRET); + db.updateSettings({ admin_password_hash: await auth.hashPassword("current-pass") }); + await auth.changePassword("current-pass", "new-pass-1234"); + expect(await auth.verifyLogin("new-pass-1234")).toBe(true); + expect(await auth.verifyLogin("current-pass")).toBe(false); + await expect(auth.changePassword("wrong", "other-1234")).rejects.toMatchObject({ statusCode: 401 }); + await expect(auth.changePassword("new-pass-1234", "short")).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("verifyLogin reflects the stored hash", async () => { + const db = stubDb(); + const auth = new AuthService(db, SECRET); + db.updateSettings({ admin_password_hash: await auth.hashPassword("topsecret-1") }); + expect(await auth.verifyLogin("topsecret-1")).toBe(true); + expect(await auth.verifyLogin("nope")).toBe(false); + }); +}); diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..2b50d3e --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,151 @@ +import { randomBytes, scrypt as scryptCallback, createHmac, timingSafeEqual, type ScryptOptions } from "node:crypto"; +import type { Database } from "./database.js"; + +// Tuned for a 1GB single-core host: ~16MB peak, tens of milliseconds per hash. +// Argon2id would allocate ~64MB per hash here and is deliberately avoided. +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; +const SCRYPT_KEYLEN = 64; +const SCRYPT_MAXMEM = 64 * 1024 * 1024; + +function deriveKey(password: string | Buffer, salt: Buffer, options: ScryptOptions): Promise { + return new Promise((resolve, reject) => { + scryptCallback(password, salt, SCRYPT_KEYLEN, options, (error, key) => (error ? reject(error) : resolve(key))); + }); +} + +// Session lifetime: 30 days. A single admin user does not need rolling sessions. +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +// Login brute-force protection. Small, bounded in-memory map. +const MAX_FAILS = 5; +const RATE_LIMIT_CAP = 512; + +type FailEntry = { fails: number; until: number }; + +export class AuthService { + private readonly secret: string; + private readonly fails = new Map(); + private readonly log: (line: string) => void; + + constructor(private readonly db: Database, secret?: string, log: (line: string) => void = () => {}) { + this.log = log; + if (!secret) { + // No secret configured: sessions are ephemeral and lost on restart. This + // is safe but forces re-login; operators should set EASYX_SESSION_SECRET. + this.secret = randomBytes(32).toString("hex"); + this.log("EASYX_SESSION_SECRET not set; generated an ephemeral session secret (lost on restart, forcing re-login). Set EASYX_SESSION_SECRET to persist sessions."); + } else if (secret.length < 16) { + this.secret = secret; + this.log("WARNING: EASYX_SESSION_SECRET is shorter than 16 characters; using it, but a longer random value is recommended."); + } else { + this.secret = secret; + } + } + + async hashPassword(password: string): Promise { + const salt = randomBytes(16); + const derived = await deriveKey(password, salt, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM }); + return `scrypt$${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}$${salt.toString("hex")}$${derived.toString("hex")}`; + } + + async verifyPassword(password: string, stored?: string): Promise { + if (!stored || !stored.startsWith("scrypt$")) return false; + const parts = stored.split("$"); + if (parts.length !== 4) return false; + const [params, saltHex, hashHex] = parts.slice(1) as [string, string, string]; + const [N, r, p] = params.split(":").map(Number); + if (!N || !r || !p) return false; + const expected = Buffer.from(hashHex, "hex"); + let derived: Buffer; + try { + derived = await deriveKey(password, Buffer.from(saltHex, "hex"), { N, r, p, maxmem: SCRYPT_MAXMEM }); + } catch { + return false; + } + return derived.length === expected.length && timingSafeEqual(derived, expected); + } + + createSession(): string { + const payload = Buffer.from(JSON.stringify({ uid: "admin", iat: Date.now(), exp: Date.now() + SESSION_TTL_MS })).toString("base64url"); + const sig = createHmac("sha256", this.secret).update(payload).digest("base64url"); + return `${payload}.${sig}`; + } + + verifySession(value?: string): boolean { + if (!value) return false; + const dot = value.lastIndexOf("."); + if (dot < 0) return false; + const payload = value.slice(0, dot); + const sig = value.slice(dot + 1); + const expected = createHmac("sha256", this.secret).update(payload).digest("base64url"); + if (expected.length !== sig.length || !timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(sig, "utf8"))) return false; + try { + const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: number }; + return typeof data.exp === "number" && data.exp > Date.now(); + } catch { + return false; + } + } + + checkRateLimit(ip: string): { allowed: boolean; retryAfter: number } { + const entry = this.fails.get(ip); + if (!entry) return { allowed: true, retryAfter: 0 }; + if (entry.until > Date.now()) return { allowed: false, retryAfter: Math.ceil((entry.until - Date.now()) / 1000) }; + return { allowed: true, retryAfter: 0 }; + } + + recordFailure(ip: string): void { + const entry = this.fails.get(ip) ?? { fails: 0, until: 0 }; + entry.fails += 1; + if (entry.fails >= MAX_FAILS) { + const backoff = Math.min(60, 2 ** (entry.fails - MAX_FAILS + 1)); + entry.until = Date.now() + backoff * 1000; + } + this.fails.set(ip, entry); + if (this.fails.size > RATE_LIMIT_CAP) { + const oldest = this.fails.keys().next().value; + if (oldest !== undefined) this.fails.delete(oldest); + } + } + + resetFailures(ip: string): void { + this.fails.delete(ip); + } + + private getPasswordHash(): string { + return String((this.db.getSettings() as Record)["admin_password_hash"] ?? ""); + } + + async verifyLogin(password: string): Promise { + return this.verifyPassword(password, this.getPasswordHash()); + } + + private async setPassword(password: string): Promise { + await this.db.updateSettings({ admin_password_hash: await this.hashPassword(password) }); + } + + /** Ensure a password exists; bootstrap from env or a generated one-time password. */ + async bootstrap(): Promise { + if (this.getPasswordHash()) return; + const envPassword = process.env.EASYX_ADMIN_PASSWORD; + if (envPassword && envPassword.length >= 8) { + await this.setPassword(envPassword); + this.log("Admin password initialized from EASYX_ADMIN_PASSWORD."); + return; + } + if (envPassword) this.log("EASYX_ADMIN_PASSWORD was shorter than 8 characters; ignoring it and generating a random password instead."); + const generated = randomBytes(9).toString("base64url").replace(/[-_]/g, "0"); + await this.setPassword(generated); + this.log(`No admin password configured. Generated initial password: ${generated} (change it immediately in Settings).`); + } + + async changePassword(current: string, next: string): Promise { + if (!(await this.verifyPassword(current, this.getPasswordHash()))) { + throw Object.assign(new Error("Current password is incorrect"), { statusCode: 401 }); + } + if (next.length < 8) throw Object.assign(new Error("New password must be at least 8 characters"), { statusCode: 400 }); + await this.setPassword(next); + } +} diff --git a/server/database.ts b/server/database.ts index 6434497..b9bf245 100644 --- a/server/database.ts +++ b/server/database.ts @@ -24,6 +24,7 @@ export type DownloadItem = { identityKey?: string; title?: string; pageUrl?: string; mediaType: string; filename?: string; qualityScore: number; expectedBytes?: number; publishedAt?: string; metadata: Record; status: string; progress: number; downloadedBytes: number; checksumSha256?: string; visualHash?: string; storagePath?: string; error?: string; + attempts?: number; nextRetryAt?: string; downloadStartedAt?: string; downloadFinishedAt?: string; createdAt: string; updatedAt: string; }; @@ -115,14 +116,20 @@ export class Database { if (!itemColumns.has("download_started_at")) this.sqlite.exec("ALTER TABLE items ADD COLUMN download_started_at TEXT"); if (!itemColumns.has("download_finished_at")) this.sqlite.exec("ALTER TABLE items ADD COLUMN download_finished_at TEXT"); if (!itemColumns.has("downloaded_bytes")) this.sqlite.exec("ALTER TABLE items ADD COLUMN downloaded_bytes INTEGER NOT NULL DEFAULT 0"); + if (!itemColumns.has("attempts")) this.sqlite.exec("ALTER TABLE items ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"); + if (!itemColumns.has("next_retry_at")) this.sqlite.exec("ALTER TABLE items ADD COLUMN next_retry_at TEXT"); this.sqlite.exec("CREATE INDEX IF NOT EXISTS items_visual_hash_idx ON items(performer_id,media_type,visual_hash)"); this.sqlite.exec("CREATE INDEX IF NOT EXISTS items_storage_path_idx ON items(storage_path)"); this.migrateNitterToPublicX(); this.setDefault("retentionDays", 0); this.setDefault("maxConcurrentDownloads", 2); + this.setDefault("downloadRetryAttempts", 5); + this.setDefault("downloadRetryBaseSeconds", 30); + this.setDefault("downloadStallTimeoutSeconds", 120); this.setDefault("autoQueueDiscovered", true); this.setDefault("defaultScrapeIntervalMinutes", 360); this.setDefault("defaultLiveIntervalSeconds", 10); + this.setDefault("admin_password_hash", ""); for (const [key, value] of Object.entries(outputDefaults)) this.setDefault(key, value); } @@ -451,30 +458,36 @@ export class Database { } nextQueued(): DownloadItem | undefined { - const row = this.sqlite.prepare("SELECT * FROM items WHERE status='queued' ORDER BY created_at LIMIT 1").get() as any; + const row = this.sqlite.prepare("SELECT * FROM items WHERE status='queued' AND (next_retry_at IS NULL OR next_retry_at<=?) ORDER BY created_at LIMIT 1").get(now()) as any; return row ? this.mapItem(row) : undefined; } requeueInterruptedDownloads() { const stamp = now(); - const queued = this.sqlite.prepare("UPDATE items SET status='queued',progress=0,downloaded_bytes=0,error=NULL,download_started_at=NULL,download_finished_at=NULL,updated_at=? WHERE status='downloading'").run(stamp); + const queued = this.sqlite.prepare("UPDATE items SET status='queued',progress=0,downloaded_bytes=0,error=NULL,attempts=0,next_retry_at=NULL,download_started_at=NULL,download_finished_at=NULL,updated_at=? WHERE status='downloading'").run(stamp); const cancelled = this.sqlite.prepare("UPDATE items SET status='cancelled',error=NULL,download_finished_at=?,updated_at=? WHERE status IN ('stopping','cancelling')").run(stamp, stamp); return Number(queued.changes) + Number(cancelled.changes); } retryFailedItems() { - const result = this.sqlite.prepare("UPDATE items SET status='queued',progress=0,downloaded_bytes=0,error=NULL,download_started_at=NULL,download_finished_at=NULL,updated_at=? WHERE status='failed'").run(now()); + const result = this.sqlite.prepare("UPDATE items SET status='queued',progress=0,downloaded_bytes=0,error=NULL,attempts=0,next_retry_at=NULL,download_started_at=NULL,download_finished_at=NULL,updated_at=? WHERE status='failed'").run(now()); return Number(result.changes); } + scheduleRetry(itemId: string, error: string, attempts: number, nextRetryAt: string) { + this.sqlite.prepare("UPDATE items SET status='queued',progress=0,downloaded_bytes=0,error=?,attempts=?,next_retry_at=?,download_started_at=NULL,download_finished_at=NULL,updated_at=? WHERE id=?") + .run(error, attempts, nextRetryAt, now(), itemId); + } + setItemStatus(itemId: string, status: string, values: { progress?: number; downloadedBytes?: number; error?: string | null; checksum?: string; storagePath?: string; duplicateOf?: string } = {}) { const stamp = now(); this.sqlite.prepare(`UPDATE items SET status=?,progress=COALESCE(?,progress),downloaded_bytes=CASE WHEN ?='queued' THEN 0 ELSE COALESCE(?,downloaded_bytes) END,error=?,checksum_sha256=COALESCE(?,checksum_sha256),storage_path=COALESCE(?,storage_path),duplicate_of=COALESCE(?,duplicate_of), + attempts=CASE WHEN ? IN ('completed','duplicate') THEN 0 ELSE attempts END,next_retry_at=CASE WHEN ? IN ('completed','duplicate') THEN NULL ELSE next_retry_at END, download_started_at=CASE WHEN ?='queued' THEN NULL WHEN ?='downloading' AND download_started_at IS NULL THEN ? ELSE download_started_at END, download_finished_at=CASE WHEN ? IN ('queued','downloading','paused','stopping','cancelling') THEN NULL WHEN ? IN ('completed','duplicate','failed','cancelled','deleted') THEN ? ELSE download_finished_at END, updated_at=? WHERE id=?`) .run(status, values.progress ?? null, status, values.downloadedBytes ?? null, values.error ?? null, values.checksum ?? null, values.storagePath ?? null, values.duplicateOf ?? null, - status, status, stamp, status, status, stamp, stamp, itemId); + status, status, status, status, stamp, status, status, stamp, stamp, itemId); return this.getItem(itemId); } @@ -489,7 +502,7 @@ export class Database { } findVisualDuplicate(visualHash: string, exceptId: string, performerId: string, mediaType: string, maximumDistance = 5): DownloadItem | undefined { - const candidates = (this.sqlite.prepare("SELECT * FROM items WHERE performer_id=? AND media_type=? AND visual_hash IS NOT NULL AND id<>? AND status='completed'").all(performerId, mediaType, exceptId) as any[]) + const candidates = (this.sqlite.prepare("SELECT * FROM items WHERE performer_id=? AND media_type=? AND visual_hash IS NOT NULL AND id<>? AND status='completed' ORDER BY updated_at DESC LIMIT 200").all(performerId, mediaType, exceptId) as any[]) .map((row) => ({ row, distance: hammingDistance(visualHash, String(row.visual_hash)) })) .filter((candidate) => candidate.distance <= maximumDistance) .sort((left, right) => left.distance - right.distance || Number(right.row.quality_score) - Number(left.row.quality_score)); @@ -529,6 +542,7 @@ export class Database { filename: row.filename ?? undefined, qualityScore: row.quality_score, expectedBytes: row.expected_bytes ?? undefined, publishedAt: row.published_at ?? undefined, metadata: asJson(row.metadata_json, {}), status: row.status, progress: row.progress, downloadedBytes: Number(row.downloaded_bytes ?? 0), checksumSha256: row.checksum_sha256 ?? undefined, visualHash: row.visual_hash ?? undefined, storagePath: row.storage_path ?? undefined, error: row.error ?? undefined, + attempts: Number(row.attempts ?? 0), nextRetryAt: row.next_retry_at ?? undefined, downloadStartedAt: row.download_started_at ?? undefined, downloadFinishedAt: row.download_finished_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at }; } diff --git a/server/downloader.ts b/server/downloader.ts index 78e2cf4..b6a79ca 100644 --- a/server/downloader.ts +++ b/server/downloader.ts @@ -11,7 +11,7 @@ import { filenameFromUrl, safeSegment } from "./utils.js"; import { downloadOutputPath, recordingEncodingArgs } from "./output-settings.js"; import { outputSettings } from "../packages/output-settings.js"; -type ActiveDownload = { child?: ChildProcess; abort?: AbortController; paused: boolean; encoding?: boolean; action?: "stop" | "cancel" | "delete" }; +type ActiveDownload = { child?: ChildProcess; abort?: AbortController; paused: boolean; encoding?: boolean; action?: "stop" | "cancel" | "delete"; stalled?: boolean }; export class DownloadQueue { private active = new Map(); @@ -30,6 +30,7 @@ export class DownloadQueue { fs.mkdirSync(this.mediaRoot, { recursive: true }); fs.mkdirSync(this.downloadsRoot, { recursive: true, mode: 0o700 }); this.db.requeueInterruptedDownloads(); + this.cleanupStaleDownloads(); this.timer = setInterval(() => void this.tick(), 1000); this.timer.unref(); void this.tick(); @@ -45,7 +46,9 @@ export class DownloadQueue { if (item.status === "queued") return this.db.setItemStatus(itemId, "paused"); const control = this.active.get(itemId); if (item.status !== "downloading" || !control) throw Object.assign(new Error(`Cannot pause an item with status '${item.status}'`), { statusCode: 409 }); - control.paused = true; this.signal(control, "SIGSTOP"); + control.paused = true; + if (control.child) this.signal(control, "SIGSTOP"); + else control.abort?.abort(); return this.db.setItemStatus(itemId, "paused"); } @@ -115,15 +118,22 @@ export class DownloadQueue { let temporary = ""; let temporaryDirectory = ""; let preserveTemporary = false; - let lastProgress = 0; let lastBytes = 0; let lastProgressUpdate = 0; + let lastProgress = 0; let lastBytes = 0; let lastProgressUpdate = 0; let lastActivity = Date.now(); const reportProgress = (progress?: number, downloadedBytes?: number, force = false) => { const nextProgress = progress === undefined ? lastProgress : Math.max(lastProgress, Math.min(0.99, Math.max(0, progress))); const nextBytes = downloadedBytes === undefined ? lastBytes : Math.max(lastBytes, downloadedBytes); const stamp = Date.now(); - if (!force && stamp - lastProgressUpdate < 250 && nextProgress - lastProgress < 0.005 && nextBytes - lastBytes < 256 * 1024) return; - lastProgress = nextProgress; lastBytes = nextBytes; lastProgressUpdate = stamp; + if (!force && stamp - lastProgressUpdate < 250 && nextProgress - lastProgress < 0.005 && nextBytes - lastBytes < 256 * 1024) { lastActivity = stamp; return; } + lastProgress = nextProgress; lastBytes = nextBytes; lastProgressUpdate = stamp; lastActivity = stamp; if (!control.action) this.db.setItemStatus(item.id, control.paused ? "paused" : "downloading", { progress: nextProgress, downloadedBytes: nextBytes }); }; + const stallTimeoutMs = Math.max(0, Number(this.db.getSettings().downloadStallTimeoutSeconds ?? 120)) * 1000; + const stallTimer = stallTimeoutMs > 0 ? setInterval(() => { + if (!control.action && !control.paused && Date.now() - lastActivity > stallTimeoutMs) { + control.stalled = true; control.abort?.abort(); this.signal(control, "SIGKILL"); + } + }, 5000) : undefined; + stallTimer?.unref(); try { const plugin = this.plugins.get(item.pluginId); if (!plugin.resolveDownload) throw new Error("This plugin cannot resolve downloads"); @@ -174,7 +184,7 @@ export class DownloadQueue { checksum = hash.digest("hex"); } if (control.action === "cancel" || control.action === "delete") throw new Error("Download cancelled"); - if (item.mediaType === "video" && item.metadata.live === true && settings.recordingPreset !== "source") { + if (item.mediaType === "video" && item.metadata.live === true && settings.recordingPreset && settings.recordingPreset !== "source") { const encoded = path.join(temporaryDirectory, "encoded.mp4"); control.action = undefined; control.encoding = true; this.db.setItemStatus(item.id, "downloading", { progress: 0.99 }); @@ -228,31 +238,67 @@ export class DownloadQueue { }); } catch (error) { let message = error instanceof Error ? error.message : String(error); - if (control.encoding && !control.action && temporary && fs.existsSync(temporary)) { - try { - const recoveryDirectory = path.join(this.mediaRoot, ".recording-recovery", safeSegment(item.id)); - this.prepareOutputDirectory(recoveryDirectory); - const recovery = this.availableDestination(path.join(recoveryDirectory, path.basename(temporary)), item.id); - fs.renameSync(temporary, recovery); temporary = ""; - message += ` Recording preserved for recovery at ${path.relative(this.mediaRoot, recovery)}.`; - } catch { - preserveTemporary = true; - message += ` Recording preserved in staging at ${path.relative(this.mediaRoot, temporary)}; recover it before retrying.`; + if (!control.action && !control.paused && temporary && fs.existsSync(temporary)) { + const partialBytes = fs.statSync(temporary).size; + const isLiveRecording = (item.metadata as Record | undefined)?.live === true; + if (control.encoding || (isLiveRecording && partialBytes > 0)) { + try { + const recoveryDirectory = path.join(this.mediaRoot, ".recording-recovery", safeSegment(item.id)); + this.prepareOutputDirectory(recoveryDirectory); + const recovery = this.availableDestination(path.join(recoveryDirectory, path.basename(temporary)), item.id); + fs.renameSync(temporary, recovery); temporary = ""; + message += ` Recording preserved for recovery at ${path.relative(this.mediaRoot, recovery)}.`; + } catch { + preserveTemporary = true; + message += ` Recording preserved in staging at ${path.relative(this.mediaRoot, temporary)}; recover it before retrying.`; + } } } if (control.action) { if (control.action !== "delete") this.db.setItemStatus(item.id, "cancelled", { error: null }); this.writeLog?.("info", "download", control.action === "stop" ? "Recording stopped" : "Download cancelled", { itemId: item.id, title: item.title }); + } else if (control.paused) { + this.db.setItemStatus(item.id, "paused", { error: null }); } else { - this.db.setItemStatus(item.id, "failed", { error: message }); - this.writeLog?.("error", "download", "Download failed", { itemId: item.id, pluginId: item.pluginId, title: item.title, error: message }); + if (control.stalled) message = "Download timed out (no progress received within the configured stall timeout)"; + this.handleFailure(item.id, message, control); } } finally { + if (stallTimer) clearInterval(stallTimer); if (temporaryDirectory && !preserveTemporary) fs.rmSync(temporaryDirectory, { recursive: true, force: true }); if (control.action === "delete") this.db.deleteItem(item.id); } } + private handleFailure(itemId: string, message: string, control: ActiveDownload) { + const settings = this.db.getSettings(); + const maxAttempts = Math.max(0, Number(settings.downloadRetryAttempts ?? 5)); + const attempts = (this.db.getItem(itemId)?.attempts ?? 0) + 1; + if (attempts < maxAttempts) { + const base = Math.max(1, Number(settings.downloadRetryBaseSeconds ?? 30)); + const delay = Math.min(base * 2 ** (attempts - 1), 3600) * 1000; + const jitter = Math.floor(Math.random() * Math.min(delay, 30_000)); + const nextRetryAt = new Date(Date.now() + delay + jitter).toISOString(); + this.db.scheduleRetry(itemId, message, attempts, nextRetryAt); + this.writeLog?.("warn", "download", "Download failed, scheduling automatic retry", { itemId, attempt: attempts, maxAttempts, nextRetryAt, error: message }); + } else { + this.db.setItemStatus(itemId, "failed", { error: message }); + this.writeLog?.("error", "download", "Download failed permanently after exhausting retries", { itemId, attempts, error: message }); + } + } + + private cleanupStaleDownloads() { + try { + const root = this.downloadsRoot; + if (!fs.existsSync(root)) return; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (this.active.has(entry.name)) continue; + fs.rmSync(path.join(root, entry.name), { recursive: true, force: true }); + } + } catch { /* Best-effort startup cleanup; never blocks startup. */ } + } + private get downloadsRoot() { return path.join(this.mediaRoot, ".downloads"); } private prepareOutputDirectory(directory: string) { diff --git a/server/index.ts b/server/index.ts index 5cd26ad..f445b82 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,8 @@ import { LibraryDatabase } from "./library-database.js"; import { Catalog } from "./catalog.js"; import { registerLibraryRoutes } from "./library-routes.js"; import { settingsSchema } from "./output-settings.js"; +import { AuthService } from "./auth.js"; +import type { FastifyRequest, FastifyReply } from "fastify"; const port = Number(process.env.PORT ?? 3210); const dataDir = path.resolve(process.env.EASYX_DATA_DIR ?? "data"); @@ -31,6 +33,8 @@ const logStore = new LogStore(); const appLogger = pino({ level: process.env.EASYX_LOG_LEVEL ?? "info" }, logStore.stream); const writeLog: LogWriter = (level, scope, message, details) => appLogger[level]({ scope, ...(details === undefined ? {} : { details }) }, message); const db = new Database(dataDir); +const auth = new AuthService(db, process.env.EASYX_SESSION_SECRET, (line) => appLogger.info({ scope: "auth" }, line)); +await auth.bootstrap(); const libraryDb = new LibraryDatabase(dataDir); const catalog = new Catalog(libraryDb, mediaDir, dataDir, undefined, (relativePath) => db.storedMediaMetadata(relativePath)); const pluginRepositories = new PluginRepositoryManager(dataDir, path.resolve("plugins"), externalPluginsDir); @@ -41,13 +45,19 @@ const queue = new DownloadQueue( (item) => catalog.deleteStoredMedia(item.storagePath!), ); const browserLogin = new BrowserLoginManager(dataDir); -const liveCams = new LiveCamService(db, plugins); +const liveCams = new LiveCamService(db, plugins, undefined, path.join(dataDir, ".proxy-cache")); queue.start(); const app = Fastify({ loggerInstance: appLogger, bodyLimit: 8 * 1024 * 1024 }); const discoveryStatus = { running: false, completed: 0, total: 0, progress: 0, query: "", error: "" }; const performerRefreshStatus = { running: false, completed: 0, total: 0, progress: 0, error: "" }; +function ensureBrowserLoginEnabled() { + if (process.env.EASYX_ENABLE_BROWSER_LOGIN !== "true") { + throw Object.assign(new Error("Integrated browser login is disabled on this instance (set EASYX_ENABLE_BROWSER_LOGIN=true to enable it)"), { statusCode: 503 }); + } +} + function refreshLiveCamFavorites(providerId?: string) { if (providerId && !plugins.get(providerId, false).listFollowedLiveCams) return; const sync = providerId ? liveCams.syncFavorites(providerId).then((result) => [result]) : liveCams.syncAllFavorites(); @@ -66,11 +76,73 @@ app.setErrorHandler((error, request, reply) => { reply.status(status >= 400 && status < 600 ? status : 500).send({ error: message }); }); +const SESSION_COOKIE = "easyx_session"; +function parseCookies(header: string | undefined): Record { + const out: Record = {}; + if (!header) return out; + for (const raw of header.split(";")) { + const index = raw.indexOf("="); + if (index < 0) continue; + const key = raw.slice(0, index).trim(); + if (key) out[key] = decodeURIComponent(raw.slice(index + 1).trim()); + } + return out; +} +function cookieSecure(request: FastifyRequest): boolean { + return request.headers["x-forwarded-proto"] === "https" || process.env.EASYX_COOKIE_SECURE === "true"; +} +function sessionCookie(request: FastifyRequest): string | undefined { + return parseCookies(request.headers.cookie)[SESSION_COOKIE]; +} +// Global authentication gate. Added before any route registration so it wraps +// every endpoint (including those registered via plugins) and the static UI. +app.addHook("onRequest", (request, reply, done) => { + const path = new URL(request.url, "http://localhost").pathname; + // Endpoints that must work without a session. + if (path === "/api/auth/login" || path === "/api/auth/me" || path === "/api/health" || path === "/api/version") return done(); + // Static assets and the internal browser proxy are served without authentication so the SPA shell can render. + if (!path.startsWith("/api/")) return done(); + if (!auth.verifySession(sessionCookie(request))) return reply.status(401).send({ error: "unauthorized" }); + // CSRF protection for state-changing requests: the cookie is SameSite=Lax and a + // custom header that browsers cannot attach on cross-site requests is required. + if (request.method !== "GET" && request.method !== "OPTIONS" && request.headers["x-requested-with"] !== "EasyX") { + return reply.status(403).send({ error: "csrf" }); + } + done(); +}); + await app.register(fastifyHttpProxy, { upstream: "http://127.0.0.1:6080", prefix: "/browser", websocket: true }); const library = registerLibraryRoutes(app, libraryDb, catalog, db, dataDir); app.get("/api/health", async () => ({ ok: true, product: "Open EasyX", version: appVersion, plugins: plugins.list().length, library: libraryDb.stats().total, scan: catalog.status })); app.get("/api/version", async () => ({ version: appVersion })); + +app.post("/api/auth/login", async (request, reply) => { + const limit = auth.checkRateLimit(request.ip); + if (!limit.allowed) return reply.status(429).header("retry-after", String(limit.retryAfter)).send({ error: "Too many attempts; please wait and try again" }); + const { password } = z.object({ password: z.string().min(1).max(200) }).parse(request.body); + if (!(await auth.verifyLogin(password))) { + auth.recordFailure(request.ip); + return reply.status(401).send({ error: "Invalid password" }); + } + auth.resetFailures(request.ip); + const maxAge = 30 * 24 * 60 * 60; + reply.header("set-cookie", `${SESSION_COOKIE}=${auth.createSession()}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax${cookieSecure(request) ? "; Secure" : ""}`); + return { ok: true }; +}); +app.post("/api/auth/logout", async (request, reply) => { + reply.header("set-cookie", `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecure(request) ? "; Secure" : ""}`); + return { ok: true }; +}); +app.post("/api/auth/change-password", async (request) => { + const body = z.object({ current: z.string().min(1).max(200), next: z.string().min(8).max(200) }).parse(request.body); + await auth.changePassword(body.current, body.next); + return { ok: true }; +}); +app.get("/api/auth/me", async (request, reply) => { + if (auth.verifySession(sessionCookie(request))) return { authenticated: true, user: "admin" }; + return reply.status(401).send({ error: "unauthorized" }); +}); app.get("/api/dashboard", async () => ({ stats: db.stats(), performers: db.listPerformers(), sources: db.listSources(), items: db.listItems(30) })); app.get<{ Querystring: Record }>("/api/logs", async (request) => { const query = z.object({ limit: z.coerce.number().int().min(1).max(1_000).default(500), level: z.enum(["debug", "info", "warn", "error"]).optional(), search: z.string().trim().max(200).optional() }).parse(request.query); @@ -169,6 +241,7 @@ app.post<{ Params: { id: string }; Body: Record | undefined }>( return { plugin: plugins.list().find((entry) => entry.manifest.id === request.params.id), test }; }); app.post<{ Params: { id: string }; Body: { text?: unknown } }>("/api/plugins/:id/browser-login/paste", async (request) => { + ensureBrowserLoginEnabled(); plugins.get(request.params.id, false); const value = z.string().min(1).max(100_000).parse(request.body?.text); return browserLogin.paste(request.params.id, value); @@ -574,7 +647,11 @@ app.post<{ Params: { id: string } }>("/api/items/:id/stop", async (request) => q app.post<{ Params: { id: string } }>("/api/items/:id/cancel", async (request) => queue.cancel(request.params.id)); app.delete<{ Params: { id: string } }>("/api/items/:id", async (request) => queue.delete(request.params.id)); -app.get("/api/settings", async () => ({ ...db.getSettings(), mediaRoot: mediaDir, ...library.settings() })); +app.get("/api/settings", async () => { + // Never expose the admin password hash to the client. + const { admin_password_hash: _omitted, ...settings } = db.getSettings(); + return { ...settings, mediaRoot: mediaDir, ...library.settings() }; +}); app.put<{ Body: Record }>("/api/settings", async (request) => { const parsed = settingsSchema.safeParse(request.body); if (!parsed.success) throw Object.assign(new Error(parsed.error.issues.map((issue) => issue.message).join(" ")), { statusCode: 400 }); diff --git a/server/live-cams.test.ts b/server/live-cams.test.ts index c9baeff..68dd3f2 100644 --- a/server/live-cams.test.ts +++ b/server/live-cams.test.ts @@ -309,7 +309,7 @@ describe("Open EasyX live cams", () => { const cam = await service.get("test.live", "alice"); expect(searched).toBe(false); expect(cam).toMatchObject({ username: "alice", providerId: "test.live" }); - const recording = service.record("test.live", cam); + const recording = await service.record("test.live", cam); expect(recording.status).toBe("queued"); expect(database.getItem(recording.itemId)).toMatchObject({ status: "queued", mediaType: "video", pageUrl: "https://live.test/alice", filename: expect.stringMatching(/^alice-.*\.mp4$/) }); }); diff --git a/server/live-cams.ts b/server/live-cams.ts index 2cae39c..22a1d9b 100644 --- a/server/live-cams.ts +++ b/server/live-cams.ts @@ -1,4 +1,6 @@ -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; import type { FastifyReply } from "fastify"; import type { LiveCam, LiveCamFavoriteSnapshot, LiveCamQuery, LiveStream } from "../packages/plugin-sdk/index.js"; import type { Database, LiveCamFavorite, Performer, Source } from "./database.js"; @@ -35,7 +37,10 @@ export class LiveCamService { private favoriteWrites = new Map>(); private favoriteEpoch = new Map(); - constructor(private readonly db: Database, private readonly plugins: PluginManager, private readonly request: typeof fetch = fetch) {} + private readonly cacheDir?: string; + constructor(private readonly db: Database, private readonly plugins: PluginManager, private readonly request: typeof fetch = fetch, cacheDir?: string) { + this.cacheDir = cacheDir; + } private findPerformer(providerId: string, cam: Pick, performers = this.db.listPerformers()): Performer | undefined { const identities = new Set([cam.username, cam.id, `live:${cam.username}`].map((value) => value.trim().toLowerCase())); @@ -376,7 +381,7 @@ export class LiveCamService { return { performer, source, created: !existing, sourceCreated: !existingSource }; } - record(providerId: string, cam: LiveCam): { itemId: string; status: string } { + async record(providerId: string, cam: LiveCam): Promise<{ itemId: string; status: string }> { const entry = this.livePlugins(providerId)[0]; if (!entry) throw Object.assign(new Error("The selected live-cam plugin is not installed"), { statusCode: 404 }); const plugin = this.plugins.get(providerId); @@ -390,9 +395,16 @@ export class LiveCamService { const externalId = `manual-live:${username.toLowerCase()}:${session}`; const safeName = username.replace(/[^a-z0-9_.-]+/gi, "-").replace(/^-+|-+$/g, "") || "live"; const { performer, source } = this.createPerformer(providerId, cam); + let recordingAudioUrl: string | undefined; + if (plugin.resolveLiveStream) { + try { + const stream = await plugin.resolveLiveStream(this.plugins.context(providerId), cam); + if (stream.audioUrl) recordingAudioUrl = stream.audioUrl; + } catch { /* Audio merge is best-effort; the capture tool may already include audio. */ } + } this.db.ingestItems(source, [{ externalId, title: cam.title ?? `${username} live`, pageUrl: cam.pageUrl, mediaType: "video", - publishedAt: startedAt.toISOString(), filename: `${safeName}-${session}.mp4`, metadata: { extractorUrl: cam.pageUrl, live: true }, + publishedAt: startedAt.toISOString(), filename: `${safeName}-${session}.mp4`, metadata: { extractorUrl: cam.pageUrl, live: true, ...(recordingAudioUrl ? { recordingAudioUrl } : {}) }, }]); const item = this.db.getItemBySourceExternalId(source.id, externalId); if (!item) throw new Error("The live recording could not be added to the download queue"); @@ -475,14 +487,67 @@ export class LiveCamService { for (const key of ["_HLS_msn", "_HLS_part", "_HLS_skip"]) { const value = text(query[key]); if (value) sourceUrl.searchParams.set(key, value); } - const response = await this.request(sourceUrl, { headers: { ...entry.headers, ...(range ? { range } : {}) }, signal: AbortSignal.timeout(25_000) }); + const cacheKey = this.proxyCacheKey(sourceUrl.toString(), range, entry.headers); + const cached = this.readProxyCache(cacheKey); + if (cached) return reply.status(200).type(cached.contentType).header("cache-control", "no-store").send(cached.buffer); + let response: Response; + try { response = await this.fetchUpstream(sourceUrl, entry.headers, range); } + catch (error) { + return reply.status(502).send({ error: `Upstream live provider unreachable: ${error instanceof Error ? error.message : String(error)}` }); + } if (!response.ok) return reply.status(response.status).send({ error: `Live provider returned HTTP ${response.status}` }); const contentType = response.headers.get("content-type") ?? "application/octet-stream"; const buffer = Buffer.from(await response.arrayBuffer()); const playlist = contentType.includes("mpegurl") || buffer.subarray(0, 7).toString() === "#EXTM3U"; + if (!playlist) this.writeProxyCache(cacheKey, buffer, contentType); reply.status(response.status).type(playlist ? "application/vnd.apple.mpegurl" : contentType).header("cache-control", "no-store"); const contentRange = response.headers.get("content-range"); const acceptRanges = response.headers.get("accept-ranges"); if (contentRange) reply.header("content-range", contentRange); if (acceptRanges) reply.header("accept-ranges", acceptRanges); return reply.send(playlist ? this.rewritePlaylist(buffer.toString("utf8"), response.url, entry.headers) : buffer); } + + private proxyCacheKey(url: string, range: string | undefined, headers: Record): string { + return createHash("sha256").update(`${url}|${range ?? ""}|${JSON.stringify(Object.entries(headers).sort())}`).digest("hex").slice(0, 32); + } + + private readProxyCache(key: string): { buffer: Buffer; contentType: string } | undefined { + if (!this.cacheDir) return undefined; + try { + const dataPath = path.join(this.cacheDir, `${key}.bin`); + const metaPath = path.join(this.cacheDir, `${key}.ct`); + const stat = fs.statSync(dataPath); + if (Date.now() - stat.mtimeMs > 5 * 60_000) { fs.rmSync(dataPath, { force: true }); fs.rmSync(metaPath, { force: true }); return undefined; } + return { buffer: fs.readFileSync(dataPath), contentType: fs.readFileSync(metaPath, "utf8") || "application/octet-stream" }; + } catch { return undefined; } + } + + private writeProxyCache(key: string, buffer: Buffer, contentType: string): void { + if (!this.cacheDir) return; + try { + if (!fs.existsSync(this.cacheDir)) fs.mkdirSync(this.cacheDir, { recursive: true }); + fs.writeFileSync(path.join(this.cacheDir, `${key}.bin`), buffer, { mode: 0o600 }); + fs.writeFileSync(path.join(this.cacheDir, `${key}.ct`), contentType, { mode: 0o600 }); + const entries = fs.readdirSync(this.cacheDir).filter((name) => name.endsWith(".bin")).map((name) => ({ name, mtime: fs.statSync(path.join(this.cacheDir!, name)).mtimeMs })); + const cap = 500; + if (entries.length > cap) { + for (const stale of entries.sort((a, b) => a.mtime - b.mtime).slice(0, entries.length - cap)) { + fs.rmSync(path.join(this.cacheDir!, stale.name), { force: true }); + fs.rmSync(path.join(this.cacheDir!, `${stale.name.slice(0, -4)}.ct`), { force: true }); + } + } + } catch { /* Disk cache is best-effort; never blocks the proxy. */ } + } + + private async fetchUpstream(url: URL, headers: Record, range: string | undefined): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + return await this.request(url, { headers: { ...headers, ...(range ? { range } : {}) }, signal: AbortSignal.timeout(60_000) }); + } catch (error) { + lastError = error; + if (attempt < 2) await new Promise((resolve) => { const timer = setTimeout(resolve, 1000 * (attempt + 1)); timer.unref?.(); }); + } + } + throw lastError instanceof Error ? lastError : new Error("Upstream fetch failed"); + } } diff --git a/server/output-settings.ts b/server/output-settings.ts index 5b6eb70..bc6a7b7 100644 --- a/server/output-settings.ts +++ b/server/output-settings.ts @@ -18,16 +18,18 @@ export function downloadOutputPath(settings: Record, item: Down const options = outputSettings(settings); const original = path.parse(originalFilename); const date = new Date(item.publishedAt || item.createdAt); const stamp = Number.isNaN(date.valueOf()) ? "unknown" : date.toISOString(); - const extension = item.metadata.live === true && item.mediaType === "video" && options.recordingPreset !== "source" ? ".mp4" : original.ext; + const extension = item.metadata.live === true && item.mediaType === "video" && options.recordingPreset && options.recordingPreset !== "source" ? ".mp4" : original.ext; return renderOutputPath(options, { performer, site, filename: original.name, title: item.title || original.name, id: item.id, date: stamp.slice(0, 10), time: stamp.slice(11, 19).replaceAll(":", "-"), year: stamp.slice(0, 4), month: stamp.slice(5, 7), day: stamp.slice(8, 10), }, extension); } -export function recordingEncodingArgs(preset: RecordingPreset, input: string, output: string): string[] { +export function recordingEncodingArgs(preset: RecordingPreset, input: string, output: string, audioUrl?: string): string[] { if (preset === "source") return []; - return ["-y", "-nostdin", "-v", "error", "-i", input, "-map", "0:v:0", "-map", "0:a:0?", + const inputs = ["-y", "-nostdin", "-v", "error", "-i", input]; + if (audioUrl) inputs.push("-i", audioUrl); + return [...inputs, "-map", "0:v:0", "-map", audioUrl ? "1:a:0?" : "0:a:0?", "-c:v", preset === "h265" ? "libx265" : "libx264", "-preset", preset === "h264-small" ? "fast" : "medium", "-crf", preset === "h264-high" ? "18" : "26", "-pix_fmt", "yuv420p", "-vf", preset === "h264-small" ? "scale=w='min(1280,iw)':h='min(720,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2" : "scale=trunc(iw/2)*2:trunc(ih/2)*2", diff --git a/src/AppChrome.tsx b/src/AppChrome.tsx index 290a52f..e3cb4ef 100644 --- a/src/AppChrome.tsx +++ b/src/AppChrome.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import { Menu, RefreshCw, Search, ShieldCheck, X } from "lucide-react"; +import { LogOut, Menu, RefreshCw, Search, ShieldCheck, X } from "lucide-react"; import { api } from "./api"; import { UnifiedNavigation } from "./UnifiedNavigation"; @@ -85,6 +85,11 @@ export function AppChrome({ title, scanningLibrary = false, onScanLibrary, onRef const scanRunning = scanningLibrary || scan.running; useEffect(() => { void api<{ version: string }>("/api/version").then((result) => setVersion(result.version || "unknown")).catch(() => setVersion("unknown")); }, []); + const signOut = async () => { + try { await api("/api/auth/logout", { method: "POST" }); } catch { /* ignore network errors; we still drop the session locally */ } + window.dispatchEvent(new Event("easyx:unauthorized")); + }; + return