pub const DIRECT_UPLOAD_JS: &str = "// Ocre direct uploads (Active Storage\'s `direct_upload: true`) and rich text\n// embeds (Action Text attachments), served at\n// /ocre/direct-upload.js by `ocre::storage::direct_upload_script()`.\n//\n// <input type=\"file\" name=\"video\" data-direct-upload-url=\"/videos/uploads\">\n//\n// When its form is submitted, each chosen file goes straight from the browser\n// to R2: a POST to the input\'s URL (`storage::direct_upload` answers the signed\n// PUT), then the PUT itself. The form is then submitted without the file,\n// with hidden fields `video_key` (the signed key) and `video_filename` for\n// `storage::attach_direct_upload`. With `multiple`, the fields repeat.\n//\n// <input type=\"file\" name=\"video\" data-multipart-upload-url=\"/videos/uploads\">\n//\n// Large files (`storage::multipart_uploads`): the file is sent in parts, 4 at\n// a time, each retried 3 times. The finished parts are kept in localStorage,\n// so after a lost connection or a closed tab, choosing the same file again\n// sends only the missing parts. The form then gets the same hidden fields.\n//\n// Events, dispatched on the input and bubbling (Active Storage\'s names):\n// direct-uploads:start / direct-uploads:end on the form, and per file\n// direct-upload:start, direct-upload:progress (detail.progress, 0 to 100),\n// direct-upload:error (detail.error; call preventDefault() to skip the alert)\n// and direct-upload:end, each with detail.file.\n(() => {\n const emit = (target, name, detail = {}) =>\n target.dispatchEvent(new CustomEvent(name, { bubbles: true, cancelable: true, detail }));\n\n const put = (upload, file, input) =>\n new Promise((resolve, reject) => {\n const request = new XMLHttpRequest();\n request.open(\"PUT\", upload.url);\n for (const [name, value] of Object.entries(upload.headers)) request.setRequestHeader(name, value);\n request.upload.addEventListener(\"progress\", (event) => {\n if (event.lengthComputable) emit(input, \"direct-upload:progress\", { file, progress: (event.loaded / event.total) * 100 });\n });\n request.addEventListener(\"load\", () =>\n request.status >= 200 && request.status < 300 ? resolve() : reject(new Error(`R2 answered ${request.status}`)),\n );\n request.addEventListener(\"error\", () => reject(new Error(\"the upload failed\")));\n request.send(file);\n });\n\n const sign = async (input, file) => {\n const response = await fetch(input.dataset.directUploadUrl, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify({ filename: file.name, content_type: file.type || \"application/octet-stream\", size: file.size }),\n });\n const body = await response.json().catch(() => ({}));\n if (!response.ok) {\n const fields = body.error && body.error.fields ? Object.values(body.error.fields).flat().join(\", \") : \"\";\n throw new Error(fields || (body.error && body.error.message) || `the app answered ${response.status}`);\n }\n return body;\n };\n\n const json = async (url, body) => {\n const response = await fetch(url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", accept: \"application/json\" },\n body: JSON.stringify(body),\n });\n const answer = await response.json().catch(() => ({}));\n if (!response.ok) {\n const fields = answer.error && answer.error.fields ? Object.values(answer.error.fields).flat().join(\", \") : \"\";\n throw new Error(fields || (answer.error && answer.error.message) || `the app answered ${response.status}`);\n }\n return answer;\n };\n\n // PUTs one part and resolves with its ETag; `onprogress(bytes)` reports the bytes sent.\n const putPart = (url, blob, onprogress) =>\n new Promise((resolve, reject) => {\n const request = new XMLHttpRequest();\n request.open(\"PUT\", url);\n request.upload.addEventListener(\"progress\", (event) => onprogress(event.loaded));\n request.addEventListener(\"load\", () => {\n if (request.status < 200 || request.status >= 300) {\n return reject(Object.assign(new Error(`the part was refused (${request.status})`), { status: request.status }));\n }\n // Straight to R2: the ETag header (the bucket\'s CORS rule must expose it); through the Worker: JSON.\n const etag = request.getResponseHeader(\"etag\") || (JSON.parse(request.responseText || \"{}\").etag ?? \"\");\n etag ? resolve(etag) : reject(new Error(\"R2 did not expose the part\'s ETag: add ETag to the bucket\'s CORS ExposeHeaders\"));\n });\n request.addEventListener(\"error\", () => reject(new Error(\"the upload failed\")));\n request.send(blob);\n });\n\n const retried = async (attempt, tries = 3) => {\n for (let i = 1; ; i++) {\n try {\n return await attempt();\n } catch (error) {\n if (i >= tries) throw error;\n await new Promise((done) => setTimeout(done, 1000 * 2 ** i));\n }\n }\n };\n\n // Sends `file` in parts and resolves with its signed key, resuming a\n // previous attempt at the same file (same URL, name, size and date).\n const multipart = async (input, file) => {\n const url = input.dataset.multipartUploadUrl;\n const memory = `ocre-multipart:${url}:${file.name}:${file.size}:${file.lastModified}`;\n const resumed = localStorage.getItem(memory) !== null;\n try {\n return await sendParts(input, file, url, memory);\n } catch (error) {\n // The upload R2 kept is gone (completed, aborted or expired): start over once.\n if (!resumed || error.status !== 404) throw error;\n localStorage.removeItem(memory);\n return sendParts(input, file, url, memory);\n }\n };\n\n const sendParts = async (input, file, url, memory) => {\n let state = JSON.parse(localStorage.getItem(memory) || \"null\");\n if (!state) {\n const upload = await json(url, { filename: file.name, content_type: file.type || \"application/octet-stream\", size: file.size });\n state = { ...upload, done: {} };\n localStorage.setItem(memory, JSON.stringify(state));\n }\n const sent = {};\n const report = () => {\n const bytes = Object.values(sent).reduce((sum, value) => sum + value, 0);\n emit(input, \"direct-upload:progress\", { file, progress: file.size ? (bytes / file.size) * 100 : 100 });\n };\n const missing = [];\n for (let part = 1; part <= state.part_count; part++) {\n if (state.done[part]) sent[part] = Math.min(state.part_size, file.size - (part - 1) * state.part_size);\n else missing.push(part);\n }\n report();\n const ids = { signed_key: state.signed_key, upload_id: state.upload_id };\n for (let first = 0; first < missing.length; first += 1000) {\n const { urls } = await json(`${url}/parts`, { ...ids, parts: missing.slice(first, first + 1000) });\n const queue = Object.entries(urls).map(([part, partUrl]) => [Number(part), partUrl]);\n const worker = async () => {\n while (queue.length) {\n const [part, partUrl] = queue.shift();\n const blob = file.slice((part - 1) * state.part_size, part * state.part_size);\n const etag = await retried(() =>\n putPart(partUrl, blob, (bytes) => {\n sent[part] = bytes;\n report();\n }),\n );\n sent[part] = blob.size;\n state.done[part] = etag;\n localStorage.setItem(memory, JSON.stringify(state));\n }\n };\n await Promise.all([worker(), worker(), worker(), worker()]);\n }\n const parts = Object.entries(state.done).map(([part, etag]) => ({ part_number: Number(part), etag }));\n await json(`${url}/complete`, { ...ids, parts });\n localStorage.removeItem(memory);\n return state.signed_key;\n };\n\n const hidden = (form, name, value) => {\n const field = document.createElement(\"input\");\n field.type = \"hidden\";\n field.name = name;\n field.value = value;\n field.dataset.directUpload = \"\";\n form.append(field);\n };\n\n // Files dropped into a Trix editor with data-embeds-url=\"/posts/embeds\"\n // (Action Text attachments): POSTed as `file`, then shown from the `url`\n // the app answers; a refused file is removed with an alert.\n document.addEventListener(\"trix-attachment-add\", (event) => {\n const url = event.target.dataset.embedsUrl;\n const attachment = event.attachment;\n if (!url || !attachment.file) return;\n const body = new FormData();\n body.append(\"file\", attachment.file);\n const request = new XMLHttpRequest();\n request.open(\"POST\", url);\n request.setRequestHeader(\"accept\", \"application/json\");\n request.upload.addEventListener(\"progress\", (progress) => {\n if (progress.lengthComputable) attachment.setUploadProgress((progress.loaded / progress.total) * 100);\n });\n request.addEventListener(\"load\", () => {\n if (request.status === 200) {\n const { url: src } = JSON.parse(request.responseText);\n attachment.setAttributes({ url: src, href: src });\n } else {\n attachment.remove();\n alert(`${attachment.file.name} could not be added (${request.status}).`);\n }\n });\n request.send(body);\n });\n\n document.addEventListener(\"submit\", async (event) => {\n const form = event.target;\n const inputs = [...form.querySelectorAll(\"input[type=file][data-direct-upload-url], input[type=file][data-multipart-upload-url]\")].filter(\n (input) => !input.disabled && input.files.length > 0,\n );\n if (inputs.length === 0) return;\n event.preventDefault();\n event.stopImmediatePropagation();\n emit(form, \"direct-uploads:start\");\n form.querySelectorAll(\"[data-direct-upload]\").forEach((field) => field.remove());\n try {\n for (const input of inputs) {\n for (const file of input.files) {\n emit(input, \"direct-upload:start\", { file });\n try {\n let signedKey;\n if (input.dataset.multipartUploadUrl) {\n signedKey = await multipart(input, file);\n } else {\n const upload = await sign(input, file);\n await put(upload, file, input);\n signedKey = upload.signed_key;\n }\n hidden(form, `${input.name}_key`, signedKey);\n hidden(form, `${input.name}_filename`, file.name);\n emit(input, \"direct-upload:end\", { file });\n } catch (error) {\n if (emit(input, \"direct-upload:error\", { file, error })) alert(`${file.name}: ${error.message}`);\n throw error;\n }\n }\n }\n } catch {\n form.querySelectorAll(\"[data-direct-upload]\").forEach((field) => field.remove());\n return;\n }\n inputs.forEach((input) => (input.disabled = true));\n emit(form, \"direct-uploads:end\");\n HTMLFormElement.prototype.submit.call(form);\n }, true);\n})();\n";Expand description
The browser side of direct uploads (Active Storage’s activestorage.js):
a script that sends the files of <input type="file" data-direct-upload-url="..."> to R2 before its form is submitted.
Each file is signed by a POST to the input’s URL (a handler calling
direct_upload), then PUT to R2 with progress events
(direct-upload:start|progress|error|end); the form then submits
<name>_key and <name>_filename for attach_direct_upload instead
of the file. It also uploads the files dropped into a Trix editor with
data-embeds-url (Action Text attachments). Serve it with direct_upload_script.