Code to accompany this post: https://finnian.io/blog/uploading-files-to-s3-react-native-ruby-on-rails/
Also thanks to @f-g-p for contributing some edits and the axios + expo example code below.
Code to accompany this post: https://finnian.io/blog/uploading-files-to-s3-react-native-ruby-on-rails/
Also thanks to @f-g-p for contributing some edits and the axios + expo example code below.
| import { Image } from "react-native-image-crop-picker" | |
| interface IReport { | |
| field_one: string | |
| field_two: string | |
| field_three: string | |
| attachments: string[] | |
| } | |
| interface IDraftAttachment { | |
| signature: { | |
| url: 'https://bucket.s3.region.amazonaws.com/etc', | |
| headers: { | |
| "Content-Type": "image/jpeg", | |
| "Content-MD5": "3Tbhfs6EB0ukAPTziowN0A==" | |
| }, | |
| signed_id: 'signedidoftheblob' | |
| }, | |
| file: Image | |
| } | |
| export class Api { | |
| async uploadAttachment( | |
| attachment: IDraftAttachment, | |
| onProgress: (progress: number, total: number) => void | |
| ): Promise<void> { | |
| console.log('Uploading attachment to:', attachment.signature) | |
| const attachmentUrl = await new Promise<string>((resolve, reject) => { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('PUT', attachment.signature.url, true); | |
| Object.keys(attachment.signature.headers).forEach(key => { | |
| xhr.setRequestHeader(key, attachment.signature.headers[key]) | |
| }) | |
| xhr.onreadystatechange = () => { | |
| if (xhr.readyState !== 4) return | |
| if (xhr.status === 200) return resolve() | |
| reject(xhr.status) | |
| } | |
| xhr.upload.onprogress = e => { | |
| onProgress(e.loaded, e.total) | |
| } | |
| xhr.send({ | |
| uri: attachment.file.path, | |
| type: attachment.file.mime, | |
| name: attachment.file.filename || Date.now().toString() | |
| }); | |
| }) | |
| } | |
| async createReport(report: IReport): Promise<number> { | |
| const result = await axios.post('/api/reports', report) | |
| return result.data.id // ID of the newly-created report | |
| } | |
| } |
| // source: @f-g-p (https://github.com/f-g-p) | |
| export type PresignedUploadResponse = { | |
| data: { | |
| url: string; | |
| headers: { | |
| 'Content-MD5': string; | |
| 'Content-Disposition': string; | |
| }; | |
| signed_id: string; | |
| }; | |
| }; | |
| async uploadAsync( | |
| presignedData: PresignedUploadResponse, | |
| file: ImageResult, | |
| onProgress: (progress: number, total: number) => void | |
| ): Promise<string> { | |
| const base64String = await FileSystem.readAsStringAsync(file.uri, { encoding: 'base64' }); | |
| const buffer = Buffer.from(base64String, 'base64'); | |
| const headers = { ...presignedData.data.headers, 'content-type': '' }; | |
| await axios.put(presignedData.data.url, buffer, { | |
| headers, | |
| onUploadProgress: (progressEvent) => onProgress(progressEvent.loaded, progressEvent.total), | |
| }); | |
| return presignedData.data.signed_id; | |
| }, |
| import { Buffer } from 'buffer' | |
| import RNFS from 'react-native-fs' | |
| import ImagePicker, { | |
| Image as ImagePickerImage, | |
| Options as ImagePickerOptions, | |
| } from "react-native-image-crop-picker" | |
| // https://github.com/ivpusic/react-native-image-crop-picker#request-object | |
| const options: ImagePickerOptions = { | |
| cropping: false, | |
| multiple: true, | |
| sortOrder: "asc", | |
| maxFiles: 10, | |
| // we don't need the base64 here as we upload the file directly | |
| // from the FS rather than a base64-encoded string of it | |
| includeBase64: false | |
| } | |
| // call openImageLibrary from a <Button> or similar | |
| const openImageLibrary = async () => { | |
| const results = (await ImagePicker.openPicker(options)) as ImagePickerImage[] | |
| results.forEach(async r => { | |
| const hex = await RNFS.hash(r.path, 'md5') | |
| const base64 = Buffer.from(hex, 'hex').toString('base64') | |
| // save base64 somewhere with the attachment for use when retrieving the presigned url | |
| }) | |
| } |
| // If you're using expo, this code will apply most to you. | |
| async function retrieveDirectUpload(uri: string) { | |
| const info = await FileSystem.getInfoAsync(uri, { | |
| size: true, | |
| md5: true, | |
| }); | |
| // the expo docs for getInfoAsync don't specify that the md5 is in hex, which took a while to figure out | |
| const base64 = Buffer.from(info.md5, 'hex').toString('base64'); | |
| const splitUri = info.uri.split('/'); | |
| const filename = splitUri[splitUri.length - 1]; | |
| // call the Rails backend however you like to get the url & headers to upload to | |
| const result = await retrieveDirectUploadUrl({ | |
| filename, | |
| byteSize: info.size, | |
| contentType: 'image/jpeg', // this must match the value below | |
| checksum: base64, | |
| }); | |
| return result; | |
| } | |
| async function uploadFile(uri: string) { | |
| const info = retrieveDirectUpload(uri); | |
| const headers = { | |
| 'Content-Type': 'image/jpeg', // this must match the content-type of the file you're uploading | |
| ...JSON.parse(info.headers), // these are the headers coming back from `service_headers_for_direct_upload` | |
| }; | |
| const task = FileSystem.createUploadTask( | |
| info.url, | |
| uri, | |
| { | |
| headers, | |
| httpMethod: 'PUT', | |
| }, | |
| data => | |
| console.log(data.totalBytesExpectedToSend, data.totalBytesExpectedToSend), | |
| ); | |
| return task.uploadAsync(); | |
| } |
| class Api::PresignedUploadController < Api::BaseController | |
| # POST /api/presigned-upload | |
| def create | |
| create_blob | |
| render_success( | |
| data: { | |
| url: @blob.service_url_for_direct_upload(expires_in: 30.minutes), | |
| headers: @blob.service_headers_for_direct_upload, | |
| signed_id: @blob.signed_id | |
| } | |
| ) | |
| end | |
| private | |
| def create_blob | |
| @blob = ActiveStorage::Blob.create_before_direct_upload!( | |
| filename: blob_params[:filename], | |
| byte_size: blob_params[:byte_size], | |
| checksum: blob_params[:checksum], | |
| content_type: blob_params[:content_type] | |
| ) | |
| end | |
| def blob_params | |
| params.require(:file).permit(:filename, :byte_size, :checksum, :content_type) | |
| end | |
| end |
| class Report < ApplicationRecord | |
| has_many_attached :attachments | |
| # etc | |
| end |
| class Api::ReportsController < Api::BaseController | |
| # POST /api/reports | |
| def create | |
| @report = Report.new(report_params) | |
| if @report.save | |
| render_success(data: @report) | |
| else | |
| render_error(400, object: @report) | |
| end | |
| end | |
| private | |
| def report_params | |
| params.permit( | |
| :field_one, :field_two, :field_three, | |
| attachments: [] # this is where the signed_ids will end up | |
| ) | |
| end | |
| end |