Forest instantly provides all common admin tasks such as CRUD operations, simple chart rendering, user group management, and WYSIWYG interface editor. That’s what makes Forest a quick and easy solution to get your admin interface started.
This guide assists developers in having the admin completely tailored to cater to their specific needs. We’ve developed Forest so that your admin is 100% customizable. It means that you have full control over your data, your back-end business logic and the UI.
Forest provides a very simple API-based framework to handle all parts of your admin back-end configuration. You should be good to go in no time, with virtually no learning curve.
Before you start writing a single line of code, it’s a good idea to get an overview of how Forest works.
Install the Forest Liana on your application

There are three steps on the initialization phase:
Where the magic happens

The magic of Forest lies in its architecture. Forest is divided into two main components:
The main advantage of Forest’s architecture is that absolutely no data transits over our servers. The user accesses application data directly from the client and Forest is just deployed as a service to display and interact with the data. Read more about hosting.
With Forest, your data are transferred directly from your application to your browser while remaining invisible to our servers. See how it works.

We use a two-step authentication to connect you to both Forest’s server and your Admin API.
To retrieve your UI configuration
When logging into your account, your credentials are sent to the Forest’s server which returns the UI token to authenticate your session.
To manage your data
Your password is sent to your Admin API which returns the data token signed by the FOREST_AUTH_SECRET you chose. Each of your requests to your Admin API are authenticated with the data token.

Your admin uses the UI token to make request about the UI configuration. And the data token is used to make queries on your admin API to manage your data. All our tokens are generated using the JWT standard.
If you haven’t yet, you should have Forest installed on your application. The relevant information is below. You should be set in a few minutes, no more. If you encounter any issue, feel free to drop us a line at support@forestadmin.com or using the live chat available on our homepage.
Tip: You’re using a framework based off Rails? Express? Head over to our Github account. If support is just a matter of a couple tweaks, it’ll be listed there along with specific instructions.
# Add the liana to your application's Gemfile
gem 'forest_liana'
# Bundle it
bundle install
# Install it with the provided environment secret
rails g forest_liana:install YOUR-SUPER-SECRET-SECRET-KEY
// Install the liana
npm install forest-express-mongoose --save
// Add the following code to your app.js file:
app.use(require('forest-express-mongoose').init({
modelsDir: __dirname + '/models', // Your models directory.
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
mongoose: require('mongoose') // The database connection.
}));
// Setup Forest environment variables and do not version them
FOREST_ENV_SECRET=FOREST-ENV-SECRET
FOREST_AUTH_SECRET=FOREST-AUTH-SECRET // Choose a secure auth secret
// Install the liana
npm install forest-express-sequelize --save
// Add the following code to your app.js file:
app.use(require('forest-express-sequelize').init({
modelsDir: __dirname + '/models', // Your models directory.
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
sequelize: require('./models').sequelize // The database connection.
}));
// Setup Forest environment variables and do not version them
FOREST_ENV_SECRET=FOREST-ENV-SECRET
FOREST_AUTH_SECRET=FOREST-AUTH-SECRET // Choose a secure auth secret
As an admin user, KPIs are one of the most important things to follow day by day. Your customers’ growth, Monthly Recurring Revenue (MRR), Paid VS Free accounts are some common examples.
Forest can render three types of charts:
Ensure you’ve enabled the Edit Layout mode to add, edit or delete a chart.
Forest provides a straightforward UI to configure step-by-step the charts you want. The only information the UI needs to handle such charts is:

Sometimes, charts data are complicated and closely tied to your business. Forest allows you to code how the chart is computed. Choose “URL” as the data source when configuring your chart. Forest will make the HTTP call to this address when retrieving the chart values for the rendering.

