var assign = require('object-assign'); var cloneDeep = require('lodash.clonedeep'); var bodyParser = require('body-parser'); var methodOverride = require('method-override'); var session = require('express-session'); var flash = require('connect-flash'); var passport = require('passport'); var DigestStrategy = require('passport-http').DigestStrategy; var Q = require('q'); var pluralize = require('pluralize'); var OKQuery = require('okquery'); // Configure auth passport.use(new DigestStrategy({qop: 'auth'}, function authenticate(username, done) { if (!process.env.OK_USER || !process.env.OK_PASS) { return done(new Error('No user or pass configured on server')); } else { return done(null, process.env.OK_USER, process.env.OK_PASS); } })); /** * OKAdminView! */ function OKAdminView(options) { if (!(this instanceof OKAdminView)) return new OKAdminView(options); if (!options.app) throw new Error('No Express app provided to OKAdminView'); if (!options.express) throw new Error('No Express provided to OKAdminView'); if (!options.resourceConfig) throw new Error('No resourceConfig provided to OKAdminView'); if (!options.resourceCache) throw new Error('No resourceCache provided to OKAdminView'); if (!options.templateProvider) throw new Error('No templateProvider provided to OKAdminView'); if (!options.meta) throw new Error('No metadata provided to OKAdminView'); if (!options.errorHandler) throw new Error('No error handler provided to OKAdminView'); var app = options.app; var express = options.express; var meta = options.meta; var resourceCache = this._resourceCache = options.resourceCache; var resourceConfig = this._resourceConfig = cloneDeep(options.resourceConfig); var provider = options.templateProvider; var error = this._error = options.errorHandler; // Load templates var templates = this._templates = ['index', 'resource', 'resource_new'].reduce(function(cache, name) { var template = provider.getTemplate(name); if (!template) throw new Error('Admin theme does not have needed template: "' + name + '"'); cache[name] = template; return cache; }, {}); // OKAdmin middleware is a router, so mounts on 'use' Object.defineProperty(this, 'mount', { value: 'use', writable: false, enumerable: true }); // Resources which apper on the index are a function of // the resource configuration. var indexQueries = this._indexQueries = Object.keys(resourceConfig) .map(function(key) { var config = resourceConfig[key]; var type = config.type; var staticData = config.static || {}; // Get parent level resource var resource = resourceCache.get(config.type); if (!resource) throw new Error('Something weird is going on'); var id = resource.getID(staticData); // Check to see if there's a more specific instance resource = resourceCache.get(type, id) || resource; if (resource.bound) { return OKQuery({resource: resource});; } else { return OKQuery({resource: resource, query: config.query}) } }); this._middleware = createMiddleware(this); function createMiddleware(view) { var router = express.Router({ strict: app.get('strict routing') }); // Enable basic sessions for flash messages router.use(session({ secret: 'okadmin', resave: false, saveUninitialized: false })); // Enable flash messaging router.use(flash()); // Parse form data router.use(bodyParser.urlencoded({extended: true})); // HTML forms only support POST and GET methods // We extend this by adding hidden input fields to the forms which // specify the actual method desired. router.use(methodOverride(function(req, res) { // Parse out the hidden field if (req.body && typeof req.body === 'object' && '_method' in req.body) { var method = req.body._method; delete req.body._method return method } })); // This should really be mounted on the router, but can't be due to // https://github.com/jaredhanson/passport-http/pull/16 app.use('/admin/', passport.initialize()); app.all('/admin/:path*', passport.authenticate('digest', {session: false})); router.get('/', function readIndex(req, res, next) { fetchIndexTemplateData(meta, indexQueries).then(function(data) { view.renderIndex(req, res, assign(data, { success: req.flash('success'), errors: req.flash('errors') })); }).fail(error(req, res, 500)); }); router.get('/:type/__new__/', function createResourceView(req, res, next) { var type = req.params.type || ''; var resource = resourceCache.get(type); if (!resource) { error(req, res, 404)(new Error('No such resource ' + type)); } else { var templateData = transformData(meta, resource, {}); view.renderResourceNew(req, res, assign(templateData, { success: req.flash('success'), errors: req.flash('errors'), })); } }); router.get('/:type/:id/', function readResource(req, res, next) { var type = req.params.type || ''; var id = req.params.id || ''; var resource = resourceCache.get(type, id); var query = OKQuery({ resource: resource, query: id }); fetchResourceTemplateData(meta, query, transformData) .then(function(data) { if (!data) { resourceMissingHandler(req, res)() } else { view.renderResource(req, res, assign(data, { success: req.flash('success'), errors: req.flash('errors') })); } }).fail(function(err) { if (err.message === 'No resource data') { error(req, res, 404)(new Error('No such resource')); } else { error(req, res, 500)(err); } }); }); router.post('/:type/', function createResource(req, res, next) { var type = req.params.type; var resource = resourceCache.get(type); var data = req.body; if (!resource) { error(req, res, 400)(new Error('No such resource ' + type)); } else { try { resource.assertValid(data); resource.create(data).then(function(created) { req.flash('success', {action: 'create'}); res.redirect(303, resource.getID(data)); }).fail(error(req, res, 500)); } catch (errors) { var templateData = transformData(meta, resource, data); view.renderResource(req, res, assign(templateData, {errors: errors})); } } }); router.put('/:type/__batch__/', function putBatch(req, res, next) { var type = req.params.type; var body = req.body || {}; var resourcesJSON = body[type]; var resource = resourceCache.get(type); if (!resourcesJSON || !resourcesJSON.length) { error(req, res, 400)(new Error('Bad request')); } else if (!resource) { error(req, res, 404)(new Error('No such resource')); } else { try { var ids = []; var resourcesParsed = resourcesJSON.map(function(resourceJSON) { var data = JSON.parse(resourceJSON); ids.push(resource.getID(data)); return data; }); } catch (e) { error(req, res, 500)(new Error('Resource batch contains invalid JSON')); return; } resource.updateBatch(ids, resourcesParsed).then(function(results) { req.flash('success', {action: 'batch_update'}); res.redirect(303, '../..'); }).fail(error(req, res, 500)); } }); router.put('/:type/:id/', function updateResource(req, res, next) { var type = req.params.type; var id = req.params.id; var data = req.body; var resource = resourceCache.get(type, id); if (!resource) { error(req, res, 400)(new Error('No such resource ' + type)); } else { try { resource.assertValid(data); resource.update(id, data).then(function(updated) { req.flash('success', {action: 'update'}); res.redirect(303, '../' + resource.getID(updated)); }).fail(error(req, res, 500)); } catch (errors) { var templateData = transformData(meta, resource, data); view.renderResource(req, res, assign(templateData, {errors: errors})); } } }); router.delete('/:type/:id/', function deleteResource(req, res, next) { var type = req.params.type; var id = req.params.id; var resource = resourceCache.get(type, id); if (!resource) { error(req, res, 500)(new Error('No such resource ' + type)); } else { resource.destroy(id).then(function() { req.flash('success', {action: 'delete'}); res.redirect(303, '../..'); }).fail(error(req, res, 500)); } }); return router; } } /** * Yields formatted template data for a single resource */ function transformData(meta, resource, data) { meta = meta || {}; resource = resource || {}; data = data || {}; var spec = Object.keys(resource.spec).reduce(function(cache, prop) { var value = data[prop]; var propSpec = cache[prop]; // Decorate spec with actual resource values propSpec.value = value; // Some fields should not be shown to the user if (propSpec.type === 'meta' || propSpec.static) { propSpec.hidden = true; } return cache; }, resource.spec); return { meta: meta, resource: { id: resource.getID(data), type: resource.type, spec: spec } }; } OKAdminView.prototype.middleware = function() { return this._middleware; }; OKAdminView.prototype.renderIndex = function(req, res, data) { data = data || {}; this._templates['index'].render(data).then(function(rendered) { res.send(rendered); }).fail(this._error(req, res, 500)); }; OKAdminView.prototype.renderResource = function(req, res, data) { data = data || {}; this._templates['resource'].render(data).then(function(rendered) { res.send(rendered); }).fail(this._error(req, res, 500)); }; OKAdminView.prototype.renderResourceNew = function(req, res, data) { data = data || {meta: {}, resource: {}}; this._templates['resource_new'].render(data).then(function(rendered) { res.send(rendered); }).fail(this._error(req, res, 500)); }; /** * Annotate template data with schema info */ function fetchIndexTemplateData(meta, queries) { return Q.promise(function(resolve, reject) { Q.all(queries.map(function(query) { return query.get(); })).then(function(results) { var resources = results.reduce(function(cache, result, i) { if (!result) return cache; var resource = queries[i].resource; // We want the raw object spec var spec = resource.spec; var key = pluralize(resource.type); if (!cache[key]) { cache[key] = { type: resource.type, spec: spec, data: [] }; } if (result.length) { result.forEach(addData) } else { addData(result); } function addData(data) { // Report id to template under standard name data.id = resource.getID(data); cache[key].data.push(data); } return cache; }, {}); resolve({ meta: meta, resources: resources }); }).fail(reject); }); } /** * Annotate template data with schema info */ function fetchResourceTemplateData(meta, query, fn) { fn = fn || function(m, r, d) { return {meta: m, resource: d}; }; return Q.promise(function(resolve, reject) { query.get().then(function(data) { if (!data) { reject(new Error('No resource data')); } else { var resource = query.resource; resolve(fn(meta, resource, data)); } }).fail(reject); }); } module.exports = OKAdminView;