var path = require('path'); var format = require('util').format; var withTrailingSlash = require('okutil').withTrailingSlash; var withoutTrailingSlash = require('okutil').withoutTrailingSlash; var assign = require('object-assign'); var express = require('express'); var Q = require('q'); var OKQuery = require('okquery'); var OKView = require('okview'); var OKAdminView = require('okadminview'); var OKDB = require('okdb'); var OKResource = require('okresource') var OKTemplate = require('oktemplate'); var OKServer = require('okserver'); var OKSchema = require('okschema'); var OKS3Service = require('okservices/oks3'); var OKTwitterService = require('okservices/oktwitter') var OKWebhookService = require('okservices/okwebhook') require('dotenv').load(); /** * OKCMS! * Basically takes configuration and gives you a server. */ function OKCMS(options) { if (!(this instanceof OKCMS)) return new OKCMS(options); options = options || {}; var app = express(); app.enable('strict routing'); app.disable('x-powered-by'); var schemaConfig = options.schemas || {}; var resourceConfig = options.resources || []; var viewConfig = options.views || { '/': { template: 'index' } }; var serviceConfig = options.services || {}; var adminConfig = options.admin || {} var root = this._root = options.root || 'public'; var templateRoot = options.templateRoot || 'templates'; var adminPath = this._adminPath = adminConfig.path || '/admin'; var adminTheme = this._adminTheme = adminConfig.theme || 'okadmin' var adminRoot = this._adminRoot = adminConfig.root || path.join(__dirname, '../themes/' + adminTheme + '/public'); var adminLibPath = this._adminLibPath = adminConfig.libPath || path.join(adminPath, "_lib"); var adminLibRoot = this._adminLibRoot = adminConfig.libRoot || path.join(__dirname, '../themes/okadmin/public'); var adminTemplateRoot = adminConfig.templateRoot || path.join(__dirname, '../themes/okadmin/templates'); var debug = !!options.debug; var production = !!options.production; var metaUser = options.meta || {}; var metaDefault = { project: 'OKCMS', production: production, debug: debug }; var meta = assign({ static: '' }, metaDefault, metaUser); var adminMeta = assign({ static: withoutTrailingSlash(adminPath), services: options.services, }, metaDefault, metaUser); var templateProvider = this._templateProvider = new OKTemplate({ root: templateRoot, debug: debug }); var adminTemplateProvider = this._adminTemplateProvider = new OKTemplate({ root: adminTemplateRoot, debug: debug }); var schemas = this._schemas = this._createSchemas(schemaConfig); var db = new OKDB({ db: options.db || 'fs', schemas: schemas }); var resourceCache = this._resourceCache = this._createResources(resourceConfig, db, schemas); this._resolveForeignKeys(resourceCache) var errorHandler = createErrorHandlerProducer( templateProvider, adminTemplateProvider, debug); // Create view instances from config var views = this._views = this._createViews(viewConfig, db, meta, resourceCache, templateProvider, errorHandler); var adminViews = this._adminViews = this._createAdminViews(adminConfig, adminPath, app, express, resourceConfig, resourceCache, adminTemplateProvider, adminMeta, errorHandler); // Create services var services = {} Object.keys(serviceConfig).forEach(function(key){ var config = serviceConfig[key] switch (key) { case 's3': services.s3 = OKS3Service({ express: express, s3: config, }); break case 'twitter': services.twitter = OKTwitterService({ express: express, credentials: config, }); break case 'webhook': services.webhook = OKWebhookService({ express: express, config: config, }); break default: services[key] = config.lib({ express: express, config: config, meta: adminMeta, db: resourceCache, errorHandler: errorHandler, }); break } }); var server = this._server = new OKServer({ express: express, app: app, // Merge admin views with normal views views: assign(views, adminViews), // Specify root folders and paths for serving static assets root: root, adminRoot: adminRoot, adminPath: adminPath, adminLibRoot: adminLibRoot, adminLibPath: adminLibPath, services: services, errorHandler: errorHandler }); } OKCMS.prototype.listen = function listen(port, options) { options = options || {}; this._server.listen(port); }; OKCMS.prototype._createSchemas = function(schemaConfig) { schemaConfig = schemaConfig || {}; return Object.keys(schemaConfig).reduce(function(cache, key) { var spec = schemaConfig[key]; // All resources have an autoincrementing index so we can order them suckas // TODO Screw the __ prefix, just consider 'index' a reserved word spec.__index = {type: 'meta', autoincrement: true}; // All resources have a dateCreated field spec.dateCreated = {type: 'meta'}; cache[key] = OKSchema(spec); return cache; }, {}); } OKCMS.prototype._createResources = function(resourceConfig, db, schemaCache) { resourceConfig = resourceConfig || {}; var resources = resourceConfig.map(function(config) { var type = config.type; var schema = schemaCache[type]; if (!schema) throw new Error('Resource config references nonexistent schema ' + type); var resource = new OKResource({ type: type, db: db, schema: schema }); // Static resources have some data defined by configuration and // are a special case if (config.static) { resource = resource.instance({static: config.static}); } return resource; }); return ResourceCache(resources); }; OKCMS.prototype._resolveForeignKeys = function(resourceCache) { resourceCache.forEach(function(resource) { Object.keys(resource.foreignKeys).forEach(function(field) { var foreignKeyType = resource.foreignKeys[field] var keyedResource = resourceCache.get(foreignKeyType) if (!keyedResource) { throw new Error(format( "Foreign key field '%s' in '%s' resource references unknown" + "resource of type '%s'", field, resource.type, foreignKeyType)) } resource._linkForeignKey(field, resourceCache.get(foreignKeyType)) }) }) } OKCMS.prototype._createViews = function(viewConfig, db, meta, resourceCache, templateProvider, errorHandler) { viewConfig = viewConfig || {}; var self = this; var createQueries = this._createQueries.bind(this); return Object.keys(viewConfig).reduce(function(cache, route) { var config = viewConfig[route]; var templateName = config.template || getDefaultTemplate(route, config); var template = templateProvider.getTemplate(templateName); if (!template) { throw new Error(format('No template named "%s" found', templateName)); } var queries = createQueries(config.data, resourceCache); // Don't forget to add that trailing slash if the user forgot cache[withTrailingSlash(route)] = OKView({ mount: 'get', // User defined views are read only route: route, template: template, queries: queries, meta: meta, errorHandler: errorHandler }); return cache; }, {}); /** * Returns the default template for a view config */ function getDefaultTemplate(route, config) { // Root route defaults to index if (/^\/?$/.test(route)) return 'index'; // Otherwise default to the backing resource name else if (config && config.data && config.data.type) return config.data.type; // Otherwise dunno else return '404'; } }; OKCMS.prototype._createAdminViews = function(adminConfig, path, app, express, resourceConfig, resourceCache, templateProvider, meta, errorHandler) { var views = {}; var withTrail = withTrailingSlash(path); var withoutTrail = withoutTrailingSlash(path); // Stoopid fix for a bug in Express. Need to do this // to ensure strict routing is not broken for the nested // admin router. // See: https://github.com/strongloop/express/issues/2281 // TODO Get rid of this crap views[withoutTrail] = { mount: 'get', middleware: function() { return function(req, res) { res.redirect(301, withTrail); } } }; // Add real view at trailing slash route views[withTrail] = OKAdminView({ app: app, express: express, resourceConfig: resourceConfig, resourceCache: resourceCache, templateProvider: templateProvider, meta: meta, errorHandler: errorHandler, dashboardConfig: adminConfig.dashboard || {} }); return views; }; OKCMS.prototype._createQueries = function(queryConfig, resourceCache) { if (!queryConfig) return []; if (!queryConfig.length) queryConfig = [queryConfig]; return queryConfig.map(function(config) { var type = config.type; var resource = resourceCache.get(type, config.query); if (!resource) throw new Error('Query configured with nonexistent resource'); // Default to "select all" query var query = config.query || '*'; return new OKQuery({ resource: resource, query: query, as: config.as, sortBy: config.sortBy, descending: config.descending, groupBy: config.groupBy }); }); }; /** * Stupid lil cache to help deal with the fact that * resources can be indexed by either type or a type + id combo. */ function ResourceCache(resources) { if (!(this instanceof ResourceCache)) return new ResourceCache(resources); resources = resources || []; var cache = this._cache = {}; resources.forEach(function(resource) { if (!resource) throw new Error('Undefined resource given to ResourceCache'); if (resource.bound) { cache[resource.type] = resource.parent; cache[resource.type + ':' + resource.getID()] = resource; } else { cache[resource.type] = resource; } }); } ResourceCache.prototype.get = function(type, id) { if (!type) return; if (id && this._cache[type + ':' + id]) { return this._cache[type + ':' + id]; } else { return this._cache[type]; } }; ResourceCache.prototype.forEach = function(cb) { cb = cb || function() {} Object.keys(this._cache).forEach(function(key) { cb(this._cache[key]) }.bind(this)) } /** * Higher order function implementing customizable error handling */ function createErrorHandlerProducer(templateProvider, adminTemplateProvider, debugMode) { var template404 = templateProvider.getTemplate('404') || adminTemplateProvider.getTemplate('404'); var template5xx = templateProvider.getTemplate('5xx') || adminTemplateProvider.getTemplate('5xx'); if (!template404 || !template5xx) throw new Error('No error templates provided by admin theme or user') return debugMode ? createDebugHandler : createErrorHandler; function createErrorHandler(req, res, status) { return function handleError(err) { switch (status) { case 404: template404.render().then(function(rendered) { res.status(status); res.send(rendered); }); break; default: template5xx.render().then(function(rendered) { res.status(status); res.send(rendered); }); } }; } function createDebugHandler(req, res, status) { return function handleError(err) { res.send(err.stack); }; } } module.exports = { createApp: function(options) { return OKCMS(options); }, OKResource: OKResource, OKView: OKView };