import cookieSession from "cookie-session"; import express, { type NextFunction, type Request, type Response } from "express"; import { loadConfig } from "./config.ts"; import { beginAuth, completeAuth, isAuthorised } from "./oidc.ts"; import { deniedPage, pairingPage, qrPayload } from "./pages.ts"; import { TokenStore } from "./tokens.ts"; import { UploadDir, isValidObjectName } from "./upload.ts"; const config = loadConfig(); const tokens = new TokenStore(config.stateDir, config.pairingCodeTtlSeconds * 1000); const uploads = new UploadDir(config.downloadsDir, config.maxUploadBytes); const app = express(); app.disable("x-powered-by"); // TLS terminates at the Cilium ingress; without this `secure` cookies die. app.set("trust proxy", 1); app.use( cookieSession({ name: "puploader", secret: config.cookieSecret, httpOnly: true, sameSite: "lax", secure: true, maxAge: 8 * 60 * 60 * 1000, }), ); app.get("/healthz", (_req, res) => { res.type("text/plain").send("ok"); }); /** Browser SSO gate: redirects to authentik when there is no session. */ function requireBrowserSso(req: Request, res: Response, next: NextFunction): void { const session = req.session as CookieSessionInterfaces.CookieSessionObject; if (typeof session.username === "string") { next(); return; } beginAuth(config) .then((auth) => { session.oidcState = auth.state; session.oidcVerifier = auth.codeVerifier; session.returnTo = req.path; res.redirect(auth.url); }) .catch(next); } app.get("/oidc/callback", (req, res, next) => { const session = req.session as CookieSessionInterfaces.CookieSessionObject; const state = session.oidcState; const codeVerifier = session.oidcVerifier; if (typeof state !== "string" || typeof codeVerifier !== "string") { res.status(400).type("text/plain").send("no login in progress"); return; } completeAuth(config, req, { state, codeVerifier }) .then((identity) => { delete session.oidcState; delete session.oidcVerifier; if (!isAuthorised(config, identity)) { res.status(403).type("html").send(deniedPage(identity.username, config.oidc.requiredGroup)); return; } session.username = identity.username; const returnTo = typeof session.returnTo === "string" ? session.returnTo : "/"; delete session.returnTo; res.redirect(returnTo); }) .catch(next); }); app.get("/", requireBrowserSso, (req, res, next) => { const session = req.session as CookieSessionInterfaces.CookieSessionObject; const username = session.username as string; const pairing = tokens.issuePairingCode(username); pairingPage(username, qrPayload(config.publicOrigin, pairing.code), config.pairingCodeTtlSeconds) .then((html) => res.type("html").send(html)) .catch(next); }); /** * App-initiated SSO: the app opens this in a Custom Tab; after the same * browser login the pairing code goes back through the deep link. The app * never sees OIDC — the phone's browser does, exactly like the QR path. */ app.get("/pair/app", requireBrowserSso, (req, res) => { const session = req.session as CookieSessionInterfaces.CookieSessionObject; const pairing = tokens.issuePairingCode(session.username as string); res.redirect(`${config.appScheme}://pair?c=${pairing.code}`); }); app.post("/api/pair", express.json({ limit: "4kb" }), (req, res) => { const code = (req.body as { code?: unknown })?.code; if (typeof code !== "string" || !/^[0-9a-f]{64}$/.test(code)) { res.status(400).json({ error: "malformed pairing code" }); return; } const redeemed = tokens.redeemPairingCode(code); if (!redeemed) { res.status(410).json({ error: "pairing code expired or already used" }); return; } console.log(`paired device #${tokens.deviceCount} for ${redeemed.username}`); res.json({ token: redeemed.token, name: redeemed.username }); }); /** Bearer gate for the upload API. */ function requireDeviceToken(req: Request, res: Response, next: NextFunction): void { const header = req.headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice(7) : undefined; const username = token ? tokens.authenticate(token) : undefined; if (!username) { res.status(401).json({ error: "invalid device token" }); return; } res.locals.username = username; next(); } app.head("/api/object/:name", requireDeviceToken, (req, res) => { const name = req.params.name; if (typeof name !== "string" || !isValidObjectName(name)) { res.status(400).end(); return; } uploads .exists(name) .then((exists) => res.status(exists ? 204 : 404).end()) .catch(() => res.status(500).end()); }); app.put("/api/upload", requireDeviceToken, (req, res, next) => { const name = req.query.name; if (typeof name !== "string" || !isValidObjectName(name)) { res.status(400).json({ error: "name must be ." }); return; } uploads .receive(name, req) .then((outcome) => { switch (outcome.status) { case "created": console.log(`stored ${name} for ${res.locals.username}`); res.status(201).json({ stored: name }); return; case "exists": res.status(200).json({ stored: name, existed: true }); return; case "hash-mismatch": res.status(409).json({ error: "content hash mismatch", actual: outcome.actual }); return; case "too-large": res.status(413).json({ error: "upload exceeds the configured maximum" }); return; } }) .catch(next); }); // Uniform error surface; the details go to the log, not the client. app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); if (!res.headersSent) { res.status(500).json({ error: "internal error" }); } }); async function main(): Promise { await uploads.init(); app.listen(config.port, () => { console.log(`internaluploader-server listening on :${config.port}`); }); } void main();