Files
internaluploader/server/src/index.ts
michal d8a606ce46 internaluploader: server + docs
One-way upload server: authentik SSO pairing (QR + app-initiated deep
link), hashed device tokens, streamed sha256-verified uploads into the
metube directory as <sha256>.<ext>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-28 23:18:15 +01:00

179 lines
6.0 KiB
TypeScript

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 <sha256 hex>.<extension>" });
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<void> {
await uploads.init();
app.listen(config.port, () => {
console.log(`internaluploader-server listening on :${config.port}`);
});
}
void main();