1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Add JavaScript direct upload support (#81)

This commit is contained in:
Javan Makhmali 2017-07-27 19:47:03 -04:00 committed by David Heinemeier Hansson
parent dc235c4d13
commit 6262891b56
16 changed files with 3139 additions and 0 deletions

5
.babelrc Normal file
View file

@ -0,0 +1,5 @@
{
"presets": [
["env", { "modules": false } ]
]
}

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
.byebug_history .byebug_history
/node_modules
test/dummy/db/*.sqlite3 test/dummy/db/*.sqlite3
test/dummy/db/*.sqlite3-journal test/dummy/db/*.sqlite3-journal
test/dummy/log/*.log test/dummy/log/*.log

View file

@ -96,6 +96,44 @@ Variation of image attachment:
6. Optional: Add `gem "google-cloud-storage", "~> 1.3"` to your Gemfile if you want to use Google Cloud Storage. 6. Optional: Add `gem "google-cloud-storage", "~> 1.3"` to your Gemfile if you want to use Google Cloud Storage.
7. Optional: Add `gem "mini_magick"` to your Gemfile if you want to use variants. 7. Optional: Add `gem "mini_magick"` to your Gemfile if you want to use variants.
## Direct uploads
Active Storage, with its included JavaScript library, supports uploading directly from the client to the cloud.
### Direct upload installation
1. Include `activestorage.js` in your application's JavaScript bundle.
Using the asset pipeline:
```js
//= require activestorage
```
Using the npm package:
```js
import * as ActiveStorage from "activestorage"
ActiveStorage.start()
```
2. Annotate file inputs with the direct upload URL.
```ruby
<%= form.file_field :attachments, multiple: true, data: { direct_upload_url: rails_direct_uploads_url } %>
```
3. That's it! Uploads begin upon form submission.
### Direct upload JavaScript events
| Event name | Event target | Event data (`event.detail`) | Description |
| --- | --- | --- | --- |
| `direct-uploads:start` | `<form>` | None | A form containing files for direct upload fields was submit. |
| `direct-upload:initialize` | `<input>` | `{id, file}` | Dispatched for every file after form submission. |
| `direct-upload:start` | `<input>` | `{id, file}` | A direct upload is starting. |
| `direct-upload:before-blob-request` | `<input>` | `{id, file, xhr}` | Before making a request to your application for direct upload metadata. |
| `direct-upload:before-storage-request` | `<input>` | `{id, file, xhr}` | Before making a request to store a file. |
| `direct-upload:progress` | `<input>` | `{id, file, progress}` | As requests to store files progress. |
| `direct-upload:error` | `<input>` | `{id, file, error}` | An error occurred. An `alert` will display unless this event is canceled. |
| `direct-upload:end` | `<input>` | `{id, file}` | A direct upload has ended. |
| `direct-uploads:end` | `<form>` | None | All direct uploads have ended. |
## Compatibility & Expectations ## Compatibility & Expectations
Active Storage only works with the development version of Rails 5.2+ (as of July 19, 2017). This separate repository is a staging ground for the upcoming inclusion in rails/rails prior to the Rails 5.2 release. It is not intended to be a long-term stand-alone repository. Active Storage only works with the development version of Rails 5.2+ (as of July 19, 2017). This separate repository is a staging ground for the upcoming inclusion in rails/rails prior to the Rails 5.2 release. It is not intended to be a long-term stand-alone repository.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,52 @@
import { getMetaValue } from "./helpers"
export class BlobRecord {
constructor(file, checksum, url) {
this.file = file
this.attributes = {
filename: file.name,
content_type: file.type,
byte_size: file.size,
checksum: checksum
}
this.xhr = new XMLHttpRequest
this.xhr.open("POST", url, true)
this.xhr.responseType = "json"
this.xhr.setRequestHeader("Content-Type", "application/json")
this.xhr.setRequestHeader("Accept", "application/json")
this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
this.xhr.setRequestHeader("X-CSRF-Token", getMetaValue("csrf-token"))
this.xhr.addEventListener("load", event => this.requestDidLoad(event))
this.xhr.addEventListener("error", event => this.requestDidError(event))
}
create(callback) {
this.callback = callback
this.xhr.send(JSON.stringify({ blob: this.attributes }))
}
requestDidLoad(event) {
const { status, response } = this.xhr
if (status >= 200 && status < 300) {
this.attributes.signed_id = response.signed_blob_id
this.uploadURL = response.upload_to_url
this.callback(null, this.toJSON())
} else {
this.requestDidError(event)
}
}
requestDidError(event) {
this.callback(`Error creating Blob for "${this.file.name}". Status: ${this.xhr.status}`)
}
toJSON() {
const result = {}
for (const key in this.attributes) {
result[key] = this.attributes[key]
}
return result
}
}

View file

@ -0,0 +1,31 @@
export class BlobUpload {
constructor(blob) {
this.blob = blob
this.file = blob.file
this.xhr = new XMLHttpRequest
this.xhr.open("PUT", blob.uploadURL, true)
this.xhr.setRequestHeader("Content-Type", blob.attributes.content_type)
this.xhr.setRequestHeader("Content-MD5", blob.attributes.checksum)
this.xhr.addEventListener("load", event => this.requestDidLoad(event))
this.xhr.addEventListener("error", event => this.requestDidError(event))
}
create(callback) {
this.callback = callback
this.xhr.send(this.file)
}
requestDidLoad(event) {
const { status, response } = this.xhr
if (status >= 200 && status < 300) {
this.callback(null, this.xhr.response)
} else {
this.requestDidError(event)
}
}
requestDidError(event) {
this.callback(`Error storing "${this.file.name}". Status: ${this.xhr.status}`)
}
}

View file

@ -0,0 +1,43 @@
import { FileChecksum } from "./file_checksum"
import { BlobRecord } from "./blob_record"
import { BlobUpload } from "./blob_upload"
let id = 0
export class DirectUpload {
constructor(file, options = {}) {
this.id = id++
this.file = file
this.url = options.url
this.delegate = options.delegate
}
create(callback) {
const fileChecksum = new FileChecksum(this.file)
fileChecksum.create((error, checksum) => {
const blob = new BlobRecord(this.file, checksum, this.url)
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)
blob.create(error => {
if (error) {
callback(error)
} else {
const upload = new BlobUpload(blob)
notify(this.delegate, "directUploadWillStoreFileWithXHR", upload.xhr)
upload.create(error => {
if (error) {
callback(error)
} else {
callback(null, blob.toJSON())
}
})
}
})
})
}
}
function notify(object, methodName, ...messages) {
if (object && typeof object[methodName] == "function") {
return object[methodName](...messages)
}
}

View file

@ -0,0 +1,67 @@
import { DirectUpload } from "./direct_upload"
import { dispatchEvent } from "./helpers"
export class DirectUploadController {
constructor(input, file) {
this.input = input
this.file = file
this.directUpload = new DirectUpload(this.file, { url: this.url, delegate: this })
this.dispatch("initialize")
}
start(callback) {
const hiddenInput = document.createElement("input")
hiddenInput.type = "hidden"
hiddenInput.name = this.input.name
this.input.insertAdjacentElement("beforebegin", hiddenInput)
this.dispatch("start")
this.directUpload.create((error, attributes) => {
if (error) {
hiddenInput.parentNode.removeChild(hiddenInput)
this.dispatchError(error)
} else {
hiddenInput.value = attributes.signed_id
}
this.dispatch("end")
callback(error)
})
}
uploadRequestDidProgress(event) {
const progress = event.loaded / event.total * 100
if (progress) {
this.dispatch("progress", { progress })
}
}
get url() {
return this.input.getAttribute("data-direct-upload-url")
}
dispatch(name, detail = {}) {
detail.file = this.file
detail.id = this.directUpload.id
return dispatchEvent(this.input, `direct-upload:${name}`, { detail })
}
dispatchError(error) {
const event = this.dispatch("error", { error })
if (!event.defaultPrevented) {
alert(error)
}
}
// DirectUpload delegate
directUploadWillCreateBlobWithXHR(xhr) {
this.dispatch("before-blob-request", { xhr })
}
directUploadWillStoreFileWithXHR(xhr) {
this.dispatch("before-storage-request", { xhr })
xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event))
}
}

View file

@ -0,0 +1,50 @@
import { DirectUploadController } from "./direct_upload_controller"
import { findElements, dispatchEvent, toArray } from "./helpers"
const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"
export class DirectUploadsController {
constructor(form) {
this.form = form
this.inputs = findElements(form, inputSelector).filter(input => input.files.length)
}
start(callback) {
const controllers = this.createDirectUploadControllers()
const startNextController = () => {
const controller = controllers.shift()
if (controller) {
controller.start(error => {
if (error) {
callback(error)
this.dispatch("end")
} else {
startNextController()
}
})
} else {
callback()
this.dispatch("end")
}
}
this.dispatch("start")
startNextController()
}
createDirectUploadControllers() {
const controllers = []
this.inputs.forEach(input => {
toArray(input.files).forEach(file => {
const controller = new DirectUploadController(input, file)
controllers.push(controller)
})
})
return controllers
}
dispatch(name, detail = {}) {
return dispatchEvent(this.form, `direct-uploads:${name}`, { detail })
}
}

View file

@ -0,0 +1,48 @@
import SparkMD5 from "spark-md5"
const fileSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
export class FileChecksum {
constructor(file) {
this.file = file
this.chunkSize = 2097152 // 2MB
this.chunkCount = Math.ceil(this.file.size / this.chunkSize)
this.chunkIndex = 0
}
create(callback) {
this.callback = callback
this.md5Buffer = new SparkMD5.ArrayBuffer
this.fileReader = new FileReader
this.fileReader.addEventListener("load", event => this.fileReaderDidLoad(event))
this.fileReader.addEventListener("error", event => this.fileReaderDidError(event))
this.readNextChunk()
}
fileReaderDidLoad(event) {
this.md5Buffer.append(event.target.result)
if (!this.readNextChunk()) {
const binaryDigest = this.md5Buffer.end(true)
const base64digest = btoa(binaryDigest)
this.callback(null, base64digest)
}
}
fileReaderDidError(event) {
this.callback(`Error reading ${this.file.name}`)
}
readNextChunk() {
if (this.chunkIndex < this.chunkCount) {
const start = this.chunkIndex * this.chunkSize
const end = Math.min(start + this.chunkSize, this.file.size)
const bytes = fileSlice.call(this.file, start, end)
this.fileReader.readAsArrayBuffer(bytes)
this.chunkIndex++
return true
} else {
return false
}
}
}

View file

@ -0,0 +1,42 @@
export function getMetaValue(name) {
const element = findElement(document.head, `meta[name="${name}"]`)
if (element) {
return element.getAttribute("content")
}
}
export function findElements(root, selector) {
if (typeof root == "string") {
selector = root
root = document
}
const elements = root.querySelectorAll(selector)
return toArray(elements)
}
export function findElement(root, selector) {
if (typeof root == "string") {
selector = root
root = document
}
return root.querySelector(selector)
}
export function dispatchEvent(element, type, eventInit = {}) {
const { bubbles, cancelable, detail } = eventInit
const event = document.createEvent("Event")
event.initEvent(type, bubbles || true, cancelable || true)
event.detail = detail || {}
element.dispatchEvent(event)
return event
}
export function toArray(value) {
if (Array.isArray(value)) {
return value
} else if (Array.from) {
return Array.from(value)
} else {
return [].slice.call(value)
}
}

View file

@ -0,0 +1,11 @@
import { start } from "./ujs"
import { DirectUpload } from "./direct_upload"
export { start, DirectUpload }
function autostart() {
if (window.ActiveStorage) {
start()
}
}
setTimeout(autostart, 1)

View file

@ -0,0 +1,74 @@
import { DirectUploadsController } from "./direct_uploads_controller"
import { findElement } from "./helpers"
const processingAttribute = "data-direct-uploads-processing"
let started = false
export function start() {
if (!started) {
started = true
document.addEventListener("submit", didSubmitForm)
document.addEventListener("ajax:before", didSubmitRemoteElement)
}
}
function didSubmitForm(event) {
handleFormSubmissionEvent(event)
}
function didSubmitRemoteElement(event) {
if (event.target.tagName == "FORM") {
handleFormSubmissionEvent(event)
}
}
function handleFormSubmissionEvent(event) {
const form = event.target
if (form.hasAttribute(processingAttribute)) {
event.preventDefault()
return
}
const controller = new DirectUploadsController(form)
const { inputs } = controller
if (inputs.length) {
event.preventDefault()
form.setAttribute(processingAttribute, "")
inputs.forEach(disable)
controller.start(error => {
form.removeAttribute(processingAttribute)
if (error) {
inputs.forEach(enable)
} else {
submitForm(form)
}
})
}
}
function submitForm(form) {
let button = findElement(form, "input[type=submit]")
if (button) {
const { disabled } = button
button.disabled = false
button.click()
button.disabled = disabled
} else {
button = document.createElement("input")
button.type = "submit"
button.style = "display:none"
form.appendChild(button)
button.click()
form.removeChild(button)
}
}
function disable(input) {
input.disabled = true
}
function enable(input) {
input.disabled = false
}

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "activestorage",
"version": "0.1.0",
"description": "Attach cloud and local files in Rails applications",
"main": "app/assets/javascripts/activestorage.js",
"repository": "git+https://github.com/rails/activestorage.git",
"author": "Javan Makhmali <javan@javan.us>",
"license": "MIT",
"devDependencies": {
"babel-core": "^6.25.0",
"babel-loader": "^7.1.1",
"babel-preset-env": "^1.6.0",
"spark-md5": "^3.0.0",
"webpack": "^3.4.0"
},
"files": [
"app/assets/javascripts/activestorage.js"
],
"scripts": {
"build": "webpack -p"
}
}

27
webpack.config.js Normal file
View file

@ -0,0 +1,27 @@
const webpack = require("webpack")
const path = require("path")
module.exports = {
entry: {
"activestorage": path.resolve(__dirname, "app/javascript/activestorage/index.js"),
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "app/assets/javascripts"),
library: "ActiveStorage",
libraryTarget: "umd"
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader"
}
}
]
}
}

2627
yarn.lock Normal file

File diff suppressed because it is too large Load diff