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>
This commit is contained in:
178
server/src/index.ts
Normal file
178
server/src/index.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user