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 |