Add authentication feature with session management
- Implemented authentication logic in src/server/auth.js, including session token creation and verification. - Integrated authentication routes for login, logout, and user session retrieval in the Fastify server. - Updated docker-compose.yml to include optional authentication environment variables. - Added @fastify/cookie dependency for cookie management in package.json and package-lock.json. - Enhanced the frontend with login form and session handling in App.svelte, including UI updates for authenticated states. - Styled authentication components in app.css for better user experience.
This commit is contained in:
Binary file not shown.
@@ -19,6 +19,11 @@ services:
|
|||||||
# Each title is eligible again after this many hours (daily).
|
# Each title is eligible again after this many hours (daily).
|
||||||
WATCH_TITLE_INTERVAL_HOURS: "24"
|
WATCH_TITLE_INTERVAL_HOURS: "24"
|
||||||
FLARESOLVERR_URL: http://flaresolverr:8191/v1
|
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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Generated
+34
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
"@fastify/static": "^10.1.2",
|
"@fastify/static": "^10.1.2",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
@@ -327,6 +328,39 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
"@fastify/static": "^10.1.2",
|
"@fastify/static": "^10.1.2",
|
||||||
"@picocss/pico": "^2.1.1",
|
"@picocss/pico": "^2.1.1",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -3,9 +3,12 @@ const fs = require("fs");
|
|||||||
const Fastify = require("fastify");
|
const Fastify = require("fastify");
|
||||||
const fastifyStatic = require("@fastify/static");
|
const fastifyStatic = require("@fastify/static");
|
||||||
|
|
||||||
|
const fastifyCookie = require("@fastify/cookie");
|
||||||
|
|
||||||
const { openDatabase } = require("./db.js");
|
const { openDatabase } = require("./db.js");
|
||||||
const { DownloadQueue } = require("./queue.js");
|
const { DownloadQueue } = require("./queue.js");
|
||||||
const { startWatcher } = require("./watcher.js");
|
const { startWatcher } = require("./watcher.js");
|
||||||
|
const { loadAuthConfig, registerAuth } = require("./auth.js");
|
||||||
const routesPlugin = require("./routes.js");
|
const routesPlugin = require("./routes.js");
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
@@ -25,6 +28,7 @@ async function main() {
|
|||||||
|
|
||||||
const db = openDatabase(DATA_DIR);
|
const db = openDatabase(DATA_DIR);
|
||||||
const queue = new DownloadQueue(db);
|
const queue = new DownloadQueue(db);
|
||||||
|
const authConfig = loadAuthConfig();
|
||||||
const app = Fastify({ logger: true });
|
const app = Fastify({ logger: true });
|
||||||
|
|
||||||
// Allow POST/PATCH with Content-Type: application/json and an empty body
|
// 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(routesPlugin, { db, queue, dataDir: DATA_DIR });
|
||||||
|
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
@@ -84,6 +90,11 @@ async function main() {
|
|||||||
app.log.info(`Data dir: ${DATA_DIR}`);
|
app.log.info(`Data dir: ${DATA_DIR}`);
|
||||||
app.log.info(`Watch cron: ${WATCH_CRON}`);
|
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(`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) => {
|
main().catch((err) => {
|
||||||
|
|||||||
+295
-176
@@ -8,6 +8,15 @@
|
|||||||
let loadingChapters = $state({});
|
let loadingChapters = $state({});
|
||||||
let expanded = $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 = {}) {
|
async function api(path, options = {}) {
|
||||||
const method = (options.method || "GET").toUpperCase();
|
const method = (options.method || "GET").toUpperCase();
|
||||||
const headers = { ...(options.headers || {}) };
|
const headers = { ...(options.headers || {}) };
|
||||||
@@ -28,14 +37,66 @@
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.status === 401 && authRequired) {
|
||||||
|
authenticated = false;
|
||||||
|
authUsername = "";
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.error || data.message || res.statusText || "Request failed");
|
throw new Error(data.error || data.message || res.statusText || "Request failed");
|
||||||
}
|
}
|
||||||
return data;
|
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() {
|
async function refresh() {
|
||||||
const [t, j] = await Promise.all([
|
const [t, j] = await Promise.all([
|
||||||
api("/api/titles"),
|
api("/api/titles"),
|
||||||
@@ -168,14 +229,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
void checkAuth().catch((err) => {
|
||||||
|
error = err instanceof Error ? err.message : String(err);
|
||||||
|
authReady = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!authReady || (authRequired && !authenticated)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void refresh();
|
void refresh();
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await refresh();
|
if (authRequired && !authenticated) {
|
||||||
for (const [titleId, isOpen] of Object.entries(expanded)) {
|
return;
|
||||||
if (isOpen) {
|
}
|
||||||
await loadChapters(Number(titleId));
|
try {
|
||||||
|
await refresh();
|
||||||
|
for (const [titleId, isOpen] of Object.entries(expanded)) {
|
||||||
|
if (isOpen) {
|
||||||
|
await loadChapters(Number(titleId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
/* 401 handled in api() */
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -184,195 +262,236 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="app-shell">
|
<main class="app-shell">
|
||||||
<header>
|
<header class="app-header">
|
||||||
<h1>Watch & Download</h1>
|
<h1>Watch & Download</h1>
|
||||||
|
{#if authReady && authRequired && authenticated}
|
||||||
|
<div class="header-auth">
|
||||||
|
{#if authUsername}
|
||||||
|
<span class="muted">{authUsername}</span>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="outline contrast" onclick={logout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if error}
|
{#if !authReady}
|
||||||
<p class="error-banner" role="alert">{error}</p>
|
<p class="muted">Loading…</p>
|
||||||
{/if}
|
{:else if authRequired && !authenticated}
|
||||||
|
{#if loginError}
|
||||||
<form class="add-title" onsubmit={addTitle}>
|
<p class="error-banner" role="alert">{loginError}</p>
|
||||||
<label>
|
{/if}
|
||||||
Series URL
|
<form class="login-form" onsubmit={login}>
|
||||||
<input
|
<label>
|
||||||
type="url"
|
Username
|
||||||
bind:value={url}
|
<input
|
||||||
placeholder=""
|
type="text"
|
||||||
required
|
bind:value={loginUser}
|
||||||
disabled={busy}
|
autocomplete="username"
|
||||||
/>
|
required
|
||||||
</label>
|
disabled={loginBusy}
|
||||||
<button type="submit" disabled={busy || !url.trim()}>
|
/>
|
||||||
{busy ? "Working…" : "Add & watch"}
|
</label>
|
||||||
</button>
|
<label>
|
||||||
</form>
|
Password
|
||||||
|
<input
|
||||||
<section class="jobs-strip" aria-live="polite">
|
type="password"
|
||||||
<div class="section-heading">
|
bind:value={loginPass}
|
||||||
<h2>Activity</h2>
|
autocomplete="current-password"
|
||||||
<button
|
required
|
||||||
type="button"
|
disabled={loginBusy}
|
||||||
class="outline contrast"
|
/>
|
||||||
disabled={jobs.length === 0}
|
</label>
|
||||||
onclick={clearQueue}
|
<button type="submit" disabled={loginBusy || !loginPass}>
|
||||||
>
|
{loginBusy ? "Signing in…" : "Sign in"}
|
||||||
Clear queue
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
{#if jobs.length === 0}
|
{:else}
|
||||||
<p class="muted">No active jobs.</p>
|
{#if error}
|
||||||
{:else}
|
<p class="error-banner" role="alert">{error}</p>
|
||||||
<ul class="job-list">
|
|
||||||
{#each jobs as job (job.id)}
|
|
||||||
<li class="job-row">
|
|
||||||
<div class="job-info">
|
|
||||||
<strong>{job.status}</strong>
|
|
||||||
— {job.message || job.type}
|
|
||||||
{#if job.title_name}
|
|
||||||
<span class="muted">({job.title_name})</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if job.chapter_id}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="outline contrast job-cancel"
|
|
||||||
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<form class="add-title" onsubmit={addTitle}>
|
||||||
<h2>Titles</h2>
|
<label>
|
||||||
{#if titles.length === 0}
|
Series URL
|
||||||
<p class="muted">No titles yet. Add a series URL above.</p>
|
<input
|
||||||
{:else}
|
type="url"
|
||||||
{#each titles as title (title.id)}
|
bind:value={url}
|
||||||
<article>
|
placeholder=""
|
||||||
<header>
|
required
|
||||||
<div class="title-meta">
|
disabled={busy}
|
||||||
<strong>{title.title}</strong>
|
/>
|
||||||
<span class="muted">{title.source}</span>
|
</label>
|
||||||
<span class="progress-text">
|
<button type="submit" disabled={busy || !url.trim()}>
|
||||||
{title.downloaded} / {title.total} downloaded
|
{busy ? "Working…" : "Add & watch"}
|
||||||
{#if title.downloading > 0}
|
</button>
|
||||||
· {title.downloading} downloading
|
</form>
|
||||||
|
|
||||||
|
<section class="jobs-strip" aria-live="polite">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Activity</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outline contrast"
|
||||||
|
disabled={jobs.length === 0}
|
||||||
|
onclick={clearQueue}
|
||||||
|
>
|
||||||
|
Clear queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if jobs.length === 0}
|
||||||
|
<p class="muted">No active jobs.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="job-list">
|
||||||
|
{#each jobs as job (job.id)}
|
||||||
|
<li class="job-row">
|
||||||
|
<div class="job-info">
|
||||||
|
<strong>{job.status}</strong>
|
||||||
|
— {job.message || job.type}
|
||||||
|
{#if job.title_name}
|
||||||
|
<span class="muted">({job.title_name})</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if title.failed > 0}
|
</div>
|
||||||
· {title.failed} failed
|
{#if job.chapter_id}
|
||||||
{/if}
|
<button
|
||||||
</span>
|
type="button"
|
||||||
</div>
|
class="outline contrast job-cancel"
|
||||||
<p class="muted url-line">
|
onclick={() => cancelChapter(job.chapter_id, job.title_id)}
|
||||||
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
>
|
||||||
{#if title.last_checked_at}
|
Cancel
|
||||||
· last checked {title.last_checked_at}
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
· watch {title.enabled ? "on" : "off"}
|
</li>
|
||||||
</p>
|
{/each}
|
||||||
<div class="row-actions">
|
</ul>
|
||||||
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
|
{/if}
|
||||||
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
|
</section>
|
||||||
</button>
|
|
||||||
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
|
|
||||||
Sync now
|
|
||||||
</button>
|
|
||||||
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
|
|
||||||
{title.enabled ? "Disable watch" : "Enable watch"}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if expanded[title.id]}
|
<section>
|
||||||
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
|
<h2>Titles</h2>
|
||||||
<p class="muted">Loading chapters…</p>
|
{#if titles.length === 0}
|
||||||
{:else}
|
<p class="muted">No titles yet. Add a series URL above.</p>
|
||||||
<ul class="chapter-list">
|
{:else}
|
||||||
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
{#each titles as title (title.id)}
|
||||||
<li class="chapter-card">
|
<article>
|
||||||
<div class="chapter-label">{ch.label}</div>
|
<header>
|
||||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
<div class="title-meta">
|
||||||
{#if ch.error}
|
<strong>{title.title}</strong>
|
||||||
<div class="muted chapter-error">{ch.error}</div>
|
<span class="muted">{title.source}</span>
|
||||||
{/if}
|
<span class="progress-text">
|
||||||
<div class="chapter-actions">
|
{title.downloaded} / {title.total} downloaded
|
||||||
{#if ch.status === "downloading"}
|
{#if title.downloading > 0}
|
||||||
|
· {title.downloading} downloading
|
||||||
|
{/if}
|
||||||
|
{#if title.failed > 0}
|
||||||
|
· {title.failed} failed
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted url-line">
|
||||||
|
<a href={title.url} target="_blank" rel="noreferrer">{title.url}</a>
|
||||||
|
{#if title.last_checked_at}
|
||||||
|
· last checked {title.last_checked_at}
|
||||||
|
{/if}
|
||||||
|
· watch {title.enabled ? "on" : "off"}
|
||||||
|
</p>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="outline" onclick={() => toggleExpand(title.id)}>
|
||||||
|
{expanded[title.id] ? "Hide chapters" : "Show chapters"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline" disabled={busy} onclick={() => syncTitle(title.id)}>
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline secondary" onclick={() => toggleEnabled(title)}>
|
||||||
|
{title.enabled ? "Disable watch" : "Enable watch"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="outline contrast" onclick={() => removeTitle(title.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if expanded[title.id]}
|
||||||
|
{#if loadingChapters[title.id] && !chaptersByTitle[title.id]}
|
||||||
|
<p class="muted">Loading chapters…</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="chapter-list">
|
||||||
|
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
||||||
|
<li class="chapter-card">
|
||||||
|
<div class="chapter-label">{ch.label}</div>
|
||||||
|
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||||
|
{#if ch.error}
|
||||||
|
<div class="muted chapter-error">{ch.error}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="chapter-actions">
|
||||||
|
{#if ch.status === "downloading"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outline contrast"
|
||||||
|
onclick={() => cancelChapter(ch.id, title.id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="outline contrast"
|
class="outline"
|
||||||
onclick={() => cancelChapter(ch.id, title.id)}
|
onclick={() => redownload(ch.id, title.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
Redownload
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
<button
|
</li>
|
||||||
type="button"
|
{/each}
|
||||||
class="outline"
|
</ul>
|
||||||
onclick={() => redownload(ch.id, title.id)}
|
<div class="table-wrap">
|
||||||
>
|
<table>
|
||||||
Redownload
|
<thead>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Chapter</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ch.label}</td>
|
<th>Chapter</th>
|
||||||
<td>
|
<th>Status</th>
|
||||||
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
<th></th>
|
||||||
{#if ch.error}
|
</tr>
|
||||||
<div class="muted">{ch.error}</div>
|
</thead>
|
||||||
{/if}
|
<tbody>
|
||||||
</td>
|
{#each chaptersByTitle[title.id] || [] as ch (ch.id)}
|
||||||
<td>
|
<tr>
|
||||||
<div class="chapter-actions">
|
<td>{ch.label}</td>
|
||||||
{#if ch.status === "downloading"}
|
<td>
|
||||||
|
<span class={`status status-${ch.status}`}>{ch.status}</span>
|
||||||
|
{#if ch.error}
|
||||||
|
<div class="muted">{ch.error}</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="chapter-actions">
|
||||||
|
{#if ch.status === "downloading"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outline contrast"
|
||||||
|
onclick={() => cancelChapter(ch.id, title.id)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="outline contrast"
|
class="outline"
|
||||||
onclick={() => cancelChapter(ch.id, title.id)}
|
onclick={() => redownload(ch.id, title.id)}
|
||||||
>
|
>
|
||||||
Cancel
|
Redownload
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
<button
|
</td>
|
||||||
type="button"
|
</tr>
|
||||||
class="outline"
|
{/each}
|
||||||
onclick={() => redownload(ch.id, title.id)}
|
</tbody>
|
||||||
>
|
</table>
|
||||||
Redownload
|
</div>
|
||||||
</button>
|
{/if}
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</article>
|
||||||
</article>
|
{/each}
|
||||||
{/each}
|
{/if}
|
||||||
{/if}
|
</section>
|
||||||
</section>
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -39,6 +39,40 @@ h1 {
|
|||||||
font-size: clamp(1.5rem, 5vw, 2rem);
|
font-size: clamp(1.5rem, 5vw, 2rem);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.muted {
|
||||||
|
|||||||
Reference in New Issue
Block a user