Skip to main content

DIRECT_UPLOAD_JS

Constant DIRECT_UPLOAD_JS 

Source
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.