diff --git a/docker/frontend/src/engine/libraryBackup.ts b/docker/frontend/src/engine/libraryBackup.ts index 4bed896..e59ae8e 100644 --- a/docker/frontend/src/engine/libraryBackup.ts +++ b/docker/frontend/src/engine/libraryBackup.ts @@ -7,7 +7,9 @@ // // The folder holds one file of rows — thumbnail, star, recipe, the frame's size // and write time, and no handle, which is not a thing that can be written down — -// and the tiles beside it, one file per frame under `thumbs/`, so the +// and the tiles beside it, one file per frame under `thumbs/` — a +// component the browser refuses to write spelled out as `%XX` (see `spell`) so +// the tiles of such a folder land too — so the // folder mirrors the library exactly and a tile can be looked at, copied or // rsynced by hand. A frame that is being restored is matched to a frame on the // disk by that size and write time, so putting a catalogue back costs the tiles @@ -197,6 +199,50 @@ async function writable(): Promise { return handle; } +// Chrome will not spell a name it will not put on a disk: no `* ? " < > | : \ /`, +// no control or format character, no space, dot or tilde at either end, no +// `.lnk` `.scf` `.url`, and it has to be UTF-8 — the whole of what Chromium's +// `IsSafePathComponent` refuses, and every one of those is a name a camera or a +// person is free to give a folder. The library is read through a handle, which +// lets every one of them through, so the refusal only ever surfaces here, on the +// way out: `getDirectoryHandle` says `Name is not allowed` and the whole backup +// stops. This spells such a name instead — the offending character as `%XX` of +// its UTF-8 bytes — and it is the same function on the way back in, so a tile +// under a folder called `2024 : ảnh.` still comes home. A name that was already +// safe is handed back untouched, which is what keeps the folder a person can +// read, copy or rsync by hand. +const UNSAFE = /[<>:"|?*\\/]|\p{Cc}|\p{Cf}|[\uFDD0-\uFDEF\uFFFE\uFFFF]/u; +const EDGE = /^[ .~]|[ .~]$/; +const SHORTCUT = /\.(lnk|scf|url)$/i; + +const hex = (ch: string) => + [...new TextEncoder().encode(ch)].map((b) => `%${b.toString(16).toUpperCase().padStart(2, '0')}`).join(''); + +function spell(name: string): string { + // `%` spells here even though a disk takes it: a name that holds one would + // otherwise spell the same as a name whose character was spelled out, and the + // two tiles would land on each other's file. + if ( + name && + name !== '.' && + name !== '..' && + !name.includes('%') && + !UNSAFE.test(name) && + !EDGE.test(name) && + !SHORTCUT.test(name) + ) { + return name; + } + const chars = [...name]; + return chars + .map((ch, i) => { + const edge = (i === 0 || i === chars.length - 1) && /[ .~]/.test(ch); + return ch === '%' || edge || UNSAFE.test(ch) ? hex(ch) : ch; + }) + .join('') + .replace(SHORTCUT, (m) => `%2E${m.slice(1)}`); +} + // The directories are asked for by the path in the frame id, and the id of every // frame in a folder starts with that folder's: a cache of what has been opened // turns a folder of 220 000 frames into a walk of the folders it is made of. @@ -209,13 +255,14 @@ async function at(root: FileSystemDirectoryHandle, rel: string, create = false): let path = ''; for (const part of rel.split('/')) { if (!part) continue; - path = path ? `${path}/${part}` : part; + const name = spell(part); + path = path ? `${path}/${name}` : name; const had = cache.get(path); if (had) { node = had; continue; } - node = await node.getDirectoryHandle(part, { create }); + node = await node.getDirectoryHandle(name, { create }); cache.set(path, node); } return node; @@ -224,7 +271,7 @@ async function at(root: FileSystemDirectoryHandle, rel: string, create = false): async function put(root: FileSystemDirectoryHandle, rel: string, blob: Blob): Promise { const cut = rel.lastIndexOf('/'); const dir = cut < 0 ? root : await at(root, rel.slice(0, cut), true); - const file = await dir.getFileHandle(cut < 0 ? rel : rel.slice(cut + 1), { create: true }); + const file = await dir.getFileHandle(spell(cut < 0 ? rel : rel.slice(cut + 1)), { create: true }); // One write at a time and closed at once: a writable that is left open is a // file that is not on the disk yet, and a backup that is interrupted is one the // next run has to write again. @@ -263,6 +310,7 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi await put(root, CATALOGUE, new Blob([JSON.stringify(cat)], { type: 'application/json' })); let written = 0; + let skipped = 0; let done = 0; const rows: { id: string; size: number }[] = []; for (const photo of photos) { @@ -274,11 +322,19 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi if (had.get(photo.id) === photo.thumb.size) continue; const parts = photo.id.split('/'); parts.pop(); - const dir = await at(root, [THUMBS, ...parts].join('/'), true); - const file = await dir.getFileHandle(photo.name, { create: true }); - const writer = await file.createWritable(); - await writer.write(photo.thumb); - await writer.close(); + const rel = [THUMBS, ...parts, photo.name].join('/'); + try { + await put(root, rel, photo.thumb); + } catch (err) { + // A frame the browser will not write is not a library that is never + // written: the run goes on without it, and the name it stopped on goes to + // the console, since the line on the screen is one line over a strip of + // photographs. Whatever survives `spell` is a name longer than a folder + // will hold, and a name spelled back is a name the restore still finds. + if (skipped < 5) console.warn('backup: could not write', rel, err); + skipped++; + continue; + } rows.push({ id: photo.id, size: photo.thumb.size }); written++; // The list is kept in step with the writes: a run that is interrupted @@ -287,6 +343,7 @@ export async function backupNow(onProgress?: (p: BackupProgress) => void): Promi if (rows.length >= 200) await keep(WRITTEN, rows.splice(0, rows.length)); } await keep(WRITTEN, rows); + if (skipped) console.warn(`backup: ${skipped} of ${photos.length} tiles skipped`); onProgress?.({ done: photos.length, total: photos.length }); const meta: Meta = { id: 'meta', at: at0, photos: photos.length, wrote: written }; await keep(WHERE, [meta]); @@ -329,7 +386,7 @@ export async function restoreNow(onProgress?: (p: BackupProgress) => void): Prom const parts = id.split('/'); const name = parts.pop() as string; const dir = await at(root, [THUMBS, ...parts].join('/')); - const file = await dir.getFileHandle(name); + const file = await dir.getFileHandle(spell(name)); return new Blob([await (await file.getFile()).arrayBuffer()], { type: 'image/jpeg' }); } catch { // A tile that is not there — or that has been taken away by hand — leaves