oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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. */
26export const MAX_BYTES = 20 * 1024 * 1024
27
28/** §2.7's four types, the same list the server's allowlist holds. */
29export 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 */
39export 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. */
49export 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 */
63export 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 */
101export 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. */
109export 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 */
133export 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 */
194export 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