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:
2026-09-28 23:17:25 +01:00
committed by Michal
commit d8a606ce46
47 changed files with 4196 additions and 0 deletions

37
server/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# internaluploader-server. Build context is server/.
#
# Registries are fully qualified: podman enforces short-name resolution and
# fails a non-interactive build with "cannot prompt without a TTY" rather than
# choosing one.
FROM docker.io/library/node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.test.json ./
COPY src ./src
COPY test ./test
# Typecheck and test INSIDE the image build — an image that compiles but fails
# its own tests should not reach a registry.
RUN npm run typecheck
RUN npm test
RUN npm run build
# Drop devDependencies from what gets copied forward.
RUN npm prune --omit=dev
FROM docker.io/library/node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /src/node_modules ./node_modules
COPY --from=build /src/dist ./dist
COPY --from=build /src/package.json ./package.json
# uid 1000 — the same uid/gid the metube container writes /downloads with, so
# both apps' files on the share look identical.
USER node
EXPOSE 8080
CMD ["node", "dist/index.js"]

14
server/build.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Build and push the server image. Tag = <date>-<git sha>, matching the
# convention of the other mysources.co.uk images (e.g. authentik-qr-login).
set -euo pipefail
cd "$(dirname "$0")"
REGISTRY="mysources.co.uk/michal"
NAME="internaluploader-server"
TAG="$(date +%Y%m%d)-$(git rev-parse --short=12 HEAD)"
IMAGE="${REGISTRY}/${NAME}:${TAG}"
podman build -t "${IMAGE}" .
podman push "${IMAGE}"
echo "pushed ${IMAGE}"

1569
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "internaluploader-server",
"version": "1.0.0",
"private": true,
"description": "One-way upload endpoint for the InternalUploader Android app: SSO-paired devices drop files into the metube directory as <sha256>.<ext>. No listing, no download.",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "node --test --experimental-strip-types test/*.test.ts",
"start": "node dist/index.js"
},
"engines": {
"node": "22.x"
},
"dependencies": {
"cookie-session": "^2.1.0",
"express": "^5.1.0",
"openid-client": "^5.6.5",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/cookie-session": "^2.0.49",
"@types/express": "^5.0.6",
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.5",
"typescript": "^5.9.3"
}
}

50
server/src/config.ts Normal file
View File

@@ -0,0 +1,50 @@
/** All configuration is environment variables; the deployment cites each name. */
export interface Config {
port: number;
/** e.g. https://puploader.ad.itaz.eu — used for the OIDC redirect and QR payload. */
publicOrigin: string;
oidc: {
issuer: string;
clientId: string;
clientSecret: string;
requiredGroup: string;
};
cookieSecret: string;
/** Where finished files land — the metube download directory bind mount. */
downloadsDir: string;
/** Persistent state (device token store). A docker named volume. */
stateDir: string;
maxUploadBytes: number;
/** Deep-link scheme the Android app registers, for /pair/app. */
appScheme: string;
pairingCodeTtlSeconds: number;
}
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable ${name}`);
}
return value;
}
export function loadConfig(): Config {
return {
port: Number(process.env.PU_PORT ?? 8080),
publicOrigin: required("PU_PUBLIC_ORIGIN").replace(/\/$/, ""),
oidc: {
issuer: required("PU_OIDC_ISSUER"),
clientId: required("PU_OIDC_CLIENT_ID"),
clientSecret: required("PU_OIDC_CLIENT_SECRET"),
requiredGroup: required("PU_REQUIRED_GROUP"),
},
cookieSecret: required("PU_COOKIE_SECRET"),
downloadsDir: process.env.PU_DOWNLOADS_DIR ?? "/downloads",
stateDir: process.env.PU_STATE_DIR ?? "/state",
// 32 GiB default: large enough for any single video, small enough that a
// runaway client cannot fill the volume in one request unnoticed.
maxUploadBytes: Number(process.env.PU_MAX_UPLOAD_BYTES ?? 32 * 1024 ** 3),
appScheme: process.env.PU_APP_SCHEME ?? "internaluploader",
pairingCodeTtlSeconds: Number(process.env.PU_PAIRING_CODE_TTL_SECONDS ?? 300),
};
}

178
server/src/index.ts Normal file
View 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();

106
server/src/oidc.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { IncomingMessage } from "node:http";
import { Issuer, generators, type Client } from "openid-client";
import type { Config } from "./config.ts";
let issuerPromise: Promise<Issuer<Client>> | undefined;
/** Discovery, memoised — but NOT on failure, or one early miss poisons the pod. */
export async function getIssuer(config: Config): Promise<Issuer<Client>> {
if (!issuerPromise) {
issuerPromise = Issuer.discover(config.oidc.issuer).catch((error: unknown) => {
issuerPromise = undefined;
throw error;
});
}
return issuerPromise;
}
export function redirectUri(config: Config): string {
return `${config.publicOrigin}/oidc/callback`;
}
async function clientFor(config: Config): Promise<Client> {
const issuer = await getIssuer(config);
return new issuer.Client({
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
redirect_uris: [redirectUri(config)],
response_types: ["code"],
});
}
export interface AuthRequest {
url: string;
state: string;
codeVerifier: string;
}
export async function beginAuth(config: Config): Promise<AuthRequest> {
const client = await clientFor(config);
const state = generators.state();
const codeVerifier = generators.codeVerifier();
return {
// No `prompt: login` here, deliberately — unlike qr-login, an existing
// authentik session is exactly what we want to reuse: pairing is an
// authorization act, not a step-up authentication.
url: client.authorizationUrl({
scope: "openid email profile",
state,
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: "S256",
}),
state,
codeVerifier,
};
}
export interface Identity {
username: string;
groups: string[];
}
export async function completeAuth(
config: Config,
request: IncomingMessage,
expected: { state: string; codeVerifier: string },
): Promise<Identity> {
const client = await clientFor(config);
const uri = redirectUri(config);
const tokenSet = await client.callback(uri, client.callbackParams(request), {
state: expected.state,
code_verifier: expected.codeVerifier,
});
const claims = tokenSet.claims();
const groups = Array.isArray(claims.groups)
? claims.groups.filter((g): g is string => typeof g === "string")
: undefined;
// Fail closed, and say which failure it is. An absent claim means the
// provider is missing its scope mapping; treating that as "no groups" would
// lock everyone out silently, and as "allow" would let everyone in silently.
if (groups === undefined) {
throw new Error(
"ID token carries no `groups` claim — the authentik provider is missing " +
"the profile scope mapping, so membership cannot be checked",
);
}
const username =
typeof claims.preferred_username === "string" ? claims.preferred_username : undefined;
if (!username) {
throw new Error(
"ID token carries no `preferred_username` claim — uploads could not be " +
"attributed to a user",
);
}
return { username, groups };
}
export function isAuthorised(config: Config, identity: Identity): boolean {
return identity.groups.includes(config.oidc.requiredGroup);
}

73
server/src/pages.ts Normal file
View File

@@ -0,0 +1,73 @@
import { toDataURL } from "qrcode";
export interface QrPayload {
v: 1;
u: string;
c: string;
}
export function qrPayload(publicOrigin: string, code: string): string {
const payload: QrPayload = { v: 1, u: publicOrigin, c: code };
return JSON.stringify(payload);
}
/**
* The whole UI: one page with a QR code. Deliberately says nothing about
* where files go or how to reach the server — the QR carries everything the
* app needs, and this page is already behind SSO.
*/
export async function pairingPage(
username: string,
payload: string,
ttlSeconds: number,
): Promise<string> {
const qrDataUri = await toDataURL(payload, { margin: 1, width: 320 });
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>InternalUploader — pair a device</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: system-ui, sans-serif;
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 100vh; margin: 0; gap: 1rem;
background: Canvas; color: CanvasText; text-align: center; padding: 0 1rem;
}
img { border-radius: 8px; background: white; padding: 8px; }
small { opacity: .6 }
a.button {
display: inline-block; padding: .5rem 1rem; border-radius: 6px;
border: 1px solid currentColor; text-decoration: none; color: inherit;
}
</style>
</head>
<body>
<h1>Pair a device</h1>
<p>Signed in as <strong>${escapeHtml(username)}</strong>. Scan with the InternalUploader app.</p>
<img src="${qrDataUri}" alt="pairing QR code" width="320" height="320">
<small>Code is single-use and expires in ${Math.round(ttlSeconds / 60)} minutes.</small>
<a class="button" href="/">New code</a>
</body>
</html>`;
}
export function deniedPage(username: string, group: string): string {
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Not authorised</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; margin-top: 4rem">
<h1>Not authorised</h1>
<p><strong>${escapeHtml(username)}</strong> is not in the <strong>${escapeHtml(group)}</strong> group.</p>
</body></html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

126
server/src/tokens.ts Normal file
View File

@@ -0,0 +1,126 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/**
* Pairing codes and device tokens.
*
* Pairing codes are IN MEMORY on purpose: they live five minutes and are spent
* once, so surviving a restart buys nothing — and a restart invalidating any
* outstanding QR is the safe direction.
*
* Device tokens persist, HASHED. The stored file never contains anything a
* reader could replay; the plaintext token exists only in the pairing response
* and on the phone.
*/
export interface PairingCode {
code: string;
username: string;
expiresAt: number;
}
export interface DeviceRecord {
/** sha256 hex of the bearer token. */
tokenHash: string;
username: string;
createdAt: string;
/** Coarse (per-day) so routine uploads do not rewrite the file constantly. */
lastUsedDay: string;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export class TokenStore {
private readonly file: string;
private readonly tmp: string;
private readonly pairingTtlMs: number;
private devices: DeviceRecord[] = [];
private pending = new Map<string, PairingCode>();
// No parameter properties: `node --test` runs this file under strip-only
// type stripping, which rejects them.
constructor(stateDir: string, pairingTtlMs: number) {
this.pairingTtlMs = pairingTtlMs;
mkdirSync(stateDir, { recursive: true });
this.file = join(stateDir, "devices.json");
this.tmp = join(stateDir, "devices.json.tmp");
try {
this.devices = JSON.parse(readFileSync(this.file, "utf8")) as DeviceRecord[];
} catch {
this.devices = [];
}
}
private persist(): void {
// Write-then-rename so a crash mid-write cannot truncate the live store.
writeFileSync(this.tmp, JSON.stringify(this.devices, null, 2));
renameSync(this.tmp, this.file);
}
/** Mint a single-use pairing code for a browser-authenticated user. */
issuePairingCode(username: string): PairingCode {
this.sweep();
const entry: PairingCode = {
code: randomBytes(32).toString("hex"),
username,
expiresAt: Date.now() + this.pairingTtlMs,
};
this.pending.set(entry.code, entry);
return entry;
}
/** Exchange a pairing code for a device token. Single use, TTL enforced. */
redeemPairingCode(code: string): { token: string; username: string } | undefined {
this.sweep();
const entry = this.pending.get(code);
if (!entry) {
return undefined;
}
this.pending.delete(entry.code);
const token = randomBytes(32).toString("base64url");
this.devices.push({
tokenHash: sha256Hex(token),
username: entry.username,
createdAt: new Date().toISOString(),
lastUsedDay: today(),
});
this.persist();
return { token, username: entry.username };
}
/** Bearer-token check. Returns the owning username, or undefined. */
authenticate(token: string): string | undefined {
const hash = Buffer.from(sha256Hex(token), "hex");
for (const device of this.devices) {
const stored = Buffer.from(device.tokenHash, "hex");
if (stored.length === hash.length && timingSafeEqual(stored, hash)) {
if (device.lastUsedDay !== today()) {
device.lastUsedDay = today();
this.persist();
}
return device.username;
}
}
return undefined;
}
get deviceCount(): number {
return this.devices.length;
}
private sweep(): void {
const now = Date.now();
for (const [code, entry] of this.pending) {
if (entry.expiresAt <= now) {
this.pending.delete(code);
}
}
}
}

118
server/src/upload.ts Normal file
View File

@@ -0,0 +1,118 @@
import { createHash, randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, rename, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import type { Readable } from "node:stream";
/**
* `<64 hex sha256>` plus an optional short, lowercase, alphanumeric extension.
* Everything else is rejected before any filesystem call — the name is the only
* client-controlled thing that ever reaches a path.
*/
const NAME_PATTERN = /^[0-9a-f]{64}(\.[a-z0-9]{1,16})?$/;
export function isValidObjectName(name: string): boolean {
return NAME_PATTERN.test(name);
}
export function hashOf(name: string): string {
return name.slice(0, 64);
}
export type UploadOutcome =
| { status: "created" }
| { status: "exists" }
| { status: "hash-mismatch"; actual: string }
| { status: "too-large" };
export class UploadDir {
private readonly downloadsDir: string;
private readonly maxBytes: number;
private readonly tmpDir: string;
// No parameter properties: `node --test` runs this file under strip-only
// type stripping, which rejects them.
constructor(downloadsDir: string, maxBytes: number) {
this.downloadsDir = downloadsDir;
this.maxBytes = maxBytes;
// Same filesystem as the destination, or the final rename stops being
// atomic and a half-written file could be seen with its final name.
this.tmpDir = join(downloadsDir, ".internaluploader-tmp");
}
async init(): Promise<void> {
await mkdir(this.tmpDir, { recursive: true });
}
async exists(name: string): Promise<boolean> {
try {
await stat(join(this.downloadsDir, name));
return true;
} catch {
return false;
}
}
/**
* Stream a request body to disk, verify its sha256 matches the declared
* name, and only then rename it into the downloads directory. Idempotent:
* a name that already exists is reported without touching anything.
*/
async receive(name: string, body: Readable): Promise<UploadOutcome> {
if (await this.exists(name)) {
return { status: "exists" };
}
const tmpPath = join(this.tmpDir, randomUUID());
const hash = createHash("sha256");
let received = 0;
try {
await new Promise<void>((resolve, reject) => {
const out = createWriteStream(tmpPath, { flags: "wx", mode: 0o644 });
body.on("data", (chunk: Buffer) => {
received += chunk.length;
if (received > this.maxBytes) {
body.destroy();
out.destroy();
reject(new TooLargeError());
return;
}
hash.update(chunk);
});
body.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
body.pipe(out);
});
const actual = hash.digest("hex");
if (actual !== hashOf(name)) {
await unlink(tmpPath);
return { status: "hash-mismatch", actual };
}
// Lost race with a concurrent identical upload: same content, so the
// file already on disk is exactly what this request carried.
if (await this.exists(name)) {
await unlink(tmpPath);
return { status: "exists" };
}
await rename(tmpPath, join(this.downloadsDir, name));
return { status: "created" };
} catch (error) {
await unlink(tmpPath).catch(() => {});
if (error instanceof TooLargeError) {
return { status: "too-large" };
}
throw error;
}
}
}
class TooLargeError extends Error {
constructor() {
super("upload exceeds the configured maximum");
}
}

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { TokenStore } from "../src/tokens.ts";
test("pairing code round-trip mints a working device token", async () => {
const dir = mkdtempSync(join(tmpdir(), "tokens-"));
const store = new TokenStore(dir, 60_000);
const pairing = store.issuePairingCode("michal");
assert.match(pairing.code, /^[0-9a-f]{64}$/);
const redeemed = store.redeemPairingCode(pairing.code);
assert.ok(redeemed);
assert.equal(redeemed.username, "michal");
// Single use.
assert.equal(store.redeemPairingCode(pairing.code), undefined);
// The token authenticates, wrong tokens do not.
assert.equal(store.authenticate(redeemed.token), "michal");
assert.equal(store.authenticate("not-a-token"), undefined);
// Nothing on disk contains the plaintext token.
const persisted = await readFile(join(dir, "devices.json"), "utf8");
assert.ok(!persisted.includes(redeemed.token));
// A fresh store reads the same devices back.
const reloaded = new TokenStore(dir, 60_000);
assert.equal(reloaded.authenticate(redeemed.token), "michal");
});
test("expired pairing codes are refused", () => {
const dir = mkdtempSync(join(tmpdir(), "tokens-"));
const store = new TokenStore(dir, -1); // already expired at issue time
const pairing = store.issuePairingCode("michal");
assert.equal(store.redeemPairingCode(pairing.code), undefined);
});

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { createHash, randomBytes } from "node:crypto";
import { mkdtempSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { test } from "node:test";
import { UploadDir, hashOf, isValidObjectName } from "../src/upload.ts";
test("object name validation", () => {
const hash = "a".repeat(64);
assert.ok(isValidObjectName(hash));
assert.ok(isValidObjectName(`${hash}.mp4`));
assert.ok(isValidObjectName(`${hash}.webm`));
assert.ok(!isValidObjectName(`${hash}.`));
assert.ok(!isValidObjectName(`${hash}.MP4`), "uppercase extension");
assert.ok(!isValidObjectName(`${hash}.m p4`), "space in extension");
assert.ok(!isValidObjectName(`../../etc/passwd`), "traversal");
assert.ok(!isValidObjectName(`${"a".repeat(63)}.mp4`), "short hash");
assert.ok(!isValidObjectName(`${hash}.${"x".repeat(17)}`), "long extension");
assert.equal(hashOf(`${hash}.mp4`), hash);
});
function payload(bytes: Buffer): { name: string; body: Readable } {
const digest = createHash("sha256").update(bytes).digest("hex");
return { name: `${digest}.bin`, body: Readable.from([bytes]) };
}
test("receive stores a verified upload and is idempotent", async () => {
const dir = mkdtempSync(join(tmpdir(), "uploads-"));
const uploads = new UploadDir(dir, 1024 * 1024);
await uploads.init();
const bytes = randomBytes(4096);
const { name, body } = payload(bytes);
assert.deepEqual(await uploads.receive(name, body), { status: "created" });
assert.deepEqual(await readFile(join(dir, name)), bytes);
assert.ok(await uploads.exists(name));
const again = await uploads.receive(name, Readable.from([bytes]));
assert.deepEqual(again, { status: "exists" });
// The temp dir holds no leftovers either way.
assert.deepEqual(await readdir(join(dir, ".internaluploader-tmp")), []);
});
test("receive rejects a body that does not match its name", async () => {
const dir = mkdtempSync(join(tmpdir(), "uploads-"));
const uploads = new UploadDir(dir, 1024 * 1024);
await uploads.init();
const name = `${"0".repeat(64)}.bin`;
const outcome = await uploads.receive(name, Readable.from([randomBytes(16)]));
assert.equal(outcome.status, "hash-mismatch");
await assert.rejects(stat(join(dir, name)));
assert.deepEqual(await readdir(join(dir, ".internaluploader-tmp")), []);
});
test("receive enforces the size ceiling", async () => {
const dir = mkdtempSync(join(tmpdir(), "uploads-"));
const uploads = new UploadDir(dir, 1000);
await uploads.init();
const bytes = randomBytes(4096);
const { name, body } = payload(bytes);
const outcome = await uploads.receive(name, body);
assert.equal(outcome.status, "too-large");
assert.deepEqual(await readdir(join(dir, ".internaluploader-tmp")), []);
});

26
server/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": false,
// Relative imports carry `.ts` and TypeScript rewrites them to `.js` on
// emit. This is what lets the SAME source run two ways: compiled to
// dist/ for the image, and under Node's strip-only type stripping for
// `node --test`, which resolves imports by their real filename. Without it
// any src module that imports another src module is untestable.
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

13
server/tsconfig.test.json Normal file
View File

@@ -0,0 +1,13 @@
{
// The tests run under Node's type stripping, which resolves imports by their
// real filename — hence the `.ts` extensions in the test sources. The build
// tsconfig cannot allow those, so tests get their own noEmit config.
// Without this the test files would be the one surface nothing checks.
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}