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 dashboardConfig = options.dashboardConfig || {} var dashboardResourceConfig = dashboardConfig.resources || {} 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; var dashConf = dashboardResourceConfig[type] || {} var groupBy = dashConf.groupBy var sortBy = dashConf.sortBy var descending = dashConf.descending if (resource.bound) { return OKQuery({ resource: resource, groupBy: groupBy, sortBy: sortBy, descending: descending }) } else { return OKQuery({ resource: resource, query: config.query, groupBy: groupBy, sortBy: sortBy, descending: descending }) } }); 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, limit: '10mb'})); // 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, dashboardConfig).then(function(data) { view.renderIndex(req, res, assign(data, { success: req.flash('success'), errors: req.flash('errors') })); }).fail(error(req, res, 500)); }); router.get('/index.json', function readIndex(req, res, next) { fetchIndexTemplateData(meta, indexQueries, dashboardConfig).then(function(data) { data.meta = Object.assign({}, data.meta) delete data.meta.services view.renderJSON(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 { fetchNewTemplateData(meta, resource, transformData).then(function(data) { view.renderResourceNew(req, res, assign(data, { success: req.flash('success'), errors: req.flash('errors'), })) }).fail(error(req, res, 500)) } }); 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, { JSON: JSON, 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 spec = resource.spec var templateData = transformData(meta, spec, 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 spec = resource.spec var templateData = transformData(meta, spec, 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, spec, resource, data) { meta = meta || {}; resource = resource || {}; data = data || {}; var spec = Object.keys(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; }, 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)); }; OKAdminView.prototype.renderJSON = function(req, res, data) { data = data || {}; res.json(data); }; /** * Annotate template data with schema info */ function fetchIndexTemplateData(meta, queries, dashboardConfig) { var resourceConfig = dashboardConfig.resources || {} return Q.promise(function(resolve, reject) { Q.all(queries.map(function(query) { return query.get(); })).then(function(results) { var templateData = results.reduce(function(acc, result, i) { result = result.length ? result : [result] var resource = queries[i].resource; var key = pluralize(resource.type) if (acc[key]) { acc[key].data = acc[key].data.concat(result) } else { // We want the raw object spec var spec = resource.spec; var dashConf = resourceConfig[resource.type] || {} var groupBy = dashConf.groupBy var descending = dashConf.descending || false acc[key] = { type: resource.type, spec: spec, data: result, groupBy: groupBy, descending: descending, display: dashConf.display, title: dashConf.title || "title", } } return acc }, {}) resolve({ meta: meta, resources: templateData }) }).fail(reject) }); } function fetchNewTemplateData(meta, resource, transformFn) { return Q.promise(function(resolve, reject) { if (!resource.hasForeignKey) { done({spec: resource.spec, resource: resource}) } else { fetchForeignKeyOptions(resource).then(done).fail(reject) } function done(results) { resolve(transformFn(meta, results.spec, results.resource, {})) } }) } /** * Annotate template data with schema info */ function fetchResourceTemplateData(meta, query, transformFn) { return Q.promise(function(resolve, reject) { query.get().then(function(data) { if (!data) return reject(new Error('No resource data')) var resource = query.resource if (resource.hasForeignKey) { fetchForeignKeyOptions(resource).then(done).fail(reject) } else { done({spec: resource.spec, resource: resource}) } function done(results) { resolve(transformFn(meta, results.spec, results.resource, data)) } }).fail(reject) }) } function fetchForeignKeyOptions(resource) { var promises = Object.keys(resource.foreignKeys) .map(fetchOptionsForKey) var spec = resource.spec return Q.all(promises).then(done) function done() { return Q.promise(function(resolve, reject) { resolve({spec: spec, resource: resource}) }) } function fetchOptionsForKey(field) { var relatedResourceType = resource.foreignKeys[field] return resource.related(relatedResourceType).then(fillOptions) function fillOptions(results) { return Q.promise(function(resolve, reject) { spec[field].options = results.map(function(result) { return result.id }) resolve() }) } } } module.exports = OKAdminView;