Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Safe for Direct Uploads in js Libraries or Frameworks #47773

Merged
merged 6 commits into from Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions activestorage/CHANGELOG.md
Expand Up @@ -145,4 +145,12 @@

*Luke Lau*

* Safe for direct upload on Libraries or Frameworks

Enable the use of custom headers during direct uploads, which allows for
the inclusion of Authorization bearer tokens or other forms of authorization
tokens through headers.

*Radamés Roriz*

Please check [7-0-stable](https://github.com/rails/rails/blob/7-0-stable/activestorage/CHANGELOG.md) for previous changes.
10 changes: 7 additions & 3 deletions activestorage/app/assets/javascripts/activestorage.esm.js
Expand Up @@ -508,7 +508,7 @@ function toArray(value) {
}

class BlobRecord {
constructor(file, checksum, url) {
constructor(file, checksum, url, customHeaders = {}) {
this.file = file;
this.attributes = {
filename: file.name,
Expand All @@ -522,6 +522,9 @@ class BlobRecord {
this.xhr.setRequestHeader("Content-Type", "application/json");
this.xhr.setRequestHeader("Accept", "application/json");
this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
Object.keys(customHeaders).forEach((headerKey => {
this.xhr.setRequestHeader(headerKey, customHeaders[headerKey]);
}));
const csrfToken = getMetaValue("csrf-token");
if (csrfToken != undefined) {
this.xhr.setRequestHeader("X-CSRF-Token", csrfToken);
Expand Down Expand Up @@ -604,19 +607,20 @@ class BlobUpload {
let id = 0;

class DirectUpload {
constructor(file, url, delegate) {
constructor(file, url, delegate, customHeaders = {}) {
this.id = ++id;
this.file = file;
this.url = url;
this.delegate = delegate;
this.customHeaders = customHeaders;
}
create(callback) {
FileChecksum.create(this.file, ((error, checksum) => {
if (error) {
callback(error);
return;
}
const blob = new BlobRecord(this.file, checksum, this.url);
const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders);
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr);
blob.create((error => {
if (error) {
Expand Down
10 changes: 7 additions & 3 deletions activestorage/app/assets/javascripts/activestorage.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion activestorage/app/javascript/activestorage/blob_record.js
@@ -1,7 +1,7 @@
import { getMetaValue } from "./helpers"

export class BlobRecord {
constructor(file, checksum, url) {
constructor(file, checksum, url, customHeaders = {}) {
this.file = file

this.attributes = {
Expand All @@ -17,6 +17,9 @@ export class BlobRecord {
this.xhr.setRequestHeader("Content-Type", "application/json")
this.xhr.setRequestHeader("Accept", "application/json")
this.xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
Object.keys(customHeaders).forEach((headerKey) => {
this.xhr.setRequestHeader(headerKey, customHeaders[headerKey])
})

const csrfToken = getMetaValue("csrf-token")
if (csrfToken != undefined) {
Expand Down
5 changes: 3 additions & 2 deletions activestorage/app/javascript/activestorage/direct_upload.js
Expand Up @@ -5,11 +5,12 @@ import { BlobUpload } from "./blob_upload"
let id = 0

export class DirectUpload {
constructor(file, url, delegate) {
constructor(file, url, delegate, customHeaders = {}) {
this.id = ++id
this.file = file
this.url = url
this.delegate = delegate
this.customHeaders = customHeaders
}

create(callback) {
Expand All @@ -19,7 +20,7 @@ export class DirectUpload {
return
}

const blob = new BlobRecord(this.file, checksum, this.url)
const blob = new BlobRecord(this.file, checksum, this.url, this.customHeaders)
notify(this.delegate, "directUploadWillCreateBlobWithXHR", blob.xhr)

blob.create(error => {
Expand Down
69 changes: 60 additions & 9 deletions guides/source/active_storage_overview.md
Expand Up @@ -1162,11 +1162,8 @@ input[type=file][data-direct-upload-url][disabled] {
}
```

### Integrating with Libraries or Frameworks

If you want to use the Direct Upload feature from a JavaScript framework, or
you want to integrate custom drag and drop solutions, you can use the
`DirectUpload` class for this purpose. Upon receiving a file from your library
### Custom drag and drop solutions
you can use the `DirectUpload` class for this purpose. Upon receiving a file from your library
of choice, instantiate a DirectUpload and call its create method. Create takes
a callback to invoke when the upload completes.

Expand Down Expand Up @@ -1213,10 +1210,11 @@ const uploadFile = (file) => {
}
```

If you need to track the progress of the file upload, you can pass a third
parameter to the `DirectUpload` constructor. During the upload, DirectUpload
will call the object's `directUploadWillStoreFileWithXHR` method. You can then
bind your own progress handler on the XHR.
### Track the progress of the file upload
When using the `DirectUpload` constructor, it is possible to include a third parameter.
This will allow the `DirectUpload` object to invoke the `directUploadWillStoreFileWithXHR`
method during the upload process.
You can then attach your own progress handler to the XHR to suit your needs.

```js
import { DirectUpload } from "@rails/activestorage"
Expand Down Expand Up @@ -1248,6 +1246,59 @@ class Uploader {
}
```

### Integrating with Libraries or Frameworks
Once you receive a file from the library you have selected, you need to create
a `DirectUpload` instance and use its "create" method to initiate the upload process,
adding any required additional headers as necessary. The "create" method also requires
a callback function to be provided that will be triggered once the upload has finished.

```js
import { DirectUpload } from "@rails/activestorage"

class Uploader {
constructor(file, url, token) {
const headers = { 'Authentication': `Bearer ${token}` }
// INFO: Sending headers is an optional parameter. If you choose not to send headers,
// authentication will be performed using cookies or session data.
this.upload = new DirectUpload(this.file, this.url, this, headers)
}

upload(file) {
this.upload.create((error, blob) => {
if (error) {
// Handle the error
} else {
// Use the with blob.signed_id as a file reference in next request
}
})
}

directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress",
event => this.directUploadDidProgress(event))
}

directUploadDidProgress(event) {
// Use event.loaded and event.total to update the progress bar
}
}
```

To implement customized authentication, a new controller must be created on
the backend server, similar to the following:
```ruby
class DirectUploadsController < ActiveStorage::DirectUploadsController
skip_before_action :verify_authenticity_token
before_action :authenticate!

def authenticate!
@token = request.headers['Authorization']&.split&.last

return head :unauthorized unless valid_token?(@token)
end
end
```

NOTE: Using [Direct Uploads](#direct-uploads) can sometimes result in a file that uploads, but never attaches to a record. Consider [purging unattached uploads](#purging-unattached-uploads).

Testing
Expand Down