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:
118
server/src/upload.ts
Normal file
118
server/src/upload.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user