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