The value format passed to the serializer for a Value chart must be:
<value>
config/routes.rb (add the route before the Forest engine)
namespace :forest do
post '/stats/mrr' => 'stats#mrr'
end
# ...
# mount ForestLiana::Engine => '/forest'
config/application.rb
We use the gem rack-cors for the CORS configuration.
class Application < Rails::Application
# ...
config.middleware.insert_before 0, 'Rack::Cors' do
allow do
origins 'app.forestadmin.com'
resource '*', headers: :any, methods: :any
end
end
end
app/controllers/stats_controller.rb
# ForestLiana::ApplicationController takes care of the authentication for you.
class StatsController < ForestLiana::ApplicationController
def mrr
stat = ForestLiana::Model::Stat.new({
value: 500000 # Your business logic here.
})
# The serializer_model function serializes the ForestLiana::Model::Stat
# model to a valid JSONAPI payload.
render json: serialize_model(stat)
end
end
Declare the route in the Express Router
var liana = require('forest-express-mongoose');
function mrr(req, res) {
var result = 500000; // Your business logic here.
// The liana.StatSerializer function serializes the result to a valid
// JSONAPI payload.
var json = new liana.StatSerializer({ value: result }).perform();
res.send(json);
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/mrr', liana.ensureAuthenticated, mrr);
Declare the route in the Express Router
var liana = require('forest-express-sequelize');
function mrr(req, res) {
var result = 500000; // Your business logic here.
// The liana.StatSerializer function serializes the result to a valid
// JSONAPI payload.
var json = new liana.StatSerializer({ value: result }).perform();
res.send(json);
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/mrr', liana.ensureAuthenticated, mrr);
The value format passed to the serializer for a Repartition chart must be:
[
{ key: <key>, value: <value> },
{ key: <key>, value: <value> },
...
]
config/routes.rb (add the route before the Forest engine)
namespace :forest do
post '/stats/avg_price_per_supplier' => 'stats#avg_price_per_supplier'
end
# ...
# mount ForestLiana::Engine => '/forest'
config/application.rb
We use the gem rack-cors for the CORS configuration.
class Application < Rails::Application
# ...
config.middleware.insert_before 0, 'Rack::Cors' do
allow do
origins 'app.forestadmin.com'
resource '*', headers: :any, methods: :any
end
end
end
app/controllers/stats_controller.rb
# ForestLiana::ApplicationController takes care of the authentication for you.
class StatsController < ForestLiana::ApplicationController
def avg_price_per_supplier
# Your business logic here.
value = Item
.all
.group(:supplier)
.average(:price)
.map {|supplier, avg| { key: supplier.last_name, value: avg }}
stat = ForestLiana::Model::Stat.new({
value: value
})
# The serializer_model function serializes the ForestLiana::Model::Stat
# model to a valid JSONAPI payload.
render json: serialize_model(stat)
end
end
Declare the route in the Express Router
var liana = require('forest-express-mongoose');
function avgPricePerSupplier(req, res) {
// Your business logic here.
Item
.aggregate()
.group({
_id: '$supplier',
count: { $avg: '$price' }
})
.project({
key: '$_id',
value: '$count',
_id: false
})
.exec(function (err, value) {
// The liana.StatSerializer function serializes the result to a valid
// JSONAPI payload.
var json = new liana.StatSerializer({ value: value }).perform();
res.send(json);
});
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/avg_price_per_supplier', liana.ensureAuthenticated,
avgPricePerSupplier);
Declare the route in the Express Router
var liana = require('forest-express-sequelize');
function avgPricePerSupplier(req, res) {
models.item
.findAll({
attributes: [
'supplier',
[ db.sequelize.fn('avg', db.sequelize.col('price')), 'value' ]
],
group: ['supplier'],
order: ['value']
})
.then((result) => {
return result.map((r) => {
r = r.toJSON();
var ret = {
key: r.supplier,
value: r.value
};
return ret;
});
})
.then((result) => {
var json = new liana.StatSerializer({ value: result }).perform();
res.send(json);
});
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/avg_price_per_supplier', liana.ensureAuthenticated,
avgPricePerSupplier);
The value format passed to the serializer for a Line chart must be:
[
{ label: <date key>, values: { value: <value> } },
{ label: <date key>, values: { value: <value> } },
...
]
config/routes.rb (add the route before the Forest engine)
namespace :forest do
post '/stats/avg_price_per_month' => 'stats#avg_price_per_month'
end
# ...
# mount ForestLiana::Engine => '/forest'
config/application.rb
We use the gem rack-cors for the CORS configuration.
class Application < Rails::Application
# ...
config.middleware.insert_before 0, 'Rack::Cors' do
allow do
origins 'app.forestadmin.com'
resource '*', headers: :any, methods: :any
end
end
end
app/controllers/stats_controller.rb
# ForestLiana::ApplicationController takes care of the authentication for you.
class StatsController < ForestLiana::ApplicationController
def avg_price_per_month
# Your business logic here.
value = Item
.all
.group("DATE_TRUNC('month', created_at)")
.average(:price)
.map do |date, avg|
{
label: date,
values: { value: avg }
}
end
.sort_by {|x| x[:label]}
stat = ForestLiana::Model::Stat.new({
value: value
})
# The serializer_model function serializes the ForestLiana::Model::Stat
# model to a valid JSONAPI payload.
render json: serialize_model(stat)
end
end
Declare the route in the Express Router
var liana = require('forest-express-mongoose');
function avgPricePerMonth(req, res) {
// Your business logic here.
Item
.aggregate()
.group({
_id: {
month: { $month: '$createdAt' },
year: { $year: '$createdAt' }
},
createdAt: { '$last': '$createdAt' },
count: { $avg: '$price' }
})
.sort({
createdAt: 1
})
.project({
label: '$createdAt',
values: { value: '$count' },
_id: false
})
.exec(function (err, value) {
// The liana.StatSerializer function serializes the result to a valid
// JSONAPI payload.
var json = new liana.StatSerializer({ value: value }).perform();
res.send(json);
});
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/avg_price_per_month', liana.ensureAuthenticated,
avgPricePerMonth);
Declare the route in the Express Router
var liana = require('forest-express-sequelize');
function avgPricePerMonth(req, res) {
models.item
.findAll({
attributes: [
[ db.sequelize.fn('avg', db.sequelize.col('price')), 'value' ],
[ db.sequelize.fn('to_char', db.sequelize.fn('date_trunc', 'month',
db.sequelize.col('createdAt')), 'YYYY-MM-DD 00:00:00'), 'key' ]
],
group: ['key'],
order: ['key']
})
.then((result) => {
return result.map((r) => {
r = r.toJSON();
var ret = {
label: r.key,
values: {
value: r.value
}
};
return ret;
});
})
.then((result) => {
var json = new liana.StatSerializer({ value: result }).perform();
res.send(json);
});
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/api/stats/avg_price_per_month', liana.ensureAuthenticated,
avgPricePerMonth);
A field that displays a computed value in your collection.

A Smart Field is a column that displays processed-on-the-fly data. It can be as simple as “massaging” attributes to make them human friendly, or more complex and use relationships to display things such as a total of orders for one of your customers or a number of users for a product.
Try it out with 1 these 2 examples (it only takes 3 minutes):
lib/forest_liana/collections/user.rb
class Forest::User
include ForestLiana::Collection
collection :users
field :fullname, type: 'String' do
"#{object.firstname} #{object.lastname}"
end
end
The Smart Field will appear in your collection.
/forest/user.js
'use strict';
var Liana = require('forest-express-mongoose');
Liana.collection('users', {
fields: [{
field: 'fullname',
type: 'String',
get: function (object) {
return object.firstName + ' ' + object.lastName;
}
}]
});
The Smart Field will appear in your collection.
/forest/user.js
'use strict';
var Liana = require('forest-express-sequelize');
Liana.collection('user', {
fields: [{
field: 'fullname',
type: 'String',
get: function (object) {
return object.firstName + ' ' + object.lastName;
}
}]
});
The Smart Field will appear in your collection.
lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
field :number_of_orders, type: 'Number' do
object.orders.length
end
end
The Smart Field will appear in your collection.
/forest/user.js
'use strict';
var Liana = require('forest-express-sequelize');
var models = require('../models');
Liana.collection('user', {
fields: [{
field: 'numberOfOrders',
type: 'Number',
get: function (object) {
// returns a Promise
return models.environment.count({
where: { projectId: project.id }
});
}
}]
});
The Smart Field will appear in your collection.
/forest/user.js
'use strict';
var Liana = require('forest-express-mongoose');
var Order = require('../models/order');
Liana.collection('users', {
fields: [{
field: 'numberOfOrders',
type: 'Number',
get: function (object) {
return Order.count({ user: object._id }); // returns a Promise
}
}]
});
The Smart Field will appear in your collection.
In order to update a Smart Field, you just need to write the logic to “unzip”
the data. Note that the set method should always return the object it’s
working on. In the example hereunder, the user is returned.
class Forest::Customer
include ForestLiana::Collection
collection :customers
set_fullname = lambda do |user, value|
names = value.split
user[:firstname] = names.first
user[:lastname] = names.last
user
end
field :fullname, type: 'String', set: set_fullname do
"#{object.firstname} #{object.lastname}"
end
end
If you need an Async process, you can also return a Promise that will return the user at the end.
'use strict';
const Liana = require('forest-express-sequelize');
Liana.collection('user', {
fields: [{
field: 'fullName',
type: 'String',
get: function (user) {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
} else {
return null;
}
},
set: function (user, value) {
var names = value.split(' ');
user.firstName = names[0];
user.lastName = names[1];
return user;
}
}]
});
If need an Async process, you can also return a Promise that will return the user at the end.
'use strict';
const Liana = require('forest-express-mongoose');
Liana.collection('user', {
fields: [{
field: 'fullName',
type: 'String',
get: function (user) {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
} else {
return null;
}
},
set: function (user, value) {
var names = value.split(' ');
user.firstName = names[0];
user.lastName = names[1];
return user;
}
}]
});
To perform search on a Smart Field, you also need to write the logic to “unzip”
the data, then the search query which is specific to your zipping. In the
example hereunder, the firstname and lastname are searched separately after
having been unzipped.
class Forest::Customer
include ForestLiana::Collection
collection :customers
search_fullname = lambda do |query, search|
firstname, lastname = search.split
query.where_clause.send(:predicates)[0] << " OR (firstname = '#{firstname}' AND lastname = '#{lastname}')"
query
end
field :fullname, type: 'String', search: search_fullname do
"#{object.firstname} #{object.lastname}"
end
end
If you are working with Async, you can also return a Promise.
'use strict';
const Liana = require('forest-express-sequelize');
Liana.collection('user', {
fields: [{
field: 'fullName',
type: 'String',
get: function (user) {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
} else {
return null;
}
},
search: function (query, search) {
let s = models.sequelize;
let split = search.split(' ');
var searchCondition = s.and(
{ firstName: { $ilike: split[0] }},
{ lastName: { $ilike: split[1] }}
);
let searchConditions = _.find(query.where.$and, '$or');
searchConditions.$or.push(searchCondition);
}
}]
});
If you are working with Async, you can also return a Promise.
'use strict';
var Liana = require('forest-express-mongoose');
var Illustration = require('../server/models/illustration');
Liana.collection('users', {
fields: [{
field: 'fullname',
type: 'String',
get: function (object) {
return object.firstName + ' ' + object.lastName;
},
search: function (query, search) {
let names = search.split(' ');
query._conditions.$or.push({
firstName: names[0],
lastName: names[1]
});
return query;
}
}]
});
A field that displays a computed reference to another collection.

