// fixtures/uploads.mjs — getting one picture from a file picker into a // deck (spec §2.7). // // Five steps, in this order, and each one can be the last: // // choose the file is a type and a size the deck accepts // sign POST /d/{sqid}/uploads — where do these bytes go // put PUT straight to the bucket (or, in development, to the // app, which has no presigning to offer) // register POST /d/{sqid}/assets — the server reads what landed, // sniffs it, sanitises an SVG, and answers with the record // op the CLIENT applies register_asset with that record, so // the change reaches the document the same way every other // change does: one op, one update, one undo step // // The last step is the whole reason register does not write the // document itself. A server writing into the Y.Doc behind the editor // would be a second writer with no seq, no actor, and nothing for undo // to take back. // // Every step is a seam the caller supplies (sign, put, register, // applyOp, and measure/hash for the two browser APIs), so the state // machine is testable with fakes and this module imports nothing. /** §2.7's ceiling: 20 MiB per image. */ export const MAX_BYTES = 20 * 1024 * 1024 /** §2.7's four types, the same list the server's allowlist holds. */ export const TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] /** * A refusal with a code the caller can turn into one of its own * strings. `code` is one of the client's own ('type', 'toolarge', * 'altfirst') or whatever the server sent in its `error` field * ('no_store', 'unsafe_svg', …); `message` is the server's own words * when it sent any, and empty when the refusal was made here — the * caller has the catalog and this module has no English. */ export class UploadError extends Error { constructor (code, message = '') { super(code) this.name = 'UploadError' this.code = code this.serverMessage = message } } /** The states an upload passes through, in order. */ export const STATES = ['idle', 'sign', 'put', 'register', 'op', 'done', 'error'] /** * Reads a file's intrinsic size by decoding it, so width and height on * the are the picture's own and the layout does not jump. * * createImageBitmap where there is one; an and an object URL * otherwise, which is also the path an SVG with no intrinsic size takes * (it reports the default 300×150, and a wrong-but-present size is * still better for layout than none). * * Never throws: a size that could not be read is {width: 0, height: 0}, * which the server bounds to 0 and the renderer then omits. */ export async function measureImage (file) { const none = { width: 0, height: 0 } try { if (typeof createImageBitmap === 'function' && typeof file.arrayBuffer === 'function') { const bmp = await createImageBitmap(file) const out = { width: bmp.width, height: bmp.height } if (typeof bmp.close === 'function') bmp.close() return out } } catch { /* fall through to the */ } if (typeof Image !== 'function' || typeof URL === 'undefined' || !URL.createObjectURL) return none const url = URL.createObjectURL(file) try { return await new Promise(resolve => { const img = new Image() img.onload = () => resolve({ width: img.naturalWidth || 0, height: img.naturalHeight || 0 }) img.onerror = () => resolve(none) img.src = url }) } finally { URL.revokeObjectURL(url) } } /** * The file's SHA-256 as lowercase hex, declared to the sign endpoint. * * It is NOT the address the bytes land at: since codex 1 and 2 the app * mints a staging key of its own and presigns a PUT for that, so * nothing a browser says decides where anything is written. The server * re-reads what landed, re-hashes it, and puts THAT under the hash it * computed; the final ref comes back from register, and this number is * only a client's claim about the file it picked — worth recording * next to the upload, never worth believing. * * crypto.subtle needs a secure context — https, or localhost, which * development is. */ export async function sha256Hex (file) { const subtle = globalThis.crypto && globalThis.crypto.subtle if (!subtle) throw new UploadError('nohash') const digest = await subtle.digest('SHA-256', await file.arrayBuffer()) return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('') } /** The file is one of the four types, and not too big. Throws otherwise. */ export function checkFile (file) { if (!file) throw new UploadError('nofile') if (!TYPES.includes(String(file.type || '').toLowerCase())) throw new UploadError('type') if (!(file.size > 0) || file.size > MAX_BYTES) throw new UploadError('toolarge') } /** * The uploader. Every step is a seam: * * sign({name, size, type, hash}) → {ref, url, headers} * put(url, file, headers) → resolves when the bytes are up * register({ref, name, width, height, alt, decorative}) → the record * applyOp(record) → the caller writes register_asset * measure(file) → {width, height} (optional) * hash(file) → hex sha256 (optional) * onState(state, detail) → progress, for the status line * * upload(file, {alt, decorative, applyOp}) resolves with the record the * server validated and the caller registered, or rejects with an * UploadError. A per-call applyOp wins over the constructor's: which * block an upload belongs to is decided when the file is CHOSEN, and a * 20 MiB upload is long enough for somebody to have selected another * one by the time it lands. */ export function createUploader ({ sign, put, register, applyOp, measure = measureImage, hash = sha256Hex, onState = () => {} }) { // One at a time. A second pick while one is in flight would race two // register_asset ops onto the same block's `asset` prop, and the // winner would be whichever request happened to come back second. let busy = false async function upload (file, { alt = '', decorative = false, applyOp: applyNow } = {}) { if (busy) throw new UploadError('busy') checkFile(file) // §2.4's rule, asked before anything moves rather than after 20 MiB // has been uploaded: an image needs a description unless it is // marked decorative. if (!String(alt).trim() && !decorative) throw new UploadError('altfirst') busy = true try { onState('sign') const digest = await hash(file) const signed = await sign({ name: file.name || '', size: file.size, type: file.type, hash: digest }) if (!signed || !signed.url || !signed.ref) throw new UploadError('failed') onState('put') await put(signed.url, file, signed.headers || {}) onState('register') // The size is read here rather than before the PUT so a slow // decode does not sit between choosing a file and anything // visibly happening. const { width, height } = await measure(file) const record = await register({ ref: signed.ref, name: file.name || '', width, height, alt: String(alt).trim(), decorative: !!decorative }) if (!record || !record.id || !record.ref) throw new UploadError('failed') onState('op') await (applyNow || applyOp)(record) onState('done', record) return record } catch (err) { const e = err instanceof UploadError ? err : new UploadError('failed', err && err.message ? err.message : '') onState('error', e) throw e } finally { busy = false } } return { upload, get busy () { return busy } } } /** * Turns one of this app's JSON refusals into an UploadError. `res` is a * fetch Response; the body is {error, message} (assets.go's assetFail). * A body that is not that shape still produces an error the caller can * word — the status alone is enough to know it did not work. */ export async function refusal (res) { let body = null try { body = await res.json() } catch { /* not JSON, which is answer enough */ } const code = body && typeof body.error === 'string' ? body.error : `http_${res.status}` return new UploadError(code, body && typeof body.message === 'string' ? body.message : '') }