aweft

recipes/uploads/main.ts

One file of recipes/uploads, at commit 9a5bb24.

// The uploads battery under the gate: a page uploads pictures, they paint from `/files/<id>`,
// each refusal reaches the page with its reason, a module makes a file of its own, and the
// static battery behind it never sees a file it did not write.
//
// Run: AWEFT_DEFAULT_H=@aweftjs/ui node --import @aweftjs/build/loader recipes/uploads/main.ts

import { createHash, randomBytes } from 'node:crypto';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { deflateSync } from 'node:zlib';

import { chromium } from 'playwright';
import { createServer as createViteServer } from 'vite';

import { records } from '@aweftjs/uploads';
import type { Files, UploadRecord } from '@aweftjs/uploads';

const here = fileURLToPath(new URL('.', import.meta.url));

let checks = 0;
let failed = 0;
const check = (ok: boolean, what: string): void => {
	checks += 1;
	if (!ok) failed += 1;
	console.log(`  ${ok ? 'ok  ' : 'FAIL'} ${what}`);
};

// --- a picture a browser will paint --------------------------------------------------------------

const crcTable = Array.from({ length: 256 }, (_, n) => {
	let c = n;
	for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
	return c >>> 0;
});
const crc32 = (bytes: Uint8Array): number => {
	let c = 0xffffffff;
	for (const byte of bytes) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8);
	return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type: string, data: Uint8Array): Uint8Array => {
	const out = new Uint8Array(12 + data.byteLength);
	const view = new DataView(out.buffer);
	view.setUint32(0, data.byteLength);
	out.set([...type].map((c) => c.charCodeAt(0)), 4);
	out.set(data, 8);
	view.setUint32(8 + data.byteLength, crc32(out.subarray(4, 8 + data.byteLength)));
	return out;
};
/** A square png `side` pixels wide, one colour or noise (noise does not compress, so it is large). */
const png = (side: number, rgb: [number, number, number] | 'noise'): Uint8Array => {
	const header = new Uint8Array(13);
	const view = new DataView(header.buffer);
	view.setUint32(0, side); view.setUint32(4, side);
	header.set([8, 2, 0, 0, 0], 8);
	const raw = rgb === 'noise' ? new Uint8Array(randomBytes(side * (1 + side * 3))) : new Uint8Array(side * (1 + side * 3));
	for (let y = 0; y < side; y += 1) {
		raw[y * (1 + side * 3)] = 0;
		if (rgb !== 'noise') for (let x = 0; x < side; x += 1) raw.set(rgb, y * (1 + side * 3) + 1 + x * 3);
	}
	const parts = [new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', header), chunk('IDAT', new Uint8Array(deflateSync(raw))), chunk('IEND', new Uint8Array(0))];
	const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
	let at = 0;
	for (const p of parts) { out.set(p, at); at += p.byteLength; }
	return out;
};
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex');

// --- the directories, the backend on a free port, the dev server in front of it ------------------

const uploadsDir = await mkdtemp(join(tmpdir(), 'aweft-recipe-uploads-'));
const siteDir = await mkdtemp(join(tmpdir(), 'aweft-recipe-site-'));
await writeFile(join(siteDir, '404.html'), '<h1>not here</h1>');
process.env.AWEFT_UPLOADS_DIR = uploadsDir;
process.env.AWEFT_SITE_DIR = siteDir;
process.env.PORT = '0';
const backend = await import('./backend/main.ts');
const api = `http://127.0.0.1:${String(backend.listener.port)}`;
process.env.AWEFT_BACKEND_PORT = String(backend.listener.port);

const dev = await createViteServer({
	configFile: join(here, 'page', 'vite.config.ts'),
	logLevel: 'warn',
	server: { port: 0 },
});
await dev.listen();
const origin = dev.resolvedUrls!.local[0]!;

// --- the page -----------------------------------------------------------------------------------

const browser = await chromium.launch();
const view = await browser.newPage({ viewport: { width: 900, height: 700 } });
const cat = png(16, [200, 40, 40]);
const big = png(300, 'noise');

const uploaded = (): Promise<UploadRecord[]> => view.evaluate(() => (globalThis as unknown as { __uploaded: UploadRecord[] }).__uploaded);
const drop = (name: string, mimeType: string, buffer: Uint8Array): Promise<void> =>
	view.setInputFiles('input[type=file]', { name, mimeType, buffer: Buffer.from(buffer) });

try {
	await view.goto(origin);
	await view.waitForSelector('#page');

	// Anonymous: the zone takes the file, the route refuses it, and the page shows why.
	await drop('early.png', 'image/png', cat);
	await view.locator('#refused', { hasText: '403' }).waitFor();
	check(true, 'an anonymous upload is refused with 403 and the page says so');

	await view.locator('input').first().fill('ada@example.com');
	await view.locator('input[type=password]').fill('zQpw77neverstored');
	await view.click('#sign-in');
	await view.locator('#who', { hasText: 'signed in as' }).waitFor();

	// The real job: a picture goes up with progress and comes back on the page.
	await drop('cat.png', 'image/png', cat);
	await view.locator('#progress', { hasText: '100%' }).waitFor();
	await view.waitForFunction(() => {
		const img = (globalThis as unknown as { document: { querySelector(s: string): { complete: boolean; naturalWidth: number } | null } }).document.querySelector('#picture');
		return img !== null && img.complete && img.naturalWidth > 0;
	});
	const [first] = await uploaded();
	check(first !== undefined && first.url === `/files/${first.id}`, 'the record came back with its url');
	check(first?.sha256 === sha256(cat), 'the record carries the hash of the bytes the page sent');
	check(first?.name === 'cat.png' && first.type === 'image/png' && first.size === cat.byteLength, 'the record carries name, type and size');
	check(typeof first?.user === 'string', 'the record carries the signed-in user');
	const painted = await view.evaluate(() => (globalThis as unknown as { document: { querySelector(s: string): { naturalWidth: number } } }).document.querySelector('#picture').naturalWidth);
	check(painted === 16, 'the picture painted from /files/<id>');

	// The file as the server sends it.
	const served = await fetch(`${api}${first!.url}`);
	check(served.status === 200 && served.headers.get('content-type') === 'image/png', 'served with its type');
	check(served.headers.get('cache-control') === 'public, max-age=31536000, immutable', 'served immutable');
	check(served.headers.get('x-content-type-options') === 'nosniff' && served.headers.get('content-security-policy') === 'sandbox', 'served with nosniff and a sandbox policy');
	check(served.headers.get('etag') === `"${first!.sha256}"`, 'the tag is the hash');
	check(Buffer.from(await served.arrayBuffer()).equals(Buffer.from(cat)), 'the bytes are the bytes');
	const cached = await fetch(`${api}${first!.url}`, { headers: { 'if-none-match': `"${first!.sha256}"` } });
	check(cached.status === 304, 'the same tag again is 304');

	// Each refusal, from the page, with the reason the route gave.
	await drop('page.html', 'text/html', new TextEncoder().encode('<script>alert(1)</script>'));
	await view.locator('#refused', { hasText: '415' }).waitFor();
	check(true, 'a type the site does not take is 415');
	await drop('not-really.jpg', 'image/jpeg', cat);
	await view.locator('#refused', { hasText: 'not-really.jpg: 415' }).waitFor();
	check(true, 'png bytes declared jpeg are 415');
	await drop('huge.png', 'image/png', big);
	await view.locator('#refused', { hasText: 'huge.png: 413' }).waitFor();
	check(true, 'a picture over the family cap is 413');
	await drop('nope.png', 'image/png', cat);
	await view.locator('#refused', { hasText: 'nope.png: 422' }).waitFor();
	check(true, 'the application\'s accept refuses with 422 and its own reason');
	check((await uploaded()).length === 1, 'none of the refused files made a record');

	// A module makes a file of its own through the trusted path.
	await view.click('#export');
	await view.locator('#exported', { hasText: '/files/' }).waitFor();
	const csvUrl = await view.locator('#exported').textContent();
	const csv = await fetch(`${api}${csvUrl!}`);
	const text = await csv.text();
	check(csv.headers.get('content-type') === 'text/csv' && text.startsWith('id,url,name,size\n') && text.includes(first!.id), 'the module\'s csv is served with its type and names the picture');

	// The readers, newest first.
	const hers = await records(backend.store, { user: first!.user! });
	check(hers.length === 2 && hers[0]!.type === 'text/csv' && hers[1]!.id === first!.id, 'records() lists the user\'s two files, newest first');

	// The static battery behind it: a URL nobody wrote gets its page, a file never reaches it.
	const nothing = await fetch(`${api}/nothing`);
	check(nothing.status === 404 && (nothing.headers.get('content-type') ?? '').startsWith('text/html'), 'an unknown URL gets the static 404 page');

	// Remove: bytes and record, and then the URL falls through to the static page.
	const keeper = backend.server.loader.get('uploads/Files') as Files;
	check(await keeper.remove(first!.id) === true, 'remove takes the file');
	const gone = await fetch(`${api}${first!.url}`);
	check(gone.status === 404 && (gone.headers.get('content-type') ?? '').startsWith('text/html'), 'a removed file is declined to the static page');
	check((await records(backend.store, { user: first!.user! })).length === 1, 'and its record is gone');

	// An anonymous post is answered before its body is read.
	let pulled = 0;
	const total = 20 * 1024 * 1024;
	const slow = new ReadableStream<Uint8Array>({
		pull: (controller) => {
			if (pulled >= total) { controller.close(); return; }
			pulled += 65536;
			controller.enqueue(new Uint8Array(65536));
		},
	});
	const anonymous = await fetch(`${api}/api/uploads`, { method: 'POST', headers: { 'content-type': 'image/png' }, body: slow, duplex: 'half' } as RequestInit).catch(() => undefined);
	const seenAt = pulled;
	check(anonymous?.status === 403, 'an anonymous post is 403');
	check(seenAt < total / 2, `and it was answered with ${String(seenAt)} of ${String(total)} bytes read`);
} finally {
	await browser.close();
	await dev.close();
	await backend.server.stop();
	await rm(uploadsDir, { recursive: true, force: true });
	await rm(siteDir, { recursive: true, force: true });
}

console.log(`\n${checks - failed}/${checks} checks passed`);
process.exit(failed === 0 ? 0 : 1);