A Smart Relationship is a Smart Field that points to another
collection. Forest supports natively all the relationships defined in your
models (belongsTo, hasMany, …) but you can also defined custom
relationships between your data.
Try it out with one these 2 examples (it only takes a few minutes):
In the following example, we add the last delivery man of a customer to your Forest admin on a collection customers.
lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
belongs_to :last_delivery_man, reference: 'delivery_men.id' do
object.orders.last.delivery_man # returns a "DeliveryMan" Model.
end
end
/forest/customer.js
'use strict';
var Liana = require('forest-express-sequelize');
var models = require('../models');
Liana.collection('user', {
fields: [{
field: 'delivery_man',
type: 'String',
get: function (object) {
// returns a Promise
return models.deliveryMan
.findOne({
// where: { ... }
});
}
}]
});
/forest/customer.js
'use strict';
var Liana = require('forest-express-sequelize');
var DeliveryMan = require('../models/delivery-man');
Liana.collection('users', {
fields: [{
field: 'delivery_man',
type: 'String',
get: function (object) {
// returns a Promise
return DeliveryMan
.findOne({
// ...
});
}
}]
});
In the following example, we add the top 3 movies of an actor to your Forest admin on a collection actors.
lib/forest_liana/collections/actor.rb
class Forest::Actor
include ForestLiana::Collection
collection :actors
has_many :top_movies, type: ['string'], reference: 'movies.id'
end
config/routes.rb
namespace :forest do
get '/actors/:actor_id/relationships/top_movies' => 'actors#top_movies'
end
app/controllers/forest/actors_controller.rb
class Forest::ActorsController < ForestLiana::ApplicationController
def top_movies
movies = Actor
.find(params['actor_id'])
.movies
.order('imdb_rating DESC')
.limit(3)
render json: serialize_models(movies, include: [], count: movies.count)
end
end
The Smart Field will appear in your collection.
/forest/actor.rb
'use strict';
var Liana = require('forest-express-mongoose');
Liana.collection('actors', {
fields: [{
field: 'topmovies',
type: ['String'],
reference: 'movies.id'
}]
});
Declare the route to the Express Router
var liana = require('forest-express-mongoose');
var Serializer = require('forest-express').ResourceSerializer;
function topMovies(req, res) {
// your business logic to retrieve the top 3 movies
.then((movies) => {
return new Serializer(liana, models.movies, movies, {}, {
count: movies.length
}).perform();
})
.then((projects) => res.send(projects))
.catch(next);
}
router.get('/forest/actor/:actorId/relationships/movies', topMovies);
The Smart Field will appear in your collection.
/forest/actor.rb
'use strict';
var Liana = require('forest-express-sequelize');
Liana.collection('actors', {
fields: [{
field: 'topmovies',
type: ['String'],
reference: 'movies.id'
}]
});
Declare the route to the Express Router
var liana = require('forest-express-sequelize');
var Serializer = require('forest-express').ResourceSerializer;
function topMovies(req, res) {
// your business logic to retrieve the top 3 movies
.then((movies) => {
return new Serializer(liana, models.movies, movies, {}, {
count: movies.length
}).perform();
})
.then((projects) => res.send(projects))
.catch(next);
}
router.get('/forest/actor/:actorId/relationships/movies', topMovies);
The Smart Field will appear in your collection.

A Smart Action is server-side logic triggered at the click of a button.

Forest provides instantly all common actions such as CRUD, sort, search, etc. Sooner or later, you will need to perform actions on your data that are specific to your business. Moderating comments, logging into a customer’s account (a.k.a impersonate) or banning a user are exactly the kind of important tasks you need to make available in order to manage your day-to-day operations.
Try it out with this example (it only takes 3 minutes): - Banning a user
In the following example, we add the Ban user action to your Forest admin on a collection customers.
lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
action 'Ban user'
end
Your action is now visible on Forest

