| 1 | // fixtures/uploads.mjs — getting one picture from a file picker into a |
| 2 | // deck (spec §2.7). |
| 3 | // |
| 4 | // Five steps, in this order, and each one can be the last: |
| 5 | // |
| 6 | // choose the file is a type and a size the deck accepts |
| 7 | // sign POST /d/{sqid}/uploads — where do these bytes go |
| 8 | // put PUT straight to the bucket (or, in development, to the |
| 9 | // app, which has no presigning to offer) |
| 10 | // register POST /d/{sqid}/assets — the server reads what landed, |
| 11 | // sniffs it, sanitises an SVG, and answers with the record |
| 12 | // op the CLIENT applies register_asset with that record, so |
| 13 | // the change reaches the document the same way every other |
| 14 | // change does: one op, one update, one undo step |
| 15 | // |
| 16 | // The last step is the whole reason register does not write the |
| 17 | // document itself. A server writing into the Y.Doc behind the editor |
| 18 | // would be a second writer with no seq, no actor, and nothing for undo |
| 19 | // to take back. |
| 20 | // |
| 21 | // Every step is a seam the caller supplies (sign, put, register, |
| 22 | // applyOp, and measure/hash for the two browser APIs), so the state |
| 23 | // machine is testable with fakes and this module imports nothing. |
| 24 | |
| 25 | /** §2.7's ceiling: 20 MiB per image. */ |
| 26 | export const MAX_BYTES = 20 * 1024 * 1024 |
| 27 | |
| 28 | /** §2.7's four types, the same list the server's allowlist holds. */ |
| 29 | export const TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] |
| 30 | |
| 31 | /** |
| 32 | * A refusal with a code the caller can turn into one of its own |
| 33 | * strings. `code` is one of the client's own ('type', 'toolarge', |
| 34 | * 'altfirst') or whatever the server sent in its `error` field |
| 35 | * ('no_store', 'unsafe_svg', …); `message` is the server's own words |
| 36 | * when it sent any, and empty when the refusal was made here — the |
| 37 | * caller has the catalog and this module has no English. |
| 38 | */ |
| 39 | export class UploadError extends Error { |
| 40 | constructor (code, message = '') { |
| 41 | super(code) |
| 42 | this.name = 'UploadError' |
| 43 | this.code = code |
| 44 | this.serverMessage = message |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /** The states an upload passes through, in order. */ |
| 49 | export const STATES = ['idle', 'sign', 'put', 'register', 'op', 'done', 'error'] |
| 50 | |
| 51 | /** |
| 52 | * Reads a file's intrinsic size by decoding it, so width and height on |
| 53 | * the <img> are the picture's own and the layout does not jump. |
| 54 | * |
| 55 | * createImageBitmap where there is one; an <img> and an object URL |
| 56 | * otherwise, which is also the path an SVG with no intrinsic size takes |
| 57 | * (it reports the default 300×150, and a wrong-but-present size is |
| 58 | * still better for layout than none). |
| 59 | * |
| 60 | * Never throws: a size that could not be read is {width: 0, height: 0}, |
| 61 | * which the server bounds to 0 and the renderer then omits. |
| 62 | */ |
| 63 | export async function measureImage (file) { |
| 64 | const none = { width: 0, height: 0 } |
| 65 | try { |
| 66 | if (typeof createImageBitmap === 'function' && typeof file.arrayBuffer === 'function') { |
| 67 | const bmp = await createImageBitmap(file) |
| 68 | const out = { width: bmp.width, height: bmp.height } |
| 69 | if (typeof bmp.close === 'function') bmp.close() |
| 70 | return out |
| 71 | } |
| 72 | } catch { /* fall through to the <img> */ } |
| 73 | if (typeof Image !== 'function' || typeof URL === 'undefined' || !URL.createObjectURL) return none |
| 74 | const url = URL.createObjectURL(file) |
| 75 | try { |
| 76 | return await new Promise(resolve => { |
| 77 | const img = new Image() |
| 78 | img.onload = () => resolve({ width: img.naturalWidth || 0, height: img.naturalHeight || 0 }) |
| 79 | img.onerror = () => resolve(none) |
| 80 | img.src = url |
| 81 | }) |
| 82 | } finally { |
| 83 | URL.revokeObjectURL(url) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * The file's SHA-256 as lowercase hex, declared to the sign endpoint. |
| 89 | * |
| 90 | * It is NOT the address the bytes land at: since codex 1 and 2 the app |
| 91 | * mints a staging key of its own and presigns a PUT for that, so |
| 92 | * nothing a browser says decides where anything is written. The server |
| 93 | * re-reads what landed, re-hashes it, and puts THAT under the hash it |
| 94 | * computed; the final ref comes back from register, and this number is |
| 95 | * only a client's claim about the file it picked — worth recording |
| 96 | * next to the upload, never worth believing. |
| 97 | * |
| 98 | * crypto.subtle needs a secure context — https, or localhost, which |
| 99 | * development is. |
| 100 | */ |
| 101 | export async function sha256Hex (file) { |
| 102 | const subtle = globalThis.crypto && globalThis.crypto.subtle |
| 103 | if (!subtle) throw new UploadError('nohash') |
| 104 | const digest = await subtle.digest('SHA-256', await file.arrayBuffer()) |
| 105 | return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('') |
| 106 | } |
| 107 | |
| 108 | /** The file is one of the four types, and not too big. Throws otherwise. */ |
| 109 | export function checkFile (file) { |
| 110 | if (!file) throw new UploadError('nofile') |
| 111 | if (!TYPES.includes(String(file.type || '').toLowerCase())) throw new UploadError('type') |
| 112 | if (!(file.size > 0) || file.size > MAX_BYTES) throw new UploadError('toolarge') |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * The uploader. Every step is a seam: |
| 117 | * |
| 118 | * sign({name, size, type, hash}) → {ref, url, headers} |
| 119 | * put(url, file, headers) → resolves when the bytes are up |
| 120 | * register({ref, name, width, height, alt, decorative}) → the record |
| 121 | * applyOp(record) → the caller writes register_asset |
| 122 | * measure(file) → {width, height} (optional) |
| 123 | * hash(file) → hex sha256 (optional) |
| 124 | * onState(state, detail) → progress, for the status line |
| 125 | * |
| 126 | * upload(file, {alt, decorative, applyOp}) resolves with the record the |
| 127 | * server validated and the caller registered, or rejects with an |
| 128 | * UploadError. A per-call applyOp wins over the constructor's: which |
| 129 | * block an upload belongs to is decided when the file is CHOSEN, and a |
| 130 | * 20 MiB upload is long enough for somebody to have selected another |
| 131 | * one by the time it lands. |
| 132 | */ |
| 133 | export function createUploader ({ sign, put, register, applyOp, measure = measureImage, hash = sha256Hex, onState = () => {} }) { |
| 134 | // One at a time. A second pick while one is in flight would race two |
| 135 | // register_asset ops onto the same block's `asset` prop, and the |
| 136 | // winner would be whichever request happened to come back second. |
| 137 | let busy = false |
| 138 | |
| 139 | async function upload (file, { alt = '', decorative = false, applyOp: applyNow } = {}) { |
| 140 | if (busy) throw new UploadError('busy') |
| 141 | checkFile(file) |
| 142 | // §2.4's rule, asked before anything moves rather than after 20 MiB |
| 143 | // has been uploaded: an image needs a description unless it is |
| 144 | // marked decorative. |
| 145 | if (!String(alt).trim() && !decorative) throw new UploadError('altfirst') |
| 146 | |
| 147 | busy = true |
| 148 | try { |
| 149 | onState('sign') |
| 150 | const digest = await hash(file) |
| 151 | const signed = await sign({ name: file.name || '', size: file.size, type: file.type, hash: digest }) |
| 152 | if (!signed || !signed.url || !signed.ref) throw new UploadError('failed') |
| 153 | |
| 154 | onState('put') |
| 155 | await put(signed.url, file, signed.headers || {}) |
| 156 | |
| 157 | onState('register') |
| 158 | // The size is read here rather than before the PUT so a slow |
| 159 | // decode does not sit between choosing a file and anything |
| 160 | // visibly happening. |
| 161 | const { width, height } = await measure(file) |
| 162 | const record = await register({ |
| 163 | ref: signed.ref, |
| 164 | name: file.name || '', |
| 165 | width, |
| 166 | height, |
| 167 | alt: String(alt).trim(), |
| 168 | decorative: !!decorative |
| 169 | }) |
| 170 | if (!record || !record.id || !record.ref) throw new UploadError('failed') |
| 171 | |
| 172 | onState('op') |
| 173 | await (applyNow || applyOp)(record) |
| 174 | onState('done', record) |
| 175 | return record |
| 176 | } catch (err) { |
| 177 | const e = err instanceof UploadError ? err : new UploadError('failed', err && err.message ? err.message : '') |
| 178 | onState('error', e) |
| 179 | throw e |
| 180 | } finally { |
| 181 | busy = false |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | return { upload, get busy () { return busy } } |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Turns one of this app's JSON refusals into an UploadError. `res` is a |
| 190 | * fetch Response; the body is {error, message} (assets.go's assetFail). |
| 191 | * A body that is not that shape still produces an error the caller can |
| 192 | * word — the status alone is enough to know it did not work. |
| 193 | */ |
| 194 | export async function refusal (res) { |
| 195 | let body = null |
| 196 | try { body = await res.json() } catch { /* not JSON, which is answer enough */ } |
| 197 | const code = body && typeof body.error === 'string' ? body.error : `http_${res.status}` |
| 198 | return new UploadError(code, body && typeof body.message === 'string' ? body.message : '') |
| 199 | } |
| 200 | |