diff --git a/--list-only/the-chaebeols-youngest-son-chapter-193.cbz b/--list-only/the-chaebeols-youngest-son-chapter-193.cbz new file mode 100644 index 0000000..a0721c1 Binary files /dev/null and b/--list-only/the-chaebeols-youngest-son-chapter-193.cbz differ diff --git a/docker-compose.yml b/docker-compose.yml index 823c391..e9522a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,11 @@ services: # Each title is eligible again after this many hours (daily). WATCH_TITLE_INTERVAL_HOURS: "24" FLARESOLVERR_URL: http://flaresolverr:8191/v1 + # Shared login (optional). When AUTH_PASSWORD is set, the UI and /api require a session. + # AUTH_USERNAME: admin + # AUTH_PASSWORD: change-me + # SESSION_SECRET: long-random-string-at-least-32-chars + # SESSION_TTL_HOURS: "168" volumes: - ./data:/app/data depends_on: diff --git a/package-lock.json b/package-lock.json index 4fe2aaa..eeba3d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@fastify/cookie": "^11.1.2", "@fastify/static": "^10.1.2", "@picocss/pico": "^2.1.1", "adm-zip": "^0.5.17", @@ -327,6 +328,39 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", diff --git a/package.json b/package.json index ccde853..e76e51a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "author": "", "license": "ISC", "dependencies": { + "@fastify/cookie": "^11.1.2", "@fastify/static": "^10.1.2", "@picocss/pico": "^2.1.1", "adm-zip": "^0.5.17", diff --git a/src/server/auth.js b/src/server/auth.js new file mode 100644 index 0000000..1bd1950 --- /dev/null +++ b/src/server/auth.js @@ -0,0 +1,248 @@ +const crypto = require("node:crypto"); + +const COOKIE_NAME = "session"; +const PUBLIC_AUTH_PATHS = new Set([ + "GET /api/auth/me", + "POST /api/auth/login", + "POST /api/auth/logout", +]); + +/** + * @typedef {object} AuthConfig + * @property {boolean} enabled - Whether AUTH_PASSWORD is set. + * @property {string} username - Expected username (default admin). + * @property {string} password - Expected password when enabled. + * @property {string} secret - HMAC secret for session cookies. + * @property {number} ttlMs - Session lifetime in milliseconds. + */ + +/** + * Reads auth settings from the environment. + * @returns {AuthConfig} Parsed config (enabled=false when AUTH_PASSWORD is empty). + */ +function loadAuthConfig() { + const password = (process.env.AUTH_PASSWORD || "").trim(); + const enabled = password.length > 0; + const username = (process.env.AUTH_USERNAME || "admin").trim() || "admin"; + const secret = (process.env.SESSION_SECRET || "").trim(); + const ttlHoursRaw = Number(process.env.SESSION_TTL_HOURS); + const ttlHours = + Number.isFinite(ttlHoursRaw) && ttlHoursRaw > 0 ? ttlHoursRaw : 168; + + if (enabled && secret.length < 32) { + throw new Error( + "SESSION_SECRET is required when AUTH_PASSWORD is set (min 32 characters).", + ); + } + + return { + enabled, + username, + password, + secret, + ttlMs: Math.round(ttlHours * 60 * 60 * 1000), + }; +} + +/** + * @param {string} input - Submitted password. + * @param {string} expected - Configured password. + * @returns {boolean} Whether they match (timing-safe when lengths match). + */ +function verifyPassword(input, expected) { + const a = Buffer.from(String(input), "utf8"); + const b = Buffer.from(String(expected), "utf8"); + if (a.length !== b.length) { + // Dummy compare so length mismatch still spends hashing time. + crypto.timingSafeEqual(b, b); + return false; + } + return crypto.timingSafeEqual(a, b); +} + +/** + * @param {string} username - Session subject. + * @param {string} secret - HMAC secret. + * @param {number} expiresAt - Unix epoch milliseconds. + * @returns {string} Signed cookie value. + */ +function createSessionToken(username, secret, expiresAt) { + const payload = Buffer.from( + JSON.stringify({ u: username, exp: expiresAt }), + "utf8", + ).toString("base64url"); + const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url"); + return `${payload}.${sig}`; +} + +/** + * @param {string|undefined} token - Cookie value. + * @param {string} secret - HMAC secret. + * @returns {{ username: string, expiresAt: number }|null} Parsed session or null. + */ +function parseSessionToken(token, secret) { + if (typeof token !== "string" || token.length === 0) { + return null; + } + const dot = token.lastIndexOf("."); + if (dot <= 0) { + return null; + } + const payload = token.slice(0, dot); + const sig = token.slice(dot + 1); + const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url"); + const sigBuf = Buffer.from(sig); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) { + return null; + } + try { + const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + if ( + typeof data?.u !== "string" || + typeof data?.exp !== "number" || + !Number.isFinite(data.exp) + ) { + return null; + } + if (Date.now() >= data.exp) { + return null; + } + return { username: data.u, expiresAt: data.exp }; + } catch { + return null; + } +} + +/** + * @param {import("fastify").FastifyRequest} request - Incoming request. + * @param {AuthConfig} config - Auth config. + * @returns {{ username: string, expiresAt: number }|null} Session when valid. + */ +function sessionFromRequest(request, config) { + if (!config.enabled) { + return null; + } + const raw = request.cookies?.[COOKIE_NAME]; + return parseSessionToken(raw, config.secret); +} + +/** + * Cookie options for setting/clearing the session. + * @param {AuthConfig} config - Auth config. + * @param {number} [maxAgeMs] - Max-Age in ms (omit when clearing). + * @returns {import("@fastify/cookie").CookieSerializeOptions} Cookie options. + */ +function sessionCookieOptions(config, maxAgeMs) { + /** @type {import("@fastify/cookie").CookieSerializeOptions} */ + const opts = { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }; + if (typeof maxAgeMs === "number") { + opts.maxAge = Math.floor(maxAgeMs / 1000); + } + return opts; +} + +/** + * Registers auth routes and an onRequest guard for /api/*. + * @param {import("fastify").FastifyInstance} app - Fastify instance (cookie plugin already registered). + * @param {AuthConfig} config - From {@link loadAuthConfig}. + * @returns {void} + */ +function registerAuth(app, config) { + app.decorateRequest("authUser", null); + + app.get("/api/auth/me", async (request) => { + if (!config.enabled) { + return { authRequired: false, authenticated: true }; + } + const session = sessionFromRequest(request, config); + if (!session) { + return { authRequired: true, authenticated: false }; + } + return { + authRequired: true, + authenticated: true, + username: session.username, + }; + }); + + app.post("/api/auth/login", async (request, reply) => { + if (!config.enabled) { + return { ok: true, authRequired: false, authenticated: true }; + } + + const body = request.body || {}; + const username = + typeof body.username === "string" && body.username.trim().length > 0 + ? body.username.trim() + : config.username; + const password = typeof body.password === "string" ? body.password : ""; + + const userOk = + username.length === config.username.length && + crypto.timingSafeEqual( + Buffer.from(username, "utf8"), + Buffer.from(config.username, "utf8"), + ); + const passOk = verifyPassword(password, config.password); + + if (!userOk || !passOk) { + return reply.code(401).send({ error: "Invalid username or password" }); + } + + const expiresAt = Date.now() + config.ttlMs; + const token = createSessionToken(config.username, config.secret, expiresAt); + reply.setCookie(COOKIE_NAME, token, sessionCookieOptions(config, config.ttlMs)); + + return { + ok: true, + authRequired: true, + authenticated: true, + username: config.username, + }; + }); + + app.post("/api/auth/logout", async (_request, reply) => { + reply.clearCookie(COOKIE_NAME, sessionCookieOptions(config)); + return { ok: true, authenticated: false }; + }); + + app.addHook("onRequest", async (request, reply) => { + if (!config.enabled) { + return; + } + + const urlPath = request.url.split("?")[0]; + if (!urlPath.startsWith("/api/")) { + return; + } + + const key = `${request.method.toUpperCase()} ${urlPath}`; + if (PUBLIC_AUTH_PATHS.has(key)) { + return; + } + + const session = sessionFromRequest(request, config); + if (!session) { + return reply.code(401).send({ error: "Unauthorized" }); + } + + request.authUser = session.username; + }); +} + +module.exports = { + COOKIE_NAME, + createSessionToken, + loadAuthConfig, + parseSessionToken, + registerAuth, + sessionCookieOptions, + sessionFromRequest, + verifyPassword, +}; diff --git a/src/server/index.js b/src/server/index.js index 707d140..f716f86 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -3,9 +3,12 @@ const fs = require("fs"); const Fastify = require("fastify"); const fastifyStatic = require("@fastify/static"); +const fastifyCookie = require("@fastify/cookie"); + const { openDatabase } = require("./db.js"); const { DownloadQueue } = require("./queue.js"); const { startWatcher } = require("./watcher.js"); +const { loadAuthConfig, registerAuth } = require("./auth.js"); const routesPlugin = require("./routes.js"); const PORT = Number(process.env.PORT) || 3000; @@ -25,6 +28,7 @@ async function main() { const db = openDatabase(DATA_DIR); const queue = new DownloadQueue(db); + const authConfig = loadAuthConfig(); const app = Fastify({ logger: true }); // Allow POST/PATCH with Content-Type: application/json and an empty body @@ -44,6 +48,8 @@ async function main() { }, ); + await app.register(fastifyCookie); + registerAuth(app, authConfig); await app.register(routesPlugin, { db, queue, dataDir: DATA_DIR }); await app.register(fastifyStatic, { @@ -84,6 +90,11 @@ async function main() { app.log.info(`Data dir: ${DATA_DIR}`); app.log.info(`Watch cron: ${WATCH_CRON}`); app.log.info(`Watch title interval: ${String(WATCH_TITLE_INTERVAL_HOURS)}h (one title per tick)`); + app.log.info( + authConfig.enabled + ? `Auth: enabled (user=${authConfig.username})` + : "Auth: disabled (set AUTH_PASSWORD to enable)", + ); } main().catch((err) => { diff --git a/web/src/App.svelte b/web/src/App.svelte index 122b018..ff3343c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,6 +8,15 @@ let loadingChapters = $state({}); let expanded = $state({}); + let authReady = $state(false); + let authRequired = $state(false); + let authenticated = $state(false); + let authUsername = $state(""); + let loginUser = $state("admin"); + let loginPass = $state(""); + let loginError = $state(""); + let loginBusy = $state(false); + async function api(path, options = {}) { const method = (options.method || "GET").toUpperCase(); const headers = { ...(options.headers || {}) }; @@ -28,14 +37,66 @@ method, headers, body, + credentials: "include", }); const data = await res.json().catch(() => ({})); + if (res.status === 401 && authRequired) { + authenticated = false; + authUsername = ""; + } if (!res.ok) { throw new Error(data.error || data.message || res.statusText || "Request failed"); } return data; } + async function checkAuth() { + const data = await api("/api/auth/me"); + authRequired = Boolean(data.authRequired); + authenticated = Boolean(data.authenticated); + authUsername = typeof data.username === "string" ? data.username : ""; + authReady = true; + } + + async function login(event) { + event.preventDefault(); + loginError = ""; + loginBusy = true; + try { + const data = await api("/api/auth/login", { + method: "POST", + body: JSON.stringify({ + username: loginUser.trim(), + password: loginPass, + }), + }); + authRequired = Boolean(data.authRequired); + authenticated = Boolean(data.authenticated); + authUsername = typeof data.username === "string" ? data.username : loginUser.trim(); + loginPass = ""; + await refresh(); + } catch (err) { + loginError = err instanceof Error ? err.message : String(err); + authenticated = false; + } finally { + loginBusy = false; + } + } + + async function logout() { + error = ""; + try { + await api("/api/auth/logout", { method: "POST" }); + } catch { + /* still clear local session state */ + } + authenticated = false; + authUsername = ""; + titles = []; + jobs = []; + chaptersByTitle = {}; + } + async function refresh() { const [t, j] = await Promise.all([ api("/api/titles"), @@ -168,14 +229,31 @@ } $effect(() => { + void checkAuth().catch((err) => { + error = err instanceof Error ? err.message : String(err); + authReady = true; + }); + }); + + $effect(() => { + if (!authReady || (authRequired && !authenticated)) { + return; + } void refresh(); const id = setInterval(() => { void (async () => { - await refresh(); - for (const [titleId, isOpen] of Object.entries(expanded)) { - if (isOpen) { - await loadChapters(Number(titleId)); + if (authRequired && !authenticated) { + return; + } + try { + await refresh(); + for (const [titleId, isOpen] of Object.entries(expanded)) { + if (isOpen) { + await loadChapters(Number(titleId)); + } } + } catch { + /* 401 handled in api() */ } })(); }, 2000); @@ -184,195 +262,236 @@
-
+