config/application.rb
We use the gem rack-cors for the CORS configuration.
class Application < Rails::Application
# ...
config.middleware.insert_before 0, 'Rack::Cors' do
allow do
origins 'app.forestadmin.com'
resource '*', headers: :any, methods: :any
end
end
end
config/routes.rb (add the route before the Forest engine)
namespace :forest do
post '/actions/ban-user' => 'customers#ban_user'
end
# ...
# mount ForestLiana::Engine => '/forest'
app/controllers/forest/customers_controller.rb
# ForestLiana::ApplicationController takes care of the authentication for you.
class Forest::CustomersController < ForestLiana::ApplicationController
def ban_user
# Your business logic to send an email here.
render nothing: true, status: 204
end
end
/forest/customers.js
var Liana = require('forest-express-mongoose');
Liana.collection('customers', {
actions: [{ name: 'Ban user' }]
});
⚠ Notice, that Forest requires automatically the files inside the /forest
directory. It’s nor necessary nor advised to require theses files manually
in your code.
Your action is now visible on Forest

Declare the route to the Express Router
var liana = require('forest-express-mongoose');
function banUser(req, res) {
// Your business logic to ban a user here.
res.status(204).send();
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/forest/actions/ban-user', liana.ensureAuthenticated, banUser);
/forest/customers.js
var Liana = require('forest-express-sequelize');
Liana.collection('customers', {
actions: [{ name: 'Ban user' }]
});
⚠ Notice, that Forest requires automatically the files inside the /forest
directory. It’s nor necessary nor advised to require theses files manually
in your code.
Your action is now visible on Forest

Declare the route to the Express Router
var liana = require('forest-express-sequelize');
function banUser(req, res) {
// Your business logic to ban a user here.
res.status(204).send();
}
// liana.ensureAuthenticated middleware takes care of the authentication for you.
router.post('/forest/actions/ban-user', liana.ensureAuthenticated, banUser);
When your logic requires a parameter from the Human in front of the screen.
lib/forest_liana/collections/rental.rb
Declare the list of fields you want to be filled before the action is triggered
by passing a fields argument to the action method.
To each field you can add a description that will help humans understand what value is expected.
class Forest::Rental
include ForestLiana::Collection
collection :rentals
action 'refund', fields: [{
field: 'Amount', type: 'Number', description: "The amount to refund to our customer"
}]
end
Six types of field are currently supported: Boolean, Date, Number,
String, File and Enum. If you choose the Enum type, you can pass the
list of possible values through the enums key:
{ field: 'Country', type: 'Enum', enums: ['USA', 'CA', 'NZ'] }
The action form will appear when triggering the refund action.

/forest/customers.js
Declare the list of fields you want to be filled before the action is
triggered by passing a fields argument to the action method.
To each field you can add a description that will help humans understand what value is expected.
var Liana = require('forest-express-mongoose');
Liana.collection('rentals', {
actions: [{
name: 'Refund',
fields: [
{ field: 'Amount', type: 'Number', description: 'The amount to refund to our customer' }
]
}]
});
Six types of field are currently supported: Boolean, Date, Number,
String, File and Enum. If you choose the Enum type, you can pass the
list of possible values through the enums key:
{ field: 'Country', type: 'Enum', enums: ['USA', 'CA', 'NZ'] }
The action form will appear when triggering the ‘Refund’ action.

/forest/customers.js
Declare the list of fields you want to be filled before the action is
triggered by passing a fields argument to the action method.
To each field you can add a description that will help humans understand what value is expected.
var Liana = require('forest-express-sequelize');
Liana.collection('rentals', {
actions: [{
name: 'Refund',
fields: [
{ field: 'Amount', type: 'Number', description: 'The amount to refund to our customer' }
]
}]
});
Six types of field are currently supported: Boolean, Date, Number,
String, File and Enum. If you choose the Enum type, you can pass the
list of possible values through the enums key:
{ field: 'Country', type: 'Enum', enums: ['USA', 'CA', 'NZ'] }
The action form will appear when triggering the ‘Refund’ action.

Selected records ids are passed to the HTTP request when triggering the action
button. The fields/values will be passed to the values attributes.
{
"data": {
"attributes": {
"ids": ["4285","1343"],
"collection_name": "rentals",
"values": {
"Amount":"1.56"
}
}
},
"type":"rentals"
}
You can respond to the HTTP request with a status message to notify the user whether the action was successfuly executed.
To display a custom error message, you have to respond with a 400 Bad Request
status code and a simple payload like this: { error: 'The error message.' }.
To display custom success message, you have to respond with a 200 OK
status code and a simple payload like this: { success: 'The success message.' }.
The response of an action will be considered as a file to download if you pass the option ‘download’ to the action declaration. It’s very useful if you need actions like “Generate an invoice” or “Download PDF”.
/lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
action 'Download file', download: true
end
config/routes.rb (add the route before the Forest engine)
namespace :forest do
post '/actions/download-file' => 'customers#download_file'
end
# ...
# mount ForestLiana::Engine => '/forest'
app/controllers/forest/customers_controller.rb
class Forest::CustomersController < ForestLiana::ApplicationController
def download_file
data = open('https://.../file.pdf')
send_data data.read, filename: 'myfile.pdf', type: 'application/pdf',
disposition: 'attachment'
end
end
The action returns now a file as a response
/forest/customer.js
var Liana = require('forest-express-sequelize');
Liana.collection('customer', {
actions: [{ name: 'Download file', download: true }]
});
In your Express route
function generateInvoice(request, response) {
var options = {
root: __dirname + '/../public/docs',
dotfiles: 'deny',
headers: {
'Access-Control-Expose-Headers': 'Content-Disposition',
'Content-Disposition': 'attachment; filename="invoice-234.pdf"'
}
};
var fileName = 'invoice-234.pdf';
response.sendFile(fileName, options, (error) => {
if (error) { next(error); }
});
}
The action returns now a file as a response
/forest/customers.js
var Liana = require('forest-express-mongoose');
Liana.collection('customers', {
actions: [{ name: 'Download file', download: true }]
});
In your Express route
function generateInvoice(request, response) {
var options = {
root: __dirname + '/../public/docs',
dotfiles: 'deny',
headers: {
'Access-Control-Expose-Headers': 'Content-Disposition',
'Content-Disposition': 'attachment; filename="invoice-234.pdf"'
}
};
var fileName = 'invoice-234.pdf';
response.sendFile(fileName, options, (error) => {
if (error) { next(error); }
});
}
The action returns now a file as a response
Passing the option global: true makes your Smart Action visible directly from
your collection without having to select records before. For example, our
“Import data” Smart Action example uses this option.
/lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
action 'Import data', global: true
end
/forest/customer.js
var Liana = require('forest-express-sequelize');
Liana.collection('customer', {
actions: [{ name: 'Import data', global: true }]
});
/forest/customers.js
var Liana = require('forest-express-mongoose');
Liana.collection('customers', {
actions: [{ name: 'Import data', global: true }]
});

A Segment is a subset of a collection gathering filtered records.
Forest provides a straightforward UI to configure step-by-step the segments you want. The only information the UI needs to create a segment is a name and some filters.

Sometimes, segment filters are complicated and closely tied to your business. Forest allows you to code how the segment is computed.
In the following example, we add the VIP segment to your Forest admin on a collection customers.
lib/forest_liana/collections/customer.rb
class Forest::Customer
include ForestLiana::Collection
collection :customers
segment 'VIP' do
{ id: Customer
.joins(:orders)
.group('customers.id')
.having('count(orders.id) > 5')
.map(&:id) }
end
end
You’re free to implement the business logic you want. The only requirement is to return a list of your collection IDs (customer IDs in this example).
Your segment is now visible on Forest

/forest/customers.js
var Liana = require('forest-express-mongoose');
Liana.collection('customers', {
segments: [{
name: 'VIP',
where: () => {
// Compute and return a list of customer IDs.
}
}]
});
You’re free to implement the business logic you need. The only requirement is to return a list of your collection IDs (customer IDs in this example).
Your segment is now visible on Forest

/forest/customers.js
var Liana = require('forest-express-sequelize');
Liana.collection('customers', {
segments: [{
name: 'VIP',
where: () => {
// Compute and return a list of customer IDs.
}
}]
});
You’re free to implement the business logic you need. The only requirement is to return a list of your collection IDs (customer IDs in this example).
Your segment is now visible on Forest

Forest is able to render your data models to its customizable UI. It covers the majority of use cases. If needed, you can create a Smart Collection to go one step further in the customization of your admin. A Smart Collection is a Forest Collection based on your API implementation. Reports or aggregated tables are two of the most common use cases.
In the following example, the application has one table Item. Each item has a
brand field (String). We create a collection Brand that contains each
brand and their corresponding number of items.
Forest uses the JSON API standard. Your Smart Collection implementation should return a valid JSON API payload. You can do it manually or use the library you want. The following examples show you how to do it manually.
lib/forest_liana/collections/brand.rb
class Forest::Brand
include ForestLiana::Collection
collection :brands
field :brand, type: 'String'
field :count, type: 'Number'
end
config/routes.rb (before mounting the Forest Rails engine)
namespace :forest do
resources :brands
end
app/controllers/forest/brands_controller.rb
# ForestLiana::ApplicationController takes care of the authentication for you.
class Forest::BrandsController < ForestLiana::ApplicationController
def index
brands = Item.all
.group(:brand)
.order(:brand)
.count
.map.with_index do |f, index|
{
id: index,
type: 'brands',
attributes: {
brand: f.first,
count: f.second
}
}
end
render json: { data: brands }
end
end
The Smart Collection will appear in the navigation bar.
/forest/brands.js
var Liana = require('forest-express-mongoose');
Liana.collection('brands', {
fields: [
{ field: 'brand', type: 'String' },
{ field: 'count', type: 'Number' }
]
});
Declare the route to the Express Router
var Liana = require('forest-express-mongoose');
router.get('/forest/brands', Liana.ensureAuthenticated, function (req, res) {
Item
.aggregate()
.group({
_id: '$brand',
count: { $sum: 1 }
})
.sort({
_id: 1
})
.project({
brand: '$_id',
count: '$count',
_id: false
})
.exec(function (err, values) {
values = values.map(function (value, index) {
return {
id: index,
type: 'brands',
attributes: {
brand: value.brand,
count: value.count
}
};
});
res.send({ data: values });
});
});
The Smart Collection will appear in the navigation bar.
/forest/brands.js
var Liana = require('forest-express-sequelize');
Liana.collection('brands', {
fields: [
{ field: 'brand', type: 'String' },
{ field: 'count', type: 'Number' }
]
});
Declare the route to the Express Router
var Liana = require('forest-express-sequelize');
var models = require('../models');
router.get('/forest/brands', Liana.ensureAuthenticated, function (req, res) {
// Your business logic here to retrieve the data you want.
.then(function (values) {
values = values.map(function (value, index) {
return {
id: index,
type: 'brands',
attributes: {
brand: value.brand,
count: value.count
}
};
});
res.send({ data: values });
});
});
The Smart Collection will appear in the navigation bar.
Smart Views are taking data visualization to the next level. Ditch the table view and display your orders on a Map, your events in a Calendar, your movies, pictures and profiles in a Gallery. All of that with the easiness of Forest.

A Smart View replaces the standard table view for a collection. It allows you to display data in a way that is best suited for its usage by your teams.
Common use case Smart Views are already available for you to deploy immediately by plugging in your data.
Try it out with one these 3 examples (each one taking about 10 minutes):
If you need more customization you can easily tweak one or build your own. Just add your own JS, HTML and CSS through an easy and standard Ember.js Component, and Forest will inject dynamically in the UI.
You will add your HTML and CSS to the TEMPLATE section and your JS code to the JAVASCRIPT section.
When writing your Component, access your data using the records property
made available by Forest with your collection, as such:
{{#each records as |record|}}
<div>{{record.forest-firstName}}</div>
<div>{{record.forest-lastName}}</div>
<div>{{record.forest-location}}</div>
{{/each}}
For each record, you will access its attributes through the
.forest-**attributeName** method. (The forest- preceding the attribute
name is required).
Forest makes available 6 properties and 3 actions on the view to help you make it truly smart, as well as some pre-built Ember blocks that won’t require you to do anything for most of the commonly used actions, and they are listed below.
Some plug and play Ember blocks for common actions in Smart Views.
{{table/table-footer records=records currentPage=currentPage
fetchPage="fetchPage" currentUser=currentUser customView=customView
updateRecordPerPage="updateRecordPerPage" collection=collection
numberOfPages=numberOfPages fetchRecords="fetchRecords"}}
<button {{action 'fetchRecords'}}>
Refresh data
</button>
<button {{action 'deleteRecords' record}}>
Delete record
</button>
You can pass an array or a single record.
<button {{action 'triggerSmartAction' collection 'Generate invoice' record}}>
Generate invoice
</button>
You can pass an array or a single record.
collection (Model): The current collection.currentPage (Number): The current page.isLoading (Boolean): Indicates if the UI is currently loading records ornumberOfPages (Number): The total number of available pages.records (Array): Your data entries.searchValue (String): The current search.

Open the Collection settings - Smart Views
TEMPLATE
<style>
#map_canvas { width: 100%; }
</style>
<div id="map_canvas"></div>
JAVASCRIPT
'use strict';
import Ember from 'ember';
export default Ember.Component.extend({
tagName: '',
router: Ember.inject.service('-routing'),
map: null,
displayMap: function () {
var markers = [];
$('#map_canvas').height($('.l-content').height());
this.get('records').forEach(function (record) {
var split = record.get('forest-geoloc').split(',');
markers.push([split[0], split[1], record.get('id')]);
});
var geocoder = new window.google.maps.Geocoder();
var latlng = new window.google.maps.LatLng(37.7869148, -122.3998675);
var myOptions = {
zoom: 13,
center: latlng,
mapTypeId: window.google.maps.MapTypeId.ROADMAP
};
this.map = new window.google.maps.Map(
window.document.getElementById('map_canvas'), myOptions);
this.addMarker(markers);
}.observes('records.[]').on('didInsertElement'),
addMarker: function (markers) {
var that = this;
markers.forEach(function (marker) {
var lat = parseFloat(marker[0]);
var lng = parseFloat(marker[1]);
var myLatlng = new window.google.maps.LatLng(lat, lng);
var recordId = marker[2];
var record = that.get('records').findBy('id', recordId);
var displayValue = record.get(
that.get('collection.displayFieldWithNamespace')) ||
record.get('forest-email') || record.get('id');
var infowindow = new window.google.maps.InfoWindow({
content: '<strong>' + that.get('collection.displayName') +
'</strong><p>' + displayValue + '</p>'
});
var markerObj = new window.google.maps.Marker({
position: myLatlng,
map: that.get('map')
});
markerObj.addListener('click', function () {
that.get('router')
.transitionTo('rendering.data.collection.list.viewEdit.details',
[that.get('collection.id'), recordId]);
});
markerObj.addListener('mouseover', function () {
infowindow.open(that.get('map'), this);
});
markerObj.addListener('mouseout', function () {
infowindow.close();
});
});
}
});
Having done that, your Map view is now rendered on your list view.

Open the Collection settings - Smart Views
TEMPLATE
<style>
#calendar {
padding: 20px;
background: white;
height:100%
}
#calendar .fc-toolbar.fc-header-toolbar .fc-left {
font-size: 14px;
font-weight: bold;
}
#calendar .fc-day-header {
padding: 10px 0;
background-color: #f7f7f7;
}
#calendar .fc-time {
background-color: #f7f7f7;
}
#calendar .fc-event {
background-color: #f7f7f7;
border: 1px solid #ddd;
color: #555;
font-size: 14px;
}
</style>
<div id='calendar'></div>
JAVASCRIPT
'use strict';
import Ember from 'ember';
export default Ember.Component.extend({
router: Ember.inject.service('-routing'),
loaded: false,
loadPlugin: function() {
var that = this;
Ember.run.scheduleOnce('afterRender', this, function () {
Ember.$.getScript('//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.1.0/fullcalendar.min.js', function () {
that.set('loaded', true);
$('#calendar').fullCalendar({
defaultView: 'agendaWeek',
allDaySlot: false,
minTime: '08:00:00',
eventClick: function (event, jsEvent, view) {
that.get('router').transitionTo('rendering.data.collection.list.viewEdit.details', [that.get('collection.id'), event.id]);
}
});
});
var cssLink = $('<link>');
$('head').append(cssLink);
cssLink.attr({
rel: 'stylesheet',
type: 'text/css',
href: '//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.1.0/fullcalendar.min.css'
});
});
}.on('init'),
setEvent: function () {
var events = [];
$('#calendar').fullCalendar('removeEvents');
this.get('records').forEach(function (chef) {
chef.get('forest-chef_availabilities').then(function (availabilities) {
availabilities.forEach(function (availability) {
var event = {
id: chef.get('id'),
title: chef.get('forest-lastname'),
start: availability.get('forest-available_at')
};
$('#calendar').fullCalendar('renderEvent', event, true);
});
});
});
}.observes('loaded', 'records.[]')
});
Having done that, your Calendar view is now rendered on your list view.

Open the Collection settings - Smart Views
TEMPLATE
<style>
.c-movies.l-table--content {
text-align: center;
margin-top: 15px;
margin-bottom: 50px;
overflow-y: auto;
}
.c-movies__image img {
height: 350px;
width: 250px;
margin: 3px;
border: 1px solid #bbb;
border-radius: 3px;
transition: all .3s ease-out;
}
.c-movies__image:hover {
transform: scale(1.05);
}
</style>
<section class="l-view__element l-view__element--table u-f-g c-movies l-table l-table--content">
<div>
{{#each records as |record|}}
{{#link-to 'rendering.data.collection.list.viewEdit.details' collection.id record.id class="c-movies__image"}}
<img class="c-movies__image" src={{record.forest-poster}}>
{{/link-to}}
{{/each}}
</div>
</section>
{{table/table-footer records=records currentPage=currentPage
fetchPage="fetchPage" currentUser=currentUser customView=customView
updateRecordPerPage="updateRecordPerPage" collection=collection
numberOfPages=numberOfPages fetchRecords="fetchRecords"}}
JAVASCRIPT
'use strict';
import Ember from 'ember';
export default Ember.Component.extend({
actions: {
updateRecordPerPage() {
this.get('customView')
.save()
.then(() => this.sendAction('fetchRecords'));
},
fetchRecords(olderOrNewer) {
this.sendAction('fetchRecords', olderOrNewer);
}
}
});
Having done that, your Gallery view is now rendered on your list view.
Forest is able to leverage data from third party services by reconciliating it with your application’s data, providing it directly to your admin. All your admin actions can be performed at the same place, bringing additional intelligence to your admin and ensuring consistency.
Configuring the Intercom integration for Forest allows you to have your user’s session data (location, browser type, …) and support conversations directly alongside the corresponding user from your application.
config/initializers/forest_liana.rb
# ...
ForestLiana.integrations = {
intercom: {
app_id: 'YOUR_INTERCOM_APP_ID',
api_key: 'YOUR_INTERCOM_API_KEY',
mapping: 'User'
}
}
Intercom is now plugged to Forest

Pass the Intercom options to the Forest init function.
app.use(Liana.init({
// ...
integrations: {
intercom: {
appId: 'YOUR_INTERCOM_APP_ID',
apiKey: 'YOUR_INTERCOM_API_KEY',
intercom: require('intercom-client'),
mapping: 'user'
}
}
}));
Intercom is now plugged to Forest

Pass the Intercom options to the Forest init function.
app.use(Liana.init({
// ...
integrations: {
intercom: {
appId: 'YOUR_INTERCOM_APP_ID',
apiKey: 'YOUR_INTERCOM_API_KEY',
intercom: require('intercom-client'),
mapping: 'user'
}
}
}));
Intercom is now plugged to Forest

Configuring the Stripe integration for Forest allows you to have your user’s
payments, invoices and cards alongside the corresponding user from your
application. A refund action is also available out-of-the-box on the
user_collection configured.
config/initializers/forest_liana.rb
# ...
ForestLiana.integrations = {
stripe: {
api_key: 'YOUR_STRIPE_SECRET_KEY',
mapping: 'User.stripe_id'
}
}
Stripe is now plugged to Forest

Pass the Stripe options to the Forest init function.
app.use(Liana.init({
// ...
integrations: {
stripe: {
apiKey: 'YOUR_STRIPE_SECRET_KEY',
mapping: 'user.stripeId',
stripe: require('stripe')
}
}
}));
Stripe is now plugged to Forest

Pass the Stripe options to the Forest init function.
app.use(Liana.init({
// ...
integrations: {
stripe: {
apiKey: 'YOUR_STRIPE_SECRET_KEY',
mapping: 'user.stripeId',
stripe: require('stripe')
}
}
}));
Stripe is now plugged to Forest

Forest makes it really simple to deploy your admin to production.
Click “Deploy to production”
On your production server
Provide your FOREST_ENV_SECRET (given by Forest) and the FOREST_AUTH_SECRET (random string) in the environment variables of your production server.
On your production server
Deploy your Forest UI configuration to your production environment
Now that you’re all set, you can duplicate the UI configuration of your development environment into your production environment to avoid having to repeat everything from the beginning. It will overwrite the default UI configuration.
Select your environment, click on “Deploy”, then “Save”. You’re now ready to use Forest on production!

Forest provides instantly all the common tasks you need as an admin of your application. Sometimes, these tasks are very specific to your business. Our goal is to provide the simplest and most beautiful way to extend the default behavior automatically implemented by the Liana. That’s why the Liana only generates a standard REST API. The learning curve to extend it is next to none, meaning you can set up your own custom business logic in a matter of minutes.
lib/forest_liana/controllers/user_controller.rb
if ForestLiana::UserSpace.const_defined?('UserController')
ForestLiana::UserSpace::UserController.class_eval do
def update
# your business logic here
head :no_content
end
end
end
The previous example shows how to extend a UPDATE on a record. list, get,
update, create and destroy are the methods you can override or extend.
Instead of reimplementing all the business logic of a method, you can call the default behavior by invoking your aliased method.
if ForestLiana::UserSpace.const_defined?('UserController')
ForestLiana::UserSpace::UserController.class_eval do
alias_method :default_update, :update
def update
# your business logic here
# Continue with the default implementation
default_update
head :no_content
end
end
end
The parameters (params) passed to the method includes the collection and the
ID of the record (except for the list method). A before_filter named
find_resource is executed before the method. This filter is in charge of
injecting to @resource the current model you are acting on.
Declare the route before the Forest Liana middleware
app.delete('/forest/users/:recordId', function (req, res, next) {
// Your business logic here.
});
Instead of reimplementing all the business logic of a method, you can call the
default behavior by invoking next().
Implementing the impersonate action allows you to login as one of your users and see exactly what they see on their screen.
The following example shows how to create an Impersonate Action on a
collection named User.
More details can be found in the Creating an action section.

The impersonate process can be done in 2 steps:
POST '/actions/impersonate' returns a generated URL with a JWT token
as a query param. The token contains the user ID to impersonate and the
details of the admin who triggered the action.GET '/actions/impersonate?token=...' verifies the JWT token, fetches the
user from the database and configures the session depending on your
authentication system.config/routes.rb
namespace :forest do
post '/actions/impersonate' => 'actions#impersonate_token'
get '/actions/impersonate' => 'actions#impersonate'
end
app/controllers/forest/actions_controller.rb
class Forest::CustomersController < ForestLiana::ApplicationController
skip_before_action :authenticate_user_from_jwt, only: [:impersonate]
# Only if you use Pretender
impersonates :user
def impersonate_token
# Find the user to impersonate.
user = User.find(params['data']['attributes']['ids'].first)
# Build the payload of the JWT token.
payload = {
user_id: user.id,
admin_email: forest_user['data']['data']['email'],
admin_teams: forest_user['data']['data']['teams']
}
# Generate the token using a random secret key.
token = JWT.encode(payload, ENV['IMPERSONATE_SECRET_KEY'], 'HS256')
# Respond with the URL.
render json: {
html: "<a href='http://localhost:3000/forest/actions/impersonate?token=#{token}'>"\
"Login as #{user.email}</a>".html_safe
}
end
def impersonate
# Decode the JWT token.
payload = JWT.decode(params['token'], ENV['IMPERSONATE_SECRET_KEY']).first
# Fetch the user from the database.
user = User.find(payload['user_id'])
# Impersonate the user using the gem Pretender
# (https://github.com/ankane/pretender).
impersonate_user(user)
# Redirect to the root page of the application.
redirect_to '/'
end
end
Declare the route to the Express Router
var liana = require('forest-express-mongoose');
function impersonateToken(req, res, next) {
// Find the user to impersonate
User.findById(req.body.data.attributes.ids[0], (err, user) => {
if (err) { return next(err); }
// Build the payload of the JWT token
var payload = {
userId: user.id,
adminEmail: req.user.data.email,
adminTeams: req.user.data.teams,
};
// Generate the token using a random secret key
var token = jwt.sign(payload, process.env.IMPERSONATE_SECRET_KEY);
// Respond with the URL
res.json({
html: '<a href="http://localhost:3000/forest/actions/impersonate?token=' +
token + '">Login as…</a>'
});
});
}
function impersonate(req, res, next) {
// Decode the JWT token.
var payload = jwt.verify(req.query.token, process.env.IMPERSONATE_SECRET_KEY);
// Fetch the user from the database.
User.findById(payload.userId, (err, user) => {
if (err) { return next(err); }
// Impersonate the user using Passport.js and redirect to the root of
// the application.
req.login(user, () => res.redirect('/'));
});
}
router.post('/forest/actions/impersonate', liana.ensureAuthenticated,
impersonateToken);
router.get('/forest/actions/impersonate', impersonate);
Declare the route to the Express Router
var liana = require('forest-express-sequelize');
var models = require('../models');
function impersonateToken(req, res, next) {
// Find the user to impersonate
models.user
.findById(req.body.data.attributes.ids[0])
.then((user) => {
// Build the payload of the JWT token
var payload = {
userId: user.id,
adminEmail: req.user.data.email,
adminTeams: req.user.data.teams,
};
// Generate the token using a random secret key
var token = jwt.sign(payload, process.env.IMPERSONATE_SECRET_KEY);
// Respond with the URL
res.json({
html: '<a href="http://localhost:3000/forest/actions/impersonate?token=' +
token + '">Login as…</a>'
});
});
}
function impersonate(req, res, next) {
// Decode the JWT token.
var payload = jwt.verify(req.query.token, process.env.IMPERSONATE_SECRET_KEY);
// Fetch the user from the database.
models.user
.findById(payload.userId)
.then((user) => {
// Impersonate the user using Passport.js and redirect to the root of
// the application.
req.login(user, () => res.redirect('/'));
});
}
router.post('/forest/actions/impersonate', liana.ensureAuthenticated,
impersonateToken);
router.get('/forest/actions/impersonate', impersonate);

Forest natively supports data creation but it’s sometimes more efficient to simply import it. This “How-to” shows a way to achieve that in your Forest admin.
Your action is now visible

config/routes.rb
namespace :forest do
post '/actions/bulk-import' => 'actions#bulk_import'
end
app/controllers/forest/actions_controller.rb
In the following example, we use data_uri to parse the encoded file. You should add it to your Gemfile too.
class Forest::ActionsController < ForestLiana::ApplicationController
def bulk_import
uri = URI::Data.new(params.dig('data', 'attributes', 'values', 'file'))
uri.data.force_encoding('utf-8')
CSV.parse(uri.data).each do |row|
# Your business logic to create your data here.
end
render json: { success: 'Data successfuly imported!' }
end
end
Your action is now visible

Declare the route in the Express Router
In the following example, we use parse-data-uri and csv NPM packages to parse the encoded file. You should add it to your package.json too.
const parseDataUri = require('parse-data-uri');
const csv = require('csv');
function bulkImport(req, res, next) {
let parsed = parseDataUri(req.body.data.attributes.values.file);
csv.parse(parsed.data, function (err, row) {
// Your business logic to create your data here.
res.send({ success: 'Data successfuly imported!' })
});
}
router.put('/forest/actions/bulk-import', liana.ensureAuthenticated, updateMovie);
Image uploading is one of the most common operations people do in their admin. There’s plenty of ways to handle it in your application. Forest supports natively the most common file attachment libraries. In case you’re not using one of them, Forest gives you the flexibility to plug your own file upload business logic.
Paperclip and CarrierWave are both well known libraries to manage file attachments in Rails. Forest supports them natively, which means you don’t need to configure anything more to upload and crop your images in Forest.
The following example shows you how to handle the update of a record image. The image field should be a string that contains the image URL. You have to configure the file picker widget on it.

Having done that, your image is now rendered on your Details view. You can upload a new one when editing your record and clicking on the image. Optionally, you can also crop it.

Hitting the Apply changes button will update your record with your new image encoded in base64 (RFC 2397).
Now, you have to extend the default behavior of the
PUT method on your admin API to upload your image where you want. The
following example shows you how to upload the image to AWS S3.
app/decorators/controllers/forest_liana/resources_controller.rb
ForestLiana::ResourcesController.class_eval do
alias_method :default_update, :update
# Regexp to match the RFC2397 prefix.
REGEXP = /\Adata:([-\w]+\/[-\w\+\.]+)?;base64,(.*)/m
def update
# Create the S3 client.
s3 = AWS::S3.new(access_key_id: ENV['S3_ACCESS_KEY'],
secret_access_key: ENV['S3_SECRET_KEY'])
bucket = s3.buckets[ENV['S3_BUCKET']]
# Parse the "data" URL scheme (RFC 2397).
data_uri_parts = raw_data.match(REGEXP) || []
# Upload the image.
obj = bucket.objects.create(filename(data_uri_parts),
base64_image(data_uri_parts),
opts(data_uri_parts))
# Inject the new poster URL to the params.
url = obj.public_url().to_s
params['resource']['data']['attributes']['poster'] = url
params['data']['attributes']['poster'] = url
# Finally, call the default PUT behavior.
default_update
end
private
def raw_data
params['data']['attributes']['poster']
end
def base64_image(data_uri_parts)
Base64.decode64(data_uri_parts[2])
end
def extension(data_uri_parts)
MIME::Types[data_uri_parts[1]].first.preferred_extension
end
def filetype(data_uri_parts)
MIME::Types[data_uri_parts[1]].first.to_s
end
def filename(data_uri_parts)
('a'..'z').to_a.shuffle[0..7].join + ".#{extension(data_uri_parts)}"
end
def opts(data_uri_parts)
{
acl: 'public_read',
content_type: filetype(data_uri_parts)
}
end
end
File upload is now handled.
Declare the route before the Forest Express middleware
var AWS = require('aws-sdk');
function randomFilename() {
return require('crypto').randomBytes(48, function(err, buffer) {
var token = buffer.toString('hex');
});
}
function updateMovie(req, res, next) {
// Create the S3 client.
var s3Bucket = new AWS.S3({ params: { Bucket: process.env.S3_BUCKET }});
// Parse the "data" URL scheme (RFC 2397).
var rawData = req.body.data.attributes.pictureUrl;
var base64Image = rawData.replace(/^data:image\/\w+;base64,/, '');
// Generate a random filename.
var filename = randomFilename();
var data = {
Key: filename,
Body: new Buffer(base64Image, 'base64'),
ContentEncoding: 'base64',
ACL: 'public-read'
};
// Upload the image.
s3Bucket.upload(data, function(err, response) {
if (err) { return reject(err); }
// Inject the new poster URL to the params.
req.body.data.attributes.pictureUrl = response.Location;
// Finally, call the default PUT behavior.
next();
});
};
router.put('/forest/movies/:movieId', liana.ensureAuthenticated, updateMovie);
File upload is now handled.
