activestorage-resumable 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class ResumableUploadsController < ActiveStorage::BaseController
5
+ def create
6
+ blob = ActiveStorage::Blob.find_or_create_before_resumable_upload!(blob_args)
7
+ render json: resumable_upload_json(blob)
8
+ end
9
+
10
+ def update
11
+ blob = ActiveStorage::Blob.find_by(key: params[:signed_blob_id])
12
+ blob.update(blob_args)
13
+ render json: resumable_upload_json(blob)
14
+ end
15
+
16
+ private
17
+
18
+ def blob_args
19
+ params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :metadata).to_h.symbolize_keys
20
+ end
21
+
22
+ def resumable_upload_json(blob)
23
+ blob.as_json(root: false, methods: :signed_id)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ export function getMetaValue(name) {
2
+ const element = findElement(document.head, `meta[name="${name}"]`)
3
+ if (element) {
4
+ return element.getAttribute("content")
5
+ }
6
+ }
7
+
8
+ export function findElements(root, selector) {
9
+ if (typeof root == "string") {
10
+ selector = root
11
+ root = document
12
+ }
13
+ const elements = root.querySelectorAll(selector)
14
+ return toArray(elements)
15
+ }
16
+
17
+ export function findElement(root, selector) {
18
+ if (typeof root == "string") {
19
+ selector = root
20
+ root = document
21
+ }
22
+ return root.querySelector(selector)
23
+ }
24
+
25
+ export function dispatchEvent(element, type, eventInit = {}) {
26
+ const { disabled } = element
27
+ const { bubbles, cancelable, detail } = eventInit
28
+ const event = document.createEvent("Event")
29
+
30
+ event.initEvent(type, bubbles || true, cancelable || true)
31
+ event.detail = detail || {}
32
+
33
+ try {
34
+ element.disabled = false
35
+ element.dispatchEvent(event)
36
+ } finally {
37
+ element.disabled = disabled
38
+ }
39
+
40
+ return event
41
+ }
42
+
43
+ export function toArray(value) {
44
+ if (Array.isArray(value)) {
45
+ return value
46
+ } else if (Array.from) {
47
+ return Array.from(value)
48
+ } else {
49
+ return [].slice.call(value)
50
+ }
51
+ }
@@ -0,0 +1,11 @@
1
+ import { start } from "./ujs-resumable"
2
+ import { ResumableUpload } from "./resumable_upload"
3
+ export { start, ResumableUpload }
4
+
5
+ function autostart() {
6
+ if (window.ActiveStorageResumable) {
7
+ start()
8
+ }
9
+ }
10
+
11
+ setTimeout(autostart, 1)
@@ -0,0 +1,73 @@
1
+ import { getMetaValue } from "./helpers"
2
+
3
+ export class ResumableBlobRecord {
4
+ constructor(file, checksum, url) {
5
+ this.file = file
6
+
7
+ this.attributes = {
8
+ filename: file.name,
9
+ content_type: file.type,
10
+ byte_size: file.size,
11
+ checksum: checksum
12
+ }
13
+
14
+ this.xhr = new XMLHttpRequest
15
+ this.xhr.open("POST", url, true)
16
+ this.xhr.responseType = "json"
17
+ this.xhr.setRequestHeader("Content-Type", "application/json")
18
+ this.xhr.setRequestHeader("Accept", "application/json")
19
+ this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
20
+
21
+ const csrfToken = getMetaValue("csrf-token")
22
+ if (csrfToken != undefined) {
23
+ this.xhr.setRequestHeader("X-CSRF-Token", csrfToken)
24
+ }
25
+
26
+ this.xhr.addEventListener("load", event => this.requestDidLoad(event))
27
+ this.xhr.addEventListener("error", event => this.requestDidError(event))
28
+ }
29
+
30
+ get status() {
31
+ return this.xhr.status
32
+ }
33
+
34
+ get response() {
35
+ const { responseType, response } = this.xhr
36
+ if (responseType == "json") {
37
+ return response
38
+ } else {
39
+ // Shim for IE 11: https://connect.microsoft.com/IE/feedback/details/794808
40
+ return JSON.parse(response)
41
+ }
42
+ }
43
+
44
+ create(callback) {
45
+ this.callback = callback
46
+ this.xhr.send(JSON.stringify({ blob: this.attributes }))
47
+ }
48
+
49
+ requestDidLoad(event) {
50
+ if (this.status >= 200 && this.status < 300) {
51
+ const { response } = this
52
+ const { resumable_upload } = response
53
+ delete response.resumable_upload
54
+ this.attributes = response
55
+ this.resumableUploadData = resumable_upload
56
+ this.callback(null, this.toJSON())
57
+ } else {
58
+ this.requestDidError(event)
59
+ }
60
+ }
61
+
62
+ requestDidError(event) {
63
+ this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.status}`)
64
+ }
65
+
66
+ toJSON() {
67
+ const result = {}
68
+ for (const key in this.attributes) {
69
+ result[key] = this.attributes[key]
70
+ }
71
+ return result
72
+ }
73
+ }
@@ -0,0 +1,56 @@
1
+ import Upload from "@fnix/gcs-browser-upload"
2
+ import { getMetaValue } from "./helpers"
3
+
4
+ export class ResumableBlobUpload {
5
+ constructor(blob) {
6
+ this.blob = blob
7
+ this.file = blob.file
8
+
9
+ this.gcsBrowserUpload = new Upload({
10
+ id: this.blob.attributes.key,
11
+ url: this.blob.attributes.resumable_url,
12
+ file: this.file,
13
+ contentType: this.file.type
14
+ })
15
+ }
16
+
17
+ async create(callback) {
18
+ this.callback = callback
19
+ try {
20
+ await this.gcsBrowserUpload.start()
21
+
22
+ const { headers } = this.gcsBrowserUpload.lastResult
23
+ let updateHeaders = {
24
+ "Content-Type": "application/json",
25
+ "Accept": "application/json"
26
+ }
27
+ const csrfToken = getMetaValue("csrf-token")
28
+ if (csrfToken != undefined) {
29
+ updateHeaders["X-CSRF-Token"] = csrfToken
30
+ }
31
+
32
+ const response = await fetch(`/rails/active_storage/resumable_uploads/${this.blob.attributes.key}`, {
33
+ method: "PUT",
34
+ headers: updateHeaders,
35
+ body: JSON.stringify({
36
+ blob: { checksum: headers["x-goog-hash"].match("md5=(.*)")[1] }
37
+ })
38
+ })
39
+
40
+ if (!response.ok) {
41
+ this.callback(`Failed to update ${this.blob.attributes.key} blob checksum`)
42
+ } else {
43
+ this.callback(null, this.gcsBrowserUpload.lastResult)
44
+ }
45
+ } catch (e) {
46
+ this.requestDidError(e)
47
+ } finally {
48
+ this.gcsBrowserUpload = null
49
+ }
50
+ }
51
+
52
+ requestDidError() {
53
+ const status = !!this.gcsBrowserUpload.lastResult && this.gcsBrowserUpload.lastResult.status
54
+ this.callback(`Error storing "${this.file.name}". Status: ${status}`)
55
+ }
56
+ }
@@ -0,0 +1,66 @@
1
+ import SparkMD5 from "spark-md5"
2
+
3
+ const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
4
+
5
+ export class ResumableFileChecksum {
6
+ static create(file, callback) {
7
+ const instance = new ResumableFileChecksum(file)
8
+ instance.create(callback)
9
+ }
10
+
11
+ constructor(file) {
12
+ this.file = file
13
+ this.chunkSize = 2097152 // 2MB
14
+ this.chunkIndex = 0
15
+
16
+ let chunkCount = Math.ceil(this.file.size / this.chunkSize)
17
+ if (chunkCount > 5) { chunkCount = 5 }
18
+ this.chunkCount = chunkCount
19
+ }
20
+
21
+ create(callback) {
22
+ this.callback = callback
23
+ this.md5Buffer = new SparkMD5.ArrayBuffer
24
+ this.fileReader = new FileReader
25
+ this.fileReader.addEventListener("load", event => this.fileReaderDidLoad(event))
26
+ this.fileReader.addEventListener("error", event => this.fileReaderDidError(event))
27
+ this.readNextChunk()
28
+ }
29
+
30
+ str2ArrayBuffer(str) {
31
+ var buf = new ArrayBuffer(str.length * 2) // 2 bytes for each char
32
+ var bufView = new Uint16Array(buf)
33
+ for (var i = 0, strLen = str.length; i < strLen; i++) {
34
+ bufView[i] = str.charCodeAt(i)
35
+ }
36
+ return buf
37
+ }
38
+
39
+
40
+ fileReaderDidLoad(event) {
41
+ this.md5Buffer.append(event.target.result)
42
+
43
+ if (!this.readNextChunk()) {
44
+ const binaryDigest = this.md5Buffer.end(true)
45
+ const base64digest = btoa(binaryDigest)
46
+ this.callback(null, base64digest)
47
+ }
48
+ }
49
+
50
+ fileReaderDidError(event) {
51
+ this.callback(`Error reading ${this.file.name}`)
52
+ }
53
+
54
+ readNextChunk() {
55
+ if (this.chunkIndex < this.chunkCount || (this.chunkIndex == 0 && this.chunkCount == 0)) {
56
+ const start = this.chunkIndex * this.chunkSize
57
+ const end = Math.min(start + this.chunkSize, this.file.size)
58
+ const bytes = fileSlice.call(this.file, start, end)
59
+ this.fileReader.readAsArrayBuffer(bytes)
60
+ this.chunkIndex++
61
+ return true
62
+ } else {
63
+ return false
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,48 @@
1
+ import { ResumableFileChecksum } from "./resumable_file_checksum"
2
+ import { ResumableBlobRecord } from "./resumable_blob_record"
3
+ import { ResumableBlobUpload } from "./resumable_blob_upload"
4
+
5
+ let id = 0
6
+
7
+ export class ResumableUpload {
8
+ constructor(file, url, delegate) {
9
+ this.id = ++id
10
+ this.file = file
11
+ this.url = url
12
+ this.delegate = delegate
13
+ }
14
+
15
+ create(callback) {
16
+ ResumableFileChecksum.create(this.file, (error, checksum) => {
17
+ if (error) {
18
+ callback(error)
19
+ return
20
+ }
21
+
22
+ const blob = new ResumableBlobRecord(this.file, checksum, this.url)
23
+ notify(this.delegate, "resumableUploadWillCreateBlobWithXHR", blob.xhr)
24
+
25
+ blob.create(error => {
26
+ if (error) {
27
+ callback(error)
28
+ } else {
29
+ const upload = new ResumableBlobUpload(blob)
30
+ notify(this.delegate, "resumableUploadWillStoreFileWithGcsBrowserUpload", upload.gcsBrowserUpload)
31
+ upload.create(error => {
32
+ if (error) {
33
+ callback(error)
34
+ } else {
35
+ callback(null, blob.toJSON())
36
+ }
37
+ })
38
+ }
39
+ })
40
+ })
41
+ }
42
+ }
43
+
44
+ function notify(object, methodName, ...messages) {
45
+ if (object && typeof object[methodName] == "function") {
46
+ return object[methodName](...messages)
47
+ }
48
+ }
@@ -0,0 +1,69 @@
1
+ import { ResumableUpload } from "./resumable_upload"
2
+ import { dispatchEvent } from "./helpers"
3
+
4
+ export class ResumableUploadController {
5
+ constructor(input, file) {
6
+ this.input = input
7
+ this.file = file
8
+ this.resumableUpload = new ResumableUpload(this.file, this.url, this)
9
+ this.dispatch("initialize")
10
+ }
11
+
12
+ start(callback) {
13
+ const hiddenInput = document.createElement("input")
14
+ hiddenInput.type = "hidden"
15
+ hiddenInput.name = this.input.name
16
+ this.input.insertAdjacentElement("beforebegin", hiddenInput)
17
+
18
+ this.dispatch("start")
19
+
20
+ this.resumableUpload.create((error, attributes) => {
21
+ if (error) {
22
+ hiddenInput.parentNode.removeChild(hiddenInput)
23
+ this.dispatchError(error)
24
+ } else {
25
+ hiddenInput.value = attributes.signed_id
26
+ }
27
+
28
+ this.dispatch("end")
29
+ callback(error)
30
+ })
31
+ }
32
+
33
+ uploadRequestDidProgress(event) {
34
+ const progress = event.loaded / event.total * 100
35
+ if (progress) {
36
+ this.dispatch("progress", { progress })
37
+ }
38
+ }
39
+
40
+ get url() {
41
+ return this.input.getAttribute("data-resumable-upload-url")
42
+ }
43
+
44
+ dispatch(name, detail = {}) {
45
+ detail.file = this.file
46
+ detail.id = this.resumableUpload.id
47
+ return dispatchEvent(this.input, `resumable-upload:${name}`, { detail })
48
+ }
49
+
50
+ dispatchError(error) {
51
+ const event = this.dispatch("error", { error })
52
+ if (!event.defaultPrevented) {
53
+ alert(error)
54
+ }
55
+ }
56
+
57
+ // ResumableUpload delegate
58
+
59
+ resumableUploadWillCreateBlobWithXHR(xhr) {
60
+ this.dispatch("before-blob-request", { xhr })
61
+ }
62
+
63
+ resumableUploadWillStoreFileWithGcsBrowserUpload(gcsBrowserUpload) {
64
+ this.dispatch("before-storage-request", { gcsBrowserUpload })
65
+ gcsBrowserUpload.opts.onChunkUpload = (event) => {
66
+ this.uploadRequestDidProgress({ lengthComputable: true, loaded: event.uploadedBytes, total: event.totalBytes })
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,50 @@
1
+ import { ResumableUploadController } from "./resumable_upload_controller"
2
+ import { findElements, dispatchEvent, toArray } from "./helpers"
3
+
4
+ const inputSelector = "input[type=file][data-resumable-upload-url]:not([disabled])"
5
+
6
+ export class ResumableUploadsController {
7
+ constructor(form) {
8
+ this.form = form
9
+ this.inputs = findElements(form, inputSelector).filter(input => input.files.length)
10
+ }
11
+
12
+ start(callback) {
13
+ const controllers = this.createResumableUploadControllers()
14
+
15
+ const startNextController = () => {
16
+ const controller = controllers.shift()
17
+ if (controller) {
18
+ controller.start(error => {
19
+ if (error) {
20
+ callback(error)
21
+ this.dispatch("end")
22
+ } else {
23
+ startNextController()
24
+ }
25
+ })
26
+ } else {
27
+ callback()
28
+ this.dispatch("end")
29
+ }
30
+ }
31
+
32
+ this.dispatch("start")
33
+ startNextController()
34
+ }
35
+
36
+ createResumableUploadControllers() {
37
+ const controllers = []
38
+ this.inputs.forEach(input => {
39
+ toArray(input.files).forEach(file => {
40
+ const controller = new ResumableUploadController(input, file)
41
+ controllers.push(controller)
42
+ })
43
+ })
44
+ return controllers
45
+ }
46
+
47
+ dispatch(name, detail = {}) {
48
+ return dispatchEvent(this.form, `resumable-uploads:${name}`, { detail })
49
+ }
50
+ }