activestorage-resumable 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ }