We're going to integrate Uppy with "upload from Google Drive" feature with rails backend and ActiveStorage, in this case we are going to upload multiple images at once and using the direct upload concept.
What we need?
1. Install Uppy JS into rails using webpacker
- Figure out how direct upload works with ActiveStorage
- A companion server (a proxy server to handle google auth)
## 1. Install Uppy JS into rails using webpacker
```json
// package.json
{
"name": "platform",
"private": true,
"dependencies": {
"@rails/webpacker": "3.5",
"dotenv": "^6.1.0",
"uppy": "^0.28.0"
},
"devDependencies": {
"webpack-dev-server": "2.11.2"
}
}
```
To hide credential from git history, we add `dotenv` npm package to do so
```js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
const dotenv = require('dotenv')
const dotenvFiles = [
`.env.${process.env.NODE_ENV}.local`,
'.env.local',
`.env.${process.env.NODE_ENV}`,
'.env'
]
dotenvFiles.forEach((dotenvFile) => {
dotenv.config({ path: dotenvFile, silent: true })
})
environment.plugins.prepend('Environment', new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(process.env))))
module.exports = environment
```
```
# .env
APPLICATION_URL=http://localhost:3001
UPPY_COMPANION_SERVER_URL=http://localhost:3020
```
```js
// app/javascripts/uppy.js
const Uppy = require("@uppy/core")
const Dashboard = require("@uppy/dashboard")
const XHRUpload = require("@uppy/xhr-upload")
const GoogleDrive = require("@uppy/google-drive")
const input = document.querySelector("input[type=file]")
const form = document.querySelector("form")
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
const uppyTrigger = "#js-uppy-upload"
const uppyTriggerElement = document.querySelector(uppyTrigger)
const startUppy = () => {
const uppy = Uppy({
debug: true,
restrictions: {
allowedFileTypes: ['image/*', '.jpg', '.jpeg', '.png', '.gif']
},
})
.use(Dashboard, { trigger: uppyTrigger })
.use(GoogleDrive, {
target: Dashboard,
serverUrl: process.env.UPPY_COMPANION_SERVER_URL || "http://localhost:3020"
})
.use(XHRUpload, {
endpoint: `${process.env.APPLICATION_URL}/uploader/image`,
bundle: false,
headers: { "X-CSRF-Token": csrfToken }
})
uppy.on("upload-success", (file, body) => {
uppy.setFileState(file.id, {
xhr: Object.assign({}, file, {
uploadURL: body["uploadURL"],
signedId: body["signedId"]
})
})
})
uppy.on("complete", result => {
result.successful.forEach(file => {
insertImageSignedId(form, input, file.xhr.signedId)
})
})
}
const insertImageSignedId = (form, input, signed_id) => {
const hiddenField = document.createElement("input")
hiddenField.setAttribute("type", "hidden")
hiddenField.setAttribute("value", signed_id)
hiddenField.name = input.name
form.appendChild(hiddenField)
}
if (uppyTriggerElement) {
startUppy()
}
```
And add uppy.js into layout
```erb
<!DOCTYPE html>
<html>
<head>
<title>Uppy Example</title>
<%= stylesheet_link_tag 'admin/default', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'admin', 'data-turbolinks-track': 'reload' %>
<%= csrf_meta_tags %>
</head>
<body>
<div class="container">
<%= yield %>
<%= javascript_pack_tag 'uppy' %>
</div>
</body>
</html>
```
form example
```erb
<div class="form-group">
<%= f.label "Images" %>
<div id="js-uppy-upload" class="form-control">
Upload
</div>
<%= %>
<%= f.file_field :images, multiple: true, class: "hidden" %>
</div>
```
## 2. Figure out how direct upload works with ActiveStorage
ActuveStorage has it's own direct upload JS client, however, it's not useful in this case because it's only able to upload real file data from local but not able to handle google drive upload, therefore we need to implement our own `direct upload` API for it, thankfully it's easy
Add a new endpoint into your rails project, this simplest implementation is below:
```rb
class UploaderController < ApplicationController
# TODO: Add CSRF protection
skip_before_action :verify_authenticity_token
# TODO: Add authentication, cookie based authentication should be good enough
def image
# XHRUpload will send image 1 by 1
# https://uppy.io/docs/xhr-upload/#bundle-false
file = params[:files].first
blob = ActiveStorage::Blob.create_after_upload!(
io: file,
filename: file.original_filename,
content_type: file.content_type
)
render json: { uploadURL: url_for(blob), signedId: blob.signed_id }
end
end
```
Cons here is that we still need to process image file in our server, I can't find a way to avoid it with ActiveStorage, if you know a better way I'm happy to hear, thanks :)
Now the upload workflow will be
User select files from google drive -> Client tell companion server to upload file to our API -> API server process image, save it to AWS S3 and return ActiveStorage's record's (blob) signed_id -> Client add signed_id into form field and submit with it -> Rails server bind uploaded image with form record
## 3. A companion server (a proxy server to handle google auth)
I use the heroku node template, so there are heroku cli dependency (to load .env file), you can remove this dependency simply by loading `.env` yourself
```json
// package.json
{
"name": "example-uppy-companion-server",
"version": "1.0.0",
"description": "Example uppy companion server",
"engines": {
"node": "10.x"
},
"main": "index.js",
"scripts": {
"start": "heroku local"
},
"dependencies": {
"@uppy/companion": "^0.15.0",
"body-parser": "^1.18.3",
"express": "^4.16.4",
"express-session": "^1.15.6"
},
"devDependencies": {
"request": "^2.81.0",
"tape": "^4.7.0"
}
}
```
```js
// index.js
const express = require('express')
const companion = require('@uppy/companion')
const bodyParser = require('body-parser')
const session = require('express-session')
const app = express()
app.use(bodyParser.json())
app.use(session({
secret: 'some-secret',
resave: true,
saveUninitialized: true
}))
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*')
res.setHeader(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS, PUT, PATCH, DELETE'
)
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Origin, Content-Type, Accept'
)
res.setHeader('Access-Control-Allow-Credentials', 'true')
next()
})
// Routes
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/plain')
res.send('Welcome to Companion')
})
// initialize uppy
const uppyOptions = {
providerOptions: {
google: {
key: process.env.PROVIDER_GOOGLE_DRIVE_KEY || "",
secret: process.env.PROVIDER_GOOGLE_DRIVE_SECRET || 5
}
},
server: {
host: process.env.PROVIDER_SERVER_HOST || "localhost:3020",
protocol: process.env.PROVIDER_SERVER_PROTOCOL || "http"
},
filePath: "./output",
secret: "some-secret",
debug: true
};
app.use(companion.app(uppyOptions))
// handle 404
app.use((req, res, next) => {
return res.status(404).json({ message: 'Not Found' })
})
// handle server errors
app.use((err, req, res, next) => {
console.error('\x1b[31m', err.stack, '\x1b[0m')
res.status(err.status || 500).json({ message: err.message, error: err })
})
companion.socket(app.listen(process.env.PORT || 3020), uppyOptions)
console.log('Welcome to Companion!')
```
```
# .env
PROVIDER_SERVER_HOST=localhost:3020
PROVIDER_SERVER_PROTOCOL=http
PROVIDER_GOOGLE_DRIVE_KEY=PROVIDER_GOOGLE_DRIVE_KEY
PROVIDER_GOOGLE_DRIVE_SECRET=PROVIDER_GOOGLE_DRIVE_SECRET
PORT=3020
```
https://stackoverflow.com/questions/50872912/integration-rails-active-storage-with-js-uploaders
https://github.com/rails/rails/issues/32251
https://medium.com/weareevermore/manual-uploads-using-activestorage-47808dab1b65