Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
# 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
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
23 changes: 23 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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=<openssl rand -hex 32>
#
# Local/LAN-only testing without a domain: replace the site block with
# :8443 { reverse_proxy open-easyx:3210 }
# and browse https://<host-ip>: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}
}
}
42 changes: 0 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
94 changes: 94 additions & 0 deletions server/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { admin_password_hash: initialHash };
return {
getSettings: () => store,
updateSettings: (values: Record<string, unknown>) => { 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<string, unknown>)["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);
});
});
Loading