// 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 : '')
}