diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index f3a602e1..75fef4b5 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -284,12 +284,40 @@ router.post('/upload', async (ctx) => { const raw = body['file']; const files = Array.isArray(raw) ? raw : raw ? [raw] : []; + /* + WRITE ASIDE, THEN RENAME. Never straight to the final path. + + `Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the + upload takes, that name exists holding a partial file — and anything watching the folder cannot + tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A + watcher transcribing voice memos as they land would produce a clean transcript of the first few + seconds of a two-minute recording and report success. + + A rename within one directory is atomic, so afterwards the final name either does not exist or is + a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher, + an agent reading the folder — instead of each inventing a heuristic like "the size stopped + changing", which cannot tell a finished upload from a stalled one. + + `.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was + going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not + create is a worse trade than a fragment somebody can see. + */ for (const file of files) { if (!(file instanceof File)) continue; const filePath = resolve(targetDir, file.name); if (!isInside(rootDir, filePath)) continue; await mkdir(dirname(filePath), { recursive: true }); - await Bun.write(filePath, file); + + const partPath = `${filePath}.part`; + try { + await Bun.write(partPath, file); + await rename(partPath, filePath); + } catch (err) { + // Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is + // the case worth being able to see. + await rm(partPath, { force: true }).catch(() => {}); + throw err; + } } return ctx.json({ ok: true }); diff --git a/src/servers/api/sendoff/router.ts b/src/servers/api/sendoff/router.ts index b29f046b..4699ff7c 100644 --- a/src/servers/api/sendoff/router.ts +++ b/src/servers/api/sendoff/router.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises'; +import { mkdir, rename, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { extname, join } from 'node:path'; import { createRouter } from '../../create-router'; @@ -149,9 +149,19 @@ sendoffRouter.post('/send', async (ctx) => { await mkdir(dir, { recursive: true }); const written: { name: string; size: number }[] = []; + // Aside, then rename — the same reasoning as the file browser upload, and it matters more here + // because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the + // notification below may point at a name that is still being written. for (const file of files) { const path = freePath(dir, safeFilename(file.name)); - await Bun.write(path, await file.arrayBuffer()); + const partPath = `${path}.part`; + try { + await Bun.write(partPath, await file.arrayBuffer()); + await rename(partPath, path); + } catch (err) { + await rm(partPath, { force: true }).catch(() => {}); + throw err; + } written.push({ name: path.split('/').pop() ?? file.name, size: file.size }); }