Watch & Download

+ {#if authReady && authRequired && authenticated} +
+ {#if authUsername} + {authUsername} + {/if} + +
+ {/if}
- {#if error} - - {/if} - -
- - -
- -
-
-

Activity

- -
- {#if jobs.length === 0} -

No active jobs.

- {:else} -
    - {#each jobs as job (job.id)} -
  • -
    - {job.status} - — {job.message || job.type} - {#if job.title_name} - ({job.title_name}) - {/if} -
    - {#if job.chapter_id} - - {/if} -
  • - {/each} -
+ + {:else} + {#if error} + {/if} -
-
-

Titles

- {#if titles.length === 0} -

No titles yet. Add a series URL above.

- {:else} - {#each titles as title (title.id)} -
-
-
- {title.title} - {title.source} - - {title.downloaded} / {title.total} downloaded - {#if title.downloading > 0} - · {title.downloading} downloading +
+ + +
+ +
+
+

Activity

+ +
+ {#if jobs.length === 0} +

No active jobs.

+ {:else} +
    + {#each jobs as job (job.id)} +
  • +
    + {job.status} + — {job.message || job.type} + {#if job.title_name} + ({job.title_name}) {/if} - {#if title.failed > 0} - · {title.failed} failed - {/if} - -
    -

    - {title.url} - {#if title.last_checked_at} - · last checked {title.last_checked_at} +

+ {#if job.chapter_id} + {/if} - · watch {title.enabled ? "on" : "off"} -

-
- - - - -
-
+ + {/each} + + {/if} +
- {#if expanded[title.id]} - {#if loadingChapters[title.id] && !chaptersByTitle[title.id]} -

Loading chapters…

- {:else} -
diff --git a/web/src/app.css b/web/src/app.css index cb66f22..0070084 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -39,6 +39,40 @@ h1 { font-size: clamp(1.5rem, 5vw, 2rem); line-height: 1.2; overflow-wrap: anywhere; + margin-bottom: 0; +} + +.app-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1rem; + margin-bottom: 1.25rem; +} + +.header-auth { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.header-auth button { + width: auto; + margin-bottom: 0; +} + +form.login-form { + display: grid; + gap: 0.75rem; + max-width: 22rem; + margin-block: 1rem 2rem; +} + +form.login-form button { + width: 100%; + margin-bottom: 0; } .muted {