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 @@
-
-
-
-
-
-
-
-
+
+ {/each}
+
+ {/if}
+
- {#if expanded[title.id]}
- {#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
- Loading chapters…
- {:else}
-
- {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
- -
-
{ch.label}
- {ch.status}
- {#if ch.error}
- {ch.error}
- {/if}
-
- {#if ch.status === "downloading"}
+
+ 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
+ {/if}
+ {#if title.failed > 0}
+ · {title.failed} failed
+ {/if}
+
+
+
+ {title.url}
+ {#if title.last_checked_at}
+ · last checked {title.last_checked_at}
+ {/if}
+ · watch {title.enabled ? "on" : "off"}
+
+
+
+
+
+
+
+
+
+ {#if expanded[title.id]}
+ {#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
+ Loading chapters…
+ {:else}
+
+ {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
+ -
+
{ch.label}
+ {ch.status}
+ {#if ch.error}
+ {ch.error}
+ {/if}
+
+ {#if ch.status === "downloading"}
+
+ {/if}
- {/if}
-
-
-
- {/each}
-
-
-
-
-
- | Chapter |
- Status |
- |
-
-
-
- {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
+
+
+ {/each}
+
+
+
+
- | {ch.label} |
-
- {ch.status}
- {#if ch.error}
- {ch.error}
- {/if}
- |
-
-
- {#if ch.status === "downloading"}
+ | Chapter |
+ Status |
+ |
+
+
+
+ {#each chaptersByTitle[title.id] || [] as ch (ch.id)}
+
+ | {ch.label} |
+
+ {ch.status}
+ {#if ch.error}
+ {ch.error}
+ {/if}
+ |
+
+
+ {#if ch.status === "downloading"}
+
+ {/if}
- {/if}
-
-
- |
-
- {/each}
-
-
-
+
+
+
+ {/each}
+
+
+
+ {/if}
{/if}
- {/if}
-
- {/each}
- {/if}
-
+
+ {/each}
+ {/if}
+
+ {/if}
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 {