-
Notifications
You must be signed in to change notification settings - Fork 21.7k
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
Make ActiveStorage work for API only apps #32208
Comments
Dynamically determine the base class for the Active Storage controllers when they're loaded by the application - ActionController::Base for ordinary applications and ActionController::API for API-only applications. Fixes #32208.
@hector Would you mind sharing how you used ActiveStorage outside of a Form field? |
Sure, add the attribute Remember you need to import the ActiveStorage javascript as suggested in the readme: For this importing step I had to do a variation since I am using React and ES6 imports, I needed more control over success/failure of the direct uploads and I am using React's controlled components. I extracted the code from import React from 'react';
import {DirectUploadsController} from '../../lib/activestorage/direct_uploads_controller';
class NewProject extends React.Component {
componentDidMount() {
this.project = new Project(); // OptionaI: I store attributes in a Project model
document.addEventListener('direct-upload:end', this.setDirectUploadAttr);
// bind methods below here, I do it automatically with a decorator
}
setDirectUploadAttr(event) {
const fileInput = event.target;
const hiddenInput = document.querySelector(`input[type="hidden"][name="${fileInput.name}"`);
// Here you have the value you have to assign to the has_one_attached attribute,
// do whatever you do assign it to. I have it in a project model
this.project[fileInput.name] = hiddenInput.value;
}
// You may use promises instead of async/await
async onFormSubmit(event) {
event.preventDefault();
try {
// Upload attached files to external storage service
const form = event.target;
const controller = new DirectUploadsController(form);
const {inputs} = controller;
if (inputs.length) {
await new Promise((resolve, reject) => {
controller.start(error => error? reject(error) : resolve());
});
}
// Run your submit function. In my case:
await this.project.save();
} catch (error) {
// Do something with the error. For example:
alert(error);
}
}
render() {
returns (
<form onSubmit={this.onFormSubmit}>
<input id="video" name="video" type="file" data-direct-upload-url="/rails/active_storage/direct_uploads" />
<button type="submit">Submit</button>
</form>
);
}
} This would be the original Rails model: class Project < ApplicationRecord
has_one_attached :video
end |
I wrote this package to make it easier to use ActiveStorage in React components, |
Thank you @hector ! That's very useful! |
A decent workaround for this issue that I have been using is in the config/initializers dir I have created a file called |
@cbothner will the react-activestorage-provider work with ReactNative? |
Right now, I’m sorry to say, it certainly will not. The activestorage
JavaScript package uses DOM Form APIs to perform the direct upload, so it
won’t work with React Native.
2018年4月21日(土) 13:27 cdesch <notifications@github.com>:
… @cbothner <https://github.com/cbothner> will the
react-activestorage-provider
<https://github.com/cbothner/react-activestorage-provider> work with
ReactNative?
—
You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
<#32208 (comment)>, or mute
the thread
<https://github.com/notifications/unsubscribe-auth/AEbXJ7kltyGN4T5RgU0OHJOBc_-ERGuVks5tq3oqgaJpZM4SjcIj>
.
|
Are there any recommendations on how to use ActiveStorage with a React Native app? |
@tommotaylor I would like to use ActiveStorage with a React Native app as well. Previously, I had a rather hacky setup combining /**
* Taken, CommonJS-ified, and heavily modified from:
* https://github.com/flyingsparx/NodeDirectUploader
*/
import RNFetchBlob from "react-native-fetch-blob";
var Blob = RNFetchBlob.polyfill.Blob;
const fs = RNFetchBlob.fs;
S3Upload.prototype.server = "";
S3Upload.prototype.signingUrl = "/sign-s3";
S3Upload.prototype.signingUrlMethod = "GET";
S3Upload.prototype.signingUrlSuccessResponses = [200, 201];
S3Upload.prototype.fileElement = null;
S3Upload.prototype.files = null;
S3Upload.prototype.onFinishS3Put = function(signResult, file) {
return console.log("base.onFinishS3Put()", signResult.publicUrl);
};
S3Upload.prototype.preprocess = function(file, next) {
console.log("base.preprocess()", file);
return next(file);
};
S3Upload.prototype.onProgress = function(percent, status, file) {
return console.log("base.onProgress()", percent, status);
};
S3Upload.prototype.onError = function(status, file) {
return console.log("base.onError()", status);
};
S3Upload.prototype.scrubFilename = function(filename) {
return filename.replace(/[^\w\d_\-\.]+/gi, "");
};
function S3Upload(options) {
if (options == null) {
options = {};
}
for (var option in options) {
if (options.hasOwnProperty(option)) {
this[option] = options[option];
}
}
var files = this.fileElement ? this.fileElement.files : this.files || [];
this.handleFileSelect(files);
}
S3Upload.prototype.handleFileSelect = function(files) {
var result = [];
for (var i = 0; i < files.length; i++) {
var file = files[i];
this.preprocess(
file,
function(processedFile) {
this.onProgress(0, "Waiting", processedFile);
result.push(this.uploadFile(processedFile));
return result;
}.bind(this)
);
}
};
S3Upload.prototype.createCORSRequest = function(method, url, opts) {
var opts = opts || {};
var xhr = new RNFetchBlob.polyfill.XMLHttpRequest();
xhr.open(method, url, true);
xhr.withCredentials = true;
return xhr;
};
S3Upload.prototype.executeOnSignedUrl = function(file, callback) {
var fileName = this.scrubFilename(file.name);
var queryString =
"?objectName=" + fileName + "&contentType=" + encodeURIComponent(file.type);
if (this.s3path) {
queryString += "&path=" + encodeURIComponent(this.s3path);
}
if (this.signingUrlQueryParams) {
var signingUrlQueryParams =
typeof this.signingUrlQueryParams === "function"
? this.signingUrlQueryParams()
: this.signingUrlQueryParams;
Object.keys(signingUrlQueryParams).forEach(function(key) {
var val = signingUrlQueryParams[key];
queryString += "&" + key + "=" + val;
});
}
var xhr = this.createCORSRequest(
this.signingUrlMethod,
this.server + this.signingUrl + queryString,
{ withCredentials: this.signingUrlWithCredentials }
);
if (this.signingUrlHeaders) {
var signingUrlHeaders =
typeof this.signingUrlHeaders === "function"
? this.signingUrlHeaders()
: this.signingUrlHeaders;
Object.keys(signingUrlHeaders).forEach(function(key) {
var val = signingUrlHeaders[key];
xhr.setRequestHeader(key, val);
});
}
xhr.onreadystatechange = function() {
if (
xhr.readyState === 4 &&
this.signingUrlSuccessResponses.indexOf(xhr.status) >= 0
) {
var result;
try {
result = JSON.parse(xhr.responseText);
} catch (error) {
this.onError("Invalid response from server", file);
return false;
}
return callback(result);
} else if (
xhr.readyState === 4 &&
this.signingUrlSuccessResponses.indexOf(xhr.status) < 0
) {
return this.onError(
"Could not contact request signing server. Status = " + xhr.status,
file
);
}
}.bind(this);
return xhr.send();
};
S3Upload.prototype.uploadToS3 = function(file, signResult) {
var xhr = this.createCORSRequest("PUT", signResult.signedUrl);
if (!xhr) {
this.onError("CORS not supported", file);
} else {
xhr.onload = function() {
if (xhr.status === 200) {
this.onProgress(100, "Upload completed", file);
return this.onFinishS3Put(signResult, file);
} else {
return this.onError("Upload error: " + xhr.status, file);
}
}.bind(this);
xhr.onerror = function(err) {
return this.onError("XHR error", file);
}.bind(this);
xhr.upload.onprogress = function(e) {
var percentLoaded;
if (e.lengthComputable) {
percentLoaded = Math.round(e.loaded / e.total * 100);
return this.onProgress(
percentLoaded,
percentLoaded === 100 ? "Finalizing" : "Uploading",
file
);
}
}.bind(this);
}
xhr.setRequestHeader("Content-Type", file.type);
if (this.contentDisposition) {
var disposition = this.contentDisposition;
if (disposition === "auto") {
if (file.type.substr(0, 6) === "image/") {
disposition = "inline";
} else {
disposition = "attachment";
}
}
var fileName = this.scrubFilename(file.name);
xhr.setRequestHeader(
"Content-Disposition",
disposition + '; filename="' + fileName + '"'
);
}
if (signResult.headers) {
var signResultHeaders = signResult.headers;
Object.keys(signResultHeaders).forEach(function(key) {
var val = signResultHeaders[key];
xhr.setRequestHeader(key, val);
});
}
if (this.uploadRequestHeaders) {
var uploadRequestHeaders = this.uploadRequestHeaders;
Object.keys(uploadRequestHeaders).forEach(function(key) {
var val = uploadRequestHeaders[key];
xhr.setRequestHeader(key, val);
});
} else {
xhr.setRequestHeader("x-amz-acl", "public-read");
}
this.httprequest = xhr;
return xhr.send(file);
};
S3Upload.prototype.uploadFile = function(file) {
var uploadToS3Callback = this.uploadToS3.bind(this, file);
if (this.getSignedUrl) return this.getSignedUrl(file, uploadToS3Callback);
return this.executeOnSignedUrl(file, uploadToS3Callback);
};
S3Upload.prototype.abortUpload = function() {
this.httprequest && this.httprequest.abort();
};
export default S3Upload; This does not work at all with ActiveStorage...and using ActiveStorage would be much nicer. |
The npm package No Direct UploadIt is easiest if you expect only to receive small enough files or if you’re not using Heroku and you don’t need direct upload to S3. React Native supports the fetch API so you can use let data = new FormData()
data.append('user[image]', fileObject)
fetch(/* user_url */, {
method: 'PUT',
body: data
}) Direct UploadIt will be harder to do a direct upload, but these are the requests that the Get the signed upload URLfetch('/rails/active_storage/direct_uploads', {
method: 'POST',
body: {
blob: {
filename: "griffin.jpeg",
content_type: "image/jpeg",
byte_size: 1020753,
checksum: /* base 64 of the MD5 hash of the file */
}
}) (The That returns a JSON response with keys Upload the filePUT the file to fetch(response.direct_upload.url, {
method: 'PUT',
headers: response.direct_upload.headers,
body: fileObject,
}) Update your Rails model with the signed_id of the blob you just madefetch(/* user_url */, {
method: 'PUT',
body: {
user: {
image: response.signed_id
}
}
}) That should be it. Create a blob, which gives you a signed upload url. PUT the file to that signed URL. Then update the rails model to assign your signed url to the attachment relation. |
I am having the same request and i am looking to send files from an iOS App to a Rails REST API which uses ActiveStorage. All the responses here seem to use a HTML form. I'd like some guidance on how to do it without a form but from any external service. Would i need to replicate/mimic a form multipart? My setup uses local disk storage, not an external provider. |
@drale2k the Direct Upload section of my answer above should work for you using a URLRequest or some other abstraction in place of javascript’s fetch. You can still use the direct upload workflow with local disk storage—that’s what I’m using in development and it works the same. You might even have an easier time with the MD5 in native iOS than others might have on React Native https://stackoverflow.com/questions/1684799/generate-hash-from-uiimage |
@cbothner Thanks!! I could do activestorage direct_upload works with reactjs and redux-sagas with the help of your comments. |
I have a promise-service with all the proper decorated headers for my requests including a bearer token, endpoint url, etc... I don't want to interpolate the strings into DirectUpload, does anyone know of a way to reuse the promise with DirectUpload? |
Here is a write up for what I did to get this working. This is my contribution to Rails community for the rest of the year 😄. My Project SetupI want to provide some context first. I have my React app split away from Rails. In production, I will have my React app in S3 with Rails in Heroku. In summary, I am not using Rails to serve my single page application. My rails server:
My react app:
High Level OverviewYou will want to use Behind the scene, Here are examples of the request with their responses, but again, you don't have to worry about this since the library handles it for you:
After getting this far, then you just take the The ImplementationPrepping Your Controllers@derigible had the right idea to skip forgery, but you guys need to understand the consequences of skipping this and removing the authenticity token. These things help prevent random requests from replaying and storing random stuff into your ActiveRecord. Now that said, I have all my endpoints protected with an access token granted through OAuth. More specifically, I am using Doorkeeper and I will be decorating the controller to include it. I didn't like @derigible method, because it was in an initializer. It doesn't follow convention well, and the next developer won't be able to predictably find where they would expect it. Here's what I did instead:
Now after prepping my controllers, if I don't send an access/bearer token then this endpoint will respond with a FrontendNow in your frontend, we have a few things to do. One, we need to decorate the requests made by
Backend
If you made any changes to your image using HTML5 canvas like I did, then you'll need to add a Yielding the following:
Good luck guys! Let me know if I can help with anything. If this helped, could you drop a 🍆 ? And thanks @cbothner for your write up. |
This is something we should definitely consider adding to the documentation for ActiveStorage. I took the above and cleaned up a little. Im using redux token auth and its a viable part of the conversation since using the JS package how its described in the docs wont work for token authenticated requests. Thanks @dagumak ! A little bit on how it differs.
import { DirectUpload } from 'activestorage/src/direct_upload';
import getHeadersFromStorage from './apiHeaders';
import { HOST, authHeaderKeys } from '../constants';
const RASURL = `${HOST}/rails/active_storage/direct_uploads`;
/**
* Promisify the create method provided by DirectUpload.
* @param {object} upload DirectUpload instance
* @return {promise} returns a promise to be used on async/await
*/
function createUpload(upload) {
return new Promise((resolve, reject) => {
upload.create((err, blob) => {
if (err) reject(err);
else resolve(blob);
});
});
}
/**
* Upload to service using ActiveStorage DirectUpload module
* @param {Object} file Image buffer to be uploaded.
* @return {Object} blob object from server.
* @see https://github.com/rails/rails/issues/32208
*/
async function activeStorageUpload(file) {
let imageBlob;
const headers = await getHeadersFromStorage();
const upload = new DirectUpload(file, RASURL, {
directUploadWillCreateBlobWithXHR: xhr => {
authHeaderKeys.forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
}
});
try {
imageBlob = await createUpload(upload);
} catch (err) {
throw err;
}
return imageBlob;
}
export default activeStorageUpload; These are my authHeaderKeys const authHeaderKeys = [
'access-token',
'token-type',
'client',
'uid',
'expiry'
]; Usage: const imageBlob = await activeStorageUpload(imageData); EditIn terms of updating just the image record like in For me, that hits my controller in the following way def update_profile
if current_api_v1_user.update_attributes(user_params)
user_serilized = UserSerializer.new(
current_api_v1_user
)
render json: {
data: user_serilized,
is_success: true,
status: 'success',
}
else
render json: { error: "Failed to Update", is_success: false }, status: 422
end
end With the above, you bypass the id needed in the default rails update route and capitalizes on using the current_user helper (in my case You could in fact do the same without creating a method or route using the default rails |
Want to also update folks who might have the same issue as I do to make this function correctly. Referencing @dagumak 's post I needed to include a class DirectUploadsController < ActiveStorage::DirectUploadsController
# I'm using JWTSession for auth and have to include it
# as this doesn't inherit due to `ActiveStorage::BaseController < ActionController::Base`
# where we are using `ApplicationController < ActionController::API` in API Mode.
include JWTSessions::RailsAuthorization
rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token
# Calling JWTSession to authorize
before_action :authorize_access_request!
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(blob_args)
render json: direct_upload_json(blob)
end
private
def blob_params
params.require(:blob).permit(
:filename,
:content-type,
# etc...
)
end
# Rescue the Auth Error
def not_authorized
render json: { error: 'Not authorized' }, status: :unauthorized
end
end The create method comes right from the source code
That comes back with all the JSON My route file then is just Rails.application.routes.draw do
post '/rails/active_storage/direct_uploads' => 'direct_uploads#create'
end My JS also looks like (Using React) const url = `${API_URL}/rails/active_storage/direct_uploads`;
const upload = new DirectUpload(file, url, {
directUploadWillCreateBlobWithXHR: xhr => {
// Put my JWT token in the auth header here
xhr.setRequestHeader('Authorization', `Bearer ${this.state.accessToken}`);
// Send progress upload updates
xhr.upload.addEventListener('progress', event => this.directUploadProgress(event));
}
}); That should help others get started on 5.2 at least. Hope we can get something to work out here for API uploads to make this a little easier. |
@robertsonsamuel You do not need to add the
Edit: Actually @robertsonsamuel I noticed you are using |
@dagumak that was entirely correct. I was able to remove that create method as well. Cleaned it up quite nicely. Thanks for pointing that out. 🎉Includes didn't effect this however, thanks for the insight! |
This issue has been automatically marked as stale because it has not been commented on for at least three months. |
For those on react native, I was able to get direct uploads working using |
I ended up using Shrine and Uppy instead of ActiveStorage because it
supports this usecase better than ActiveStorage.
…On Thu, May 7, 2020 at 3:57 PM Emmanouil Kontakis ***@***.***> wrote:
That's my solution on a react js app with progress
const upload = new DirectUpload(file, railsActiveStorageDirectUploadsUrl, {
directUploadWillCreateBlobWithXHR: (xhr) => {
// This will decorate the requests with the access token header so you won't get a 401
xhr.setRequestHeader("Authorization", `Bearer ${JSON.parse(localStorage.getItem('user')).auth_token}`)
directUploadWillStoreFileWithXHR(request) {
request.upload.addEventListener("progress",
event => this.directUploadDidProgress(event))
},
directUploadDidProgress(event) {
console.log(event)
}
})
—
You are receiving this because you commented.
Reply to this email directly, view it on GitHub
<#32208 (comment)>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAFNGS2ZXN6SQYFSUJNCWMLRQM4D3ANCNFSM4EUNYIRQ>
.
|
Inherit from ActionController::API and include only the required features. Fixes #32208.
As we wait for this ☝🏽 to be merged, anyone got a guide for using Vue with ActiveStorage? Thanks! |
You want to buffer it from disk instead to calculate the checksum. Uploading to the server needs to be buffered as well, your code calculated the checksum by loading the entire file into memory instead of buffering. |
This issue has been automatically marked as stale because it has not been commented on for at least three months. |
Can this be re-opened? I haven't tested this with rails 7, but don't think it's solved |
I haven't followed this thread, but is there a way to get someone in the Rails core team involved? It seems to be a pretty glaring deficiency in the API only mode? |
Well, this very old pull request is still open, and seems to have participation from people who are at least members of the RoR github team, so I think that they're aware of this issue?: #32238 Furthermore a workaround does exist, enabling the Flash middleware by adding |
This issue has been automatically marked as stale because it has not been commented on for at least three months. |
This issue has been automatically marked as stale because it has not been commented on for at least three months. |
This issue has been automatically marked as stale because it has not been commented on for at least three months. |
I have a Rails API serving a React SPA. It works perfect but I had to do a modification to use ActiveStorage's direct uploads.
The problem appears when trying to create a direct upload (i.e. a Blob).
ActiveStorage::DirectUploadsController
will fail with some errors which I believe are expected on a normal app but not on an API controllers. These are the errors:HTTP Origin header (http://localhost:3001/) didn't match request.base_url (http://localhost:3000)
Can't verify CSRF token authenticity.
My solution has been to change this line:
rails/activestorage/app/controllers/active_storage/direct_uploads_controller.rb
Line 6 in 4ec8bf6
and make:
class ActiveStorage::DirectUploadsController < ApplicationController
I think the problem is solved because my
ApplicationController
inherits fromActionController::API
.If my assumptions are correct, shouldn't ActiveStorage controllers inherit from
ActionController::API
orActionController::Base
depending onconfig.api_only = true
?The text was updated successfully, but these errors were encountered: