diff --git a/public/js/modalFocus.js b/public/js/modalFocus.js new file mode 100644 index 0000000..813a7f0 --- /dev/null +++ b/public/js/modalFocus.js @@ -0,0 +1,40 @@ +const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; +const stack = []; + +function focusables(modal) { + return [...modal.querySelectorAll(focusableSelector)].filter(element => + element.getClientRects().length && !element.closest('.hidden, [hidden]')); +} + +export function openModal(modal, preferredFocus) { + const previous = document.activeElement; + modal.classList.remove('hidden'); + stack.push({modal, previous}); + (preferredFocus || focusables(modal)[0] || modal.querySelector('[role="dialog"]'))?.focus(); +} + +export function closeModal(modal) { + const index = stack.findLastIndex(entry => entry.modal === modal); + if (index < 0) return; + const [{previous}] = stack.splice(index, 1); + modal.classList.add('hidden'); + if (index === stack.length) { + const target = stack.length ? focusables(stack.at(-1).modal)[0] : previous; + if (target?.isConnected) target.focus(); + } +} + +export function topModal() { return stack.at(-1)?.modal || null; } + +document.addEventListener('keydown', event => { + if (event.key !== 'Tab' || !stack.length) return; + const modal = topModal(); + const elements = focusables(modal); + if (!elements.length) { event.preventDefault(); modal.querySelector('[role="dialog"]')?.focus(); return; } + const first = elements[0], last = elements.at(-1); + if (event.shiftKey && (document.activeElement === first || !modal.contains(document.activeElement))) { + event.preventDefault(); last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !modal.contains(document.activeElement))) { + event.preventDefault(); first.focus(); + } +}, true); diff --git a/src/services/workspaceArchive.js b/src/services/workspaceArchive.js new file mode 100644 index 0000000..4c12099 --- /dev/null +++ b/src/services/workspaceArchive.js @@ -0,0 +1,61 @@ +/** Portable workspace archive: a gzip-compressed ustar file with JSON and images. */ +import {gzipSync, gunzipSync} from "node:zlib"; + +export const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024; + +function header(name, size) { + const block = Buffer.alloc(512); + block.write(name, 0, 100, "ascii"); + block.write("0000644\0", 100, "ascii"); + block.write("0000000\0", 108, "ascii"); + block.write("0000000\0", 116, "ascii"); + block.write(size.toString(8).padStart(11, "0") + "\0", 124, "ascii"); + block.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, "ascii"); + block.fill(32, 148, 156); + block.write("0", 156, "ascii"); + block.write("ustar\0", 257, "ascii"); + block.write("00", 263, "ascii"); + const checksum = block.reduce((sum, byte) => sum + byte, 0); + block.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii"); + return block; +} + +export function createWorkspaceArchive(workspace, images) { + const files = [["workspace.json", Buffer.from(JSON.stringify(workspace, null, 2))], ...images]; + const chunks = []; + let total = 1024; + for (const [name, body] of files) { + if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name)) throw new Error("Invalid archive entry"); + total += 512 + Math.ceil(body.length / 512) * 512; + if (total > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive exceeds 250 MB"); + chunks.push(header(name, body.length), body, Buffer.alloc((512 - body.length % 512) % 512)); + } + chunks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(chunks, total), {level: 1}); +} + +export function parseWorkspaceArchive(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length > MAX_ARCHIVE_BYTES) throw new Error("Archive exceeds 250 MB"); + const tar = gunzipSync(buffer, {maxOutputLength: MAX_ARCHIVE_BYTES}); + const files = new Map(); + for (let offset = 0; offset + 512 <= tar.length;) { + const block = tar.subarray(offset, offset + 512); + if (block.every(byte => byte === 0)) break; + const name = block.toString("ascii", 0, 100).split("\0")[0]; + if (!/^(workspace\.json|images\/[a-f0-9-]{36}\.(png|jpeg|webp))$/.test(name) || files.has(name) || block.toString("ascii", 156, 157) !== "0") { + throw new Error("Invalid archive entry"); + } + const recorded = Number.parseInt(block.toString("ascii", 148, 156).trim(), 8); + const copy = Buffer.from(block); copy.fill(32, 148, 156); + if (recorded !== copy.reduce((sum, byte) => sum + byte, 0)) throw new Error("Invalid archive checksum"); + const size = Number.parseInt(block.toString("ascii", 124, 136).replaceAll("\0", "").trim(), 8); + if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length) throw new Error("Invalid archive size"); + files.set(name, tar.subarray(offset + 512, offset + 512 + size)); + offset += 512 + Math.ceil(size / 512) * 512; + } + if (!files.has("workspace.json")) throw new Error("Archive has no workspace.json"); + let workspace; + try { workspace = JSON.parse(files.get("workspace.json").toString("utf8")); } + catch { throw new Error("Archive has invalid workspace.json"); } + return {workspace, images: files}; +}