179 lines
6.0 KiB
TypeScript
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();
|