From e43c02c36f69071d4c545ba47e073a768d23a6fb Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 21:51:23 +0700 Subject: [PATCH] fix(library): spell the names the browser refuses to write A backup folder is picked, not created: the frames in it carry the names a camera or a person gave them, and Chromium's IsSafePathComponent will not spell a name back onto a disk if it holds a control or format character, one of ": * ? " < > | \ /", a space, dot or tilde at either end, or a .lnk/.scf/ .url tail. The library is read through a handle, which lets all of those through, so the refusal only surfaced on the way out: getDirectoryHandle threw "Name is not allowed" on the first such folder, the run stopped there and left an empty thumbs/ behind it. Spell a component that would be refused as %XX per its UTF-8 bytes, on the way out and on the way back in, so those frames keep their tiles in the backup and the restore still finds them. A name that was already safe is handed back untouched, which keeps an existing folder readable, copyable and rsyncable by hand. The run also no longer dies on the one frame that will not write: it skips it, names it in the console, and finishes the rest. --- docker/frontend/src/engine/libraryBackup.ts | 77 ++++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) 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