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>
119 lines
3.5 KiB
TypeScript
119 lines
3.5 KiB
TypeScript
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");
|
|
}
|
|
}
|