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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +81 -0
- data/Rakefile +19 -0
- data/app/assets/javascripts/activestorage-resumable.js +7853 -0
- data/app/controllers/active_storage/resumable_uploads_controller.rb +26 -0
- data/app/javascript/activestorage-resumable/helpers.js +51 -0
- data/app/javascript/activestorage-resumable/index.js +11 -0
- data/app/javascript/activestorage-resumable/resumable_blob_record.js +73 -0
- data/app/javascript/activestorage-resumable/resumable_blob_upload.js +56 -0
- data/app/javascript/activestorage-resumable/resumable_file_checksum.js +66 -0
- data/app/javascript/activestorage-resumable/resumable_upload.js +48 -0
- data/app/javascript/activestorage-resumable/resumable_upload_controller.js +69 -0
- data/app/javascript/activestorage-resumable/resumable_uploads_controller.js +50 -0
- data/app/javascript/activestorage-resumable/ujs-resumable.js +86 -0
- data/config/routes.rb +8 -0
- data/db/migrate/20190825001409_add_resumable_url_to_active_storage_blobs.rb +5 -0
- data/lib/actionview_extensions/helpers/form_tag_helper.rb +20 -0
- data/lib/activestorage/resumable.rb +17 -0
- data/lib/activestorage/resumable/engine.rb +9 -0
- data/lib/activestorage/resumable/version.rb +7 -0
- data/lib/activestorage_extensions/blob.rb +37 -0
- data/lib/activestorage_extensions/service/gcs_service.rb +32 -0
- metadata +107 -0
@@ -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,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
|
+
}
|