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

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");
}
}