diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f44488e --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.db + +config.js + +node_modules/ + +node_modules + +content/data + +core/built diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..a814441 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,752 @@ +// # Task automation for Ghost +// +// Run various tasks when developing for and working with Ghost. +// +// **Usage instructions:** can be found in the [Custom Tasks](#custom%20tasks) section or by running `grunt --help`. +// +// **Debug tip:** If you have any problems with any Grunt tasks, try running them with the `--verbose` command + +// jshint unused: false +var overrides = require('./core/server/overrides'), + _ = require('lodash'), + chalk = require('chalk'), + fs = require('fs-extra'), + path = require('path'), + + escapeChar = process.platform.match(/^win/) ? '^' : '\\', + cwd = process.cwd().replace(/( |\(|\))/g, escapeChar + '$1'), + buildDirectory = path.resolve(cwd, '.build'), + distDirectory = path.resolve(cwd, '.dist'), + + // ## Grunt configuration + + configureGrunt = function (grunt) { + // #### Load all grunt tasks + // + // Find all of the task which start with `grunt-` and load them, rather than explicitly declaring them all + require('matchdep').filterDev(['grunt-*', '!grunt-cli']).forEach(grunt.loadNpmTasks); + + var cfg = { + // #### Common paths used by tasks + paths: { + build: buildDirectory, + releaseBuild: path.join(buildDirectory, 'release'), + dist: distDirectory, + releaseDist: path.join(distDirectory, 'release') + }, + // Standard build type, for when we have nightlies again. + buildType: 'Build', + // Load package.json so that we can create correctly versioned releases. + pkg: grunt.file.readJSON('package.json'), + + clientFiles: [ + 'server/views/default.hbs', + 'built/assets/ghost.js', + 'built/assets/ghost.css', + 'built/assets/vendor.js', + 'built/assets/vendor.css' + ], + + // ### grunt-contrib-watch + // Watch files and livereload in the browser during development. + // See the [grunt dev](#live%20reload) task for how this is used. + watch: { + livereload: { + files: [ + 'content/themes/casper/assets/css/*.css', + 'content/themes/casper/assets/js/*.js', + 'core/built/assets/*.js', + 'core/client/dist/index.html' + ], + options: { + livereload: true + } + }, + express: { + files: ['core/ghost-server.js', 'core/server/**/*.js'], + tasks: ['express:dev'], + options: { + spawn: false + } + } + }, + + // ### grunt-express-server + // Start a Ghost expess server for use in development and testing + express: { + options: { + script: 'index.js', + output: 'Ghost is running' + }, + + dev: { + options: {} + }, + test: { + options: { + node_env: 'testing' + } + } + }, + + // ### grunt-contrib-jshint + // Linting rules, run as part of `grunt validate`. See [grunt validate](#validate) and its subtasks for + // more information. + jshint: { + options: { + jshintrc: '.jshintrc' + }, + + server: [ + '*.js', + '!config*.js', // note: i added this, do we want this linted? + 'core/*.js', + 'core/server/**/*.js', + 'core/test/**/*.js', + '!core/test/coverage/**', + '!core/shared/vendor/**/*.js' + ] + }, + + jscs: { + options: { + config: true + }, + + server: { + files: { + src: [ + '*.js', + '!config*.js', // note: i added this, do we want this linted? + 'core/*.js', + 'core/server/**/*.js', + 'core/test/**/*.js', + '!core/test/coverage/**', + '!core/shared/vendor/**/*.js' + ] + } + } + }, + + // ### grunt-mocha-cli + // Configuration for the mocha test runner, used to run unit, integration and route tests as part of + // `grunt validate`. See [grunt validate](#validate) and its sub tasks for more information. + mochacli: { + options: { + ui: 'bdd', + reporter: grunt.option('reporter') || 'spec', + timeout: '30000', + save: grunt.option('reporter-output'), + require: ['core/server/overrides'] + }, + + // #### All Unit tests + unit: { + src: [ + 'core/test/unit/**/*_spec.js', + 'core/server/apps/**/tests/*_spec.js' + ] + }, + + // #### All Integration tests + integration: { + src: [ + 'core/test/integration/**/*_spec.js', + 'core/test/integration/*_spec.js' + ] + }, + + // #### All Route tests + routes: { + src: [ + 'core/test/functional/routes/**/*_spec.js' + ] + }, + + // #### All Module tests + module: { + src: [ + 'core/test/functional/module/**/*_spec.js' + ] + }, + + // #### Run single test (src is set dynamically, see grunt task 'test') + single: {} + }, + + // ### grunt-mocha-istanbul + // Configuration for the mocha test coverage generator + // `grunt coverage`. + mocha_istanbul: { + coverage: { + // they can also have coverage generated for them & the order doesn't matter + src: [ + 'core/test/unit', + 'core/server/apps' + ], + options: { + mask: '**/*_spec.js', + coverageFolder: 'core/test/coverage/unit', + mochaOptions: ['--timeout=15000', '--require', 'core/server/overrides'], + excludes: ['core/client', 'core/server/built'] + } + }, + coverage_all: { + src: [ + 'core/test/integration', + 'core/server/apps', + 'core/test/functional', + 'core/test/unit' + ], + options: { + coverageFolder: 'core/test/coverage/all', + mask: '**/*_spec.js', + mochaOptions: ['--timeout=15000', '--require', 'core/server/overrides'], + excludes: ['core/client', 'core/server/built'] + } + + } + }, + + bgShell: { + client: { + cmd: 'grunt subgrunt:watch', + bg: true + } + }, + + // ### grunt-shell + // Command line tools where it's easier to run a command directly than configure a grunt plugin + shell: { + shrinkwrap: { + command: 'npm shrinkwrap' + }, + + prune: { + command: 'npm prune' + }, + + dedupe: { + command: 'npm dedupe' + } + }, + + // ### grunt-docker + // Generate documentation from code + docker: { + docs: { + dest: 'docs', + src: ['.'], + options: { + onlyUpdated: true, + exclude: 'node_modules,bower_components,content,core/client,*test,*doc*,' + + '*vendor,config.js,*buil*,.dist*,.idea,.git*,.travis.yml,.bower*,.editorconfig,.js*,*.md', + extras: ['fileSearch'] + } + } + }, + + // ### grunt-contrib-clean + // Clean up files as part of other tasks + clean: { + built: { + src: [ + 'core/built/**', + 'core/client/dist/**', + 'core/client/public/assets/img/contributors/**', + 'core/client/app/templates/-contributors.hbs' + ] + }, + release: { + src: ['<%= paths.releaseBuild %>/**'] + }, + test: { + src: ['content/data/ghost-test.db'] + }, + tmp: { + src: ['.tmp/**'] + }, + dependencies: { + src: ['node_modules/**', 'core/client/bower_components/**', 'core/client/node_modules/**'] + } + }, + + // ### grunt-contrib-compress + // Zip up files for builds / releases + compress: { + release: { + options: { + archive: '<%= paths.releaseDist %>/Ghost-<%= pkg.version %>.zip' + }, + expand: true, + cwd: '<%= paths.releaseBuild %>/', + src: ['**'] + } + }, + + // ### grunt-update-submodules + // Grunt task to update git submodules + update_submodules: { + default: { + options: { + params: '--init' + } + } + }, + + uglify: { + prod: { + options: { + sourceMap: false + }, + files: { + 'core/shared/ghost-url.min.js': 'core/shared/ghost-url.js' + } + } + }, + + // ### grunt-subgrunt + // Run grunt tasks in submodule Gruntfiles + subgrunt: { + options: { + npmInstall: false + }, + init: { + options: { + npmInstall: true + }, + projects: { + 'core/client': 'init' + } + }, + + dev: { + 'core/client': 'shell:ember:dev' + }, + + prod: { + 'core/client': 'shell:ember:prod' + }, + + watch: { + 'core/client': ['bgShell:ember', 'watch'] + }, + + lint: { + 'core/client': 'lint' + }, + + test: { + 'core/client': 'shell:test' + } + } + }; + + // Load the configuration + grunt.initConfig(cfg); + + // # Custom Tasks + + // Ghost has a number of useful tasks that we use every day in development. Tasks marked as *Utility* are used + // by grunt to perform current actions, but isn't useful to developers. + // + // Skip ahead to the section on: + // + // * [Building assets](#building%20assets): + // `grunt init`, `grunt` & `grunt prod` or live reload with `grunt dev` + // * [Testing](#testing): + // `grunt validate`, the `grunt test-*` sub-tasks or generate a coverage report with `grunt coverage`. + + // ### Help + // Run `grunt help` on the commandline to get a print out of the available tasks and details of + // what each one does along with any available options. This is an alias for `grunt --help` + grunt.registerTask('help', + 'Outputs help information if you type `grunt help` instead of `grunt --help`', + function () { + console.log('Type `grunt --help` to get the details of available grunt tasks.'); + }); + + // ### Documentation + // Run `grunt docs` to generate annotated source code using the documentation described in the code comments. + grunt.registerTask('docs', 'Generate Docs', ['docker']); + + // Runun `grunt watch-docs` to setup livereload & watch whilst you're editing the docs + grunt.registerTask('watch-docs', function () { + grunt.config.merge({ + watch: { + docs: { + files: ['core/server/**/*', 'index.js', 'Gruntfile.js', 'config.example.js'], + tasks: ['docker'], + options: { + livereload: true + } + } + } + }); + + grunt.task.run('watch:docs'); + }); + + // ## Testing + + // Ghost has an extensive set of test suites. The following section documents the various types of tests + // and how to run them. + // + // TLDR; run `grunt validate` + + // #### Set Test Env *(Utility Task)* + // Set the NODE_ENV to 'testing' unless the environment is already set to TRAVIS. + // This ensures that the tests get run under the correct environment, using the correct database, and + // that they work as expected. Trying to run tests with no ENV set will throw an error to do with `client`. + grunt.registerTask('setTestEnv', + 'Use "testing" Ghost config; unless we are running on travis (then show queries for debugging)', + function () { + process.env.NODE_ENV = process.env.TRAVIS ? process.env.NODE_ENV : 'testing'; + cfg.express.test.options.node_env = process.env.NODE_ENV; + }); + + // #### Ensure Config *(Utility Task)* + // Make sure that we have a `config.js` file when running tests + // Ghost requires a `config.js` file to specify the database settings etc. Ghost comes with an example file: + // `config.example.js` which is copied and renamed to `config.js` by the bootstrap process + grunt.registerTask('ensureConfig', function () { + var config = require('./core/server/config'), + done = this.async(); + + if (!process.env.TEST_SUITE || process.env.TEST_SUITE !== 'client') { + config.load().then(function () { + done(); + }).catch(function (err) { + grunt.fail.fatal(err.stack); + }); + } else { + done(); + } + }); + + // #### Reset Database to "New" state *(Utility Task)* + // Drops all database tables and then runs the migration process to put the database + // in a "new" state. + grunt.registerTask('cleanDatabase', function () { + var done = this.async(), + models = require('./core/server/models'), + migration = require('./core/server/data/migration'); + + migration.reset().then(function () { + models.init(); + return migration.init(); + }).then(function () { + done(); + }).catch(function (err) { + grunt.fail.fatal(err.stack); + }); + }); + + // ### Test + // **Testing utility** + // + // `grunt test:unit/apps_spec.js` will run just the tests inside the apps_spec.js file + // + // It works for any path relative to the core/test folder. It will also run all the tests in a single directory + // You can also run a test with grunt test:core/test/unit/... to get bash autocompletion + // + // `grunt test:integration/api` - runs the api integration tests + // `grunt test:integration` - runs the integration tests in the root folder and excludes all api & model tests + grunt.registerTask('test', 'Run a particular spec file from the core/test directory e.g. `grunt test:unit/apps_spec.js`', function (test) { + if (!test) { + grunt.fail.fatal('No test provided. `grunt test` expects a filename. e.g.: `grunt test:unit/apps_spec.js`. Did you mean `npm test` or `grunt validate`?'); + } + + if (!test.match(/core\/test/) && !test.match(/core\/server/)) { + test = 'core/test/' + test; + } + + // CASE: execute folder + if (!test.match(/.js/)) { + test += '/**'; + } else if (!fs.existsSync(test)) { + grunt.fail.fatal('This file does not exist!'); + } + + cfg.mochacli.single.src = [test]; + grunt.initConfig(cfg); + grunt.task.run('test-setup', 'mochacli:single'); + }); + + // #### Stub out ghost files *(Utility Task)* + // Creates stub files in the built directory and the views directory + // so that the test environments do not need to build out the client files + grunt.registerTask('stubClientFiles', function () { + _.each(cfg.clientFiles, function (file) { + var filePath = path.resolve(cwd + '/core/' + file); + fs.ensureFileSync(filePath); + }); + }); + + // ### Validate + // **Main testing task** + // + // `grunt validate` will build, lint and test your local Ghost codebase. + // + // `grunt validate` is one of the most important and useful grunt tasks that we have available to use. It + // manages the build of your environment and then calls `grunt test` + // + // `grunt validate` is called by `npm test` and is used by Travis. + grunt.registerTask('validate', 'Run tests and lint code', function () { + if (process.env.TEST_SUITE === 'server') { + grunt.task.run(['stubClientFiles', 'test-server']); + } else if (process.env.TEST_SUITE === 'lint') { + grunt.task.run(['lint']); + } else { + grunt.task.run(['validate-all']); + } + }); + + grunt.registerTask('validate-all', 'Lint code and run all tests', + ['init', 'lint', 'test-all']); + + // ### Test-All + // **Main testing task** + // + // `grunt test-all` will lint and test your pre-built local Ghost codebase. + // + // `grunt test-all` runs all 6 test suites. See the individual sub tasks below for + // details of each of the test suites. + // + grunt.registerTask('test-all', 'Run tests for both server and client', + ['test-server', 'test-client']); + + grunt.registerTask('test-server', 'Run server tests', + ['test-routes', 'test-module', 'test-unit', 'test-integration']); + + grunt.registerTask('test-client', 'Run client tests', + ['subgrunt:test']); + + // ### Lint + // + // `grunt lint` will run the linter and the code style checker so you can make sure your code is pretty + grunt.registerTask('lint', 'Run the code style checks and linter for server', + ['jshint', 'jscs'] + ); + + grunt.registerTask('lint-all', 'Run the code style checks and linter for server and client', + ['lint', 'subgrunt:lint'] + ); + + // ### test-setup *(utility)( + // `grunt test-setup` will run all the setup tasks required for running tests + grunt.registerTask('test-setup', 'Setup ready to run tests', + ['clean:test', 'setTestEnv', 'ensureConfig'] + ); + + // ### Unit Tests *(sub task)* + // `grunt test-unit` will run just the unit tests + // + // If you need to run an individual unit test file, you can use the `grunt test:` task: + // + // `grunt test:unit/config_spec.js` + // + // This also works for folders (although it isn't recursive), E.g. + // + // `grunt test:unit/server_helpers` + // + // Unit tests are run with [mocha](http://mochajs.org/) using + // [should](https://github.com/visionmedia/should.js) to describe the tests in a highly readable style. + // Unit tests do **not** touch the database. + // A coverage report can be generated for these tests using the `grunt test-coverage` task. + grunt.registerTask('test-unit', 'Run unit tests (mocha)', + ['test-setup', 'mochacli:unit'] + ); + + // ### Integration tests *(sub task)* + // `grunt test-integration` will run just the integration tests + // + // Provided you already have a `config.js` file, you can run just the model integration tests by running: + // + // `grunt test:integration/model` + // + // Or just the api integration tests by running: + // + // `grunt test:integration/api` + // + // Integration tests are run with [mocha](http://mochajs.org/) using + // [should](https://github.com/visionmedia/should.js) to describe the tests in a highly readable style. + // Integration tests are different to the unit tests because they make requests to the database. + // + // If you need to run an individual integration test file you can use the `grunt test:` task: + // + // `grunt test:integration/api/api_tags_spec.js` + // + // Their purpose is to test that both the api and models behave as expected when the database layer is involved. + // These tests are run against sqlite3, mysql and pg on travis and ensure that differences between the databases + // don't cause bugs. At present, pg often fails and is not officially supported. + // + // A coverage report can be generated for these tests using the `grunt test-coverage` task. + grunt.registerTask('test-integration', 'Run integration tests (mocha + db access)', + ['test-setup', 'mochacli:integration'] + ); + + // ### Route tests *(sub task)* + // `grunt test-routes` will run just the route tests + // + // If you need to run an individual route test file, you can use the `grunt test:` task: + // + // `grunt test:functional/routes/admin_spec.js` + // + // Route tests are run with [mocha](http://mochajs.org/) using + // [should](https://github.com/visionmedia/should.js) and [supertest](https://github.com/visionmedia/supertest) + // to describe and create the tests. + // + // Supertest enables us to describe requests that we want to make, and also describe the response we expect to + // receive back. It works directly with express, so we don't have to run a server to run the tests. + // + // The purpose of the route tests is to ensure that all of the routes (pages, and API requests) in Ghost + // are working as expected, including checking the headers and status codes received. It is very easy and + // quick to test many permutations of routes / urls in the system. + grunt.registerTask('test-routes', 'Run functional route tests (mocha)', + ['test-setup', 'mochacli:routes'] + ); + + // ### Module tests *(sub task)* + // `grunt test-module` will run just the module tests + // + // The purpose of the module tests is to ensure that Ghost can be used as an npm module and exposes all + // required methods to interact with it. + grunt.registerTask('test-module', 'Run functional module tests (mocha)', + ['test-setup', 'mochacli:module'] + ); + + // ### Coverage + // `grunt coverage` will generate a report for the Unit Tests. + // + // This is not currently done as part of CI or any build, but is a tool we have available to keep an eye on how + // well the unit and integration tests are covering the code base. + // Ghost does not have a minimum coverage level - we're more interested in ensuring important and useful areas + // of the codebase are covered, than that the whole codebase is covered to a particular level. + // + // Key areas for coverage are: helpers and theme elements, apps / GDK, the api and model layers. + + grunt.registerTask('coverage', 'Generate unit and integration (mocha) tests coverage report', + ['test-setup', 'mocha_istanbul:coverage'] + ); + + grunt.registerTask('coverage-all', 'Generate unit and integration tests coverage report', + ['test-setup', 'mocha_istanbul:coverage_all'] + ); + + // #### Master Warning *(Utility Task)* + // Warns git users not ot use the `master` branch in production. + // `master` is an unstable branch and shouldn't be used in production as you run the risk of ending up with a + // database in an unrecoverable state. Instead there is a branch called `stable` which is the equivalent of the + // release zip for git users. + grunt.registerTask('master-warn', + 'Outputs a warning to runners of grunt prod, that master shouldn\'t be used for live blogs', + function () { + console.log(chalk.red( + 'Use the ' + chalk.bold('stable') + ' branch for live blogs. ' + + chalk.bold.underline('Never') + ' master!' + )); + console.log('>', 'Always two there are, no more, no less. A master and a ' + chalk.bold('stable') + '.'); + }); + + // ## Building assets + // + // Ghost's GitHub repository contains the un-built source code for Ghost. If you're looking for the already + // built release zips, you can get these from the [release page](https://github.com/TryGhost/Ghost/releases) on + // GitHub or from https://ghost.org/download. These zip files are created using the [grunt release](#release) + // task. + // + // If you want to work on Ghost core, or you want to use the source files from GitHub, then you have to build + // the Ghost assets in order to make them work. + // + // There are a number of grunt tasks available to help with this. Firstly after fetching an updated version of + // the Ghost codebase, after running `npm install`, you will need to run [grunt init](#init%20assets). + // + // For production blogs you will need to run [grunt prod](#production%20assets). + // + // For updating assets during development, the tasks [grunt](#default%20asset%20build) and + // [grunt dev](#live%20reload) are available. + + // ### Init assets + // `grunt init` - will run an initial asset build for you + // + // Grunt init runs `npm install && bower install` inside `core/client` as well as the standard asset build + // tasks which occur when you run just `grunt`. This fetches the latest client-side dependencies. + // + // This task is very important, and should always be run when fetching down an updated code base just after + // running `npm install`. + // + // `bower` does have some quirks, such as not running as root. If you have problems please try running + // `grunt init --verbose` to see if there are any errors. + grunt.registerTask('init', 'Prepare the project for development', + ['update_submodules', 'subgrunt:init', 'clean:tmp', 'default']); + + // ### Build assets + // `grunt build` - will build client assets (without updating the submodule) + // + // This task is identical to `grunt init`, except it does not build client dependencies + grunt.registerTask('build', 'Build client app', + ['subgrunt:init', 'clean:tmp', 'default']); + + // ### Default asset build + // `grunt` - default grunt task + // + // Build assets and dev version of the admin app. + grunt.registerTask('default', 'Build JS & templates for development', + ['subgrunt:dev']); + + // ### Production assets + // `grunt prod` - will build the minified assets used in production. + // + // It is otherwise the same as running `grunt`, but is only used when running Ghost in the `production` env. + grunt.registerTask('prod', 'Build JS & templates for production', + ['subgrunt:prod', 'uglify:prod', 'master-warn']); + + grunt.registerTask('deps', 'Prepare dependencies', + ['shell:dedupe', 'shell:prune', 'shell:shrinkwrap'] + ); + + // ### Live reload + // `grunt dev` - build assets on the fly whilst developing + // + // If you want Ghost to live reload for you whilst you're developing, you can do this by running `grunt dev`. + // This works hand-in-hand with the [livereload](http://livereload.com/) chrome extension. + // + // `grunt dev` manages starting an express server and restarting the server whenever core files change (which + // require a server restart for the changes to take effect) and also manage reloading the browser whenever + // frontend code changes. + // + // Note that the current implementation of watch only works with casper, not other themes. + grunt.registerTask('dev', 'Dev Mode; watch files and restart server on changes', + ['bgShell:client', 'express:dev', 'watch']); + + // ### Release + // Run `grunt release` to create a Ghost release zip file. + // Uses the files specified by `.npmignore` to know what should and should not be included. + // Runs the asset generation tasks for both development and production so that the release can be used in + // either environment, and packages all the files up into a zip. + grunt.registerTask('release', + 'Release task - creates a final built zip\n' + + ' - Do our standard build steps \n' + + ' - Copy files to release-folder/#/#{version} directory\n' + + ' - Clean out unnecessary files (travis, .git*, etc)\n' + + ' - Zip files in release-folder to dist-folder/#{version} directory', + function () { + grunt.config.set('copy.release', { + expand: true, + // #### Build File Patterns + // A list of files and patterns to include when creating a release zip. + // This is read from the `.npmignore` file and all patterns are inverted as the `.npmignore` + // file defines what to ignore, whereas we want to define what to include. + src: fs.readFileSync('.npmignore', 'utf8').split('\n').filter(Boolean).map(function (pattern) { + return pattern[0] === '!' ? pattern.substr(1) : '!' + pattern; + }), + dest: '<%= paths.releaseBuild %>/' + }); + + grunt.task.run(['init', 'prod', 'clean:release', 'deps', 'copy:release', 'compress:release']); + } + ); + }; + +module.exports = configureGrunt; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c0f570 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013-2017 Ghost Foundation + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..79a146a --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,46 @@ +# Privacy + +This is a plain English summary of all of the components within Ghost which may affect your privacy in some way. Please keep in mind that if you use third party Themes or Apps with Ghost, there may be additional things not listed here. + +Each of the items listed in this document can be disabled via Ghost's `config.js` file. Check out the [configuration guide](https://docs.ghost.org/v0.11.9/docs/configuring-ghost) for details. + +## Official Services + +Some official services for Ghost are enabled by default. These services connect to Ghost.org and are managed by the Ghost Foundation: the Non-Profit organisation which runs the Ghost project. + + +### Automatic Update Checks + +When a new session is started, Ghost pings a Ghost.org endpoint to check if the current version of Ghost is the latest version of Ghost. If an update is available, a notification appears inside Ghost to let you know. Ghost.org collects basic anonymised usage statistics from update check requests. + +This service can be disabled at any time. All of the information and code related to this service is available in the [update-check.js](https://github.com/TryGhost/Ghost/blob/master/core/server/update-check.js) file. + + +## Third Party Services + +Ghost uses a number of third party services for specific functionality within Ghost. + +### Gravatar + +To automatically populate your profile picture, Ghost pings [Gravatar](http://gravatar.com) to see if your email address is associated with a profile there. If it is, we pull in your profile picture. If not: nothing happens. + +### RPC Pings + +When you publish a new post, Ghost sends out an RPC ping to let third party services know that new content is available on your blog. This enables search engines and other services to discover and index content on your blog more quickly. At present Ghost sends an RPC ping to the following services when you publish a new post: + +- http://blogsearch.google.com +- http://rpc.pingomatic.com + +RPC pings only happen when Ghost is running in the `production` environment. + +### Structured Data + +Ghost outputs basic meta tags to allow rich snippets of your content to be recognised by popular social networks. Currently there are 3 supported rich data protocols which are output in `{{ghost_head}}`: + +- Schema.org - http://schema.org/docs/documents.html +- Open Graph - http://ogp.me/ +- Twitter cards - https://dev.twitter.com/cards/overview + +### Default Theme + +The default theme which comes with Ghost loads a copy of jQuery from the jQuery Foundation's [public CDN](https://code.jquery.com/jquery-1.11.3.min.js), and makes use of the Open Sans [Google Font](https://www.google.com/fonts). The theme also contains three sharing buttons to [Twitter](http://twitter.com), [Facebook](http://facebook.com), and [Google Plus](http://plus.google.com). No resources are loaded from any services, however the buttons do allow visitors to your blog to share your content publicly on these respective networks. diff --git a/README.md b/README.md new file mode 100644 index 0000000..74ba9d1 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +Ghost +Build status + +![Ghost Screenshot](https://cloud.githubusercontent.com/assets/120485/6626466/6dae46b2-c8ff-11e4-8c7c-8dd63b215f7b.jpg) + +![Ghost is a simple, powerful publishing platform that allows you to share your stories with the world.](https://cloud.githubusercontent.com/assets/120485/6626501/b2bb072c-c8ff-11e4-8e1a-2e78e68fd5c3.png) + +The project is maintained by a non-profit organisation called the **Ghost Foundation**, along with an amazing group of independent [contributors](https://github.com/TryGhost/Ghost/contributors). We're trying to make publishing software that changes the shape of online journalism. + +- [Ghost.org](https://ghost.org) +- [Latest Release](https://ghost.org/developers/) +- [Help & Support](http://help.ghost.org/) +- [Theme Docs](http://themes.ghost.org/v0.11.9/) +- [API Docs](https://api.ghost.org/v0.11.9/) +- [Contributing Guide](https://docs.ghost.org/v0.11.9/docs/contributing) +- [Feature Requests](http://ideas.ghost.org/) +- [Developer Blog](http://dev.ghost.org) +- [Self-hoster Docs](http://docs.ghost.org/v0.11.9/) + +**NOTE: If you’re stuck, can’t get something working or need some help, please head on over and join our [Slack community](https://ghost.org/slack/) rather than opening an issue.** + +# Important: Node.js version support + +See [Supported Node.js versions](https://docs.ghost.org/v0.11.9/docs/supported-node-versions) + + +# Hosting a live Ghost site + +Ghost(Pro) + +The easiest way to deploy Ghost is with our official **[Ghost(Pro)](https://ghost.org/pricing/)** managed service. You can have a fresh instance up and running in a couple of clicks with a worldwide CDN, backups, security and maintenance all done for you. + +Not only will it save you [many hours per month](https://ghost.org/pricing/#why-ghost-pro), but all revenue goes to the Ghost Foundation, which funds the maintenance and further development of Ghost itself. So you’ll be supporting open source software *and* getting a great service **at the same time**! Talk about win/win. :trophy: + +# Self-Hosters + +Other options are also available if you prefer playing around with servers by yourself, of course. The freedom of choice is in your hands. + +- [Self-hosting Guide](https://docs.ghost.org/v0.11.9/docs/getting-started-guide) + + +# Theme Developers + +If you are developing a Ghost theme for your own site or creating themes for others to use we recommend installing Ghost on your own local machine. + +1. Install ghost locally... + - [Mac](https://docs.ghost.org/v0.11.9/docs/installing-ghost-on-mac) + - [Linux](https://docs.ghost.org/v0.11.9/docs/installing-ghost-on-linux) + - [Windows](https://docs.ghost.org/v0.11.9/docs/installing-ghost-on-windows) +2. Start ghost in the `development` environment so it doesn't require a restart after each change to a theme file: + - `npm start` + +- [Theme Developer Docs](http://themes.ghost.org/v0.11.9) + + +# Contributors & Advanced Developers + +For anyone wishing to contribute to Ghost or to hack/customise core files we recommend following our development setup guides: + +- [General Contributor Guide](https://docs.ghost.org/v0.11.9/docs/contributing) +- [Developer Setup Instructions](https://docs.ghost.org/v0.11.9/docs/working-with-ghost) +- [Admin Client development guide](https://docs.ghost.org/v0.11.9/docs/working-with-the-admin-client) + + +# Staying Up to Date + +When a new version of Ghost comes out, you'll want to look over these [upgrade instructions](https://docs.ghost.org/v0.11.9/docs/how-to-upgrade-ghost) for what to do next. + +You can talk to other Ghost users and developers in our [public Slack team](https://ghost.org/slack/) (it's pretty awesome). + +New releases are announced on the [dev blog](http://dev.ghost.org/tag/releases/). You can subscribe by email or follow [@TryGhost_Dev](https://twitter.com/tryghost_dev) on Twitter, if you prefer your updates bite-sized and facetious. + +:saxophone::turtle: + + +# Copyright & License + +Copyright (c) 2013-2017 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/config.example.js b/config.example.js new file mode 100644 index 0000000..7a62fc1 --- /dev/null +++ b/config.example.js @@ -0,0 +1,153 @@ +// # Ghost Configuration +// Setup your Ghost install for various [environments](https://docs.ghost.org/v0.11.9/docs/configuring-ghost#section-about-environments). + +// Ghost runs in `development` mode by default. Full documentation can be found +// at https://docs.ghost.org/v0.11.9/docs/configuring-ghost + +var path = require('path'), + config; + +config = { + // ### Production + // When running Ghost in the wild, use the production environment. + // Configure your URL and mail settings here + production: { + url: 'http://my-ghost-blog.com', + mail: {}, + database: { + client: 'sqlite3', + connection: { + filename: path.join(__dirname, '/content/data/ghost.db') + }, + debug: false + }, + + server: { + host: '127.0.0.1', + port: '2368' + } + }, + + // ### Development **(default)** + development: { + // The url to use when providing links to the site, E.g. in RSS and email. + // Change this to your Ghost blog's published URL. + url: 'http://localhost:2368', + + // Example refferer policy + // Visit https://www.w3.org/TR/referrer-policy/ for instructions + // default 'origin-when-cross-origin', + // referrerPolicy: 'origin-when-cross-origin', + + // Example mail config + // Visit https://docs.ghost.org/v0.11.9/docs/mail-config for instructions + // ``` + // mail: { + // transport: 'SMTP', + // options: { + // service: 'Mailgun', + // auth: { + // user: '', // mailgun username + // pass: '' // mailgun password + // } + // } + // }, + // ``` + + // #### Database + // Ghost supports sqlite3 (default), MySQL & PostgreSQL + database: { + client: 'sqlite3', + connection: { + filename: path.join(__dirname, '/content/data/ghost-dev.db') + }, + debug: false + }, + // #### Server + // Can be host & port (default), or socket + server: { + // Host to be passed to node's `net.Server#listen()` + host: '127.0.0.1', + // Port to be passed to node's `net.Server#listen()`, for iisnode set this to `process.env.PORT` + port: '2368' + }, + // #### Paths + // Specify where your content directory lives + paths: { + contentPath: path.join(__dirname, '/content/') + } + }, + + // **Developers only need to edit below here** + + // ### Testing + // Used when developing Ghost to run tests and check the health of Ghost + // Uses a different port number + testing: { + url: 'http://127.0.0.1:2369', + database: { + client: 'sqlite3', + connection: { + filename: path.join(__dirname, '/content/data/ghost-test.db') + }, + pool: { + afterCreate: function (conn, done) { + conn.run('PRAGMA synchronous=OFF;' + + 'PRAGMA journal_mode=MEMORY;' + + 'PRAGMA locking_mode=EXCLUSIVE;' + + 'BEGIN EXCLUSIVE; COMMIT;', done); + } + }, + useNullAsDefault: true + }, + server: { + host: '127.0.0.1', + port: '2369' + }, + logging: false + }, + + // ### Testing MySQL + // Used by Travis - Automated testing run through GitHub + 'testing-mysql': { + url: 'http://127.0.0.1:2369', + database: { + client: 'mysql', + connection: { + host : '127.0.0.1', + user : 'root', + password : '', + database : 'ghost_testing', + charset : 'utf8' + } + }, + server: { + host: '127.0.0.1', + port: '2369' + }, + logging: false + }, + + // ### Testing pg + // Used by Travis - Automated testing run through GitHub + 'testing-pg': { + url: 'http://127.0.0.1:2369', + database: { + client: 'pg', + connection: { + host : '127.0.0.1', + user : 'postgres', + password : '', + database : 'ghost_testing', + charset : 'utf8' + } + }, + server: { + host: '127.0.0.1', + port: '2369' + }, + logging: false + } +}; + +module.exports = config; diff --git a/content/apps/README.md b/content/apps/README.md new file mode 100644 index 0000000..3ed1b89 --- /dev/null +++ b/content/apps/README.md @@ -0,0 +1,3 @@ +# Content / Apps + +Coming soon, Ghost apps will appear here. \ No newline at end of file diff --git a/content/images/README.md b/content/images/README.md new file mode 100644 index 0000000..a6f11db --- /dev/null +++ b/content/images/README.md @@ -0,0 +1,3 @@ +# Content / Images + +If using the standard file storage, Ghost will upload images to this directory. \ No newline at end of file diff --git a/content/themes/casper/LICENSE b/content/themes/casper/LICENSE new file mode 100644 index 0000000..9c0f570 --- /dev/null +++ b/content/themes/casper/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013-2017 Ghost Foundation + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/content/themes/casper/README.md b/content/themes/casper/README.md new file mode 100644 index 0000000..7c09e02 --- /dev/null +++ b/content/themes/casper/README.md @@ -0,0 +1,10 @@ +# Casper + +The default theme for [Ghost](http://github.com/tryghost/ghost/). + +To download, visit the [releases](https://github.com/TryGhost/Casper/releases) page. + +## Copyright & License + +Copyright (c) 2013-2017 Ghost Foundation - Released under the [MIT license](LICENSE). + diff --git a/content/themes/casper/assets/css/screen.css b/content/themes/casper/assets/css/screen.css new file mode 100644 index 0000000..bd2cd2e --- /dev/null +++ b/content/themes/casper/assets/css/screen.css @@ -0,0 +1,2254 @@ +/* ========================================================================== + Table of Contents + ========================================================================== */ + +/* + + 0. Normalize + 1. Icons + 2. General + 3. Utilities + 4. General + 5. Single Post + 6. Author Profile + 7. Read More + 8. Third Party Elements + 9. Pagination + 10. Subscribe + 11. Footer + 12. Media Queries (Tablet) + 13. Media Queries (Mobile) + 14. Animations + +*/ + +/* ========================================================================== + 0. normalize.css v3.0.3 | MIT License | git.io/normalize | (minified) + ========================================================================== */ + +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100% +} +body { margin: 0; } +article, aside, details, +figcaption, figure, +footer, header, +main, menu, nav, +section, summary { display:block; } +audio, canvas, progress, video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { display: none; height: 0; } +[hidden], template { display: none; } +a { background-color: transparent;} +a:active, a:hover { outline: 0; } +abbr[title] { border-bottom: 1px dotted; } +b, strong { font-weight: bold; } +dfn { font-style: italic; } +h1 { font-size: 2em; margin: 0.67em 0; } +mark { background: #ff0; color: #000; } +small { font-size: 80%; } +sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sup { top: -0.5em; } +sub { bottom: -0.25em; } +img { border: 0; } +svg:not(:root) { overflow: hidden; } +figure { margin: 1em 40px; } +hr { box-sizing: content-box; height: 0; } +pre { overflow: auto; } +code, kbd, pre, samp { font-family: monospace, monospace; font-size: 1em; } +button, input, optgroup, select, textarea { + color: inherit; + font: inherit; + margin: 0; +} +button { overflow: visible; } +button, select { text-transform: none; } +button, html input[type="button"], +input[type="reset"], input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], html input[disabled] { cursor: default; } +button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; } +input { line-height: normal; } +input[type="checkbox"], +input[type="radio"] { box-sizing: border-box; padding: 0; } +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { height: auto; } +input[type="search"] { -webkit-appearance: textfield; } +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} +legend { border: 0; padding: 0; } +textarea { overflow: auto; } +optgroup { font-weight: bold; } +table{ border-collapse: collapse; border-spacing: 0; } +td, th{ padding: 0; } + + +/* ========================================================================== + 1. Icons - Sets up the icon font and respective classes + ========================================================================== */ + +/* Import the font file with the icons in it */ +@font-face { + font-family: "casper-icons"; + src:url("../fonts/casper-icons.eot?v=1"); + src:url("../fonts/casper-icons.eot?v=1#iefix") format("embedded-opentype"), + url("../fonts/casper-icons.woff?v=1") format("woff"), + url("../fonts/casper-icons.ttf?v=1") format("truetype"), + url("../fonts/casper-icons.svg?v=1#icons") format("svg"); + font-weight: normal; + font-style: normal; +} + +/* Apply these base styles to all icons */ +[class^="icon-"]:before, [class*=" icon-"]:before { + font-family: "casper-icons", "Open Sans", sans-serif; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + text-decoration: none !important; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Each icon is created by inserting the correct character into the + content of the :before pseudo element. Like a boss. */ +.icon-ghost:before { + content: "\f600"; +} +.icon-feed:before { + content: "\f601"; +} +.icon-twitter:before { + content: "\f602"; + font-size: 1.1em; +} +.icon-google-plus:before { + content: "\f603"; +} +.icon-facebook:before { + content: "\f604"; +} +.icon-arrow-left:before { + content: "\f605"; +} +.icon-stats:before { + content: "\f606"; +} +.icon-location:before { + content: "\f607"; + margin-left: -3px; /* Tracking fix */ +} +.icon-link:before { + content: "\f608"; +} +.icon-menu:before { + content: "\f609"; +} +/* + IMPORTANT: When making any changes to the icon font, be sure to increment + the version number by 1 in the @font-face rule. `?v=1` becomes `?v=2` + This forces browsers to download the new font file. +*/ + + +/* ========================================================================== + 2. General - Setting up some base styles + ========================================================================== */ + +html { + height: 100%; + max-height: 100%; + font-size: 62.5%; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +body { + height: 100%; + max-height: 100%; + font-family: "Merriweather", serif; + letter-spacing: 0.01rem; + font-size: 1.8rem; + line-height: 1.75em; + color: #3A4145; + -webkit-font-feature-settings: 'kern' 1; + -moz-font-feature-settings: 'kern' 1; + -o-font-feature-settings: 'kern' 1; + text-rendering: geometricPrecision; +} + +::-moz-selection { + background: #D6EDFF; +} + +::selection { + background: #D6EDFF; +} + +h1, h2, h3, +h4, h5, h6 { + -webkit-font-feature-settings: 'dlig' 1, 'liga' 1, 'lnum' 1, 'kern' 1; + -moz-font-feature-settings: 'dlig' 1, 'liga' 1, 'lnum' 1, 'kern' 1; + -o-font-feature-settings: 'dlig' 1, 'liga' 1, 'lnum' 1, 'kern' 1; + color: #2E2E2E; + line-height: 1.15em; + margin: 0 0 0.4em 0; + font-family: "Open Sans", sans-serif; + text-rendering: geometricPrecision; +} + +h1 { + font-size: 5rem; + letter-spacing: -2px; + text-indent: -3px; +} + +h2 { + font-size: 3.6rem; + letter-spacing: -1px; +} + +h3 { + font-size: 3rem; + letter-spacing: -0.6px; +} + +h4 { + font-size: 2.5rem; +} + +h5 { + font-size: 2rem; +} + +h6 { + font-size: 2rem; +} + +a { + color: #4A4A4A; + transition: color 0.3s ease; +} + +a:hover { + color: #111; +} + +p, ul, ol, dl { + -webkit-font-feature-settings: 'liga' 1, 'onum' 1, 'kern' 1; + -moz-font-feature-settings: 'liga' 1, 'onum' 1, 'kern' 1; + -o-font-feature-settings: 'liga' 1, 'onum' 1, 'kern' 1; + margin: 0 0 1.75em 0; + text-rendering: geometricPrecision; +} + +ol, ul { + padding-left: 3rem; +} + +ol ol, ul ul, +ul ol, ol ul { + margin: 0 0 0.4em 0; + padding-left: 2em; +} + +dl dt { + float: left; + width: 180px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; + margin-bottom: 1em; +} + +dl dd { + margin-left: 200px; + margin-bottom: 1em +} + +li { + margin: 0.4em 0; +} + +li li { + margin: 0; +} + +li > p:last-of-type { + margin-bottom: 0; +} + +hr { + display: block; + height: 1px; + border: 0; + border-top: #EFEFEF 1px solid; + margin: 3.2em 0; + padding: 0; +} + +blockquote { + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 1.75em 0 1.75em -2.2em; + padding: 0 0 0 1.75em; + border-left: #4A4A4A 0.4em solid; +} + +blockquote p { + margin: 0.8em 0; + font-style: italic; +} + +blockquote small { + display: inline-block; + margin: 0.8em 0 0.8em 1.5em; + font-size: 0.9em; + color: #CCC; +} + +blockquote small:before { content: "\2014 \00A0"; } + +blockquote cite { + font-weight: 700; +} + +blockquote cite a { font-weight: normal; } + +mark { + background-color: #fdffb6; +} + +code, tt { + padding: 1px 3px; + font-family: Inconsolata, monospace, sans-serif; + font-size: 0.85em; + white-space: pre-wrap; + border: #E3EDF3 1px solid; + background: #F7FAFB; + border-radius: 2px; + -webkit-font-feature-settings: "liga" 0; + -moz-font-feature-settings: "liga" 0; + font-feature-settings: "liga" 0; +} + +pre { + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 0 0 1.75em 0; + border: #E3EDF3 1px solid; + width: 100%; + padding: 10px; + font-family: Inconsolata, monospace, sans-serif; + font-size: 0.9em; + white-space: pre; + overflow: auto; + background: #F7FAFB; + border-radius: 3px; +} + +pre code, pre tt { + font-size: inherit; + white-space: pre-wrap; + background: transparent; + border: none; + padding: 0; +} + +kbd { + display: inline-block; + margin-bottom: 0.4em; + padding: 1px 8px; + border: #CCC 1px solid; + color: #666; + text-shadow: #FFF 0 1px 0; + font-size: 0.9em; + font-weight: 700; + background: #F4F4F4; + border-radius: 4px; + box-shadow: + 0 1px 0 rgba(0, 0, 0, 0.2), + 0 1px 0 0 #fff inset; +} + +table { + -moz-box-sizing: border-box; + box-sizing: border-box; + margin: 1.75em 0; + width: 100%; + max-width: 100%; + background-color: transparent; +} + +table th, +table td { + padding: 8px; + line-height: 20px; + text-align: left; + vertical-align: top; + border-top: #EFEFEF 1px solid; +} + +table th { color: #000; } + +table caption + thead tr:first-child th, +table caption + thead tr:first-child td, +table colgroup + thead tr:first-child th, +table colgroup + thead tr:first-child td, +table thead:first-child tr:first-child th, +table thead:first-child tr:first-child td { + border-top: 0; +} + +table tbody + tbody { border-top: #EFEFEF 2px solid; } + +table table table { background-color: #FFF; } + +table tbody > tr:nth-child(odd) > td, +table tbody > tr:nth-child(odd) > th { + background-color: #F6F6F6; +} + +table.plain tbody > tr:nth-child(odd) > td, +table.plain tbody > tr:nth-child(odd) > th { + background: transparent; +} + +iframe, .fluid-width-video-wrapper { + display: block; + margin: 1.75em 0; +} + +/* When a video is inside the fitvids wrapper, drop the +margin on the iframe, cause it breaks stuff. */ +.fluid-width-video-wrapper iframe { + margin: 0; +} + +textarea, select, input { + width: 260px; + padding: 6px 9px; + margin: 0 0 5px 0; + outline: 0; + font-family: 'Open Sans', sans-serif; + font-size: 1.6rem; + font-weight: 100; + line-height: 1.4em; + background: #fff; + border: #e7eef2 1px solid; + border-radius: 4px; + box-shadow: none; + -webkit-appearance: none; +} + +textarea { + width: 100%; + max-width: 340px; + min-width: 250px; + height: auto; + min-height: 80px; +} + +input[type="text"]:focus, +input[type="email"]:focus, +input[type="search"]:focus, +input[type="tel"]:focus, +input[type="url"]:focus, +input[type="password"]:focus, +input[type="number"]:focus, +input[type="date"]:focus, +input[type="month"]:focus, +input[type="week"]:focus, +input[type="time"]:focus, +input[type="datetime"]:focus, +input[type="datetime-local"]:focus, +textarea:focus { + border: #bbc7cc 1px solid; + background: #fff; + outline: none; + outline-width: 0; +} + +select { + width: 270px; + height: 30px; + line-height: 30px; +} + +button { + min-height: 35px; + width: auto; + display: inline-block; + padding: 0.1rem 1.5rem; + cursor: pointer; + outline: none; + text-decoration: none; + color: #fff; + font-family: 'Open Sans', sans-serif; + font-size: 11px; /* Hacks targeting Firefox. */ + line-height: 13px; /* Hacks targeting Firefox. */ + font-weight: 300; + text-align: center; + letter-spacing: 1px; + text-transform: uppercase; + text-shadow: none; + border-radius: 0.3rem; + border: rgba(0,0,0,0.05) 0.1em solid; + background: #5ba4e5; +} + + +/* ========================================================================== + 3. Utilities - These things get used a lot + ========================================================================== */ + +/* Clears shit */ +.clearfix:before, +.clearfix:after { + content: " "; + display: table; +} +.clearfix:after { clear: both; } +.clearfix { zoom: 1; } + +/* Hides shit */ +.hidden { + text-indent: -9999px; + visibility: hidden; + display: none; +} + +/* Creates a responsive wrapper that makes our content scale nicely */ +.inner { + position: relative; + width: 80%; + max-width: 710px; + margin: 0 auto; +} + +/* Centres vertically yo. (IE8+) */ +.vertical { + display: table-cell; + vertical-align: middle; +} + +/* Wraps the main content & footer */ +.site-wrapper { + position: relative; + z-index: 10; + min-height: 100%; + background: #fff; + -webkit-transition: -webkit-transform 0.5s ease; + transition: transform 0.5s ease; +} + +body.nav-opened .site-wrapper { + overflow-x: hidden; + -webkit-transform: translate3D(-240px, 0, 0); + -ms-transform: translate3D(-240px, 0, 0); + transform: translate3D(-240px, 0, 0); + -webkit-transition: -webkit-transform 0.3s ease; + transition: transform 0.3s ease; +} + + +/* ========================================================================== + 4. General - The main styles for the the theme + ========================================================================== */ + +/* Big cover image on the home page */ +.main-header { + position: relative; + display: table; + width: 100%; + height: 100vh; + margin-bottom: 5rem; + text-align: center; + background: #222 no-repeat center center; + background-size: cover; + overflow: hidden; +} + +.main-header .inner { + width: 80%; +} + +.main-nav { + position: relative; + padding: 35px 40px; + margin: 0 0 30px 0; +} + +.main-nav a { + text-decoration: none; + font-family: 'Open Sans', sans-serif; +} + +/* Navigation */ +body.nav-opened .nav-cover { + position: fixed; + top: 0; + left: 0; + right: 240px; + bottom: 0; + z-index: 200; +} + +.nav { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 5; + width: 240px; + opacity: 0; + background: #111; + margin-bottom: 0; + text-align: left; + overflow-y: auto; + -webkit-transition: -webkit-transform 0.5s ease, + opacity 0.3s ease 0.7s; + transition: transform 0.5s ease, + opacity 0.3s ease 0.7s; +} + +body.nav-closed .nav { + -webkit-transform: translate3D(97px, 0, 0); + -ms-transform: translate3D(97px, 0, 0); + transform: translate3D(97px, 0, 0); +} + +body.nav-opened .nav { + opacity: 1; + -webkit-transition: -webkit-transform 0.3s ease, + opacity 0s ease 0s; + transition: transform 0.3s ease, + opacity 0s ease 0s; + -webkit-transform: translate3D(0, 0, 0); + -ms-transform: translate3D(0, 0, 0); + transform: translate3D(0, 0, 0); +} + +.nav-title { + position: absolute; + top: 45px; + left: 30px; + font-size: 16px; + font-weight: 100; + text-transform: uppercase; + color: #fff; +} + +.nav-close { + position: absolute; + top: 38px; + right: 25px; + width: 20px; + height: 20px; + padding: 0; + font-size: 10px; +} + +.nav-close:focus { + outline: 0; +} + +.nav-close:before, +.nav-close:after { + content: ''; + position: absolute; + top: 0; + width: 20px; + height: 1px; + background: rgb(150,150,150); + top: 15px; + -webkit-transition: background 0.15s ease; + transition: background 0.15s ease; +} + +.nav-close:before { + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); +} + +.nav-close:after { + -webkit-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + transform: rotate(-45deg); +} + +.nav-close:hover:before, +.nav-close:hover:after { + background: rgb(255,255,255); +} + +.nav ul { + padding: 90px 9% 5%; + list-style: none; + counter-reset: item; +} + +.nav li:before { + display: block; + float: right; + padding-right: 4%; + padding-left: 5px; + text-align: right; + font-size: 1.2rem; + vertical-align: bottom; + color: #B8B8B8; + content: counter(item, lower-roman); + counter-increment: item; +} +.nav li { + margin: 0; +} +.nav li a { + text-decoration: none; + line-height: 1.4; + font-size: 1.4rem; + display: block; + padding: 0.6rem 4%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.nav li a:after { + display: inline-block; + content: " ......................................................."; + color: rgba(255,255,255,0.2); + margin-left: 5px; +} +.nav .nav-current:before { + color: #fff; +} +.nav .nav-current a:after { + content: " "; + border-bottom: rgba(255,255,255,0.5) 1px solid; + width: 100%; + height: 1px; +} + +.nav a:link, +.nav a:visited { + color: #B8B8B8; +} + +.nav li.nav-current a, +.nav a:hover, +.nav a:active, +.nav a:focus { + color: #fff; +} + +.subscribe-button { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + display: block; + position: absolute; + bottom: 30px; + left: 30px; + right: 30px; + height: 38px; + padding: 0 20px; + color: #111 !important; /* Overides `.nav a:link, .nav a:visited` colour */ + text-align: center; + font-size: 12px; + font-family: "Open Sans", sans-serif; + text-transform: uppercase; + text-decoration: none; + line-height: 35px; + border-radius: 3px; + background: #fff; + transition: all ease 0.3s; +} +.subscribe-button:before { + font-size: 9px; + margin-right: 6px; +} + + +/* Create a bouncing scroll-down arrow on homepage with cover image */ +.scroll-down { + display: block; + position: absolute; + z-index: 100; + bottom: 45px; + left: 50%; + margin-left: -16px; + width: 34px; + height: 34px; + font-size: 34px; + text-align: center; + text-decoration: none; + color: rgba(255,255,255,0.7); + -webkit-transform: rotate(-90deg); + -ms-transform: rotate(-90deg); + transform: rotate(-90deg); + -webkit-animation: bounce 4s 2s infinite; + animation: bounce 4s 2s infinite; +} + +/* Stop it bouncing and increase contrast when hovered */ +.scroll-down:hover { + color: #fff; + -webkit-animation: none; + animation: none; +} + +/* Put a semi-opaque radial gradient behind the icon to make it more visible + on photos which happen to have a light background. */ +.home-template .main-header:after { + display: block; + content: " "; + width: 150px; + height: 130px; + border-radius: 100%; + position: absolute; + bottom: 0; + left: 50%; + margin-left: -75px; + background: radial-gradient(ellipse at center, rgba(0,0,0,0.15) 0%,rgba(0,0,0,0) 70%,rgba(0,0,0,0) 100%); +} + +/* Hide when there's no cover image or on page2+ */ +.no-cover .scroll-down, +.no-cover.main-header:after, +.paged .scroll-down, +.paged .main-header:after { + display: none +} + +/* Appears in the top left corner of your home page */ +.blog-logo { + display: block; + float: left; + background: none !important; /* Makes sure there is never a background */ + border: none !important; /* Makes sure there is never a border */ +} + +.blog-logo img { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + display: block; + height: 38px; + padding: 1px 0 5px 0; + width: auto; +} + +.menu-button { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + display: inline-block; + float: right; + height: 38px; + padding: 0 15px; + border-style: solid; + border-width: 1px; + opacity: 1; + text-align: center; + font-size: 12px; + text-transform: uppercase; + line-height: 35px; + white-space: nowrap; + border-radius: 3px; + transition: all 0.5s ease; +} +.menu-button:before { + font-size: 12px; + font-weight: bold; + margin-right: 6px; + position: relative; + top: 1px; +} +.menu-button:hover { + background: #fff; +} +.menu-button:focus { + outline: 0; +} + +/* When the navigation is closed */ +.nav-closed .menu-button { + color: #fff; + border-color: rgba(255, 255, 255, 0.6); +} +.nav-closed .menu-button:hover { + color: #222; +} + +/* When the navigation is closed and there is no cover image */ +.nav-closed .no-cover .menu-button { + border-color: #BFC8CD; + color: #9EABB3; +} +.nav-closed .no-cover .menu-button:hover { + border-color: #555; + color: #555; +} + +/* When the navigation is opened */ +.nav-opened .menu-button { + padding: 0 12px; + background: #111; + border-color: #111; + color: #fff; + -webkit-transform: translate3D(94px, 0, 0); + -ms-transform: translate3D(94px, 0, 0); + transform: translate3D(94px, 0, 0); + transition: all 0.3s ease; +} + +.nav-opened .menu-button .word { + opacity: 0; + transition: all 0.3s ease; +} + +/* Special styles when overlaid on an image*/ +.main-nav.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 70px; + border: none; + background: linear-gradient(to bottom, rgba(0,0,0,0.2) 0%,rgba(0,0,0,0) 100%); +} +.no-cover .main-nav.overlay { + background: none; +} + +/* The details of your blog. Defined in ghost/settings/ */ +.page-title { + margin: 10px 0 10px 0; + font-size: 5rem; + letter-spacing: -1px; + font-weight: 700; + font-family: "Open Sans", sans-serif; + color: #fff; +} + +.page-description { + margin: 0; + font-size: 2rem; + line-height: 1.5em; + font-weight: 400; + font-family: "Merriweather", serif; + letter-spacing: 0.01rem; + color: rgba(255,255,255,0.8); +} + +.no-cover.main-header { + min-height: 160px; + max-height: 40vh; + background: #f5f8fa; +} + +.no-cover .page-title { + color: rgba(0,0,0,0.8); +} + +.no-cover .page-description { + color: rgba(0,0,0,0.5); +} + +/* Add subtle load-in animation for content on the home page */ +.home-template .page-title { + -webkit-animation: fade-in-down 0.6s; + animation: fade-in-down 0.6s both; + -webkit-animation-delay: 0.2s; + animation-delay: 0.2s; +} +.home-template .page-description { + -webkit-animation: fade-in-down 0.9s; + animation: fade-in-down 0.9s both; + -webkit-animation-delay: 0.1s; + animation-delay: 0.1s; +} + +/* Every post, on every page, gets this style on its
tag */ +.post { + position: relative; + width: 80%; + max-width: 710px; + margin: 4rem auto; + padding-bottom: 4rem; + border-bottom: #EBF2F6 1px solid; + word-wrap: break-word; +} + +/* Add a little circle in the middle of the border-bottom on our .post + just for the lolz and stylepoints. */ +.post:after { + display: block; + content: ""; + width: 7px; + height: 7px; + border: #E7EEF2 1px solid; + position: absolute; + bottom: -5px; + left: 50%; + margin-left: -5px; + background: #FFF; + border-radius: 100%; + box-shadow: #FFF 0 0 0 5px; +} + +body:not(.post-template) .post-title { + font-size: 3.6rem; +} + +body.page-template .post-title { + font-size: 5rem; +} + +.post-title a { + text-decoration: none; +} + +.post-excerpt p { + margin: 0; + font-size: 0.9em; + line-height: 1.7em; +} + +.read-more { + text-decoration: none; +} + +.post-meta { + display: block; + margin: 1.75rem 0 0 0; + font-family: "Open Sans", sans-serif; + font-size: 1.5rem; + line-height: 2.2rem; + color: #9EABB3; +} + +.author-thumb { + width: 24px; + height: 24px; + float: left; + margin-right: 9px; + border-radius: 100%; +} + +.post-meta a { + color: #9EABB3; + text-decoration: none; +} + +.post-meta a:hover { + text-decoration: underline; +} + +.user-meta { + position: relative; + padding: 0.3rem 40px 0 100px; + min-height: 77px; +} + +.post-date { + display: inline-block; + margin-left: 8px; + padding-left: 12px; + border-left: #d5dbde 1px solid; + text-transform: uppercase; + font-size: 1.3rem; + white-space: nowrap; +} + +.user-image { + position: absolute; + top: 0; + left: 0; +} + +.user-name { + display: block; + font-weight: 700; +} + +.user-bio { + display: block; + max-width: 440px; + font-size: 1.4rem; + line-height: 1.5em; +} + +.publish-meta { + position: absolute; + top: 0; + right: 0; + padding: 4.3rem 0 4rem 0; + text-align: right; +} + +.publish-heading { + display: block; + font-weight: 700; +} + +.publish-date { + display: block; + font-size: 1.4rem; + line-height: 1.5em; +} + + +/* ========================================================================== + 5. Single Post - When you click on an individual post + ========================================================================== */ + +.post-template .post-header, +.page-template .post-header { + margin-bottom: 3.4rem; +} + +.post-template .post-title, +.page-template .post-title { + margin-bottom: 0; +} + +.post-template .post-meta, +.page-template .post-meta { + margin: 0; +} + +.post-template .post-date, +.page-template .post-date { + padding: 0; + margin: 0; + border: none; +} + +/* Stop elements, such as img wider than the post content, from + creating horizontal scroll - slight hack due to imperfections + with browser width % calculations and rounding */ +.post-template .content, +.page-template .content { + overflow: hidden; +} + +/* Tweak the .post wrapper style */ +.post-template .post, +.page-template .post { + margin-top: 0; + border-bottom: none; + padding-bottom: 0; +} + +/* Kill that stylish little circle that was on the border, too */ +.post-template .post:after, +.page-template .post:after { + display: none; +} + +/* Keep images centered, and allow images wider than the main + text column to break out. */ +.post-content img { + display: block; + max-width: 126%; + height: auto; + padding: 0.6em 0; + /* Centers an image by (1) pushing its left edge to the + center of its container and (2) shifting the entire image + in the opposite direction by half its own width. + Works for images that are larger than their containers. */ + position: relative; + left: 50%; + -webkit-transform: translateX(-50%); /* for Safari and iOS */ + -ms-transform: translateX(-50%); /* for IE9 */ + transform: translateX(-50%); +} + +.footnotes { + font-style: italic; + font-size: 1.3rem; + line-height: 1.6em; +} + +.footnotes li { + margin: 0.6rem 0; +} + +.footnotes p { + margin: 0; +} + +.footnotes p a:last-child { + text-decoration: none; +} + + +/* The author credit area after the post */ +.post-footer { + position: relative; + margin: 6rem 0 0 0; + padding: 3rem 0 0 0; + border-top: #EBF2F6 1px solid; +} + +.post-footer h4 { + font-size: 1.8rem; + margin: 0; +} + +.post-footer p { + margin: 1rem 0; + font-size: 1.4rem; + line-height: 1.75em; +} + +/* list of author links - location / url */ +.author-meta { + padding: 0; + margin: 0; + list-style: none; + font-size: 1.4rem; + line-height: 1; + font-style: italic; + color: #9EABB3; +} + +.author-meta a { + color: #9EABB3; +} +.author-meta a:hover { + color: #111; +} + +/* Create some space to the right for the share links */ +.post-footer .author { + margin-right: 180px; +} + +.post-footer h4 a { + color: #2e2e2e; + text-decoration: none; +} + +.post-footer h4 a:hover { + text-decoration: underline; +} + +/* Drop the share links in the space to the right. + Doing it like this means it's easier for the author bio + to be flexible at smaller screen sizes while the share + links remain at a fixed width the whole time */ +.post-footer .share { + position: absolute; + top: 3rem; + right: 0; + width: 140px; +} + +.post-footer .share a { + font-size: 1.8rem; + display: inline-block; + margin: 1rem 1.6rem 1.6rem 0; + color: #BBC7CC; + text-decoration: none; +} + +.post-footer .share .icon-twitter:hover { + color: #55acee; +} +.post-footer .share .icon-facebook:hover { + color: #3b5998; +} +.post-footer .share .icon-google-plus:hover { + color: #dd4b39; +} + + +/* ========================================================================== + 6. Author profile + ========================================================================== */ + +.post-head.main-header { + height: 65vh; + min-height: 180px; +} + +.no-cover.post-head.main-header { + height: 85px; + min-height: 0; + margin-bottom: 0; + background: transparent; +} + +.tag-head.main-header { + height: 40vh; + min-height: 180px; +} + +.author-head.main-header { + height: 40vh; + min-height: 180px; +} + +.no-cover.author-head.main-header { + height: 10vh; + min-height: 100px; + background: transparent; +} + +.author-profile { + padding: 0 15px 5rem 15px; + border-bottom: #EBF2F6 1px solid; + text-align: center; +} + +/* Add a little circle in the middle of the border-bottom */ +.author-profile:after { + display: block; + content: ""; + width: 7px; + height: 7px; + border: #E7EEF2 1px solid; + position: absolute; + bottom: -5px; + left: 50%; + margin-left: -5px; + background: #FFF; + border-radius: 100%; + box-shadow: #FFF 0 0 0 5px; +} + +.author-image { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + display: block; + position: absolute; + top: -40px; + left: 50%; + margin-left: -40px; + width: 80px; + height: 80px; + border-radius: 100%; + overflow: hidden; + padding: 6px; + background: #fff; + z-index: 2; + box-shadow: #E7EEF2 0 0 0 1px; +} + +.author-image .img { + position: relative; + display: block; + width: 100%; + height: 100%; + background-size: cover; + background-position: center center; + border-radius: 100%; +} + +.author-profile .author-image { + position: relative; + left: auto; + top: auto; + width: 120px; + height: 120px; + padding: 3px; + margin: -100px auto 0 auto; + box-shadow: none; +} + +.author-title { + margin: 1.5rem 0 1rem; +} + +.author-bio { + font-size: 1.8rem; + line-height: 1.5em; + font-weight: 200; + color: #50585D; + letter-spacing: 0; + text-indent: 0; + white-space: pre; +} + +.author-meta { + margin: 1.6rem 0; +} +/* Location, website, and link */ +.author-profile .author-meta { + margin: 2rem 0; + font-family: "Merriweather", serif; + letter-spacing: 0.01rem; + font-size: 1.7rem; +} +.author-meta span { + display: inline-block; + margin: 0 2rem 1rem 0; + word-wrap: break-word; +} +.author-meta a { + text-decoration: none; +} + +/* Turn off meta for page2+ to make room for extra + pagination prev/next links */ +.paged .author-profile .author-meta { + display: none; +} + + +/* ========================================================================== + 7. Read More - Next/Prev Post Links + ========================================================================== */ + +.read-next { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-align: stretch; + -webkit-align-items: stretch; + -ms-flex-align: stretch; + align-items: stretch; + margin-top: 10rem; +} + +.read-next-story { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + min-width: 50%; + text-decoration: none; + position: relative; + text-align: center; + color: #fff; + background: #222 no-repeat center center; + background-size: cover; + overflow: hidden; +} +.read-next-story:hover:before { + background: rgba(0,0,0,0.8); + transition: all 0.2s ease; +} +.read-next-story:hover .post:before { + color: #222; + background: #fff; + transition: all 0.2s ease; +} + +.read-next-story:before { + content: ""; + display: block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: rgba(0,0,0,0.7); + transition: all 0.5s ease; +} + +.read-next-story .post { + padding-top: 6rem; + padding-bottom: 6rem; +} + +.read-next-story .post:before { + content: "Read This Next"; + padding: 4px 10px 5px; + text-transform: uppercase; + font-size: 1.1rem; + font-family: "Open Sans", sans-serif; + color: rgba(255,255,255,0.8); + border: rgba(255,255,255,0.5) 1px solid; + border-radius: 4px; + transition: all 0.5s ease; +} +.read-next-story.prev .post:before { + content: "You Might Enjoy"; +} + +.read-next-story h2 { + margin-top: 1rem; + color: #fff; +} + +.read-next-story p { + margin: 0; + color: rgba(255,255,255,0.8); +} + +/* Special styles for posts with no cover images */ +.read-next-story.no-cover { + background: #f5f8fa; +} + +.read-next-story.no-cover:before { + display: none; +} + +.read-next-story.no-cover .post:before { + color: rgba(0,0,0,0.5); + border-color: rgba(0,0,0,0.2); +} + +.read-next-story.no-cover h2 { + color: rgba(0,0,0,0.8); +} + +.read-next-story.no-cover p { + color: rgba(0,0,0,0.5); +} + +/* if there are two posts without covers, put a border between them */ +.read-next-story.no-cover + .read-next-story.no-cover { + border-left: rgba(0,0,100,0.04) 1px solid; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +/* Correctly position site-footer when next to the .read-next container */ +.read-next + .site-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + margin: 0; +} + +/* ========================================================================== + 8. Third Party Elements - Embeds from other services + ========================================================================== */ + +/* Github */ +.gist table { + margin: 0; + font-size: 1.4rem; + text-rendering: auto; +} +.gist td { + line-height: 1.4; +} +.gist .line-number { + min-width: 25px; +} + +/* Pastebin */ +.content .embedPastebin { + margin-bottom: 1.75em; +} + + +/* ========================================================================== + 9. Pagination - Tools to let you flick between pages + ========================================================================== */ + +/* The main wrapper for our pagination links */ +.pagination { + position: relative; + width: 80%; + max-width: 710px; + margin: 4rem auto; + font-family: "Open Sans", sans-serif; + font-size: 1.3rem; + color: #9EABB3; + text-align: center; +} + +.pagination a { + color: #9EABB3; + transition: all 0.2s ease; +} + +/* Push the previous/next links out to the left/right */ +.older-posts, +.newer-posts { + position: absolute; + display: inline-block; + padding: 0 15px; + border: #bfc8cd 1px solid; + text-decoration: none; + border-radius: 4px; + transition: border 0.3s ease; +} + +.older-posts { + right: 0; +} + +.page-number { + display: inline-block; + padding: 2px 0; + min-width: 100px; +} + +.newer-posts { + left: 0; +} + +.older-posts:hover, +.newer-posts:hover { + color: #889093; + border-color: #98a0a4; +} + +.extra-pagination { + display: none; + border-bottom: #EBF2F6 1px solid; +} +.extra-pagination:after { + display: block; + content: ""; + width: 7px; + height: 7px; + border: #E7EEF2 1px solid; + position: absolute; + bottom: -5px; + left: 50%; + margin-left: -5px; + background: #FFF; + border-radius: 100%; + box-shadow: #FFF 0 0 0 5px; +} +.extra-pagination .pagination { + width: auto; +} + +/* On page2+ make all the headers smaller */ +.paged .main-header { + max-height: 30vh; +} + +/* On page2+ show extra pagination controls at the top of post list */ +.paged .extra-pagination { + display: block; +} + + +/* ========================================================================== + 10. Subscribe - Generate those email subscribers + ========================================================================== */ + +.gh-subscribe { + border: #e7eef2 1px solid; + padding: 3rem; + margin-top: 3rem; + text-align: center; + background: #f5f8fa; + border-radius: 5px; +} + +.gh-subscribe-title { + margin-bottom: 0; + font-size: 2.4rem +} + +.gh-subscribe p { + margin-top: 0; + font-size: 1.5rem; +} + +.gh-subscribe form { + display: flex; + justify-content: center; + margin: 20px 0 0 0; +} + +.gh-subscribe .form-group { + flex-grow: 1; + max-width: 300px; +} + +.gh-subscribe .subscribe-email { + box-sizing: border-box; + width: 100%; + margin: 0; + border-radius: 4px 0 0 4px; + transition: all ease 0.5s; +} + +.gh-subscribe .subscribe-email:focus { + border: #5ba4e5 1px solid; + transition: all ease 0.2s; +} + +.gh-subscribe button { + margin-left: -1px; + border-radius: 0 4px 4px 0; +} + +.gh-subscribe-rss { + font-family: 'Open Sans', sans-serif; + font-size: 1.2rem; + line-height: 1.4em; +} + +/* ========================================================================== + 11. Footer - The bottom of every page + ========================================================================== */ + +.site-footer { + position: relative; + margin: 8rem 0 0 0; + padding: 1rem 15px; + font-family: "Open Sans", sans-serif; + font-size: 1rem; + line-height: 1.75em; + color: #BBC7CC; +} + +.site-footer a { + color: #BBC7CC; + text-decoration: none; + font-weight: bold; +} + +.site-footer a:hover { + border-bottom: #bbc7cc 1px solid; +} + +.poweredby { + display: block; + width: 45%; + float: right; + text-align: right; +} + +.copyright { + display: block; + width: 45%; + float: left; +} + + +/* ========================================================================== + 12. Media Queries - Smaller than 900px + ========================================================================== */ + +@media only screen and (max-width: 900px) { + + blockquote { + margin-left: 0; + } + + .main-header { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + height: auto; + min-height: 240px; + height: 60vh; + padding: 15% 0; + } + + .scroll-down, + .home-template .main-header:after { display: none; } + + .paged .main-header { + min-height: 180px; + padding: 10% 0; + } + + .blog-logo img { + padding: 4px 0; + } + + .page-title { + font-size: 4rem; + letter-spacing: -1px; + } + + .page-description { + font-size: 1.8rem; + line-height: 1.5em; + } + + .post { + font-size: 0.95em + } + + body:not(.post-template) .post-title { + font-size: 3.2rem; + } + + body.page-template .post-title { + font-size: 4.5rem; + } + + hr { + margin: 2.4em 0; + } + + ol, ul { + padding-left: 2em; + } + + h1 { + font-size: 4.5rem; + text-indent: -2px; + } + + h2 { + font-size: 3.6rem; + } + + h3 { + font-size: 3.1rem; + } + + h4 { + font-size: 2.5rem; + } + + h5 { + font-size: 2.2rem; + } + + h6 { + font-size: 1.8rem; + } + + .author-profile { + padding-bottom: 4rem; + } + + .author-profile .author-bio { + font-size: 1.6rem; + } + + .author-meta span { + display: block; + margin: 1.5rem 0; + } + .author-profile .author-meta span { + font-size: 1.6rem; + } + + .post-head.main-header { + height:45vh; + } + + .tag-head.main-header, + .author-head.main-header { + height: 30vh; + } + + .no-cover.post-head.main-header { + height: 55px; + padding: 0; + } + + .no-cover.author-head.main-header { + padding: 0; + } + + .gh-subscribe { + padding: 2rem; + } + + .gh-subscribe-title { + font-size: 2rem + } + + .gh-subscribe p { + font-size: 1.4rem; + } + + .read-next { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + margin-top: 4rem; + } + + .read-next p { + display: none; + } + + .read-next-story.no-cover + .read-next-story.no-cover { + border-top: rgba(0,0,100,0.06) 1px solid; + border-left: none; + } + +} + + +/* ========================================================================== + 13. Media Queries - Smaller than 500px + ========================================================================== */ + +@media only screen and (max-width: 500px) { + + .main-header { + margin-bottom: 15px; + height: 40vh; + } + + .no-cover.main-header { + height: 30vh; + } + + .paged .main-header { + max-height: 20vh; + min-height: 160px; + padding: 10% 0; + } + + .main-nav { + padding: 5px; + margin-bottom: 2rem; + } + + .blog-logo { + padding: 5px; + } + + .blog-logo img { + height: 30px; + } + + .menu-button { + padding: 0 5px; + border-radius: 0; + border-width: 0; + color: #2e2e2e; + background: transparent; + } + .menu-button:hover { + color: #2e2e2e; + border-color: transparent; + background: none; + } + body.nav-opened .menu-button { + background: none; + border: transparent; + } + + .main-nav.overlay a:hover { + color: #fff; + border-color: transparent; + background: transparent; + } + + .no-cover .main-nav.overlay { + background: none; + } + .no-cover .main-nav.overlay .menu-button { + border: none; + } + + .main-nav.overlay .menu-button { + border-color: transparent; + } + + .nav-title { + top: 25px; + + } + + .nav-close { + position: absolute; + top: 18px; + } + + .nav ul { + padding: 60px 9% 5%; + } + + .inner, + .pagination { + width: auto; + margin: 2rem auto; + } + + .post { + width: auto; + margin-top: 2rem; + margin-bottom: 2rem; + margin-left: 16px; + margin-right: 16px; + padding-bottom: 2rem; + line-height: 1.65em; + } + + .post-date { + display: none; + } + + .post-template .post-header, + .page-template .post-header { + margin-bottom: 2rem; + } + + .post-template .post-date, + .page-template .post-date { + display: inline-block; + } + + hr { + margin: 1.75em 0; + } + + p, ul, ol, dl { + font-size: 0.95em; + margin: 0 0 2.5rem 0; + } + + .page-title { + font-size: 3rem; + } + + .post-excerpt p { + font-size: 0.85em; + } + + .page-description { + font-size: 1.6rem; + } + + h1, h2, h3, + h4, h5, h6 { + margin: 0 0 0.3em 0; + } + + h1 { + font-size: 2.8rem; + letter-spacing: -1px; + } + + h2 { + font-size: 2.4rem; + letter-spacing: 0; + } + + h3 { + font-size: 2.1rem; + } + + h4 { + font-size: 1.9rem; + } + + h5 { + font-size: 1.8rem; + } + + h6 { + font-size: 1.8rem; + } + + body:not(.post-template) .post-title { + font-size: 2.5rem; + } + + body.page-template .post-title { + font-size: 2.8rem; + } + + .post-template .site-footer, + .page-template .site-footer { + margin-top: 0; + } + + .post-content img { + padding: 0; + width: calc(100% + 32px); /* expand with to image + margins */ + min-width: 0; + max-width: 112%; /* fallback when calc doesn't work */ + } + + .post-meta { + font-size: 1.3rem; + margin-top: 1rem; + } + + .post-footer { + padding: 5rem 0 3rem 0; + text-align: center; + } + + .post-footer .author { + margin: 0 0 2rem 0; + padding: 0 0 1.6rem 0; + border-bottom: #EBF2F6 1px dashed; + } + + .post-footer .share { + position: static; + width: auto; + } + + .post-footer .share a { + margin: 1.4rem 0.8rem 0 0.8rem; + } + + .author-meta li { + float: none; + margin: 0; + line-height: 1.75em; + } + + .author-meta li:before { + display: none; + } + + .older-posts, + .newer-posts { + position: static; + margin: 10px 0; + } + + .page-number { + display: block; + } + + .site-footer { + margin-top: 3rem; + } + + .author-profile { + padding-bottom: 2rem; + } + + .post-head.main-header { + height: 30vh; + } + + .tag-head.main-header, + .author-head.main-header { + height: 20vh; + } + + .post-footer .author-image { + top: -60px; + } + + .author-profile .author-image { + margin-top: -70px; + } + + .author-profile .author-meta span { + font-size: 1.4rem; + } + + .paged .main-header .page-description { + display: none; + } + + .gh-subscribe { + padding: 15px; + } + + .gh-subscribe form { + margin-top: 10px; + } + + .read-next { + margin-top: 2rem; + margin-bottom: -37px; + } + + .read-next .post { + width: 100%; + } + +} + + +/* ========================================================================== + 14. Animations + ========================================================================== */ + +/* Used to fade in title/desc on the home page */ +@-webkit-keyframes fade-in-down { + 0% { + opacity: 0; + -webkit-transform: translateY(-10px); + transform: translateY(-10px); + } + 100% { + opacity: 1; + -webkit-transform: translateY(0); + transform: translateY(0); + } +} +@keyframes fade-in-down { + 0% { + opacity: 0; + -webkit-transform: translateY(-10px); + transform: translateY(-10px); + } + 100% { + opacity: 1; + -webkit-transform: translateY(0); + transform: translateY(0); + } +} + +/* Used to bounce .scroll-down on home page */ +@-webkit-keyframes bounce { + 0%, 10%, 25%, 40%, 50% { + -webkit-transform: translateY(0) rotate(-90deg); + transform: translateY(0) rotate(-90deg); + } + 20% { + -webkit-transform: translateY(-10px) rotate(-90deg); + transform: translateY(-10px) rotate(-90deg); + } + 30% { + -webkit-transform: translateY(-5px) rotate(-90deg); + transform: translateY(-5px) rotate(-90deg); + } +} +@keyframes bounce { + 0%, 10%, 25%, 40%, 50% { + -webkit-transform: translateY(0) rotate(-90deg); + transform: translateY(0) rotate(-90deg); + } + 20% { + -webkit-transform: translateY(-10px) rotate(-90deg); + transform: translateY(-10px) rotate(-90deg); + } + 30% { + -webkit-transform: translateY(-5px) rotate(-90deg); + transform: translateY(-5px) rotate(-90deg); + } +} + + +/* ========================================================================== + End of file. Animations should be the last thing here. Do not add stuff + below this point, or it will probably fuck everything up. + ========================================================================== */ diff --git a/content/themes/casper/assets/fonts/casper-icons.eot b/content/themes/casper/assets/fonts/casper-icons.eot new file mode 100644 index 0000000..2c04470 Binary files /dev/null and b/content/themes/casper/assets/fonts/casper-icons.eot differ diff --git a/content/themes/casper/assets/fonts/casper-icons.svg b/content/themes/casper/assets/fonts/casper-icons.svg new file mode 100644 index 0000000..e0d40ac --- /dev/null +++ b/content/themes/casper/assets/fonts/casper-icons.svg @@ -0,0 +1,20 @@ + + + +Generated by IcoMoon + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/content/themes/casper/assets/fonts/casper-icons.ttf b/content/themes/casper/assets/fonts/casper-icons.ttf new file mode 100644 index 0000000..b67da6b Binary files /dev/null and b/content/themes/casper/assets/fonts/casper-icons.ttf differ diff --git a/content/themes/casper/assets/fonts/casper-icons.woff b/content/themes/casper/assets/fonts/casper-icons.woff new file mode 100644 index 0000000..4312e4a Binary files /dev/null and b/content/themes/casper/assets/fonts/casper-icons.woff differ diff --git a/content/themes/casper/assets/js/index.js b/content/themes/casper/assets/js/index.js new file mode 100644 index 0000000..a54a79a --- /dev/null +++ b/content/themes/casper/assets/js/index.js @@ -0,0 +1,56 @@ +/** + * Main JS file for Casper behaviours + */ + +/* globals jQuery, document */ +(function ($, undefined) { + "use strict"; + + var $document = $(document); + + $document.ready(function () { + + var $postContent = $(".post-content"); + $postContent.fitVids(); + + $(".scroll-down").arctic_scroll(); + + $(".menu-button, .nav-cover, .nav-close").on("click", function(e){ + e.preventDefault(); + $("body").toggleClass("nav-opened nav-closed"); + }); + + }); + + // Arctic Scroll by Paul Adam Davis + // https://github.com/PaulAdamDavis/Arctic-Scroll + $.fn.arctic_scroll = function (options) { + + var defaults = { + elem: $(this), + speed: 500 + }, + + allOptions = $.extend(defaults, options); + + allOptions.elem.click(function (event) { + event.preventDefault(); + var $this = $(this), + $htmlBody = $('html, body'), + offset = ($this.attr('data-offset')) ? $this.attr('data-offset') : false, + position = ($this.attr('data-position')) ? $this.attr('data-position') : false, + toMove; + + if (offset) { + toMove = parseInt(offset); + $htmlBody.stop(true, false).animate({scrollTop: ($(this.hash).offset().top + toMove) }, allOptions.speed); + } else if (position) { + toMove = parseInt(position); + $htmlBody.stop(true, false).animate({scrollTop: toMove }, allOptions.speed); + } else { + $htmlBody.stop(true, false).animate({scrollTop: ($(this.hash).offset().top) }, allOptions.speed); + } + }); + + }; +})(jQuery); diff --git a/content/themes/casper/assets/js/jquery.fitvids.js b/content/themes/casper/assets/js/jquery.fitvids.js new file mode 100644 index 0000000..b9b8d5d --- /dev/null +++ b/content/themes/casper/assets/js/jquery.fitvids.js @@ -0,0 +1,67 @@ +/*global jQuery */ +/*jshint browser:true */ +/*! +* FitVids 1.1 +* +* Copyright 2013, Chris Coyier - http://css-tricks.com + Dave Rupert - http://daverupert.com +* Credit to Thierry Koblentz - http://www.alistapart.com/articles/creating-intrinsic-ratios-for-video/ +* Released under the WTFPL license - http://sam.zoy.org/wtfpl/ +* +*/ + +(function( $ ){ + + "use strict"; + + $.fn.fitVids = function( options ) { + var settings = { + customSelector: null + }; + + if(!document.getElementById('fit-vids-style')) { + // appendStyles: https://github.com/toddmotto/fluidvids/blob/master/dist/fluidvids.js + var head = document.head || document.getElementsByTagName('head')[0]; + var css = '.fluid-width-video-wrapper{width:100%;position:relative;padding:0;}.fluid-width-video-wrapper iframe,.fluid-width-video-wrapper object,.fluid-width-video-wrapper embed {position:absolute;top:0;left:0;width:100%;height:100%;}'; + var div = document.createElement('div'); + div.innerHTML = '

x

'; + head.appendChild(div.childNodes[1]); + } + + if ( options ) { + $.extend( settings, options ); + } + + return this.each(function(){ + var selectors = [ + "iframe[src*='player.vimeo.com']", + "iframe[src*='youtube.com']", + "iframe[src*='youtube-nocookie.com']", + "iframe[src*='kickstarter.com'][src*='video.html']", + "object", + "embed" + ]; + + if (settings.customSelector) { + selectors.push(settings.customSelector); + } + + var $allVideos = $(this).find(selectors.join(',')); + $allVideos = $allVideos.not("object object"); // SwfObj conflict patch + + $allVideos.each(function(){ + var $this = $(this); + if (this.tagName.toLowerCase() === 'embed' && $this.parent('object').length || $this.parent('.fluid-width-video-wrapper').length) { return; } + var height = ( this.tagName.toLowerCase() === 'object' || ($this.attr('height') && !isNaN(parseInt($this.attr('height'), 10))) ) ? parseInt($this.attr('height'), 10) : $this.height(), + width = !isNaN(parseInt($this.attr('width'), 10)) ? parseInt($this.attr('width'), 10) : $this.width(), + aspectRatio = height / width; + if(!$this.attr('id')){ + var videoID = 'fitvid' + Math.floor(Math.random()*999999); + $this.attr('id', videoID); + } + $this.wrap('
').parent('.fluid-width-video-wrapper').css('padding-top', (aspectRatio * 100)+"%"); + $this.removeAttr('height').removeAttr('width'); + }); + }); + }; +// Works with either jQuery or Zepto +})( window.jQuery || window.Zepto ); diff --git a/content/themes/casper/assets/screenshot-desktop.jpg b/content/themes/casper/assets/screenshot-desktop.jpg new file mode 100644 index 0000000..b7ba6d6 Binary files /dev/null and b/content/themes/casper/assets/screenshot-desktop.jpg differ diff --git a/content/themes/casper/assets/screenshot-mobile.jpg b/content/themes/casper/assets/screenshot-mobile.jpg new file mode 100644 index 0000000..5d8b963 Binary files /dev/null and b/content/themes/casper/assets/screenshot-mobile.jpg differ diff --git a/content/themes/casper/author.hbs b/content/themes/casper/author.hbs new file mode 100644 index 0000000..eb7b1de --- /dev/null +++ b/content/themes/casper/author.hbs @@ -0,0 +1,41 @@ +{{!< default}} +{{!-- The tag above means - insert everything in this file into the {body} of the default.hbs template --}} + +{{!-- The big featured header --}} + +{{!-- Everything inside the #author tags pulls data from the author --}} +{{#author}} +
+ +
+ +
+ {{#if image}} +
+
+
+ {{/if}} +

{{name}}

+ {{#if bio}} +

{{bio}}

+ {{/if}} +
+ {{#if location}}{{location}}{{/if}} + {{#if website}}{{website}}{{/if}} + {{plural ../pagination.total empty='No posts' singular='% post' plural='% posts'}} +
+
+{{/author}} + +{{!-- The main content area on the homepage --}} +
+ + {{!-- The tag below includes the post loop - partials/loop.hbs --}} + {{> "loop"}} + +
diff --git a/content/themes/casper/default.hbs b/content/themes/casper/default.hbs new file mode 100644 index 0000000..8dac16c --- /dev/null +++ b/content/themes/casper/default.hbs @@ -0,0 +1,55 @@ + + + + {{!-- Document Settings --}} + + + + {{!-- Page Meta --}} + {{meta_title}} + + + {{!-- Mobile Meta --}} + + + + {{!-- Brand icon --}} + + + {{!-- Styles'n'Scripts --}} + + + + + {{!-- Ghost outputs important style and meta data with this tag --}} + {{ghost_head}} + + + + {{!-- The blog navigation links --}} + {{navigation}} + +
+ + {{!-- All the main content gets inserted here, index.hbs, post.hbs, etc --}} + {{{body}}} + + {{!-- The tiny footer at the very bottom --}} + + +
+ + {{!-- jQuery needs to come before `{{ghost_foot}}` so that jQuery can be used in code injection --}} + + {{!-- Ghost outputs important scripts and data with this tag --}} + {{ghost_foot}} + {{!-- Fitvids makes video embeds responsive and awesome --}} + + {{!-- The main JavaScript file for Casper --}} + + + + diff --git a/content/themes/casper/index.hbs b/content/themes/casper/index.hbs new file mode 100644 index 0000000..8ca8780 --- /dev/null +++ b/content/themes/casper/index.hbs @@ -0,0 +1,27 @@ +{{!< default}} +{{!-- The tag above means - insert everything in this file into the {body} of the default.hbs template --}} + +{{!-- The big featured header --}} +
+ +
+
+

{{@blog.title}}

+

{{@blog.description}}

+
+
+ +
+ +{{!-- The main content area on the homepage --}} +
+ + {{!-- The tag below includes the post loop - partials/loop.hbs --}} + {{> "loop"}} + +
diff --git a/content/themes/casper/package.json b/content/themes/casper/package.json new file mode 100644 index 0000000..5398d37 --- /dev/null +++ b/content/themes/casper/package.json @@ -0,0 +1,36 @@ +{ + "name": "casper", + "description": "The default personal blogging theme for Ghost. Beautiful, minimal and responsive.", + "demo": "https://demo.ghost.io", + "version": "1.3.7", + "engines": { + "ghost": ">=0.9.0 <1.0.0" + }, + "license": "MIT", + "screenshots": { + "desktop": "assets/screenshot-desktop.jpg", + "mobile": "assets/screenshot-mobile.jpg" + }, + "author": { + "name": "Ghost Foundation", + "email": "hello@ghost.org", + "url": "https://ghost.org" + }, + "gpm": { + "type": "theme", + "categories": [ + "Minimal", + "Personal Blogs" + ] + }, + "keywords": [ + "ghost", + "theme" + ], + "repository": { + "type": "git", + "url": "https://github.com/TryGhost/Casper.git" + }, + "bugs": "https://github.com/TryGhost/Casper/issues", + "contributors": "https://github.com/TryGhost/Casper/graphs/contributors" +} diff --git a/content/themes/casper/page.hbs b/content/themes/casper/page.hbs new file mode 100644 index 0000000..9bea823 --- /dev/null +++ b/content/themes/casper/page.hbs @@ -0,0 +1,31 @@ +{{!< default}} + +{{!-- This is a page template. A page outputs content just like any other post, and has all the same + attributes by default, but you can also customise it to behave differently if you prefer. --}} + +{{!-- Everything inside the #post tags pulls data from the page --}} +{{#post}} + +
+ +
+ +
+
+ +
+

{{title}}

+
+ +
+ {{content}} +
+ +
+
+{{/post}} diff --git a/content/themes/casper/partials/loop.hbs b/content/themes/casper/partials/loop.hbs new file mode 100644 index 0000000..0aa0297 --- /dev/null +++ b/content/themes/casper/partials/loop.hbs @@ -0,0 +1,25 @@ +{{!-- Previous/next page links - only displayed on page 2+ --}} +
+ {{pagination}} +
+ +{{!-- This is the post loop - each post will be output using this markup --}} +{{#foreach posts}} +
+
+

{{title}}

+
+
+

{{excerpt words="26"}} »

+
+
+ {{#if author.image}}{{author.name}}{{/if}} + {{author}} + {{tags prefix=" on "}} + +
+
+{{/foreach}} + +{{!-- Previous/next page links - displayed on every page --}} +{{pagination}} diff --git a/content/themes/casper/partials/navigation.hbs b/content/themes/casper/partials/navigation.hbs new file mode 100644 index 0000000..1cf8d82 --- /dev/null +++ b/content/themes/casper/partials/navigation.hbs @@ -0,0 +1,17 @@ + + diff --git a/content/themes/casper/post.hbs b/content/themes/casper/post.hbs new file mode 100644 index 0000000..7002dae --- /dev/null +++ b/content/themes/casper/post.hbs @@ -0,0 +1,110 @@ +{{!< default}} + +{{!-- The comment above "< default" means - insert everything in this file into + the {{{body}}} of the default.hbs template, containing the blog header/footer. --}} + +{{!-- Everything inside the #post tags pulls data from the post --}} +{{#post}} + +
+ +
+ +
+
+ +
+

{{title}}

+ +
+ +
+ {{content}} +
+ +
+ + {{!-- Everything inside the #author tags pulls data from the author --}} + {{#author}} + + {{#if image}} +
+ +
+ {{/if}} + +
+

{{name}}

+ + {{#if bio}} +

{{bio}}

+ {{else}} +

Read more posts by this author.

+ {{/if}} +
+ {{#if location}}{{location}}{{/if}} + {{#if website}}{{website}}{{/if}} +
+
+ + {{/author}} + + + + {{!-- Email subscribe form at the bottom of the page --}} + {{#if @labs.subscribers}} +
+

Subscribe to {{@blog.title}}

+

Get the latest posts delivered right to your inbox.

+ {{subscribe_form placeholder="Your email address"}} + or subscribe via RSS with Feedly! +
+ {{/if}} + +
+ +
+
+ +{{!-- Links to Previous/Next posts --}} + + +{{/post}} diff --git a/content/themes/casper/tag.hbs b/content/themes/casper/tag.hbs new file mode 100644 index 0000000..0ed7187 --- /dev/null +++ b/content/themes/casper/tag.hbs @@ -0,0 +1,34 @@ +{{!< default}} +{{!-- The tag above means - insert everything in this file into the {body} of the default.hbs template --}} + +{{!-- If we have a tag cover, display that - else blog cover - else nothing --}} +
+ +
+ {{#tag}} +
+

{{name}}

+

+ {{#if description}} + {{description}} + {{else}} + A {{../pagination.total}}-post collection + {{/if}} +

+
+ {{/tag}} +
+
+ +{{!-- The main content area on the homepage --}} +
+ + {{!-- The tag below includes the post loop - partials/loop.hbs --}} + {{> "loop"}} + +
diff --git a/core/index.js b/core/index.js new file mode 100644 index 0000000..ceb0e65 --- /dev/null +++ b/core/index.js @@ -0,0 +1,14 @@ +// ## Server Loader +// Passes options through the boot process to get a server instance back +var server = require('./server'); + +// Set the default environment to be `development` +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; + +function makeGhost(options) { + options = options || {}; + + return server(options); +} + +module.exports = makeGhost; diff --git a/core/server/api/authentication.js b/core/server/api/authentication.js new file mode 100644 index 0000000..140509b --- /dev/null +++ b/core/server/api/authentication.js @@ -0,0 +1,619 @@ +var _ = require('lodash'), + validator = require('validator'), + pipeline = require('../utils/pipeline'), + dataProvider = require('../models'), + settings = require('./settings'), + mail = require('./../mail'), + apiMail = require('./mail'), + globalUtils = require('../utils'), + utils = require('./utils'), + errors = require('../errors'), + events = require('../events'), + config = require('../config'), + i18n = require('../i18n'), + authentication; + +/** + * Returns setup status + * + * @return {Promise} + */ +function checkSetup() { + return authentication.isSetup().then(function then(result) { + return result.setup[0].status; + }); +} + +/** + * Allows an assertion to be made about setup status. + * + * @param {Boolean} status True: setup must be complete. False: setup must not be complete. + * @return {Function} returns a "task ready" function + */ +function assertSetupCompleted(status) { + return function checkPermission(__) { + return checkSetup().then(function then(isSetup) { + if (isSetup === status) { + return __; + } + + var completed = i18n.t('errors.api.authentication.setupAlreadyCompleted'), + notCompleted = i18n.t('errors.api.authentication.setupMustBeCompleted'); + + function throwReason(reason) { + throw new errors.NoPermissionError(reason); + } + + if (isSetup) { + throwReason(completed); + } else { + throwReason(notCompleted); + } + }); + }; +} + +function setupTasks(setupData) { + var tasks; + + function validateData(setupData) { + return utils.checkObject(setupData, 'setup').then(function then(checked) { + var data = checked.setup[0]; + + return { + name: data.name, + email: data.email, + password: data.password, + blogTitle: data.blogTitle, + status: 'active' + }; + }); + } + + function setupUser(userData) { + var context = {context: {internal: true}}, + User = dataProvider.User; + + return User.findOne({role: 'Owner', status: 'all'}).then(function then(owner) { + if (!owner) { + throw new errors.InternalServerError( + i18n.t('errors.api.authentication.setupUnableToRun') + ); + } + + return User.setup(userData, _.extend({id: owner.id}, context)); + }).then(function then(user) { + return { + user: user, + userData: userData + }; + }); + } + + function doSettings(data) { + var user = data.user, + blogTitle = data.userData.blogTitle, + context = {context: {user: data.user.id}}, + userSettings; + + if (!blogTitle || typeof blogTitle !== 'string') { + return user; + } + + userSettings = [ + {key: 'title', value: blogTitle.trim()}, + {key: 'description', value: i18n.t('common.api.authentication.sampleBlogDescription')} + ]; + + return settings.edit({settings: userSettings}, context).return(user); + } + + function formatResponse(user) { + return user.toJSON({context: {internal: true}}); + } + + tasks = [ + validateData, + setupUser, + doSettings, + formatResponse + ]; + + return pipeline(tasks, setupData); +} + +/** + * ## Authentication API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +authentication = { + + /** + * @description generate a reset token for a given email address + * @param {Object} resetRequest + * @returns {Promise} message + */ + generateResetToken: function generateResetToken(resetRequest) { + var tasks; + + function validateRequest(resetRequest) { + return utils.checkObject(resetRequest, 'passwordreset').then(function then(data) { + var email = data.passwordreset[0].email; + + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new errors.BadRequestError( + i18n.t('errors.api.authentication.noEmailProvided') + ); + } + + return email; + }); + } + + function generateToken(email) { + var settingsQuery = {context: {internal: true}, key: 'dbHash'}; + + return settings.read(settingsQuery).then(function then(response) { + var dbHash = response.settings[0].value, + expiresAt = Date.now() + globalUtils.ONE_DAY_MS; + + return dataProvider.User.generateResetToken(email, expiresAt, dbHash); + }).then(function then(resetToken) { + return { + email: email, + resetToken: resetToken + }; + }); + } + + function sendResetNotification(data) { + var baseUrl = config.forceAdminSSL ? (config.urlSSL || config.url) : config.url, + resetUrl = baseUrl.replace(/\/$/, '') + + '/ghost/reset/' + + globalUtils.encodeBase64URLsafe(data.resetToken) + '/'; + + return mail.utils.generateContent({ + data: { + resetUrl: resetUrl + }, + template: 'reset-password' + }).then(function then(content) { + var payload = { + mail: [{ + message: { + to: data.email, + subject: i18n.t('common.api.authentication.mail.resetPassword'), + html: content.html, + text: content.text + }, + options: {} + }] + }; + + return apiMail.send(payload, {context: {internal: true}}); + }); + } + + function formatResponse() { + return { + passwordreset: [ + {message: i18n.t('common.api.authentication.mail.checkEmailForInstructions')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateRequest, + generateToken, + sendResetNotification, + formatResponse + ]; + + return pipeline(tasks, resetRequest); + }, + + /** + * ## Reset Password + * reset password if a valid token and password (2x) is passed + * @param {Object} resetRequest + * @returns {Promise} message + */ + resetPassword: function resetPassword(resetRequest) { + var tasks; + + function validateRequest(resetRequest) { + return utils.checkObject(resetRequest, 'passwordreset'); + } + + function doReset(resetRequest) { + var settingsQuery = {context: {internal: true}, key: 'dbHash'}, + data = resetRequest.passwordreset[0], + resetToken = data.token, + newPassword = data.newPassword, + ne2Password = data.ne2Password; + + return settings.read(settingsQuery).then(function then(response) { + return dataProvider.User.resetPassword({ + token: resetToken, + newPassword: newPassword, + ne2Password: ne2Password, + dbHash: response.settings[0].value + }); + }).catch(function (error) { + throw new errors.UnauthorizedError(error.message); + }); + } + + function formatResponse() { + return { + passwordreset: [ + {message: i18n.t('common.api.authentication.mail.passwordChanged')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateRequest, + doReset, + formatResponse + ]; + + return pipeline(tasks, resetRequest); + }, + + /** + * ### Accept Invitation + * @param {Object} invitation an invitation object + * @returns {Promise} + */ + acceptInvitation: function acceptInvitation(invitation) { + var tasks; + + function validateInvitation(invitation) { + return utils.checkObject(invitation, 'invitation'); + } + + function processInvitation(invitation) { + var User = dataProvider.User, + settingsQuery = {context: {internal: true}, key: 'dbHash'}, + data = invitation.invitation[0], + resetToken = data.token, + newPassword = data.password, + email = data.email, + name = data.name; + + return settings.read(settingsQuery).then(function then(response) { + return User.resetPassword({ + token: resetToken, + newPassword: newPassword, + ne2Password: newPassword, + dbHash: response.settings[0].value + }); + }).then(function then(user) { + return User.edit({name: name, email: email, slug: ''}, {id: user.id}); + }).catch(function (error) { + throw new errors.UnauthorizedError(error.message); + }); + } + + function formatResponse() { + return { + invitation: [ + {message: i18n.t('common.api.authentication.mail.invitationAccepted')} + ] + }; + } + + tasks = [ + assertSetupCompleted(true), + validateInvitation, + processInvitation, + formatResponse + ]; + + return pipeline(tasks, invitation); + }, + + /** + * ### Check for invitation + * @param {Object} options + * @returns {Promise} An invitation status + */ + isInvitation: function isInvitation(options) { + var tasks, + localOptions = _.cloneDeep(options || {}); + + function processArgs(options) { + var email = options.email; + + if (typeof email !== 'string' || !validator.isEmail(email)) { + throw new errors.BadRequestError( + i18n.t('errors.api.authentication.invalidEmailReceived') + ); + } + + return email; + } + + function checkInvitation(email) { + return dataProvider.User + .where({email: email, status: 'invited'}) + .count('id') + .then(function then(count) { + return !!count; + }); + } + + function formatResponse(isInvited) { + return {invitation: [{valid: isInvited}]}; + } + + tasks = [ + processArgs, + assertSetupCompleted(true), + checkInvitation, + formatResponse + ]; + + return pipeline(tasks, localOptions); + }, + + /** + * Checks the setup status + * @return {Promise} + */ + isSetup: function isSetup() { + var tasks, + validStatuses = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked']; + + function checkSetupStatus() { + return dataProvider.User + .where('status', 'in', validStatuses) + .count('id') + .then(function (count) { + return !!count; + }); + } + + function formatResponse(isSetup) { + return { + setup: [ + { + status: isSetup, + // Pre-populate from config if, and only if the values exist in config. + title: config.title || undefined, + name: config.user_name || undefined, + email: config.user_email || undefined + } + ] + }; + } + + tasks = [ + checkSetupStatus, + formatResponse + ]; + + return pipeline(tasks); + }, + + /** + * Executes the setup tasks and sends an email to the owner + * @param {Object} setupDetails + * @return {Promise} a user api payload + */ + setup: function setup(setupDetails) { + var tasks; + + function doSetup(setupDetails) { + return setupTasks(setupDetails); + } + + function sendNotification(setupUser) { + var data = { + ownerEmail: setupUser.email + }; + + events.emit('setup.completed', setupUser); + + return mail.utils.generateContent({data: data, template: 'welcome'}) + .then(function then(content) { + var message = { + to: setupUser.email, + subject: i18n.t('common.api.authentication.mail.yourNewGhostBlog'), + html: content.html, + text: content.text + }, + payload = { + mail: [{ + message: message, + options: {} + }] + }; + + apiMail.send(payload, {context: {internal: true}}).catch(function (error) { + errors.logError( + error.message, + i18n.t( + 'errors.api.authentication.unableToSendWelcomeEmail' + ), + i18n.t('errors.api.authentication.checkEmailConfigInstructions', {url: 'https://docs.ghost.org/v0.11.9/docs/mail-config'}) + ); + }); + }) + .return(setupUser); + } + + function formatResponse(setupUser) { + return {users: [setupUser]}; + } + + tasks = [ + assertSetupCompleted(false), + doSetup, + sendNotification, + formatResponse + ]; + + return pipeline(tasks, setupDetails); + }, + + /** + * Updates the blog setup + * @param {Object} setupDetails request payload with setup details + * @param {Object} options + * @return {Promise} a User API response payload + */ + updateSetup: function updateSetup(setupDetails, options) { + var tasks, + localOptions = _.cloneDeep(options || {}); + + function processArgs(setupDetails, options) { + if (!options.context || !options.context.user) { + throw new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner')); + } + + return _.assign({setupDetails: setupDetails}, options); + } + + function checkPermission(options) { + return dataProvider.User.findOne({role: 'Owner', status: 'all'}) + .then(function (owner) { + if (owner.id !== options.context.user) { + throw new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner')); + } + + return options.setupDetails; + }); + } + + function formatResponse(user) { + return {users: [user]}; + } + + tasks = [ + processArgs, + assertSetupCompleted(true), + checkPermission, + setupTasks, + formatResponse + ]; + + return pipeline(tasks, setupDetails, localOptions); + }, + + /** + * Revokes a bearer token. + * @param {Object} tokenDetails + * @param {Object} options + * @return {Promise} an object containing the revoked token. + */ + revoke: function revokeToken(tokenDetails, options) { + var tasks, + localOptions = _.cloneDeep(options || {}); + + function processArgs(tokenDetails, options) { + return _.assign({}, tokenDetails, options); + } + + function revokeToken(options) { + var providers = [ + dataProvider.Refreshtoken, + dataProvider.Accesstoken + ], + response = {token: options.token}; + + function destroyToken(provider, options, providers) { + return provider.destroyByToken(options) + .return(response) + .catch(provider.NotFoundError, function () { + if (!providers.length) { + return { + token: tokenDetails.token, + error: i18n.t('errors.api.authentication.invalidTokenProvided') + }; + } + + return destroyToken(providers.pop(), options, providers); + }) + .catch(function () { + throw new errors.TokenRevocationError( + i18n.t('errors.api.authentication.tokenRevocationFailed') + ); + }); + } + + return destroyToken(providers.pop(), options, providers); + } + + tasks = [ + processArgs, + revokeToken + ]; + + return pipeline(tasks, tokenDetails, localOptions); + }, + + /** + * search for token and check expiry + * + * finally delete old temporary token + * + * send welcome mail + * --> @TODO: in case user looses the redirect, no welcome mail :( + * --> send email on first login! + */ + onSetupStep3: function (data) { + var oldAccessToken, oldAccessTokenData, options = {context: {internal: true}}; + + return dataProvider.Accesstoken + .findOne({ + token: data.token + }) + .then(function (_oldAccessToken) { + if (!_oldAccessToken) { + throw new errors.NoPermissionError(i18n.t('errors.api.authentication.notTheBlogOwner')); + } + + oldAccessToken = _oldAccessToken; + oldAccessTokenData = oldAccessToken.toJSON(); + + if (oldAccessTokenData.expires < Date.now()) { + throw new errors.NoPermissionError(i18n.t('errors.middleware.oauth.tokenExpired')); + } + + var newAccessToken = globalUtils.uid(256), + refreshToken = globalUtils.uid(256), + newAccessExpiry = Date.now() + globalUtils.ONE_MONTH_MS, + refreshExpires = Date.now() + globalUtils.SIX_MONTH_MS; + + return dataProvider.Accesstoken.add({ + token: newAccessToken, + user_id: oldAccessTokenData.user_id, + client_id: oldAccessTokenData.client_id, + expires: newAccessExpiry + }, options) + .then(function then() { + return dataProvider.Refreshtoken.add({ + token: refreshToken, + user_id: oldAccessTokenData.user_id, + client_id: oldAccessTokenData.client_id, + expires: refreshExpires + }, options); + }).then(function then() { + return oldAccessToken.destroy(); + }).then(function () { + return { + access_token: newAccessToken, + refresh_token: refreshToken, + expires_in: globalUtils.ONE_MONTH_S + }; + }); + }); + } +}; + +module.exports = authentication; diff --git a/core/server/api/clients.js b/core/server/api/clients.js new file mode 100644 index 0000000..ec1f406 --- /dev/null +++ b/core/server/api/clients.js @@ -0,0 +1,61 @@ +// # Client API +// RESTful API for the Client resource +var Promise = require('bluebird'), + _ = require('lodash'), + dataProvider = require('../models'), + errors = require('../errors'), + utils = require('./utils'), + pipeline = require('../utils/pipeline'), + i18n = require('../i18n'), + + docName = 'clients', + clients; + +/** + * ### Clients API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +clients = { + + /** + * ## Read + * @param {{id}} options + * @return {Promise} Client + */ + read: function read(options) { + var attrs = ['id', 'slug'], + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + // only User Agent (type = `ua`) clients are available at the moment. + options.data = _.extend(options.data, {type: 'ua'}); + return dataProvider.Client.findOne(options.data, _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {attrs: attrs}), + // TODO: add permissions + // utils.handlePublicPermissions(docName, 'read'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(result) { + if (result) { + return {clients: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('common.api.clients.clientNotFound'))); + }); + } +}; + +module.exports = clients; diff --git a/core/server/api/configuration.js b/core/server/api/configuration.js new file mode 100644 index 0000000..6b2d3b9 --- /dev/null +++ b/core/server/api/configuration.js @@ -0,0 +1,75 @@ +// # Configuration API +// RESTful API for browsing the configuration +var _ = require('lodash'), + config = require('../config'), + Promise = require('bluebird'), + + configuration; + +function labsFlag(key) { + return { + value: (config[key] === true), + type: 'bool' + }; +} + +function fetchAvailableTimezones() { + var timezones = require('../data/timezones.json'); + return timezones; +} + +function getAboutConfig() { + return { + version: config.ghostVersion, + environment: process.env.NODE_ENV, + database: config.database.client, + mail: _.isObject(config.mail) ? config.mail.transport : '' + }; +} + +function getBaseConfig() { + return { + fileStorage: {value: (config.fileStorage !== false), type: 'bool'}, + useGravatar: {value: !config.isPrivacyDisabled('useGravatar'), type: 'bool'}, + publicAPI: labsFlag('publicAPI'), + internalTags: labsFlag('internalTags'), + blogUrl: {value: config.url.replace(/\/$/, ''), type: 'string'}, + blogTitle: {value: config.theme.title, type: 'string'}, + routeKeywords: {value: JSON.stringify(config.routeKeywords), type: 'json'} + }; +} + +/** + * ## Configuration API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +configuration = { + + /** + * Always returns {configuration: []} + * Sometimes the array contains configuration items + * @param {Object} options + * @returns {Promise} + */ + read: function read(options) { + options = options || {}; + + if (!options.key) { + return Promise.resolve({configuration: [getBaseConfig()]}); + } + + if (options.key === 'about') { + return Promise.resolve({configuration: [getAboutConfig()]}); + } + + // Timezone endpoint + if (options.key === 'timezones') { + return Promise.resolve({configuration: [fetchAvailableTimezones()]}); + } + + return Promise.resolve({configuration: []}); + } +}; + +module.exports = configuration; diff --git a/core/server/api/db.js b/core/server/api/db.js new file mode 100644 index 0000000..9234bc5 --- /dev/null +++ b/core/server/api/db.js @@ -0,0 +1,117 @@ +// # DB API +// API for DB operations +var Promise = require('bluebird'), + exporter = require('../data/export'), + importer = require('../data/importer'), + backupDatabase = require('../data/migration').backupDatabase, + models = require('../models'), + errors = require('../errors'), + utils = require('./utils'), + pipeline = require('../utils/pipeline'), + api = {}, + docName = 'db', + db; + +api.settings = require('./settings'); + +/** + * ## DB API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +db = { + /** + * ### Export Content + * Generate the JSON to export + * + * @public + * @param {{context}} options + * @returns {Promise} Ghost Export JSON format + */ + exportContent: function (options) { + var tasks = []; + + options = options || {}; + + // Export data, otherwise send error 500 + function exportContent() { + return exporter.doExport().then(function (exportedData) { + return {db: [exportedData]}; + }).catch(function (error) { + return Promise.reject(new errors.InternalServerError(error.message || error)); + }); + } + + tasks = [ + utils.handlePermissions(docName, 'exportContent'), + exportContent + ]; + + return pipeline(tasks, options); + }, + /** + * ### Import Content + * Import posts, tags etc from a JSON blob + * + * @public + * @param {{context}} options + * @returns {Promise} Success + */ + importContent: function (options) { + var tasks = []; + options = options || {}; + + function importContent(options) { + return importer.importFromFile(options) + .then(function () { + api.settings.updateSettingsCache(); + }) + .return({db: []}); + } + + tasks = [ + utils.handlePermissions(docName, 'importContent'), + importContent + ]; + + return pipeline(tasks, options); + }, + /** + * ### Delete All Content + * Remove all posts and tags + * + * @public + * @param {{context}} options + * @returns {Promise} Success + */ + deleteAllContent: function (options) { + var tasks, + queryOpts = {columns: 'id', context: {internal: true}}; + + options = options || {}; + + function deleteContent() { + var collections = [ + models.Post.findAll(queryOpts), + models.Tag.findAll(queryOpts) + ]; + + return Promise.each(collections, function then(Collection) { + return Collection.invokeThen('destroy', queryOpts); + }).return({db: []}) + .catch(function (error) { + throw new errors.InternalServerError(error.message || error); + }); + } + + tasks = [ + utils.handlePermissions(docName, 'deleteAllContent'), + backupDatabase, + deleteContent + ]; + + return pipeline(tasks, options); + } +}; + +module.exports = db; diff --git a/core/server/api/index.js b/core/server/api/index.js new file mode 100644 index 0000000..51d35d0 --- /dev/null +++ b/core/server/api/index.js @@ -0,0 +1,322 @@ +// # Ghost Data API +// Provides access from anywhere to the Ghost data layer. +// +// Ghost's JSON API is integral to the workings of Ghost, regardless of whether you want to access data internally, +// from a theme, an app, or from an external app, you'll use the Ghost JSON API to do so. + +var _ = require('lodash'), + Promise = require('bluebird'), + config = require('../config'), + configuration = require('./configuration'), + db = require('./db'), + mail = require('./mail'), + notifications = require('./notifications'), + posts = require('./posts'), + schedules = require('./schedules'), + roles = require('./roles'), + settings = require('./settings'), + tags = require('./tags'), + clients = require('./clients'), + users = require('./users'), + slugs = require('./slugs'), + themes = require('./themes'), + subscribers = require('./subscribers'), + authentication = require('./authentication'), + uploads = require('./upload'), + exporter = require('../data/export'), + slack = require('./slack'), + readThemes = require('../utils/read-themes'), + + http, + addHeaders, + cacheInvalidationHeader, + locationHeader, + contentDispositionHeaderExport, + contentDispositionHeaderSubscribers, + init; + +/** + * ### Init + * Initialise the API - populate the settings cache + * @return {Promise(Settings)} Resolves to Settings Collection + */ +init = function init() { + return settings.read({context: {internal: true}, key: 'activeTheme'}) + .then(function initActiveTheme(response) { + var activeTheme = response.settings[0].value; + return readThemes.active(config.paths.themePath, activeTheme); + }) + .then(function (result) { + config.set({paths: {availableThemes: result}}); + return settings.updateSettingsCache(); + }); +}; + +function isActiveThemeOverride(method, endpoint, result) { + return method === 'POST' && endpoint === 'themes' && result.themes && result.themes[0] && result.themes[0].active === true; +} + +/** + * ### Cache Invalidation Header + * Calculate the header string for the X-Cache-Invalidate: header. + * The resulting string instructs any cache in front of the blog that request has occurred which invalidates any cached + * versions of the listed URIs. + * + * `/*` is used to mean the entire cache is invalid + * + * @private + * @param {Express.request} req Original HTTP Request + * @param {Object} result API method result + * @return {String} Resolves to header string + */ +cacheInvalidationHeader = function cacheInvalidationHeader(req, result) { + var parsedUrl = req._parsedUrl.pathname.replace(/^\/|\/$/g, '').split('/'), + method = req.method, + endpoint = parsedUrl[0], + subdir = parsedUrl[1], + jsonResult = result.toJSON ? result.toJSON() : result, + INVALIDATE_ALL = '/*', + post, + hasStatusChanged, + wasPublishedUpdated; + + if (isActiveThemeOverride(method, endpoint, result)) { + // Special case for if we're overwriting an active theme + // @TODO: remove these crazy DIRTY HORRIBLE HACKSSS + req.app.set('activeTheme', null); + config.assetHash = null; + return INVALIDATE_ALL; + } else if (['POST', 'PUT', 'DELETE'].indexOf(method) > -1) { + if (endpoint === 'schedules' && subdir === 'posts') { + return INVALIDATE_ALL; + } + if (['settings', 'users', 'db', 'tags'].indexOf(endpoint) > -1) { + return INVALIDATE_ALL; + } else if (endpoint === 'posts') { + if (method === 'DELETE') { + return INVALIDATE_ALL; + } + + post = jsonResult.posts[0]; + hasStatusChanged = post.statusChanged; + // Invalidate cache when post was updated but not when post is draft + wasPublishedUpdated = method === 'PUT' && post.status === 'published'; + + // Remove the statusChanged value from the response + delete post.statusChanged; + + // Don't set x-cache-invalidate header for drafts + if (hasStatusChanged || wasPublishedUpdated) { + return INVALIDATE_ALL; + } else { + return config.urlFor({relativeUrl: '/' + config.routeKeywords.preview + '/' + post.uuid + '/'}); + } + } + } +}; + +/** + * ### Location Header + * + * If the API request results in the creation of a new object, construct a Location: header which points to the new + * resource. + * + * @private + * @param {Express.request} req Original HTTP Request + * @param {Object} result API method result + * @return {String} Resolves to header string + */ +locationHeader = function locationHeader(req, result) { + var apiRoot = config.urlFor('api'), + location, + newObject; + + if (req.method === 'POST') { + if (result.hasOwnProperty('posts')) { + newObject = result.posts[0]; + location = apiRoot + '/posts/' + newObject.id + '/?status=' + newObject.status; + } else if (result.hasOwnProperty('notifications')) { + newObject = result.notifications[0]; + location = apiRoot + '/notifications/' + newObject.id + '/'; + } else if (result.hasOwnProperty('users')) { + newObject = result.users[0]; + location = apiRoot + '/users/' + newObject.id + '/'; + } else if (result.hasOwnProperty('tags')) { + newObject = result.tags[0]; + location = apiRoot + '/tags/' + newObject.id + '/'; + } + } + + return location; +}; + +/** + * ### Content Disposition Header + * create a header that invokes the 'Save As' dialog in the browser when exporting the database to file. The 'filename' + * parameter is governed by [RFC6266](http://tools.ietf.org/html/rfc6266#section-4.3). + * + * For encoding whitespace and non-ISO-8859-1 characters, you MUST use the "filename*=" attribute, NOT "filename=". + * Ideally, both. Examples: http://tools.ietf.org/html/rfc6266#section-5 + * + * We'll use ISO-8859-1 characters here to keep it simple. + * + * @private + * @see http://tools.ietf.org/html/rfc598 + * @return {string} + */ + +contentDispositionHeaderExport = function contentDispositionHeaderExport() { + return exporter.fileName().then(function then(filename) { + return 'Attachment; filename="' + filename + '"'; + }); +}; + +contentDispositionHeaderSubscribers = function contentDispositionHeaderSubscribers() { + var datetime = (new Date()).toJSON().substring(0, 10); + return Promise.resolve('Attachment; filename="subscribers.' + datetime + '.csv"'); +}; + +addHeaders = function addHeaders(apiMethod, req, res, result) { + var cacheInvalidation, + location, + contentDisposition; + + cacheInvalidation = cacheInvalidationHeader(req, result); + if (cacheInvalidation) { + res.set({'X-Cache-Invalidate': cacheInvalidation}); + } + + if (req.method === 'POST') { + location = locationHeader(req, result); + if (location) { + res.set({Location: location}); + // The location header indicates that a new object was created. + // In this case the status code should be 201 Created + res.status(201); + } + } + + // Add Export Content-Disposition Header + if (apiMethod === db.exportContent) { + contentDisposition = contentDispositionHeaderExport() + .then(function addContentDispositionHeaderExport(header) { + res.set({ + 'Content-Disposition': header + }); + }); + } + + // Add Subscribers Content-Disposition Header + if (apiMethod === subscribers.exportCSV) { + contentDisposition = contentDispositionHeaderSubscribers() + .then(function addContentDispositionHeaderSubscribers(header) { + res.set({ + 'Content-Disposition': header, + 'Content-Type': 'text/csv' + }); + }); + } + + return contentDisposition; +}; + +/** + * ### HTTP + * + * Decorator for API functions which are called via an HTTP request. Takes the API method and wraps it so that it gets + * data from the request and returns a sensible JSON response. + * + * @public + * @param {Function} apiMethod API method to call + * @return {Function} middleware format function to be called by the route when a matching request is made + */ +http = function http(apiMethod) { + return function apiHandler(req, res, next) { + // We define 2 properties for using as arguments in API calls: + var object = req.body, + options = _.extend({}, req.file, req.query, req.params, { + context: { + user: ((req.user && req.user.id) || (req.user && req.user.id === 0)) ? req.user.id : null, + client: (req.client && req.client.slug) ? req.client.slug : null + } + }); + + // If this is a GET, or a DELETE, req.body should be null, so we only have options (route and query params) + // If this is a PUT, POST, or PATCH, req.body is an object + if (_.isEmpty(object)) { + object = options; + options = {}; + } + + return apiMethod(object, options).tap(function onSuccess(response) { + // Add X-Cache-Invalidate, Location, and Content-Disposition headers + return addHeaders(apiMethod, req, res, (response || {})); + }).then(function then(response) { + if (req.method === 'DELETE') { + return res.status(204).end(); + } + // Keep CSV header and formatting + if (res.get('Content-Type') && res.get('Content-Type').indexOf('text/csv') === 0) { + return res.status(200).send(response); + } + + // CASE: api method response wants to handle the express response + // example: serve files (stream) + if (_.isFunction(response)) { + return response(req, res, next); + } + + // Send a properly formatting HTTP response containing the data with correct headers + res.json(response || {}); + }).catch(function onAPIError(error) { + // To be handled by the API middleware + next(error); + }); + }; +}; + +/** + * ## Public API + */ +module.exports = { + // Extras + init: init, + http: http, + // API Endpoints + configuration: configuration, + db: db, + mail: mail, + notifications: notifications, + posts: posts, + schedules: schedules, + roles: roles, + settings: settings, + tags: tags, + clients: clients, + users: users, + slugs: slugs, + subscribers: subscribers, + authentication: authentication, + uploads: uploads, + slack: slack, + themes: themes +}; + +/** + * ## API Methods + * + * Most API methods follow the BREAD pattern, although not all BREAD methods are available for all resources. + * Most API methods have a similar signature, they either take just `options`, or both `object` and `options`. + * For RESTful resources `object` is always a model object of the correct type in the form `name: [{object}]` + * `options` is an object with several named properties, the possibilities are listed for each method. + * + * Read / Edit / Destroy routes expect some sort of identifier (id / slug / key) for which object they are handling + * + * All API methods take a context object as one of the options: + * + * @typedef context + * Context provides information for determining permissions. Usually a user, but sometimes an app, or the internal flag + * @param {Number} user (optional) + * @param {String} app (optional) + * @param {Boolean} internal (optional) + */ diff --git a/core/server/api/mail.js b/core/server/api/mail.js new file mode 100644 index 0000000..8de625c --- /dev/null +++ b/core/server/api/mail.js @@ -0,0 +1,155 @@ +// # Mail API +// API for sending Mail + +var Promise = require('bluebird'), + pipeline = require('../utils/pipeline'), + errors = require('../errors'), + mail = require('../mail'), + Models = require('../models'), + utils = require('./utils'), + notifications = require('./notifications'), + docName = 'mail', + i18n = require('../i18n'), + mode = process.env.NODE_ENV, + testing = mode !== 'production' && mode !== 'development', + mailer, + apiMail; + +/** + * Send mail helper + */ +function sendMail(object) { + if (!(mailer instanceof mail.GhostMailer) || testing) { + mailer = new mail.GhostMailer(); + } + + return mailer.send(object.mail[0].message).catch(function (err) { + if (mailer.state.usingDirect) { + notifications.add( + {notifications: [{ + type: 'warn', + message: [ + i18n.t('warnings.index.unableToSendEmail'), + i18n.t('common.seeLinkForInstructions', + {link: 'https://docs.ghost.org/v0.11.9/docs/mail-config'}) + ].join(' ') + }]}, + {context: {internal: true}} + ); + } + + return Promise.reject(new errors.EmailError(err.message)); + }); +} + +/** + * ## Mail API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + * @typedef Mail + * @param mail + */ +apiMail = { + /** + * ### Send + * Send an email + * + * @public + * @param {Mail} object details of the email to send + * @returns {Promise} + */ + send: function (object, options) { + var tasks; + + /** + * ### Format Response + * @returns {Mail} mail + */ + + function formatResponse(data) { + delete object.mail[0].options; + // Sendmail returns extra details we don't need and that don't convert to JSON + delete object.mail[0].message.transport; + object.mail[0].status = { + message: data.message + }; + + return object; + } + + /** + * ### Send Mail + */ + + function send() { + return sendMail(object, options); + } + + tasks = [ + utils.handlePermissions(docName, 'send'), + send, + formatResponse + ]; + + return pipeline(tasks, options || {}); + }, + + /** + * ### SendTest + * Send a test email + * + * @public + * @param {Object} options required property 'to' which contains the recipient address + * @returns {Promise} + */ + sendTest: function (options) { + var tasks; + + /** + * ### Model Query + */ + + function modelQuery() { + return Models.User.findOne({id: options.context.user}); + } + + /** + * ### Generate content + */ + + function generateContent(result) { + return mail.utils.generateContent({template: 'test'}).then(function (content) { + var payload = { + mail: [{ + message: { + to: result.get('email'), + subject: i18n.t('common.api.mail.testGhostEmail'), + html: content.html, + text: content.text + } + }] + }; + + return payload; + }); + } + + /** + * ### Send mail + */ + + function send(payload) { + return sendMail(payload, options); + } + + tasks = [ + modelQuery, + generateContent, + send + ]; + + return pipeline(tasks); + } +}; + +module.exports = apiMail; diff --git a/core/server/api/notifications.js b/core/server/api/notifications.js new file mode 100644 index 0000000..e736bcd --- /dev/null +++ b/core/server/api/notifications.js @@ -0,0 +1,215 @@ +// # Notifications API +// RESTful API for creating notifications +var Promise = require('bluebird'), + _ = require('lodash'), + permissions = require('../permissions'), + errors = require('../errors'), + settings = require('./settings'), + utils = require('./utils'), + pipeline = require('../utils/pipeline'), + canThis = permissions.canThis, + i18n = require('../i18n'), + + // Holds the persistent notifications + notificationsStore = [], + // Holds the last used id + notificationCounter = 0, + notifications; + +/** + * ## Notification API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +notifications = { + + /** + * ### Browse + * Fetch all notifications + * @returns {Promise(Notifications)} + */ + browse: function browse(options) { + return canThis(options.context).browse.notification().then(function () { + return {notifications: notificationsStore}; + }, function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.notifications.noPermissionToBrowseNotif'))); + }); + }, + + /** + * ### Add + * + * + * **takes:** a notification object of the form + * + * If notification message already exists, we return the existing notification object. + * + * ``` + * msg = { notifications: [{ + * status: 'alert', // A String. Can be 'alert' or 'notification' + * type: 'error', // A String. Can be 'error', 'success', 'warn' or 'info' + * message: 'This is an error', // A string. Should fit in one line. + * location: '', // A String. Should be unique key to the notification, usually takes the form of "noun.verb.message", eg: "user.invite.already-invited" + * dismissible: true // A Boolean. Whether the notification is dismissible or not. + * custom: true // A Boolean. Whether the notification is a custom message intended for particular Ghost versions. + * }] }; + * ``` + */ + add: function add(object, options) { + var tasks; + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + if (permissions.parseContext(options.context).internal) { + return Promise.resolve(options); + } + + return canThis(options.context).add.notification().then(function () { + return options; + }, function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.notifications.noPermissionToAddNotif'))); + }); + } + + /** + * ### Save Notifications + * Save the notifications + * @param {Object} options + * @returns {Object} options + */ + function saveNotifications(options) { + var defaults = { + dismissible: true, + location: 'bottom', + status: 'alert' + }, + addedNotifications = [], existingNotification; + + _.each(options.data.notifications, function (notification) { + notificationCounter = notificationCounter + 1; + + notification = _.assign(defaults, notification, { + id: notificationCounter + }); + + existingNotification = _.find(notificationsStore, {message:notification.message}); + + if (!existingNotification) { + notificationsStore.push(notification); + addedNotifications.push(notification); + } else { + addedNotifications.push(existingNotification); + } + }); + + return addedNotifications; + } + + tasks = [ + utils.validate('notifications'), + handlePermissions, + saveNotifications + ]; + + return pipeline(tasks, object, options).then(function formatResponse(result) { + return {notifications: result}; + }); + }, + + /** + * ### Destroy + * Remove a specific notification + * + * @param {{id (required), context}} options + * @returns {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * Adds the uuid of notification to "seenNotifications" array. + * @param {Object} notification + * @return {*|Promise} + */ + function markAsSeen(notification) { + var context = {internal: true}; + return settings.read({key: 'seenNotifications', context: context}).then(function then(response) { + var seenNotifications = JSON.parse(response.settings[0].value); + seenNotifications = _.uniqBy(seenNotifications.concat([notification.uuid])); + return settings.edit({settings: [{key: 'seenNotifications', value: seenNotifications}]}, {context: context}); + }); + } + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + return canThis(options.context).destroy.notification().then(function () { + return options; + }, function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.notifications.noPermissionToDestroyNotif'))); + }); + } + + function destroyNotification(options) { + var notification = _.find(notificationsStore, function (element) { + return element.id === parseInt(options.id, 10); + }); + + if (notification && !notification.dismissible) { + return Promise.reject( + new errors.NoPermissionError(i18n.t('errors.api.notifications.noPermissionToDismissNotif')) + ); + } + + if (!notification) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.notifications.notificationDoesNotExist'))); + } + + notificationsStore = _.reject(notificationsStore, function (element) { + return element.id === parseInt(options.id, 10); + }); + notificationCounter = notificationCounter - 1; + + if (notification.custom) { + return markAsSeen(notification); + } + } + + tasks = [ + utils.validate('notifications', {opts: utils.idDefaultOptions}), + handlePermissions, + destroyNotification + ]; + + return pipeline(tasks, options); + }, + + /** + * ### DestroyAll + * Clear all notifications, used for tests + * + * @private Not exposed over HTTP + * @returns {Promise} + */ + destroyAll: function destroyAll(options) { + return canThis(options.context).destroy.notification().then(function () { + notificationsStore = []; + notificationCounter = 0; + + return notificationsStore; + }, function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.notifications.noPermissionToDestroyNotif'))); + }); + } +}; + +module.exports = notifications; diff --git a/core/server/api/posts.js b/core/server/api/posts.js new file mode 100644 index 0000000..a7b90b9 --- /dev/null +++ b/core/server/api/posts.js @@ -0,0 +1,243 @@ +// # Posts API +// RESTful API for the Post resource +var Promise = require('bluebird'), + _ = require('lodash'), + dataProvider = require('../models'), + errors = require('../errors'), + utils = require('./utils'), + pipeline = require('../utils/pipeline'), + i18n = require('../i18n'), + + docName = 'posts', + allowedIncludes = [ + 'created_by', 'updated_by', 'published_by', 'author', 'tags', 'fields', + 'next', 'previous', 'next.author', 'next.tags', 'previous.author', 'previous.tags' + ], + posts; + +/** + * ### Posts API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ + +posts = { + /** + * ## Browse + * Find a paginated set of posts + * + * Will only return published posts unless we have an authenticated user and an alternative status + * parameter. + * + * Will return without static pages unless told otherwise + * + * + * @public + * @param {{context, page, limit, status, staticPages, tag, featured}} options (optional) + * @returns {Promise} Posts Collection with Meta + */ + browse: function browse(options) { + var extraOptions = ['status'], + permittedOptions, + tasks; + + // Workaround to remove static pages from results + // TODO: rework after https://github.com/TryGhost/Ghost/issues/5151 + if (options && options.context && (options.context.user || options.context.internal)) { + extraOptions.push('staticPages'); + } + permittedOptions = utils.browseDefaultOptions.concat(extraOptions); + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Post.findPage(options); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: permittedOptions}), + utils.handlePublicPermissions(docName, 'browse'), + utils.convertOptions(allowedIncludes), + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ## Read + * Find a post, by ID, UUID, or Slug + * + * @public + * @param {Object} options + * @return {Promise} Post + */ + read: function read(options) { + var attrs = ['id', 'slug', 'status', 'uuid'], + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Post.findOne(options.data, _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {attrs: attrs, opts: options.opts || []}), + utils.handlePublicPermissions(docName, 'read'), + utils.convertOptions(allowedIncludes), + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(result) { + // @TODO make this a formatResponse task? + if (result) { + return {posts: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.posts.postNotFound'))); + }); + }, + + /** + * ## Edit + * Update properties of a post + * + * @public + * @param {Post} object Post or specific properties to update + * @param {{id (required), context, include,...}} options + * @return {Promise(Post)} Edited Post + */ + edit: function edit(object, options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Post.edit(options.data.posts[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions.concat(options.opts || [])}), + utils.handlePermissions(docName, 'edit'), + utils.convertOptions(allowedIncludes), + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + if (result) { + var post = result.toJSON(options); + + // If previously was not published and now is (or vice versa), signal the change + post.statusChanged = false; + if (result.updated('status') !== result.get('status')) { + post.statusChanged = true; + } + return {posts: [post]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.posts.postNotFound'))); + }); + }, + + /** + * ## Add + * Create a new post along with any tags + * + * @public + * @param {Post} object + * @param {{context, include,...}} options + * @return {Promise(Post)} Created Post + */ + add: function add(object, options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Post.add(options.data.posts[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName), + utils.handlePermissions(docName, 'add'), + utils.convertOptions(allowedIncludes), + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + var post = result.toJSON(options); + + if (post.status === 'published') { + // When creating a new post that is published right now, signal the change + post.statusChanged = true; + } + return {posts: [post]}; + }); + }, + + /** + * ## Destroy + * Delete a post, cleans up tag relations, but not unused tags + * + * @public + * @param {{id (required), context,...}} options + * @return {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * @function deletePost + * @param {Object} options + */ + function deletePost(options) { + var Post = dataProvider.Post, + data = _.defaults({status: 'all'}, options), + fetchOpts = _.defaults({require: true, columns: 'id'}, options); + + return Post.findOne(data, fetchOpts).then(function () { + return Post.destroy(options).return(null); + }).catch(Post.NotFoundError, function () { + throw new errors.NotFoundError(i18n.t('errors.api.posts.postNotFound')); + }); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + utils.handlePermissions(docName, 'destroy'), + utils.convertOptions(allowedIncludes), + deletePost + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + } +}; + +module.exports = posts; diff --git a/core/server/api/roles.js b/core/server/api/roles.js new file mode 100644 index 0000000..e9886e8 --- /dev/null +++ b/core/server/api/roles.js @@ -0,0 +1,75 @@ +// # Roles API +// RESTful API for the Role resource +var Promise = require('bluebird'), + canThis = require('../permissions').canThis, + dataProvider = require('../models'), + pipeline = require('../utils/pipeline'), + utils = require('./utils'), + docName = 'roles', + + roles; + +/** + * ## Roles API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +roles = { + /** + * ### Browse + * Find all roles + * + * If a 'permissions' property is passed in the options object then + * the results will be filtered based on whether or not the context user has the given + * permission on a role. + * + * + * @public + * @param {{context, permissions}} options (optional) + * @returns {Promise(Roles)} Roles Collection + */ + browse: function browse(options) { + var permittedOptions = ['permissions'], + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Role.findAll(options); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: permittedOptions}), + utils.handlePermissions(docName, 'browse'), + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(results) { + var roles = results.map(function (r) { + return r.toJSON(); + }); + + if (options.permissions !== 'assign') { + return {roles: roles}; + } + + return Promise.filter(roles.map(function (role) { + return canThis(options.context).assign.role(role) + .return(role) + .catch(function () {}); + }), function (value) { + return value && value.name !== 'Owner'; + }).then(function (roles) { + return {roles: roles}; + }); + }); + } +}; + +module.exports = roles; diff --git a/core/server/api/schedules.js b/core/server/api/schedules.js new file mode 100644 index 0000000..ff964c4 --- /dev/null +++ b/core/server/api/schedules.js @@ -0,0 +1,100 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + moment = require('moment'), + config = require('../config'), + pipeline = require(config.paths.corePath + '/server/utils/pipeline'), + dataProvider = require(config.paths.corePath + '/server/models'), + i18n = require(config.paths.corePath + '/server/i18n'), + errors = require(config.paths.corePath + '/server/errors'), + apiPosts = require(config.paths.corePath + '/server/api/posts'), + utils = require('./utils'); + +/** + * Publish a scheduled post + * + * `apiPosts.read` and `apiPosts.edit` must happen in one transaction. + * We lock the target row on fetch by using the `forUpdate` option. + * Read more in models/post.js - `onFetching` + * + * object.force: you can force publishing a post in the past (for example if your service was down) + */ +exports.publishPost = function publishPost(object, options) { + if (_.isEmpty(options)) { + options = object || {}; + object = {}; + } + + var post, publishedAtMoment, + publishAPostBySchedulerToleranceInMinutes = config.times.publishAPostBySchedulerToleranceInMinutes; + + // CASE: only the scheduler client is allowed to publish (hardcoded because of missing client permission system) + if (!options.context || !options.context.client || options.context.client !== 'ghost-scheduler') { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.permissions.noPermissionToAction'))); + } + + options.context = {internal: true}; + + return pipeline([ + utils.validate('posts', {opts: utils.idDefaultOptions}), + function (cleanOptions) { + cleanOptions.status = 'scheduled'; + + return dataProvider.Base.transaction(function (transacting) { + cleanOptions.transacting = transacting; + cleanOptions.forUpdate = true; + + // CASE: extend allowed options, see api/utils.js + cleanOptions.opts = ['forUpdate', 'transacting']; + + return apiPosts.read(cleanOptions) + .then(function (result) { + post = result.posts[0]; + publishedAtMoment = moment(post.published_at); + + if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.job.notFound'))); + } + + if (publishedAtMoment.diff(moment(), 'minutes') < publishAPostBySchedulerToleranceInMinutes * -1 && object.force !== true) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.job.publishInThePast'))); + } + + return apiPosts.edit({ + posts: [{status: 'published'}]}, + _.pick(cleanOptions, ['context', 'id', 'transacting', 'forUpdate', 'opts']) + ); + }); + }); + } + ], options); +}; + +/** + * get all scheduled posts/pages + * permission check not needed, because route is not exposed + */ +exports.getScheduledPosts = function readPosts(options) { + options = options || {}; + options.context = {internal: true}; + + return pipeline([ + utils.validate('posts', {opts: ['from', 'to']}), + function (cleanOptions) { + cleanOptions.filter = 'status:scheduled'; + cleanOptions.columns = ['id', 'published_at', 'created_at']; + + if (cleanOptions.from) { + cleanOptions.filter += '+created_at:>=\'' + moment(cleanOptions.from).format('YYYY-MM-DD HH:mm:ss') + '\''; + } + + if (cleanOptions.to) { + cleanOptions.filter += '+created_at:<=\'' + moment(cleanOptions.to).format('YYYY-MM-DD HH:mm:ss') + '\''; + } + + return dataProvider.Post.findAll(cleanOptions) + .then(function (result) { + return Promise.resolve({posts: result.models}); + }); + } + ], options); +}; diff --git a/core/server/api/settings.js b/core/server/api/settings.js new file mode 100644 index 0000000..77da3ac --- /dev/null +++ b/core/server/api/settings.js @@ -0,0 +1,383 @@ +// # Settings API +// RESTful API for the Setting resource +var _ = require('lodash'), + dataProvider = require('../models'), + Promise = require('bluebird'), + config = require('../config'), + canThis = require('../permissions').canThis, + errors = require('../errors'), + events = require('../events'), + utils = require('./utils'), + i18n = require('../i18n'), + filterPackages = require('../utils/packages').filterPackages, + + docName = 'settings', + settings, + + updateConfigCache, + updateSettingsCache, + settingsFilter, + readSettingsResult, + settingsResult, + canEditAllSettings, + populateDefaultSetting, + hasPopulatedDefaults = false, + + /** + * ## Cache + * Holds cached settings + * @private + * @type {{}} + */ + settingsCache = {}; + +// @TODO figure out a better way to do this in the alpha +events.on('server:start', function () { + config.loadExtras().then(function () { + updateSettingsCache(); + }); +}); + +/** +* ### Updates Config Theme Settings +* Maintains the cache of theme specific variables that are reliant on settings. +* @private +*/ +updateConfigCache = function () { + var errorMessages = [ + i18n.t('errors.api.settings.invalidJsonInLabs'), + i18n.t('errors.api.settings.labsColumnCouldNotBeParsed'), + i18n.t('errors.api.settings.tryUpdatingLabs') + ], labsValue = {}; + + if (settingsCache.labs && settingsCache.labs.value) { + try { + labsValue = JSON.parse(settingsCache.labs.value); + } catch (e) { + errors.logError.apply(this, errorMessages); + } + } + + config.set({ + theme: { + title: (settingsCache.title && settingsCache.title.value) || '', + description: (settingsCache.description && settingsCache.description.value) || '', + logo: (settingsCache.logo && settingsCache.logo.value) || '', + cover: (settingsCache.cover && settingsCache.cover.value) || '', + navigation: (settingsCache.navigation && JSON.parse(settingsCache.navigation.value)) || [], + postsPerPage: (settingsCache.postsPerPage && settingsCache.postsPerPage.value) || 5, + permalinks: (settingsCache.permalinks && settingsCache.permalinks.value) || '/:slug/', + twitter: (settingsCache.twitter && settingsCache.twitter.value) || '', + facebook: (settingsCache.facebook && settingsCache.facebook.value) || '', + timezone: (settingsCache.activeTimezone && settingsCache.activeTimezone.value) || config.theme.timezone, + amp: (settingsCache.amp && settingsCache.amp.value === 'true') + }, + labs: labsValue + }); +}; + +/** + * ### Update Settings Cache + * Maintain the internal cache of the settings object + * @public + * @param {Object} settings + * @returns {Settings} + */ +updateSettingsCache = function (settings, options) { + options = options || {}; + settings = settings || {}; + + if (!_.isEmpty(settings)) { + _.map(settings, function (setting, key) { + settingsCache[key] = setting; + }); + + updateConfigCache(); + + return Promise.resolve(settingsCache); + } + + return dataProvider.Settings.findAll(options) + .then(function (result) { + settingsCache = readSettingsResult(result.models); + + updateConfigCache(); + + return settingsCache; + }); +}; + +// ## Helpers + +/** + * ### Settings Filter + * Filters an object based on a given filter object + * @private + * @param {Object} settings + * @param {String} filter + * @returns {*} + */ +settingsFilter = function (settings, filter) { + return _.fromPairs(_.filter(_.toPairs(settings), function (setting) { + if (filter) { + return _.some(filter.split(','), function (f) { + return setting[1].type === f; + }); + } + return true; + })); +}; + +/** + * ### Read Settings Result + * @private + * @param {Array} settingsModels + * @returns {Settings} + */ +readSettingsResult = function (settingsModels) { + var settings = _.reduce(settingsModels, function (memo, member) { + if (!memo.hasOwnProperty(member.attributes.key)) { + memo[member.attributes.key] = member.attributes; + } + + return memo; + }, {}), + themes = config.paths.availableThemes, + res; + + if (settings.activeTheme && !_.isEmpty(themes)) { + res = filterPackages(themes, settings.activeTheme.value); + + settings.availableThemes = { + key: 'availableThemes', + value: res, + type: 'theme' + }; + } + + return settings; +}; + +/** + * ### Settings Result + * @private + * @param {Object} settings + * @param {String} type + * @returns {{settings: *}} + */ +settingsResult = function (settings, type) { + var filteredSettings = _.values(settingsFilter(settings, type)), + result = { + settings: filteredSettings, + meta: {} + }; + + if (type) { + result.meta.filters = { + type: type + }; + } + + return result; +}; + +/** + * ### Populate Default Setting + * @private + * @param {String} key + * @returns Promise(Setting) + */ +populateDefaultSetting = function (key) { + // Call populateDefault and update the settings cache + return dataProvider.Settings.populateDefault(key).then(function (defaultSetting) { + // Process the default result and add to settings cache + var readResult = readSettingsResult([defaultSetting]); + + // Add to the settings cache + return updateSettingsCache(readResult).then(function () { + // Get the result from the cache with permission checks + }); + }).catch(function (err) { + // Pass along NotFoundError + if (typeof err === errors.NotFoundError) { + return Promise.reject(err); + } + + // TODO: Different kind of error? + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.settings.problemFindingSetting', {key: key}))); + }); +}; + +/** + * ### Can Edit All Settings + * Check that this edit request is allowed for all settings requested to be updated + * @private + * @param {Object} settingsInfo + * @returns {*} + */ +canEditAllSettings = function (settingsInfo, options) { + var checkSettingPermissions = function (setting) { + if (setting.type === 'core' && !(options.context && options.context.internal)) { + return Promise.reject( + new errors.NoPermissionError(i18n.t('errors.api.settings.accessCoreSettingFromExtReq')) + ); + } + + return canThis(options.context).edit.setting(setting.key).catch(function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.settings.noPermissionToEditSettings'))); + }); + }, + checks = _.map(settingsInfo, function (settingInfo) { + var setting = settingsCache[settingInfo.key]; + + if (!setting) { + // Try to populate a default setting if not in the cache + return populateDefaultSetting(settingInfo.key).then(function (defaultSetting) { + // Get the result from the cache with permission checks + return checkSettingPermissions(defaultSetting); + }); + } + + return checkSettingPermissions(setting); + }); + + return Promise.all(checks); +}; + +/** + * ## Settings API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +settings = { + + /** + * ### Browse + * @param {Object} options + * @returns {*} + */ + browse: function browse(options) { + // First, check if we have populated the settings from default-settings yet + if (!hasPopulatedDefaults) { + return dataProvider.Settings.populateDefaults().then(function () { + hasPopulatedDefaults = true; + return settings.browse(options); + }); + } + + options = options || {}; + + var result = settingsResult(settingsCache, options.type); + + // If there is no context, return only blog settings + if (!options.context) { + return Promise.resolve(_.filter(result.settings, function (setting) { return setting.type === 'blog'; })); + } + + // Otherwise return whatever this context is allowed to browse + return canThis(options.context).browse.setting().then(function () { + // Omit core settings unless internal request + if (!options.context.internal) { + result.settings = _.filter(result.settings, function (setting) { return setting.type !== 'core'; }); + } + + return result; + }); + }, + + /** + * ### Read + * @param {Object} options + * @returns {*} + */ + read: function read(options) { + if (_.isString(options)) { + options = {key: options}; + } + + var getSettingsResult = function () { + var setting = settingsCache[options.key], + result = {}; + + result[options.key] = setting; + + if (setting.type === 'core' && !(options.context && options.context.internal)) { + return Promise.reject( + new errors.NoPermissionError(i18n.t('errors.api.settings.accessCoreSettingFromExtReq')) + ); + } + + if (setting.type === 'blog') { + return Promise.resolve(settingsResult(result)); + } + + return canThis(options.context).read.setting(options.key).then(function () { + return settingsResult(result); + }, function () { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.settings.noPermissionToReadSettings'))); + }); + }; + + // If the setting is not already in the cache + if (!settingsCache[options.key]) { + // Try to populate the setting from default-settings file + return populateDefaultSetting(options.key).then(function () { + // Get the result from the cache with permission checks + return getSettingsResult(); + }); + } + + // Get the result from the cache with permission checks + return getSettingsResult(); + }, + + /** + * ### Edit + * Update properties of a setting + * @param {{settings: }} object Setting or a single string name + * @param {{id (required), include,...}} options (optional) or a single string value + * @return {Promise(Setting)} Edited Setting + */ + edit: function edit(object, options) { + options = options || {}; + var self = this, + type; + + // Allow shorthand syntax where a single key and value are passed to edit instead of object and options + if (_.isString(object)) { + object = {settings: [{key: object, value: options}]}; + } + + // clean data + _.each(object.settings, function (setting) { + if (!_.isString(setting.value)) { + setting.value = JSON.stringify(setting.value); + } + }); + + type = _.find(object.settings, function (setting) { return setting.key === 'type'; }); + if (_.isObject(type)) { + type = type.value; + } + + object.settings = _.reject(object.settings, function (setting) { + return setting.key === 'type' || setting.key === 'availableThemes'; + }); + + return canEditAllSettings(object.settings, options).then(function () { + return utils.checkObject(object, docName).then(function (checkedData) { + options.user = self.user; + return dataProvider.Settings.edit(checkedData.settings, options); + }).then(function (result) { + var readResult = readSettingsResult(result); + + return updateSettingsCache(readResult).then(function () { + return settingsResult(readResult, type); + }); + }); + }); + } +}; + +module.exports = settings; +module.exports.updateSettingsCache = updateSettingsCache; diff --git a/core/server/api/slack.js b/core/server/api/slack.js new file mode 100644 index 0000000..8ef40b8 --- /dev/null +++ b/core/server/api/slack.js @@ -0,0 +1,27 @@ +// # Slack API +// API for sending Test Notifications to Slack +var events = require('../events'), + Promise = require('bluebird'), + slack; + +/** + * ## Slack API Method + * + * **See:** [API Methods](index.js.html#api%20methods) + * @typedef Slack + * @param slack + */ +slack = { + /** + * ### SendTest + * Send a test notification + * + * @public + */ + sendTest: function () { + events.emit('slack.test'); + return Promise.resolve(); + } +}; + +module.exports = slack; diff --git a/core/server/api/slugs.js b/core/server/api/slugs.js new file mode 100644 index 0000000..0fb5f6a --- /dev/null +++ b/core/server/api/slugs.js @@ -0,0 +1,83 @@ +// # Slug API +// RESTful API for the Slug resource +var dataProvider = require('../models'), + errors = require('../errors'), + Promise = require('bluebird'), + pipeline = require('../utils/pipeline'), + utils = require('./utils'), + i18n = require('../i18n'), + docName = 'slugs', + + slugs, + allowedTypes; + +/** + * ## Slugs API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +slugs = { + + /** + * ## Generate Slug + * Create a unique slug for the given type and its name + * + * @param {{type (required), name (required), context, transacting}} options + * @returns {Promise(String)} Unique string + */ + generate: function (options) { + var opts = ['type'], + attrs = ['name'], + tasks; + + // `allowedTypes` is used to define allowed slug types and map them against its model class counterpart + allowedTypes = { + post: dataProvider.Post, + tag: dataProvider.Tag, + user: dataProvider.User, + app: dataProvider.App + }; + + /** + * ### Check allowed types + * check if options.type contains an allowed type + * @param {Object} options + * @returns {Object} options + */ + function checkAllowedTypes(options) { + if (allowedTypes[options.type] === undefined) { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.slugs.unknownSlugType', {type: options.type}))); + } + return options; + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function modelQuery(options) { + return dataProvider.Base.Model.generateSlug(allowedTypes[options.type], options.data.name, {status: 'all'}); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: opts, attrs: attrs}), + utils.handlePermissions(docName, 'generate'), + checkAllowedTypes, + modelQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function (slug) { + if (!slug) { + return Promise.reject(new errors.InternalServerError(i18n.t('errors.api.slugs.couldNotGenerateSlug'))); + } + + return {slugs: [{slug: slug}]}; + }); + } +}; + +module.exports = slugs; diff --git a/core/server/api/subscribers.js b/core/server/api/subscribers.js new file mode 100644 index 0000000..dc92421 --- /dev/null +++ b/core/server/api/subscribers.js @@ -0,0 +1,320 @@ +// # Tag API +// RESTful API for the Tag resource +var Promise = require('bluebird'), + _ = require('lodash'), + fs = require('fs'), + dataProvider = require('../models'), + errors = require('../errors'), + utils = require('./utils'), + serverUtils = require('../utils'), + pipeline = require('../utils/pipeline'), + i18n = require('../i18n'), + + docName = 'subscribers', + subscribers; + +/** + * ### Subscribers API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +subscribers = { + /** + * ## Browse + * @param {{context}} options + * @returns {Promise} Subscriber Collection + */ + browse: function browse(options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Subscriber.findPage(options); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.browseDefaultOptions}), + utils.handlePermissions(docName, 'browse'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ## Read + * @param {{id}} options + * @return {Promise} Subscriber + */ + read: function read(options) { + var attrs = ['id'], + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Subscriber.findOne(options.data, _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {attrs: attrs}), + utils.handlePermissions(docName, 'read'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(result) { + if (result) { + return {subscribers: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.subscribers.subscriberNotFound'))); + }); + }, + + /** + * ## Add + * @param {Subscriber} object the subscriber to create + * @returns {Promise(Subscriber)} Newly created Subscriber + */ + add: function add(object, options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Subscriber.getByEmail(options.data.subscribers[0].email) + .then(function (subscriber) { + if (subscriber && options.context.external) { + // we don't expose this information + return Promise.resolve(subscriber); + } else if (subscriber) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.api.subscribers.subscriberAlreadyExists'))); + } + + return dataProvider.Subscriber.add(options.data.subscribers[0], _.omit(options, ['data'])).catch(function (error) { + if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.api.subscribers.subscriberAlreadyExists'))); + } + + return Promise.reject(error); + }); + }); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName), + utils.handlePermissions(docName, 'add'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + var subscriber = result.toJSON(options); + return {subscribers: [subscriber]}; + }); + }, + + /** + * ## Edit + * + * @public + * @param {Subscriber} object Subscriber or specific properties to update + * @param {{id, context, include}} options + * @return {Promise} Edited Subscriber + */ + edit: function edit(object, options) { + var tasks; + + /** + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Subscriber.edit(options.data.subscribers[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + utils.handlePermissions(docName, 'edit'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + if (result) { + var subscriber = result.toJSON(options); + + return {subscribers: [subscriber]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.subscribers.subscriberNotFound'))); + }); + }, + + /** + * ## Destroy + * + * @public + * @param {{id, context}} options + * @return {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * ### Delete Subscriber + * Make the call to the Model layer + * @param {Object} options + */ + function doQuery(options) { + return dataProvider.Subscriber.destroy(options).return(null); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + utils.handlePermissions(docName, 'destroy'), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ### Export Subscribers + * Generate the CSV to export + * + * @public + * @param {{context}} options + * @returns {Promise} Ghost Export CSV format + */ + exportCSV: function exportCSV(options) { + var tasks = []; + + options = options || {}; + + function formatCSV(data) { + var fields = ['id', 'email', 'created_at', 'deleted_at'], + csv = fields.join(',') + '\r\n', + subscriber, + field, + j, + i; + + for (j = 0; j < data.length; j = j + 1) { + subscriber = data[j]; + + for (i = 0; i < fields.length; i = i + 1) { + field = fields[i]; + csv += subscriber[field] !== null ? subscriber[field] : ''; + if (i !== fields.length - 1) { + csv += ','; + } + } + csv += '\r\n'; + } + return csv; + } + + // Export data, otherwise send error 500 + function exportSubscribers() { + return dataProvider.Subscriber.findAll(options).then(function (data) { + return formatCSV(data.toJSON(options)); + }).catch(function (error) { + return Promise.reject(new errors.InternalServerError(error.message || error)); + }); + } + + tasks = [ + utils.handlePermissions(docName, 'browse'), + exportSubscribers + ]; + + return pipeline(tasks, options); + }, + + /** + * ### Import CSV + * Import subscribers from a CSV file + * + * @public + * @param {{context}} options + * @returns {Promise} Success + */ + importCSV: function (options) { + var tasks = []; + options = options || {}; + + function importCSV(options) { + var filePath = options.path, + fulfilled = 0, + invalid = 0, + duplicates = 0; + + return serverUtils.readCSV({ + path: filePath, + columnsToExtract: [{name: 'email', lookup: /email/i}] + }).then(function (result) { + return Promise.all(result.map(function (entry) { + return subscribers.add( + {subscribers: [{email: entry.email}]}, + {context: options.context} + ).reflect(); + })).each(function (inspection) { + if (inspection.isFulfilled()) { + fulfilled = fulfilled + 1; + } else { + if (inspection.reason() instanceof errors.ValidationError) { + duplicates = duplicates + 1; + } else { + invalid = invalid + 1; + } + } + }); + }).then(function () { + return { + meta: { + stats: { + imported: fulfilled, + duplicates: duplicates, + invalid: invalid + } + } + }; + }).finally(function () { + // Remove uploaded file from tmp location + return Promise.promisify(fs.unlink)(filePath); + }); + } + + tasks = [ + utils.handlePermissions(docName, 'add'), + importCSV + ]; + + return pipeline(tasks, options); + } +}; + +module.exports = subscribers; diff --git a/core/server/api/tags.js b/core/server/api/tags.js new file mode 100644 index 0000000..c47b7d9 --- /dev/null +++ b/core/server/api/tags.js @@ -0,0 +1,194 @@ +// # Tag API +// RESTful API for the Tag resource +var Promise = require('bluebird'), + _ = require('lodash'), + dataProvider = require('../models'), + errors = require('../errors'), + utils = require('./utils'), + pipeline = require('../utils/pipeline'), + i18n = require('../i18n'), + + docName = 'tags', + allowedIncludes = ['count.posts'], + tags; + +/** + * ### Tags API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +tags = { + /** + * ## Browse + * @param {{context}} options + * @returns {Promise} Tags Collection + */ + browse: function browse(options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Tag.findPage(options); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.browseDefaultOptions}), + utils.handlePublicPermissions(docName, 'browse'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ## Read + * @param {{id}} options + * @return {Promise} Tag + */ + read: function read(options) { + var attrs = ['id', 'slug', 'visibility'], + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Tag.findOne(options.data, _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {attrs: attrs}), + utils.handlePublicPermissions(docName, 'read'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(result) { + if (result) { + return {tags: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.tags.tagNotFound'))); + }); + }, + + /** + * ## Add + * @param {Tag} object the tag to create + * @returns {Promise(Tag)} Newly created Tag + */ + add: function add(object, options) { + var tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Tag.add(options.data.tags[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName), + utils.handlePermissions(docName, 'add'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + var tag = result.toJSON(options); + + return {tags: [tag]}; + }); + }, + + /** + * ## Edit + * + * @public + * @param {Tag} object Tag or specific properties to update + * @param {{id, context, include}} options + * @return {Promise} Edited Tag + */ + edit: function edit(object, options) { + var tasks; + + /** + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.Tag.edit(options.data.tags[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + utils.handlePermissions(docName, 'edit'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse(result) { + if (result) { + var tag = result.toJSON(options); + + return {tags: [tag]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.tags.tagNotFound'))); + }); + }, + + /** + * ## Destroy + * + * @public + * @param {{id, context}} options + * @return {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * ### Delete Tag + * Make the call to the Model layer + * @param {Object} options + */ + function deleteTag(options) { + return dataProvider.Tag.destroy(options).return(null); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + utils.handlePermissions(docName, 'destroy'), + utils.convertOptions(allowedIncludes), + deleteTag + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + } +}; + +module.exports = tags; diff --git a/core/server/api/themes.js b/core/server/api/themes.js new file mode 100644 index 0000000..13631bf --- /dev/null +++ b/core/server/api/themes.js @@ -0,0 +1,161 @@ +// # Themes API +// RESTful API for Themes +var Promise = require('bluebird'), + _ = require('lodash'), + gscan = require('gscan'), + fs = require('fs-extra'), + config = require('../config'), + errors = require('../errors'), + events = require('../events'), + storage = require('../storage'), + settings = require('./settings'), + utils = require('./utils'), + i18n = require('../i18n'), + themes; + +/** + * ## Themes API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +themes = { + upload: function upload(options) { + options = options || {}; + + // consistent filename uploads + options.originalname = options.originalname.toLowerCase(); + + var storageAdapter = storage.getStorage('themes'), + zip = { + path: options.path, + name: options.originalname, + shortName: storageAdapter.getSanitizedFileName(options.originalname.split('.zip')[0]) + }, theme; + + // check if zip name is casper.zip + if (zip.name === 'casper.zip') { + throw new errors.ValidationError(i18n.t('errors.api.themes.overrideCasper')); + } + + return utils.handlePermissions('themes', 'add')(options) + .then(function () { + return gscan.checkZip(zip, {keepExtractedDir: true}); + }) + .then(function (_theme) { + theme = _theme; + theme = gscan.format(theme); + + if (!theme.results.error.length) { + return; + } + + throw new errors.ThemeValidationError( + i18n.t('errors.api.themes.invalidTheme'), + theme.results.error + ); + }) + .then(function () { + return storageAdapter.exists(config.paths.themePath + '/' + zip.shortName); + }) + .then(function (themeExists) { + // delete existing theme + if (themeExists) { + return storageAdapter.delete(zip.shortName, config.paths.themePath); + } + }) + .then(function () { + events.emit('theme.uploaded', zip.shortName); + // store extracted theme + return storageAdapter.save({ + name: zip.shortName, + path: theme.path + }, config.paths.themePath); + }) + .then(function () { + // force reload of availableThemes + // right now the logic is in the ConfigManager + // if we create a theme collection, we don't have to read them from disk + return config.loadThemes(); + }) + .then(function () { + // the settings endpoint is used to fetch the availableThemes + // so we have to force updating the in process cache + return settings.updateSettingsCache(); + }) + .then(function (settings) { + // gscan theme structure !== ghost theme structure + var themeObject = _.find(settings.availableThemes.value, {name: zip.shortName}) || {}; + if (theme.results.warning.length > 0) { + themeObject.warnings = _.cloneDeep(theme.results.warning); + } + return {themes: [themeObject]}; + }) + .finally(function () { + // remove zip upload from multer + // happens in background + Promise.promisify(fs.remove)(zip.path) + .catch(function (err) { + errors.logError(err); + }); + + // remove extracted dir from gscan + // happens in background + if (theme) { + Promise.promisify(fs.remove)(theme.path) + .catch(function (err) { + errors.logError(err); + }); + } + }); + }, + + download: function download(options) { + var themeName = options.name, + theme = config.paths.availableThemes[themeName], + storageAdapter = storage.getStorage('themes'); + + if (!theme) { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.themes.invalidRequest'))); + } + + return utils.handlePermissions('themes', 'read')(options) + .then(function () { + events.emit('theme.downloaded', themeName); + return storageAdapter.serve({isTheme: true, name: themeName}); + }); + }, + + /** + * remove theme zip + * remove theme folder + */ + destroy: function destroy(options) { + var name = options.name, + theme, + storageAdapter = storage.getStorage('themes'); + + return utils.handlePermissions('themes', 'destroy')(options) + .then(function () { + if (name === 'casper') { + throw new errors.ValidationError(i18n.t('errors.api.themes.destroyCasper')); + } + + theme = config.paths.availableThemes[name]; + + if (!theme) { + throw new errors.NotFoundError(i18n.t('errors.api.themes.themeDoesNotExist')); + } + + events.emit('theme.deleted', name); + return storageAdapter.delete(name, config.paths.themePath); + }) + .then(function () { + return config.loadThemes(); + }) + .then(function () { + return settings.updateSettingsCache(); + }); + } +}; + +module.exports = themes; diff --git a/core/server/api/upload.js b/core/server/api/upload.js new file mode 100644 index 0000000..a174752 --- /dev/null +++ b/core/server/api/upload.js @@ -0,0 +1,31 @@ +var Promise = require('bluebird'), + fs = require('fs-extra'), + pUnlink = Promise.promisify(fs.unlink), + storage = require('../storage'), + upload; + +/** + * ## Upload API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +upload = { + + /** + * ### Add Image + * + * @public + * @param {{context}} options + * @returns {Promise} location of uploaded file + */ + add: Promise.method(function (options) { + var store = storage.getStorage(); + + return store.save(options).finally(function () { + // Remove uploaded file from tmp location + return pUnlink(options.path); + }); + }) +}; + +module.exports = upload; diff --git a/core/server/api/users.js b/core/server/api/users.js new file mode 100644 index 0000000..e6be986 --- /dev/null +++ b/core/server/api/users.js @@ -0,0 +1,520 @@ +// # Users API +// RESTful API for the User resource +var Promise = require('bluebird'), + _ = require('lodash'), + dataProvider = require('../models'), + settings = require('./settings'), + canThis = require('../permissions').canThis, + errors = require('../errors'), + utils = require('./utils'), + globalUtils = require('../utils'), + config = require('../config'), + mail = require('./../mail'), + apiMail = require('./mail'), + pipeline = require('../utils/pipeline'), + i18n = require('../i18n'), + + docName = 'users', + // TODO: implement created_by, updated_by + allowedIncludes = ['count.posts', 'permissions', 'roles', 'roles.permissions'], + users, + sendInviteEmail; + +sendInviteEmail = function sendInviteEmail(user) { + var emailData; + + return Promise.join( + users.read({id: user.created_by, context: {internal: true}}), + settings.read({key: 'title'}), + settings.read({context: {internal: true}, key: 'dbHash'}) + ).then(function (values) { + var invitedBy = values[0].users[0], + blogTitle = values[1].settings[0].value, + expires = Date.now() + (14 * globalUtils.ONE_DAY_MS), + dbHash = values[2].settings[0].value; + + emailData = { + blogName: blogTitle, + invitedByName: invitedBy.name, + invitedByEmail: invitedBy.email + }; + + return dataProvider.User.generateResetToken(user.email, expires, dbHash); + }).then(function (resetToken) { + var baseUrl = config.forceAdminSSL ? (config.urlSSL || config.url) : config.url; + + emailData.resetLink = baseUrl.replace(/\/$/, '') + '/ghost/signup/' + globalUtils.encodeBase64URLsafe(resetToken) + '/'; + + return mail.utils.generateContent({data: emailData, template: 'invite-user'}); + }).then(function (emailContent) { + var payload = { + mail: [{ + message: { + to: user.email, + subject: i18n.t('common.api.users.mail.invitedByName', {invitedByName: emailData.invitedByName, blogName: emailData.blogName}), + html: emailContent.html, + text: emailContent.text + }, + options: {} + }] + }; + + return apiMail.send(payload, {context: {internal: true}}); + }); +}; +/** + * ### Users API Methods + * + * **See:** [API Methods](index.js.html#api%20methods) + */ +users = { + /** + * ## Browse + * Fetch all users + * @param {{context}} options (optional) + * @returns {Promise} Users Collection + */ + browse: function browse(options) { + var extraOptions = ['status'], + permittedOptions = utils.browseDefaultOptions.concat(extraOptions), + tasks; + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.User.findPage(options); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: permittedOptions}), + utils.handlePublicPermissions(docName, 'browse'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ## Read + * @param {{id, context}} options + * @returns {Promise} User + */ + read: function read(options) { + var attrs = ['id', 'slug', 'status', 'email', 'role'], + tasks; + + // Special handling for id = 'me' + if (options.id === 'me' && options.context && options.context.user) { + options.id = options.context.user; + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.User.findOne(options.data, _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {attrs: attrs}), + utils.handlePublicPermissions(docName, 'read'), + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options).then(function formatResponse(result) { + if (result) { + return {users: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.users.userNotFound'))); + }); + }, + + /** + * ## Edit + * @param {User} object the user details to edit + * @param {{id, context}} options + * @returns {Promise} + */ + edit: function edit(object, options) { + var extraOptions = ['editRoles'], + permittedOptions = extraOptions.concat(utils.idDefaultOptions), + tasks; + + if (object.users && object.users[0] && object.users[0].roles && object.users[0].roles[0]) { + options.editRoles = true; + } + + // The password should never be set via this endpoint, if it is passed, ignore it + if (object.users && object.users[0] && object.users[0].password) { + delete object.users[0].password; + } + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * Edit user allows the related role object to be updated as well, with some rules: + * - No change permitted to the role of the owner + * - no change permitted to the role of the context user (user making the request) + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + if (options.id === 'me' && options.context && options.context.user) { + options.id = options.context.user; + } + + return canThis(options.context).edit.user(options.id).then(function () { + // if roles aren't in the payload, proceed with the edit + if (!(options.data.users[0].roles && options.data.users[0].roles[0])) { + return options; + } + + // @TODO move role permissions out of here + var role = options.data.users[0].roles[0], + roleId = parseInt(role.id || role, 10), + editedUserId = parseInt(options.id, 10); + + return dataProvider.User.findOne( + {id: options.context.user, status: 'all'}, {include: ['roles']} + ).then(function (contextUser) { + var contextRoleId = contextUser.related('roles').toJSON(options)[0].id; + + if (roleId !== contextRoleId && editedUserId === contextUser.id) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.users.cannotChangeOwnRole'))); + } + + return dataProvider.User.findOne({role: 'Owner'}).then(function (owner) { + if (contextUser.id !== owner.id) { + if (editedUserId === owner.id) { + if (owner.related('roles').at(0).id !== roleId) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.users.cannotChangeOwnersRole'))); + } + } else if (roleId !== contextRoleId) { + return canThis(options.context).assign.role(role).then(function () { + return options; + }); + } + } + + return options; + }); + }); + }).catch(function handleError(error) { + return errors.formatAndRejectAPIError(error, i18n.t('errors.api.users.noPermissionToEditUser')); + }); + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.User.edit(options.data.users[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: permittedOptions}), + handlePermissions, + utils.convertOptions(allowedIncludes), + doQuery + ]; + + return pipeline(tasks, object, options).then(function formatResponse(result) { + if (result) { + return {users: [result.toJSON(options)]}; + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.api.users.userNotFound'))); + }); + }, + + /** + * ## Add user + * The newly added user is invited to join the blog via email. + * @param {User} object the user to create + * @param {{context}} options + * @returns {Promise} Newly created user + */ + add: function add(object, options) { + var tasks; + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + var newUser = options.data.users[0]; + return canThis(options.context).add.user(options.data).then(function () { + if (newUser.roles && newUser.roles[0]) { + var roleId = parseInt(newUser.roles[0].id || newUser.roles[0], 10); + + // @TODO move this logic to permissible + // Make sure user is allowed to add a user with this role + return dataProvider.Role.findOne({id: roleId}).then(function (role) { + if (role.get('name') === 'Owner') { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.api.users.notAllowedToCreateOwner'))); + } + + return canThis(options.context).assign.role(role); + }).then(function () { + return options; + }); + } + + return options; + }).catch(function handleError(error) { + return errors.formatAndRejectAPIError(error, i18n.t('errors.api.users.noPermissionToAddUser')); + }); + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + var newUser = options.data.users[0], + user; + + if (newUser.email) { + newUser.name = newUser.email.substring(0, newUser.email.indexOf('@')); + newUser.password = globalUtils.uid(50); + newUser.status = 'invited'; + } else { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.users.noEmailProvided'))); + } + + return dataProvider.User.getByEmail( + newUser.email + ).then(function (foundUser) { + if (!foundUser) { + return dataProvider.User.add(newUser, options); + } else { + // only invitations for already invited users are resent + if (foundUser.get('status') === 'invited' || foundUser.get('status') === 'invited-pending') { + return foundUser; + } else { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.api.users.userAlreadyRegistered'))); + } + } + }).then(function (invitedUser) { + user = invitedUser.toJSON(options); + return sendInviteEmail(user); + }).then(function () { + // If status was invited-pending and sending the invitation succeeded, set status to invited. + if (user.status === 'invited-pending') { + return dataProvider.User.edit( + {status: 'invited'}, _.extend({}, options, {id: user.id}) + ).then(function (editedUser) { + user = editedUser.toJSON(options); + }); + } + }).then(function () { + return Promise.resolve({users: [user]}); + }).catch(function (error) { + if (error && error.errorType === 'EmailError') { + error.message = i18n.t('errors.api.users.errorSendingEmail.error', {message: error.message}) + ' ' + + i18n.t('errors.api.users.errorSendingEmail.help'); + errors.logWarn(error.message); + + // If sending the invitation failed, set status to invited-pending + return dataProvider.User.edit({status: 'invited-pending'}, {id: user.id}).then(function (user) { + return dataProvider.User.findOne({id: user.id, status: 'all'}, options).then(function (user) { + return {users: [user]}; + }); + }); + } + return Promise.reject(error); + }); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName), + handlePermissions, + utils.convertOptions(allowedIncludes), + doQuery + ]; + + return pipeline(tasks, object, options); + }, + + /** + * ## Destroy + * @param {{id, context}} options + * @returns {Promise} + */ + destroy: function destroy(options) { + var tasks; + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + return canThis(options.context).destroy.user(options.id).then(function permissionGranted() { + options.status = 'all'; + return options; + }).catch(function handleError(error) { + return errors.formatAndRejectAPIError(error, i18n.t('errors.api.users.noPermissionToDestroyUser')); + }); + } + + /** + * ### Delete User + * Make the call to the Model layer + * @param {Object} options + */ + function deleteUser(options) { + return dataProvider.Base.transaction(function (t) { + options.transacting = t; + + return Promise.all([ + dataProvider.Accesstoken.destroyByUser(options), + dataProvider.Refreshtoken.destroyByUser(options), + dataProvider.Post.destroyByAuthor(options) + ]).then(function () { + return dataProvider.User.destroy(options); + }).return(null); + }).catch(function (error) { + return errors.formatAndRejectAPIError(error); + }); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate(docName, {opts: utils.idDefaultOptions}), + handlePermissions, + utils.convertOptions(allowedIncludes), + deleteUser + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, options); + }, + + /** + * ## Change Password + * @param {password} object + * @param {{context}} options + * @returns {Promise} success message + */ + changePassword: function changePassword(object, options) { + var tasks; + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + return canThis(options.context).edit.user(options.data.password[0].user_id).then(function permissionGranted() { + return options; + }).catch(function (error) { + return errors.formatAndRejectAPIError(error, i18n.t('errors.api.users.noPermissionToChangeUsersPwd')); + }); + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.User.changePassword( + options.data.password[0], + _.omit(options, ['data']) + ); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate('password'), + handlePermissions, + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResponse() { + return Promise.resolve({password: [{message: i18n.t('notices.api.users.pwdChangedSuccessfully')}]}); + }); + }, + + /** + * ## Transfer Ownership + * @param {owner} object + * @param {Object} options + * @returns {Promise} + */ + transferOwnership: function transferOwnership(object, options) { + var tasks; + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + function handlePermissions(options) { + return dataProvider.Role.findOne({name: 'Owner'}).then(function (ownerRole) { + return canThis(options.context).assign.role(ownerRole); + }).then(function () { + return options; + }).catch(function (error) { + return errors.formatAndRejectAPIError(error); + }); + } + + /** + * ### Model Query + * Make the call to the Model layer + * @param {Object} options + * @returns {Object} options + */ + function doQuery(options) { + return dataProvider.User.transferOwnership(options.data.owner[0], _.omit(options, ['data'])); + } + + // Push all of our tasks into a `tasks` array in the correct order + tasks = [ + utils.validate('owner'), + handlePermissions, + utils.convertOptions(allowedIncludes), + doQuery + ]; + + // Pipeline calls each task passing the result of one to be the arguments for the next + return pipeline(tasks, object, options).then(function formatResult(result) { + return Promise.resolve({users: result}); + }).catch(function (error) { + return errors.formatAndRejectAPIError(error); + }); + } +}; + +module.exports = users; diff --git a/core/server/api/utils.js b/core/server/api/utils.js new file mode 100644 index 0000000..8c25bc3 --- /dev/null +++ b/core/server/api/utils.js @@ -0,0 +1,319 @@ +// # API Utils +// Shared helpers for working with the API +var Promise = require('bluebird'), + _ = require('lodash'), + path = require('path'), + errors = require('../errors'), + permissions = require('../permissions'), + validation = require('../data/validation'), + i18n = require('../i18n'), + + utils; + +utils = { + // ## Default Options + // Various default options for different types of endpoints + + // ### Auto Default Options + // Handled / Added automatically by the validate function + // globalDefaultOptions - valid for every api endpoint + globalDefaultOptions: ['context', 'include'], + // dataDefaultOptions - valid for all endpoints which take object as well as options + dataDefaultOptions: ['data'], + + // ### Manual Default Options + // These must be provided by the endpoint + // browseDefaultOptions - valid for all browse api endpoints + browseDefaultOptions: ['page', 'limit', 'fields', 'filter', 'order', 'debug'], + // idDefaultOptions - valid whenever an id is valid + idDefaultOptions: ['id'], + + /** + * ## Validate + * Prepare to validate the object and options passed to an endpoint + * @param {String} docName + * @param {Object} extras + * @returns {Function} doValidate + */ + validate: function validate(docName, extras) { + /** + * ### Do Validate + * Validate the object and options passed to an endpoint + * @argument {...*} [arguments] object or object and options hash + */ + return function doValidate() { + var object, options, permittedOptions; + + if (arguments.length === 2) { + object = arguments[0]; + options = _.clone(arguments[1]) || {}; + } else if (arguments.length === 1) { + options = _.clone(arguments[0]) || {}; + } else { + options = {}; + } + + // Setup permitted options, starting with the global defaults + permittedOptions = utils.globalDefaultOptions; + + // Add extra permitted options if any are passed in + if (extras && extras.opts) { + permittedOptions = permittedOptions.concat(extras.opts); + } + + // This request will have a data key added during validation + if ((extras && extras.attrs) || object) { + permittedOptions = permittedOptions.concat(utils.dataDefaultOptions); + } + + // If an 'attrs' object is passed, we use this to pick from options and convert them to data + if (extras && extras.attrs) { + options.data = _.pick(options, extras.attrs); + options = _.omit(options, extras.attrs); + } + + /** + * ### Check Options + * Ensure that the options provided match exactly with what is permitted + * - incorrect option keys are sanitized + * - incorrect option values are validated + * @param {object} options + * @returns {Promise} + */ + function checkOptions(options) { + // @TODO: should we throw an error if there are incorrect options provided? + options = _.pick(options, permittedOptions); + + var validationErrors = utils.validateOptions(options); + + if (_.isEmpty(validationErrors)) { + return Promise.resolve(options); + } + + // For now, we can only handle showing the first validation error + return errors.logAndRejectError(validationErrors[0]); + } + + // If we got an object, check that too + if (object) { + return utils.checkObject(object, docName, options.id).then(function (data) { + options.data = data; + + return checkOptions(options); + }); + } + + // Otherwise just check options and return + return checkOptions(options); + }; + }, + + validateOptions: function validateOptions(options) { + var globalValidations = { + id: {matches: /^\d+|me$/}, + uuid: {isUUID: true}, + slug: {isSlug: true}, + page: {matches: /^\d+$/}, + limit: {matches: /^\d+|all$/}, + from: {isDate: true}, + to: {isDate: true}, + fields: {matches: /^[\w, ]+$/}, + order: {matches: /^[a-z0-9_,\. ]+$/i}, + name: {} + }, + // these values are sanitised/validated separately + noValidation = ['data', 'context', 'include', 'filter', 'forUpdate', 'transacting'], + errors = []; + + _.each(options, function (value, key) { + // data is validated elsewhere + if (noValidation.indexOf(key) === -1) { + if (globalValidations[key]) { + errors = errors.concat(validation.validate(value, key, globalValidations[key])); + } else { + // all other keys should be alpha-numeric with dashes/underscores, like tag, author, status, etc + errors = errors.concat(validation.validate(value, key, globalValidations.slug)); + } + } + }); + + return errors; + }, + + /** + * ## Detect Public Context + * Calls parse context to expand the options.context object + * @param {Object} options + * @returns {Boolean} + */ + detectPublicContext: function detectPublicContext(options) { + options.context = permissions.parseContext(options.context); + return options.context.public; + }, + /** + * ## Apply Public Permissions + * Update the options object so that the rules reflect what is permitted to be retrieved from a public request + * @param {String} docName + * @param {String} method (read || browse) + * @param {Object} options + * @returns {Object} options + */ + applyPublicPermissions: function applyPublicPermissions(docName, method, options) { + return permissions.applyPublicRules(docName, method, options); + }, + + /** + * ## Handle Public Permissions + * @param {String} docName + * @param {String} method (read || browse) + * @returns {Function} + */ + handlePublicPermissions: function handlePublicPermissions(docName, method) { + var singular = docName.replace(/s$/, ''); + + /** + * Check if this is a public request, if so use the public permissions, otherwise use standard canThis + * @param {Object} options + * @returns {Object} options + */ + return function doHandlePublicPermissions(options) { + var permsPromise; + + if (utils.detectPublicContext(options)) { + permsPromise = utils.applyPublicPermissions(docName, method, options); + } else { + permsPromise = permissions.canThis(options.context)[method][singular](options.data); + } + + return permsPromise.then(function permissionGranted() { + return options; + }).catch(function handleError(error) { + return errors.formatAndRejectAPIError(error); + }); + }; + }, + + /** + * ## Handle Permissions + * @param {String} docName + * @param {String} method (browse || read || edit || add || destroy) + * @returns {Function} + */ + handlePermissions: function handlePermissions(docName, method) { + var singular = docName.replace(/s$/, ''); + + /** + * ### Handle Permissions + * We need to be an authorised user to perform this action + * @param {Object} options + * @returns {Object} options + */ + return function doHandlePermissions(options) { + var permsPromise = permissions.canThis(options.context)[method][singular](options.id); + + return permsPromise.then(function permissionGranted() { + return options; + }).catch(errors.NoPermissionError, function handleNoPermissionError(error) { + // pimp error message + error.message = i18n.t('errors.api.utils.noPermissionToCall', {method: method, docName: docName}); + // forward error to next catch() + return Promise.reject(error); + }).catch(function handleError(error) { + return errors.formatAndRejectAPIError(error); + }); + }; + }, + + trimAndLowerCase: function trimAndLowerCase(params) { + params = params || ''; + if (_.isString(params)) { + params = params.split(','); + } + + return _.map(params, function (item) { + return item.trim().toLowerCase(); + }); + }, + + prepareInclude: function prepareInclude(include, allowedIncludes) { + return _.intersection(this.trimAndLowerCase(include), allowedIncludes); + }, + + prepareFields: function prepareFields(fields) { + return this.trimAndLowerCase(fields); + }, + + /** + * ## Convert Options + * @param {Array} allowedIncludes + * @returns {Function} doConversion + */ + convertOptions: function convertOptions(allowedIncludes) { + /** + * Convert our options from API-style to Model-style + * @param {Object} options + * @returns {Object} options + */ + return function doConversion(options) { + if (options.include) { + options.include = utils.prepareInclude(options.include, allowedIncludes); + } + if (options.fields) { + options.columns = utils.prepareFields(options.fields); + delete options.fields; + } + + return options; + }; + }, + /** + * ### Check Object + * Check an object passed to the API is in the correct format + * + * @param {Object} object + * @param {String} docName + * @returns {Promise(Object)} resolves to the original object if it checks out + */ + checkObject: function (object, docName, editId) { + if (_.isEmpty(object) || _.isEmpty(object[docName]) || _.isEmpty(object[docName][0])) { + return errors.logAndRejectError(new errors.BadRequestError(i18n.t('errors.api.utils.noRootKeyProvided', {docName: docName}))); + } + + // convert author property to author_id to match the name in the database + if (docName === 'posts') { + if (object.posts[0].hasOwnProperty('author')) { + object.posts[0].author_id = object.posts[0].author; + delete object.posts[0].author; + } + } + + // will remove unwanted null values + _.each(object[docName], function (value, index) { + if (!_.isObject(object[docName][index])) { + return; + } + + object[docName][index] = _.omitBy(object[docName][index], _.isNull); + }); + + if (editId && object[docName][0].id && parseInt(editId, 10) !== parseInt(object[docName][0].id, 10)) { + return errors.logAndRejectError(new errors.BadRequestError(i18n.t('errors.api.utils.invalidIdProvided'))); + } + + return Promise.resolve(object); + }, + checkFileExists: function (fileData) { + return !!(fileData.mimetype && fileData.path); + }, + checkFileIsValid: function (fileData, types, extensions) { + var type = fileData.mimetype, + ext = path.extname(fileData.name).toLowerCase(); + + if (_.includes(types, type) && _.includes(extensions, ext)) { + return true; + } + return false; + } +}; + +module.exports = utils; diff --git a/core/server/apps/amp/index.js b/core/server/apps/amp/index.js new file mode 100644 index 0000000..12f3b15 --- /dev/null +++ b/core/server/apps/amp/index.js @@ -0,0 +1,15 @@ +var router = require('./lib/router'), + registerAmpHelpers = require('./lib/helpers'), + + // Dirty requires + config = require('../../config'); + +module.exports = { + activate: function activate(ghost) { + registerAmpHelpers(ghost); + }, + + setupRoutes: function setupRoutes(blogRouter) { + blogRouter.use('*/' + config.routeKeywords.amp + '/', router); + } +}; diff --git a/core/server/apps/amp/lib/helpers/amp_components.js b/core/server/apps/amp/lib/helpers/amp_components.js new file mode 100644 index 0000000..6b3ee65 --- /dev/null +++ b/core/server/apps/amp/lib/helpers/amp_components.js @@ -0,0 +1,35 @@ +// # Amp Components Helper +// Usage: `{{amp_components}}` +// +// Reads through the AMP HTML and adds neccessary scripts +// for each extended component +// If more components need to be added, we can add more scripts. +// Here's the list of all supported extended components: https://www.ampproject.org/docs/reference/extended.html +// By default supported AMP HTML tags (no additional script tag necessary): +// amp-img, amp-ad, amp-embed, amp-video and amp-pixel. +var hbs = require('express-hbs'); + +function ampComponents() { + var components = [], + html = this.post.html || this.html; + + if (!html) { + return; + } + + if (html.indexOf('.gif') !== -1) { + components.push(''); + } + + if (html.indexOf(''); + } + + if (html.indexOf(''); + } + + return new hbs.handlebars.SafeString(components.join('\n')); +} + +module.exports = ampComponents; diff --git a/core/server/apps/amp/lib/helpers/amp_content.js b/core/server/apps/amp/lib/helpers/amp_content.js new file mode 100644 index 0000000..1724f6c --- /dev/null +++ b/core/server/apps/amp/lib/helpers/amp_content.js @@ -0,0 +1,189 @@ +// # Amp Content Helper +// Usage: `{{amp_content}}` +// +// Turns content html into a safestring so that the user doesn't have to +// escape it or tell handlebars to leave it alone with a triple-brace. +// +// Converts normal HTML into AMP HTML with Amperize module and uses a cache to return it from +// there if available. The cacheId is a combination of `updated_at` and the `slug`. +var hbs = require('express-hbs'), + Promise = require('bluebird'), + Amperize = require('amperize'), + moment = require('moment'), + sanitizeHtml = require('sanitize-html'), + config = require('../../../../config'), + errors = require('../../../../errors'), + makeAbsoluteUrl = require('../../../../utils/make-absolute-urls'), + cheerio = require('cheerio'), + amperize = new Amperize(), + amperizeCache = {}, + allowedAMPTags = [], + allowedAMPAttributes = {}, + cleanHTML, + ampHTML; + +allowedAMPTags = ['html', 'body', 'article', 'section', 'nav', 'aside', 'h1', 'h2', + 'h3', 'h4', 'h5', 'h6', 'header', 'footer', 'address', 'p', 'hr', + 'pre', 'blockquote', 'ol', 'ul', 'li', 'dl', 'dt', 'dd', 'figure', + 'figcaption', 'div', 'main', 'a', 'em', 'strong', 'small', 's', 'cite', + 'q', 'dfn', 'abbr', 'data', 'time', 'code', 'var', 'samp', 'kbd', 'sub', + 'sup', 'i', 'b', 'u', 'mark', 'ruby', 'rb', 'rt', 'rtc', 'rp', 'bdi', + 'bdo', 'span', 'br', 'wbr', 'ins', 'del', 'source', 'track', 'svg', 'g', + 'path', 'glyph', 'glyphref', 'marker', 'view', 'circle', 'line', 'polygon', + 'polyline', 'rect', 'text', 'textpath', 'tref', 'tspan', 'clippath', + 'filter', 'lineargradient', 'radialgradient', 'mask', 'pattern', 'vkern', + 'hkern', 'defs', 'stop', 'use', 'foreignobject', 'symbol', 'desc', 'title', + 'table', 'caption', 'colgroup', 'col', 'tbody', 'thead', 'tfoot', 'tr', 'td', + 'th', 'button', 'noscript', 'acronym', 'center', 'dir', 'hgroup', 'listing', + 'multicol', 'nextid', 'nobr', 'spacer', 'strike', 'tt', 'xmp', 'amp-img', + 'amp-video', 'amp-ad', 'amp-embed', 'amp-anim', 'amp-iframe', 'amp-pixel', + 'amp-audio', 'O:P']; + +allowedAMPAttributes = { + '*': ['itemid', 'itemprop', 'itemref', 'itemscope', 'itemtype', 'accesskey', 'class', 'dir', 'draggable', + 'id', 'lang', 'tabindex', 'title', 'translate', 'aria-*', 'role', 'placeholder', 'fallback', 'lightbox', + 'overflow', 'amp-access', 'amp-access-*', 'i-amp-access-id'], + h1: ['align'], + h2: ['align'], + h3: ['align'], + h4: ['align'], + h5: ['align'], + h6: ['align'], + p: ['align'], + blockquote: ['align'], + ol: ['reversed', 'start', 'type'], + li: ['value'], + div: ['align'], + a: ['href', 'hreflang', 'rel', 'role', 'tabindex', 'target', 'download', 'media', 'type', 'border', 'name'], + time: ['datetime'], + bdo: ['dir'], + ins: ['datetime'], + del: ['datetime'], + source: ['src', 'srcset', 'sizes', 'media', 'type', 'kind', 'label', 'srclang'], + track: ['src', 'default', 'kind', 'label', 'srclang'], + svg: ['*'], + g: ['*'], + glyph: ['*'], + glyphref: ['*'], + marker: ['*'], + path: ['*'], + view: ['*'], + circle: ['*'], + line: ['*'], + polygon: ['*'], + polyline: ['*'], + rect: ['*'], + text: ['*'], + textpath: ['*'], + tref: ['*'], + tspan: ['*'], + clippath: ['*'], + filter: ['*'], + hkern: ['*'], + lineargradient: ['*'], + mask: ['*'], + pattern: ['*'], + radialgradient: ['*'], + stop: ['*'], + vkern: ['*'], + defs: ['*'], + symbol: ['*'], + use: ['*'], + foreignobject: ['*'], + desc: ['*'], + title: ['*'], + table: ['sortable', 'align', 'border', 'bgcolor', 'cellpadding', 'cellspacing', 'width'], + colgroup: ['span'], + col: ['span'], + tr: ['align', 'bgcolor', 'height', 'valign'], + td: ['align', 'bgcolor', 'height', 'valign', 'colspan', 'headers', 'rowspan'], + th: ['align', 'bgcolor', 'height', 'valign', 'colspan', 'headers', 'rowspan', 'abbr', 'scope', 'sorted'], + button: ['disabled', 'name', 'role', 'tabindex', 'type', 'value', 'formtarget'], + // built ins + 'amp-img': ['media', 'noloading', 'alt', 'attribution', 'placeholder', 'src', 'srcset', 'width', 'height', 'layout'], + 'amp-pixel': ['src'], + 'amp-video': ['src', 'srcset', 'media', 'noloading', 'width', 'height', 'layout', 'alt', 'attribution', + 'autoplay', 'controls', 'loop', 'muted', 'poster', 'preload'], + 'amp-embed': ['media', 'noloading', 'width', 'height', 'layout', 'type', 'data-*', 'json'], + 'amp-ad': ['media', 'noloading', 'width', 'height', 'layout', 'type', 'data-*', 'json'], + // extended components we support + 'amp-anim': ['media', 'noloading', 'alt', 'attribution', 'placeholder', 'src', 'srcset', 'width', 'height', 'layout'], + 'amp-audio': ['src', 'width', 'height', 'autoplay', 'loop', 'muted', 'controls'], + 'amp-iframe': ['src', 'srcdoc', 'width', 'height', 'layout', 'frameborder', 'allowfullscreen', 'allowtransparency', + 'sandbox', 'referrerpolicy'] +}; + +function getAmperizeHTML(html, post) { + if (!html) { + return; + } + + // make relative URLs abolute + html = makeAbsoluteUrl(html, config.url, post.url).html(); + + if (!amperizeCache[post.id] || moment(new Date(amperizeCache[post.id].updated_at)).diff(new Date(post.updated_at)) < 0) { + return new Promise(function (resolve) { + amperize.parse(html, function (err, res) { + if (err) { + if (err.src) { + errors.logError(err.message, 'AMP HTML couldn\'t get parsed: ' + err.src); + } else { + errors.logError(err); + } + + // save it in cache to prevent multiple calls to Amperize until + // content is updated. + amperizeCache[post.id] = {updated_at: post.updated_at, amp: html}; + // return the original html on an error + return resolve(html); + } + + amperizeCache[post.id] = {updated_at: post.updated_at, amp: res}; + return resolve(amperizeCache[post.id].amp); + }); + }); + } + + return Promise.resolve(amperizeCache[post.id].amp); +} + +function ampContent() { + var amperizeHTML = { + amperize: getAmperizeHTML(this.html, this) + }; + + return Promise.props(amperizeHTML).then(function (result) { + var $; + + // our Amperized HTML + ampHTML = result.amperize || ''; + + // Use cheerio to traverse through HTML and make little clean-ups + $ = cheerio.load(ampHTML); + + // We have to remove source children in video, as source + // is whitelisted for audio, but causes validation + // errors in video, because video will be stripped out. + // @TODO: remove this, when Amperize support video transform + $('video').children('source').remove(); + $('video').children('track').remove(); + + // Case: AMP parsing failed and we returned the regular HTML, + // then we have to remove remaining, invalid HTML tags. + $('audio').children('source').remove(); + $('audio').children('track').remove(); + + ampHTML = $.html(); + + // @TODO: remove this, when Amperize supports HTML sanitizing + cleanHTML = sanitizeHtml(ampHTML, { + allowedTags: allowedAMPTags, + allowedAttributes: allowedAMPAttributes, + selfClosing: ['source', 'track'] + }); + + return new hbs.handlebars.SafeString(cleanHTML); + }); +} + +module.exports = ampContent; diff --git a/core/server/apps/amp/lib/helpers/index.js b/core/server/apps/amp/lib/helpers/index.js new file mode 100644 index 0000000..62002f8 --- /dev/null +++ b/core/server/apps/amp/lib/helpers/index.js @@ -0,0 +1,16 @@ +var ampContentHelper = require('./amp_content'), + ampComponentsHelper = require('./amp_components'), + registerAsyncThemeHelper = require('../../../../helpers').registerAsyncThemeHelper, + ghostHead = require('../../../../helpers/ghost_head'), + registerAmpHelpers; + +registerAmpHelpers = function (ghost) { + ghost.helpers.registerAsync('amp_content', ampContentHelper); + + ghost.helpers.register('amp_components', ampComponentsHelper); + + // we use the {{ghost_head}} helper, but call it {{amp_ghost_head}}, so it's consistent + registerAsyncThemeHelper('amp_ghost_head', ghostHead); +}; + +module.exports = registerAmpHelpers; diff --git a/core/server/apps/amp/lib/router.js b/core/server/apps/amp/lib/router.js new file mode 100644 index 0000000..28d81fe --- /dev/null +++ b/core/server/apps/amp/lib/router.js @@ -0,0 +1,85 @@ +var path = require('path'), + express = require('express'), + _ = require('lodash'), + ampRouter = express.Router(), + + // Dirty requires + config = require('../../../config'), + errors = require('../../../errors'), + templates = require('../../../controllers/frontend/templates'), + postLookup = require('../../../controllers/frontend/post-lookup'), + setResponseContext = require('../../../controllers/frontend/context'); + +function controller(req, res, next) { + var defaultView = path.resolve(__dirname, 'views', 'amp.hbs'), + paths = templates.getActiveThemePaths(req.app.get('activeTheme')), + data = req.body; + + if (res.error) { + data.error = res.error; + } + + setResponseContext(req, res, data); + + // we have to check the context. Our context must be ['post', 'amp'], otherwise we won't render the template + if (_.includes(res.locals.context, 'post') && _.includes(res.locals.context, 'amp')) { + if (paths.hasOwnProperty('amp.hbs')) { + return res.render('amp', data); + } else { + return res.render(defaultView, data); + } + } else { + return next(); + } +} + +function getPostData(req, res, next) { + postLookup(res.locals.relativeUrl) + .then(function (result) { + if (result && result.post) { + req.body.post = result.post; + } + + next(); + }) + .catch(function (err) { + next(err); + }); +} + +function checkIfAMPIsEnabled(req, res, next) { + var ampIsEnabled = config.theme.amp; + + if (ampIsEnabled) { + return next(); + } + + // CASE: we don't support amp pages for static pages + if (req.body.post && req.body.post.page) { + return errors.error404(req, res, next); + } + + /** + * CASE: amp is disabled, we serve 404 + * + * Alternatively we could redirect to the original post, as the user can enable/disable AMP every time. + * + * If we would call `next()`, express jumps to the frontend controller (server/controllers/frontend/index.js fn single) + * and tries to lookup the post (again) and checks whether the post url equals the requested url (post.url !== req.path). + * This check would fail if the blog is setup on a subdirectory. + */ + errors.error404(req, res, next); +} + +// AMP frontend route +ampRouter.route('/') + .get( + getPostData, + checkIfAMPIsEnabled, + controller + ); + +module.exports = ampRouter; +module.exports.controller = controller; +module.exports.getPostData = getPostData; +module.exports.checkIfAMPIsEnabled = checkIfAMPIsEnabled; diff --git a/core/server/apps/amp/lib/views/amp.hbs b/core/server/apps/amp/lib/views/amp.hbs new file mode 100644 index 0000000..f0f2c77 --- /dev/null +++ b/core/server/apps/amp/lib/views/amp.hbs @@ -0,0 +1,832 @@ + + + + {{!-- Document Settings --}} + + + {{!-- Page Meta --}} + {{meta_title}} + + + {{!-- Mobile Meta --}} + + + + {{!-- Brand icon --}} + + + {{amp_ghost_head}} + + {{!-- Styles'n'Scripts --}} + + + + {{!-- The AMP boilerplate --}} + + + + {{amp_components}} + + + + + {{#post}} +
+ +
+ +
+
+ +
+

{{title}}

+ +
+ {{#if image}} +
+ +
+ {{/if}} +
+ + {{amp_content}} + +
+ +
+
+ {{/post}} + + + diff --git a/core/server/apps/amp/tests/amp_components_spec.js b/core/server/apps/amp/tests/amp_components_spec.js new file mode 100644 index 0000000..7b4bf87 --- /dev/null +++ b/core/server/apps/amp/tests/amp_components_spec.js @@ -0,0 +1,59 @@ +var should = require('should'), + +// Stuff we are testing + ampComponentsHelper = require('../lib/helpers/amp_components'); + +describe('{{amp_components}} helper', function () { + it('adds script tag for a gif', function () { + var post = { + html: 'yoda' + }, + rendered; + + rendered = ampComponentsHelper.call( + {relativeUrl: '/post/amp/', safeVersion: '0.3', context: ['amp', 'post'], post: post}, + {data: {root: {context: ['amp', 'post']}}}); + + should.exist(rendered); + rendered.should.match(/' + + '', + updated_at: 'Wed Jul 27 2016 18:17:22 GMT+0200 (CEST)', + id: 1 + }, + ampResult = ampContentHelper.call(testData); + + ampResult.then(function (rendered) { + should.exist(rendered); + rendered.string.should.be.equal(''); + done(); + }).catch(done); + }); + }); +}); diff --git a/core/server/apps/amp/tests/router_spec.js b/core/server/apps/amp/tests/router_spec.js new file mode 100644 index 0000000..3e52bad --- /dev/null +++ b/core/server/apps/amp/tests/router_spec.js @@ -0,0 +1,216 @@ +/*globals describe, beforeEach, afterEach, it*/ +var rewire = require('rewire'), + ampController = rewire('../lib/router'), + path = require('path'), + sinon = require('sinon'), + Promise = require('bluebird'), + errors = require('../../../errors'), + should = require('should'), + configUtils = require('../../../../test/utils/configUtils'), + sandbox = sinon.sandbox.create(); + +// Helper function to prevent unit tests +// from failing via timeout when they +// should just immediately fail +function failTest(done) { + return function (err) { + done(err); + }; +} + +describe('AMP Controller', function () { + var res, + req, + defaultPath, + setResponseContextStub; + + beforeEach(function () { + res = { + render: sandbox.spy(), + locals: { + context: ['amp', 'post'] + } + }; + + req = { + app: {get: function () { return 'casper'; }}, + route: {path: '/'}, + query: {r: ''}, + params: {}, + body: {} + }; + + defaultPath = path.join(configUtils.config.paths.appRoot, '/core/server/apps/amp/lib/views/amp.hbs'); + + configUtils.set({ + theme: { + permalinks: '/:slug/' + } + }); + }); + + afterEach(function () { + sandbox.restore(); + configUtils.restore(); + }); + + it('should render default amp page when theme has no amp template', function (done) { + configUtils.set({paths: {availableThemes: {casper: {}}}}); + + setResponseContextStub = sandbox.stub(); + ampController.__set__('setResponseContext', setResponseContextStub); + + res.render = function (view) { + view.should.eql(defaultPath); + done(); + }; + + ampController.controller(req, res, failTest(done)); + }); + + it('should render theme amp page when theme has amp template', function (done) { + configUtils.set({paths: {availableThemes: {casper: { + 'amp.hbs': '/content/themes/casper/amp.hbs' + }}}}); + + setResponseContextStub = sandbox.stub(); + ampController.__set__('setResponseContext', setResponseContextStub); + + res.render = function (view) { + view.should.eql('amp'); + done(); + }; + + ampController.controller(req, res, failTest(done)); + }); + + it('should render with error when error is passed in', function (done) { + configUtils.set({paths: {availableThemes: {casper: {}}}}); + res.error = 'Test Error'; + + setResponseContextStub = sandbox.stub(); + ampController.__set__('setResponseContext', setResponseContextStub); + + res.render = function (view, context) { + view.should.eql(defaultPath); + context.should.eql({error: 'Test Error'}); + done(); + }; + + ampController.controller(req, res, failTest(done)); + }); + + it('does not render amp page when amp context is missing', function (done) { + var renderSpy; + configUtils.set({paths: {availableThemes: {casper: {}}}}); + + setResponseContextStub = sandbox.stub(); + ampController.__set__('setResponseContext', setResponseContextStub); + + res.locals.context = ['post']; + res.render = sandbox.spy(function () { + done(); + }); + + renderSpy = res.render; + + ampController.controller(req, res, failTest(done)); + renderSpy.called.should.be.false(); + }); + + it('does not render amp page when context is other than amp and post', function (done) { + var renderSpy; + configUtils.set({paths: {availableThemes: {casper: {}}}}); + + setResponseContextStub = sandbox.stub(); + ampController.__set__('setResponseContext', setResponseContextStub); + + res.locals.context = ['amp', 'page']; + res.render = sandbox.spy(function () { + done(); + }); + + renderSpy = res.render; + + ampController.controller(req, res, failTest(done)); + renderSpy.called.should.be.false(); + }); +}); + +describe('AMP getPostData', function () { + var res, req, postLookupStub, next; + + beforeEach(function () { + res = { + locals: { + relativeUrl: '/welcome-to-ghost/amp/' + } + }; + + req = { + body: { + post: {} + } + }; + + next = function () {}; + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should successfully get the post data from slug', function (done) { + postLookupStub = sandbox.stub(); + postLookupStub.returns(new Promise.resolve({ + post: { + id: '1', + slug: 'welcome-to-ghost', + isAmpURL: true + } + })); + + ampController.__set__('postLookup', postLookupStub); + + ampController.getPostData(req, res, function () { + req.body.post.should.be.eql({ + id: '1', + slug: 'welcome-to-ghost', + isAmpURL: true + } + ); + done(); + }); + }); + it('should return error if postlookup returns NotFoundError', function (done) { + postLookupStub = sandbox.stub(); + postLookupStub.returns(new Promise.reject(new errors.NotFoundError('not found'))); + + ampController.__set__('postLookup', postLookupStub); + + ampController.getPostData(req, res, function (err) { + should.exist(err); + should.exist(err.message); + should.exist(err.statusCode); + should.exist(err.errorType); + err.message.should.be.eql('not found'); + err.statusCode.should.be.eql(404); + err.errorType.should.be.eql('NotFoundError'); + req.body.post.should.be.eql({}); + done(); + }); + }); + it('should return error and if postlookup returns error', function (done) { + postLookupStub = sandbox.stub(); + postLookupStub.returns(new Promise.reject('not found')); + + ampController.__set__('postLookup', postLookupStub); + + ampController.getPostData(req, res, function (err) { + should.exist(err); + err.should.be.eql('not found'); + req.body.post.should.be.eql({}); + done(); + }); + }); +}); diff --git a/core/server/apps/dependencies.js b/core/server/apps/dependencies.js new file mode 100644 index 0000000..cfdf8f1 --- /dev/null +++ b/core/server/apps/dependencies.js @@ -0,0 +1,52 @@ + +var _ = require('lodash'), + fs = require('fs'), + path = require('path'), + Promise = require('bluebird'), + spawn = require('child_process').spawn, + win32 = process.platform === 'win32'; + +function AppDependencies(appPath) { + this.appPath = appPath; +} + +AppDependencies.prototype.install = function installAppDependencies() { + var spawnOpts, + self = this; + + return new Promise(function (resolve, reject) { + fs.stat(path.join(self.appPath, 'package.json'), function (err) { + if (err) { + // File doesn't exist - nothing to do, resolve right away? + resolve(); + } else { + // Run npm install in the app directory + spawnOpts = { + cwd: self.appPath + }; + + self.spawnCommand('npm', ['install', '--production'], spawnOpts) + .on('error', reject) + .on('exit', function (err) { + if (err) { + reject(err); + } + + resolve(); + }); + } + }); + }); +}; + +// Normalize a command across OS and spawn it; taken from yeoman/generator +AppDependencies.prototype.spawnCommand = function (command, args, opt) { + var winCommand = win32 ? 'cmd' : command, + winArgs = win32 ? ['/c'].concat(command, args) : args; + + opt = opt || {}; + + return spawn(winCommand, winArgs, _.defaults({stdio: 'inherit'}, opt)); +}; + +module.exports = AppDependencies; diff --git a/core/server/apps/index.js b/core/server/apps/index.js new file mode 100644 index 0000000..2d63342 --- /dev/null +++ b/core/server/apps/index.js @@ -0,0 +1,100 @@ + +var _ = require('lodash'), + Promise = require('bluebird'), + errors = require('../errors'), + api = require('../api'), + loader = require('./loader'), + i18n = require('../i18n'), + config = require('../config'), + // Holds the available apps + availableApps = {}; + +function getInstalledApps() { + return api.settings.read({context: {internal: true}, key: 'installedApps'}).then(function (response) { + var installed = response.settings[0]; + + installed.value = installed.value || '[]'; + + try { + installed = JSON.parse(installed.value); + } catch (e) { + return Promise.reject(e); + } + + return installed.concat(config.internalApps); + }); +} + +function saveInstalledApps(installedApps) { + return getInstalledApps().then(function (currentInstalledApps) { + var updatedAppsInstalled = _.difference(_.uniq(installedApps.concat(currentInstalledApps)), config.internalApps); + + return api.settings.edit({settings: [{key: 'installedApps', value: updatedAppsInstalled}]}, {context: {internal: true}}); + }); +} + +module.exports = { + init: function () { + var appsToLoad; + + try { + // We have to parse the value because it's a string + api.settings.read({context: {internal: true}, key: 'activeApps'}).then(function (response) { + var aApps = response.settings[0]; + + appsToLoad = JSON.parse(aApps.value) || []; + + appsToLoad = appsToLoad.concat(config.internalApps); + }); + } catch (e) { + errors.logError( + i18n.t('errors.apps.failedToParseActiveAppsSettings.error', {message: e.message}), + i18n.t('errors.apps.failedToParseActiveAppsSettings.context'), + i18n.t('errors.apps.failedToParseActiveAppsSettings.help') + ); + + return Promise.resolve(); + } + + // Grab all installed apps, install any not already installed that are in appsToLoad. + return getInstalledApps().then(function (installedApps) { + var loadedApps = {}, + recordLoadedApp = function (name, loadedApp) { + // After loading the app, add it to our hash of loaded apps + loadedApps[name] = loadedApp; + + return Promise.resolve(loadedApp); + }, + loadPromises = _.map(appsToLoad, function (app) { + // If already installed, just activate the app + if (_.includes(installedApps, app)) { + return loader.activateAppByName(app).then(function (loadedApp) { + return recordLoadedApp(app, loadedApp); + }); + } + + // Install, then activate the app + return loader.installAppByName(app).then(function () { + return loader.activateAppByName(app); + }).then(function (loadedApp) { + return recordLoadedApp(app, loadedApp); + }); + }); + + return Promise.all(loadPromises).then(function () { + // Save our installed apps to settings + return saveInstalledApps(_.keys(loadedApps)); + }).then(function () { + // Extend the loadedApps onto the available apps + _.extend(availableApps, loadedApps); + }).catch(function (err) { + errors.logError( + err.message || err, + i18n.t('errors.apps.appWillNotBeLoaded.error'), + i18n.t('errors.apps.appWillNotBeLoaded.help') + ); + }); + }); + }, + availableApps: availableApps +}; diff --git a/core/server/apps/loader.js b/core/server/apps/loader.js new file mode 100644 index 0000000..6644a3e --- /dev/null +++ b/core/server/apps/loader.js @@ -0,0 +1,126 @@ + +var path = require('path'), + _ = require('lodash'), + Promise = require('bluebird'), + AppProxy = require('./proxy'), + config = require('../config'), + AppSandbox = require('./sandbox'), + AppDependencies = require('./dependencies'), + AppPermissions = require('./permissions'), + i18n = require('../i18n'), + loader; + +function isInternalApp(name) { + return _.includes(config.internalApps, name); +} + +// Get the full path to an app by name +function getAppAbsolutePath(name) { + if (isInternalApp(name)) { + return path.join(config.paths.internalAppPath, name); + } + + return path.join(config.paths.appPath, name); +} + +// Get a relative path to the given apps root, defaults +// to be relative to __dirname +function getAppRelativePath(name, relativeTo) { + relativeTo = relativeTo || __dirname; + + var relativePath = path.relative(relativeTo, getAppAbsolutePath(name)); + + if (relativePath.charAt(0) !== '.') { + relativePath = './' + relativePath; + } + + return relativePath; +} + +// Load apps through a pseudo sandbox +function loadApp(appPath, isInternal) { + var sandbox = new AppSandbox({internal: isInternal}); + + return sandbox.loadApp(appPath); +} + +function getAppByName(name, permissions) { + // Grab the app class to instantiate + var AppClass = loadApp(getAppRelativePath(name), isInternalApp(name)), + appProxy = new AppProxy({ + name: name, + permissions: permissions, + internal: isInternalApp(name) + }), + app; + + // Check for an actual class, otherwise just use whatever was returned + if (_.isFunction(AppClass)) { + app = new AppClass(appProxy); + } else { + app = AppClass; + } + + return { + app: app, + proxy: appProxy + }; +} + +// The loader is responsible for loading apps +loader = { + // Load a app and return the instantiated app + installAppByName: function (name) { + // Install the apps dependencies first + var appPath = getAppAbsolutePath(name), + deps = new AppDependencies(appPath); + + return deps.install() + .then(function () { + // Load app permissions + var perms = new AppPermissions(appPath); + + return perms.read().catch(function (err) { + // Provide a helpful error about which app + return Promise.reject(new Error(i18n.t('errors.apps.permissionsErrorLoadingApp.error', {name: name, message: err.message}))); + }); + }) + .then(function (appPerms) { + var appInfo = getAppByName(name, appPerms), + app = appInfo.app, + appProxy = appInfo.proxy; + + // Check for an install() method on the app. + if (!_.isFunction(app.install)) { + return Promise.reject(new Error(i18n.t('errors.apps.noInstallMethodLoadingApp.error', {name: name}))); + } + + // Run the app.install() method + // Wrapping the install() with a when because it's possible + // to not return a promise from it. + return Promise.resolve(app.install(appProxy)).return(app); + }); + }, + + // Activate a app and return it + activateAppByName: function (name) { + var perms = new AppPermissions(getAppAbsolutePath(name)); + + return perms.read().then(function (appPerms) { + var appInfo = getAppByName(name, appPerms), + app = appInfo.app, + appProxy = appInfo.proxy; + + // Check for an activate() method on the app. + if (!_.isFunction(app.activate)) { + return Promise.reject(new Error(i18n.t('errors.apps.noActivateMethodLoadingApp.error', {name: name}))); + } + + // Wrapping the activate() with a when because it's possible + // to not return a promise from it. + return Promise.resolve(app.activate(appProxy)).return(app); + }); + } +}; + +module.exports = loader; diff --git a/core/server/apps/permissions.js b/core/server/apps/permissions.js new file mode 100644 index 0000000..900222c --- /dev/null +++ b/core/server/apps/permissions.js @@ -0,0 +1,56 @@ +var fs = require('fs'), + Promise = require('bluebird'), + path = require('path'), + parsePackageJson = require('../utils/packages').parsePackageJSON; + +function AppPermissions(appPath) { + this.appPath = appPath; + this.packagePath = path.join(this.appPath, 'package.json'); +} + +AppPermissions.prototype.read = function () { + var self = this; + + return this.checkPackageContentsExists().then(function (exists) { + if (!exists) { + // If no package.json, return default permissions + return Promise.resolve(AppPermissions.DefaultPermissions); + } + + // Read and parse the package.json + return self.getPackageContents().then(function (parsed) { + // If no permissions in the package.json then return the default permissions. + if (!(parsed.ghost && parsed.ghost.permissions)) { + return Promise.resolve(AppPermissions.DefaultPermissions); + } + + // TODO: Validation on permissions object? + + return Promise.resolve(parsed.ghost.permissions); + }); + }); +}; + +AppPermissions.prototype.checkPackageContentsExists = function () { + var self = this; + + // Mostly just broken out for stubbing in unit tests + return new Promise(function (resolve) { + fs.stat(self.packagePath, function (err) { + var exists = !err; + resolve(exists); + }); + }); +}; + +// Get the contents of the package.json in the appPath root +AppPermissions.prototype.getPackageContents = function () { + return parsePackageJson(this.packagePath); +}; + +// Default permissions for an App. +AppPermissions.DefaultPermissions = { + posts: ['browse', 'read'] +}; + +module.exports = AppPermissions; diff --git a/core/server/apps/private-blogging/index.js b/core/server/apps/private-blogging/index.js new file mode 100644 index 0000000..d8d16f6 --- /dev/null +++ b/core/server/apps/private-blogging/index.js @@ -0,0 +1,30 @@ +var config = require('../../config'), + errors = require('../../errors'), + i18n = require('../../i18n'), + middleware = require('./lib/middleware'), + router = require('./lib/router'); + +module.exports = { + activate: function activate() { + if (config.paths.subdir) { + var paths = config.paths.subdir.split('/'); + + if (paths.pop() === config.routeKeywords.private) { + errors.logErrorAndExit( + new Error(i18n.t('errors.config.urlCannotContainPrivateSubdir.error')), + i18n.t('errors.config.urlCannotContainPrivateSubdir.description'), + i18n.t('errors.config.urlCannotContainPrivateSubdir.help') + ); + } + } + }, + + setupMiddleware: function setupMiddleware(blogApp) { + blogApp.use(middleware.checkIsPrivate); + blogApp.use(middleware.filterPrivateRoutes); + }, + + setupRoutes: function setupRoutes(blogRouter) { + blogRouter.use('/' + config.routeKeywords.private + '/', router); + } +}; diff --git a/core/server/apps/private-blogging/lib/middleware.js b/core/server/apps/private-blogging/lib/middleware.js new file mode 100644 index 0000000..c89c6e4 --- /dev/null +++ b/core/server/apps/private-blogging/lib/middleware.js @@ -0,0 +1,181 @@ +var _ = require('lodash'), + fs = require('fs'), + config = require('../../../config'), + crypto = require('crypto'), + path = require('path'), + api = require('../../../api'), + Promise = require('bluebird'), + errors = require('../../../errors'), + session = require('cookie-session'), + utils = require('../../../utils'), + i18n = require('../../../i18n'), + privateRoute = '/' + config.routeKeywords.private + '/', + protectedSecurity = [], + privateBlogging; + +function verifySessionHash(salt, hash) { + if (!salt || !hash) { + return Promise.resolve(false); + } + + return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) { + var hasher = crypto.createHash('sha256'); + + hasher.update(response.settings[0].value + salt, 'utf8'); + + return hasher.digest('hex') === hash; + }); +} + +privateBlogging = { + checkIsPrivate: function checkIsPrivate(req, res, next) { + return api.settings.read({context: {internal: true}, key: 'isPrivate'}).then(function then(response) { + var pass = response.settings[0]; + + if (_.isEmpty(pass.value) || pass.value === 'false') { + res.isPrivateBlog = false; + return next(); + } + + res.isPrivateBlog = true; + + return session({ + maxAge: utils.ONE_MONTH_MS, + signed: false + })(req, res, next); + }); + }, + + filterPrivateRoutes: function filterPrivateRoutes(req, res, next) { + if (res.isAdmin || !res.isPrivateBlog || req.url.lastIndexOf(privateRoute, 0) === 0) { + return next(); + } + + // take care of rss and sitemap 404s + if (req.path.lastIndexOf('/rss/', 0) === 0 || + req.path.lastIndexOf('/rss/') === req.url.length - 5 || + (req.path.lastIndexOf('/sitemap', 0) === 0 && req.path.lastIndexOf('.xml') === req.path.length - 4)) { + return errors.error404(req, res, next); + } else if (req.url.lastIndexOf('/robots.txt', 0) === 0) { + fs.readFile(path.resolve(__dirname, '../', 'robots.txt'), function readFile(err, buf) { + if (err) { + return next(err); + } + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Content-Length': buf.length, + 'Cache-Control': 'public, max-age=' + utils.ONE_HOUR_MS + }); + res.end(buf); + }); + } else { + return privateBlogging.authenticatePrivateSession(req, res, next); + } + }, + + authenticatePrivateSession: function authenticatePrivateSession(req, res, next) { + var hash = req.session.token || '', + salt = req.session.salt || '', + url; + + return verifySessionHash(salt, hash).then(function then(isVerified) { + if (isVerified) { + return next(); + } else { + url = config.urlFor({relativeUrl: privateRoute}); + url += req.url === '/' ? '' : '?r=' + encodeURIComponent(req.url); + return res.redirect(url); + } + }); + }, + + // This is here so a call to /private/ after a session is verified will redirect to home; + isPrivateSessionAuth: function isPrivateSessionAuth(req, res, next) { + if (!res.isPrivateBlog) { + return res.redirect(config.urlFor('home', true)); + } + + var hash = req.session.token || '', + salt = req.session.salt || ''; + + return verifySessionHash(salt, hash).then(function then(isVerified) { + if (isVerified) { + // redirect to home if user is already authenticated + return res.redirect(config.urlFor('home', true)); + } else { + return next(); + } + }); + }, + + authenticateProtection: function authenticateProtection(req, res, next) { + // if errors have been generated from the previous call + if (res.error) { + return next(); + } + + var bodyPass = req.body.password; + + return api.settings.read({context: {internal: true}, key: 'password'}).then(function then(response) { + var pass = response.settings[0], + hasher = crypto.createHash('sha256'), + salt = Date.now().toString(), + forward = req.query && req.query.r ? req.query.r : '/'; + + if (pass.value === bodyPass) { + hasher.update(bodyPass + salt, 'utf8'); + req.session.token = hasher.digest('hex'); + req.session.salt = salt; + + return res.redirect(config.urlFor({relativeUrl: decodeURIComponent(forward)})); + } else { + res.error = { + message: i18n.t('errors.middleware.privateblogging.wrongPassword') + }; + return next(); + } + }); + }, + + spamPrevention: function spamPrevention(req, res, next) { + var currentTime = process.hrtime()[0], + remoteAddress = req.connection.remoteAddress, + rateProtectedPeriod = config.rateProtectedPeriod || 3600, + rateProtectedAttempts = config.rateProtectedAttempts || 10, + ipCount = '', + message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'), + deniedRateLimit = '', + password = req.body.password; + + if (password) { + protectedSecurity.push({ip: remoteAddress, time: currentTime}); + } else { + res.error = { + message: i18n.t('errors.middleware.spamprevention.noPassword') + }; + return next(); + } + + // filter entries that are older than rateProtectedPeriod + protectedSecurity = _.filter(protectedSecurity, function filter(logTime) { + return (logTime.time + rateProtectedPeriod > currentTime); + }); + + ipCount = _.chain(protectedSecurity).countBy('ip').value(); + deniedRateLimit = (ipCount[remoteAddress] > rateProtectedAttempts); + + if (deniedRateLimit) { + errors.logError( + i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateProtectedAttempts, rfp: rateProtectedPeriod}), + i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context') + ); + message += rateProtectedPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'); + res.error = { + message: message + }; + } + return next(); + } +}; + +module.exports = privateBlogging; diff --git a/core/server/apps/private-blogging/lib/router.js b/core/server/apps/private-blogging/lib/router.js new file mode 100644 index 0000000..e5789f2 --- /dev/null +++ b/core/server/apps/private-blogging/lib/router.js @@ -0,0 +1,39 @@ +var path = require('path'), + express = require('express'), + middleware = require('./middleware'), + templates = require('../../../controllers/frontend/templates'), + setResponseContext = require('../../../controllers/frontend/context'), + privateRouter = express.Router(); + +function controller(req, res) { + var defaultView = path.resolve(__dirname, 'views', 'private.hbs'), + paths = templates.getActiveThemePaths(req.app.get('activeTheme')), + data = {}; + + if (res.error) { + data.error = res.error; + } + + setResponseContext(req, res); + if (paths.hasOwnProperty('private.hbs')) { + return res.render('private', data); + } else { + return res.render(defaultView, data); + } +} + +// password-protected frontend route +privateRouter.route('/') + .get( + middleware.isPrivateSessionAuth, + controller + ) + .post( + middleware.isPrivateSessionAuth, + middleware.spamPrevention, + middleware.authenticateProtection, + controller + ); + +module.exports = privateRouter; +module.exports.controller = controller; diff --git a/core/server/apps/private-blogging/lib/views/private.hbs b/core/server/apps/private-blogging/lib/views/private.hbs new file mode 100644 index 0000000..d37d940 --- /dev/null +++ b/core/server/apps/private-blogging/lib/views/private.hbs @@ -0,0 +1,49 @@ + + + + + + + + Ghost - Private Blog Access + + + + + + + + + + + + +
+
+
+
+
+
+
+

This blog is private

+
+ + + {{#if error}} +

{{error.message}}

+ {{/if}} +
+
+
+
+
+
+ + diff --git a/core/server/apps/private-blogging/robots.txt b/core/server/apps/private-blogging/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/core/server/apps/private-blogging/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/core/server/apps/private-blogging/tests/controller_spec.js b/core/server/apps/private-blogging/tests/controller_spec.js new file mode 100644 index 0000000..f5bbe57 --- /dev/null +++ b/core/server/apps/private-blogging/tests/controller_spec.js @@ -0,0 +1,83 @@ +/*globals describe, beforeEach, afterEach, it*/ +var privateController = require('../lib/router').controller, + path = require('path'), + sinon = require('sinon'), + configUtils = require('../../../../test/utils/configUtils'), + sandbox = sinon.sandbox.create(); + +describe('Private Controller', function () { + var res, req, defaultPath; + + // Helper function to prevent unit tests + // from failing via timeout when they + // should just immediately fail + function failTest(done) { + return function (err) { + done(err); + }; + } + + beforeEach(function () { + res = { + locals: {version: ''}, + render: sandbox.spy() + }; + + req = { + app: {get: function () { return 'casper'; }}, + route: {path: '/private/?r=/'}, + query: {r: ''}, + params: {} + }; + + defaultPath = path.join(configUtils.config.paths.appRoot, '/core/server/apps/private-blogging/lib/views/private.hbs'); + + configUtils.set({ + theme: { + permalinks: '/:slug/' + } + }); + }); + + afterEach(function () { + sandbox.restore(); + configUtils.restore(); + }); + + it('Should render default password page when theme has no password template', function (done) { + configUtils.set({paths: {availableThemes: {casper: {}}}}); + + res.render = function (view) { + view.should.eql(defaultPath); + done(); + }; + + privateController(req, res, failTest(done)); + }); + + it('Should render theme password page when it exists', function (done) { + configUtils.set({paths: {availableThemes: {casper: { + 'private.hbs': '/content/themes/casper/private.hbs' + }}}}); + + res.render = function (view) { + view.should.eql('private'); + done(); + }; + + privateController(req, res, failTest(done)); + }); + + it('Should render with error when error is passed in', function (done) { + configUtils.set({paths: {availableThemes: {casper: {}}}}); + res.error = 'Test Error'; + + res.render = function (view, context) { + view.should.eql(defaultPath); + context.should.eql({error: 'Test Error'}); + done(); + }; + + privateController(req, res, failTest(done)); + }); +}); diff --git a/core/server/apps/private-blogging/tests/middleware_spec.js b/core/server/apps/private-blogging/tests/middleware_spec.js new file mode 100644 index 0000000..507f1e3 --- /dev/null +++ b/core/server/apps/private-blogging/tests/middleware_spec.js @@ -0,0 +1,344 @@ +/*globals describe, beforeEach, afterEach, before, it*/ +var crypto = require('crypto'), + should = require('should'), + sinon = require('sinon'), + Promise = require('bluebird'), + privateBlogging = require('../lib/middleware'), + api = require('../../../api'), + errors = require('../../../errors'), + fs = require('fs'); + +should.equal(true, true); + +function hash(password, salt) { + var hasher = crypto.createHash('sha256'); + + hasher.update(password + salt, 'utf8'); + + return hasher.digest('hex'); +} + +describe('Private Blogging', function () { + var sandbox, + apiSettingsStub; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('passProtect', function () { + var req, res, next; + + beforeEach(function () { + req = {}; + res = {}; + apiSettingsStub = sandbox.stub(api.settings, 'read'); + next = sinon.spy(); + }); + + it('checkIsPrivate should call next if not private', function (done) { + apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({ + settings: [{ + key: 'isPrivate', + value: 'false' + }] + })); + + privateBlogging.checkIsPrivate(req, res, next).then(function () { + next.called.should.be.true(); + res.isPrivateBlog.should.be.false(); + + done(); + }).catch(done); + }); + + it('checkIsPrivate should load session if private', function (done) { + apiSettingsStub.withArgs(sinon.match.has('key', 'isPrivate')).returns(Promise.resolve({ + settings: [{ + key: 'isPrivate', + value: 'true' + }] + })); + + privateBlogging.checkIsPrivate(req, res, next).then(function () { + res.isPrivateBlog.should.be.true(); + + done(); + }).catch(done); + }); + + describe('not private', function () { + beforeEach(function () { + res.isPrivateBlog = false; + }); + + it('filterPrivateRoutes should call next if not private', function () { + privateBlogging.filterPrivateRoutes(req, res, next); + next.called.should.be.true(); + }); + + it('isPrivateSessionAuth should redirect if blog is not private', function () { + res = { + redirect: sinon.spy(), + isPrivateBlog: false + }; + privateBlogging.isPrivateSessionAuth(req, res, next); + res.redirect.called.should.be.true(); + }); + }); + + describe('private', function () { + var errorSpy; + + beforeEach(function () { + res.isPrivateBlog = true; + errorSpy = sandbox.spy(errors, 'error404'); + res = { + status: function () { + return this; + }, + send: function () {}, + set: function () {}, + isPrivateBlog: true + }; + }); + + it('filterPrivateRoutes should call next if admin', function () { + res.isAdmin = true; + privateBlogging.filterPrivateRoutes(req, res, next); + next.called.should.be.true(); + }); + + it('filterPrivateRoutes should call next if is the "private" route', function () { + req.path = req.url = '/private/'; + privateBlogging.filterPrivateRoutes(req, res, next); + next.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is sitemap', function () { + req.path = req.url = '/sitemap.xml'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is sitemap with param', function () { + req.url = '/sitemap.xml?weird=param'; + req.path = '/sitemap.xml'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is rss', function () { + req.path = req.url = '/rss/'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is author rss', function () { + req.path = req.url = '/author/halfdan/rss/'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is tag rss', function () { + req.path = req.url = '/tag/slimer/rss/'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should throw 404 if url is rss plus something', function () { + req.path = req.url = '/rss/sometag'; + privateBlogging.filterPrivateRoutes(req, res, next); + errorSpy.called.should.be.true(); + }); + + it('filterPrivateRoutes should render custom robots.txt', function () { + req.url = req.path = '/robots.txt'; + res.writeHead = sinon.spy(); + res.end = sinon.spy(); + sandbox.stub(fs, 'readFile', function (file, cb) { + cb(null, 'User-agent: * Disallow: /'); + }); + privateBlogging.filterPrivateRoutes(req, res, next); + res.writeHead.called.should.be.true(); + res.end.called.should.be.true(); + }); + + it('authenticateProtection should call next if error', function () { + res.error = 'Test Error'; + privateBlogging.authenticateProtection(req, res, next); + next.called.should.be.true(); + }); + + describe('with hash verification', function () { + beforeEach(function () { + apiSettingsStub.withArgs(sinon.match.has('key', 'password')).returns(Promise.resolve({ + settings: [{ + key: 'password', + value: 'rightpassword' + }] + })); + }); + + it('authenticatePrivateSession should return next if hash is verified', function (done) { + var salt = Date.now().toString(); + + req.session = { + token: hash('rightpassword', salt), + salt: salt + }; + + privateBlogging.authenticatePrivateSession(req, res, next).then(function () { + next.called.should.be.true(); + + done(); + }).catch(done); + }); + + it('authenticatePrivateSession should redirect if hash is not verified', function (done) { + req.url = '/welcome-to-ghost'; + req.session = { + token: 'wrongpassword', + salt: Date.now().toString() + }; + res.redirect = sinon.spy(); + + privateBlogging.authenticatePrivateSession(req, res, next).then(function () { + res.redirect.called.should.be.true(); + + done(); + }).catch(done); + }); + + it('isPrivateSessionAuth should redirect if hash is verified', function (done) { + var salt = Date.now().toString(); + + req.session = { + token: hash('rightpassword', salt), + salt: salt + }; + res.redirect = sandbox.spy(); + + privateBlogging.isPrivateSessionAuth(req, res, next).then(function () { + res.redirect.called.should.be.true(); + + done(); + }).catch(done); + }); + + it('isPrivateSessionAuth should return next if hash is not verified', function (done) { + req.session = { + token: 'wrongpassword', + salt: Date.now().toString() + }; + + privateBlogging.isPrivateSessionAuth(req, res, next).then(function () { + next.called.should.be.true(); + + done(); + }).catch(done); + }); + + it('authenticateProtection should return next if password is incorrect', function (done) { + req.body = {password: 'wrongpassword'}; + + privateBlogging.authenticateProtection(req, res, next).then(function () { + res.error.should.not.be.empty(); + next.called.should.be.true(); + + done(); + }).catch(done); + }); + + it('authenticateProtection should redirect if password is correct', function (done) { + req.body = {password: 'rightpassword'}; + req.session = {}; + res.redirect = sandbox.spy(); + + privateBlogging.authenticateProtection(req, res, next).then(function () { + res.redirect.called.should.be.true(); + + done(); + }).catch(done); + }); + }); + }); + }); + + describe('spamPrevention', function () { + var error = null, + res, req, spyNext; + + before(function () { + spyNext = sinon.spy(function (param) { + error = param; + }); + }); + + beforeEach(function () { + res = sinon.spy(); + req = { + connection: { + remoteAddress: '10.0.0.0' + }, + body: { + password: 'password' + } + }; + }); + + it ('sets an error when there is no password', function (done) { + req.body = {}; + + privateBlogging.spamPrevention(req, res, spyNext); + res.error.message.should.equal('No password entered'); + spyNext.calledOnce.should.be.true(); + + done(); + }); + + it ('sets and error message after 10 tries', function (done) { + var ndx; + + for (ndx = 0; ndx < 10; ndx = ndx + 1) { + privateBlogging.spamPrevention(req, res, spyNext); + } + + should.not.exist(res.error); + privateBlogging.spamPrevention(req, res, spyNext); + should.exist(res.error); + should.exist(res.error.message); + + done(); + }); + + it ('allows more tries after an hour', function (done) { + var ndx, + stub = sinon.stub(process, 'hrtime', function () { + return [10, 10]; + }); + + for (ndx = 0; ndx < 11; ndx = ndx + 1) { + privateBlogging.spamPrevention(req, res, spyNext); + } + + should.exist(res.error); + process.hrtime.restore(); + stub = sinon.stub(process, 'hrtime', function () { + return [3610000, 10]; + }); + + res = sinon.spy(); + + privateBlogging.spamPrevention(req, res, spyNext); + should.not.exist(res.error); + + process.hrtime.restore(); + done(); + }); + }); +}); diff --git a/core/server/apps/proxy.js b/core/server/apps/proxy.js new file mode 100644 index 0000000..e8558b3 --- /dev/null +++ b/core/server/apps/proxy.js @@ -0,0 +1,103 @@ +var _ = require('lodash'), + api = require('../api'), + helpers = require('../helpers'), + filters = require('../filters'), + i18n = require('../i18n'), + generateProxyFunctions; + +generateProxyFunctions = function (name, permissions, isInternal) { + var getPermission = function (perm) { + return permissions[perm]; + }, + getPermissionToMethod = function (perm, method) { + var perms = getPermission(perm); + + if (!perms) { + return false; + } + + return _.find(perms, function (name) { + return name === method; + }); + }, + runIfPermissionToMethod = function (perm, method, wrappedFunc, context, args) { + // internal apps get all permissions + if (isInternal) { + return wrappedFunc.apply(context, args); + } + + var permValue = getPermissionToMethod(perm, method); + + if (!permValue) { + throw new Error(i18n.t('errors.apps.accessResourceWithoutPermission.error', {name:name, perm: perm, method: method})); + } + + return wrappedFunc.apply(context, args); + }, + checkRegisterPermissions = function (perm, registerMethod) { + return _.wrap(registerMethod, function (origRegister, name) { + return runIfPermissionToMethod(perm, name, origRegister, this, _.toArray(arguments).slice(1)); + }); + }, + passThruAppContextToApi = function (perm, apiMethods) { + var appContext = { + app: name + }; + + return _.reduce(apiMethods, function (memo, apiMethod, methodName) { + memo[methodName] = function () { + var args = _.toArray(arguments), + options = args[args.length - 1]; + + if (_.isObject(options)) { + options.context = _.clone(appContext); + } + return apiMethod.apply({}, args); + }; + + return memo; + }, {}); + }, + proxy; + + proxy = { + filters: { + register: checkRegisterPermissions('filters', filters.registerFilter.bind(filters)), + deregister: checkRegisterPermissions('filters', filters.deregisterFilter.bind(filters)) + }, + helpers: { + register: checkRegisterPermissions('helpers', helpers.registerThemeHelper.bind(helpers)), + registerAsync: checkRegisterPermissions('helpers', helpers.registerAsyncThemeHelper.bind(helpers)) + }, + api: { + posts: passThruAppContextToApi('posts', + _.pick(api.posts, 'browse', 'read', 'edit', 'add', 'destroy') + ), + tags: passThruAppContextToApi('tags', + _.pick(api.tags, 'browse') + ), + notifications: passThruAppContextToApi('notifications', + _.pick(api.notifications, 'browse', 'add', 'destroy') + ), + settings: passThruAppContextToApi('settings', + _.pick(api.settings, 'browse', 'read', 'edit') + ) + } + }; + + return proxy; +}; + +function AppProxy(options) { + if (!options.name) { + throw new Error(i18n.t('errors.apps.mustProvideAppName.error')); + } + + if (!options.permissions) { + throw new Error(i18n.t('errors.apps.mustProvideAppPermissions.error')); + } + + _.extend(this, generateProxyFunctions(options.name, options.permissions, options.internal)); +} + +module.exports = AppProxy; diff --git a/core/server/apps/sandbox.js b/core/server/apps/sandbox.js new file mode 100644 index 0000000..70947af --- /dev/null +++ b/core/server/apps/sandbox.js @@ -0,0 +1,97 @@ + +var path = require('path'), + Module = require('module'), + i18n = require('../i18n'), + _ = require('lodash'); + +function AppSandbox(opts) { + this.opts = _.defaults(opts || {}, AppSandbox.defaults); +} + +AppSandbox.prototype.loadApp = function loadAppSandboxed(appPath) { + var appFile = require.resolve(appPath), + appBase = path.dirname(appFile); + + this.opts.appRoot = appBase; + + return this.loadModule(appPath); +}; + +AppSandbox.prototype.loadModule = function loadModuleSandboxed(modulePath) { + // Set loaded modules parent to this + var self = this, + moduleDir = path.dirname(modulePath), + parentModulePath = self.opts.parent || module.parent, + appRoot = self.opts.appRoot || moduleDir, + currentModule, + nodeRequire; + + // Resolve the modules path + modulePath = Module._resolveFilename(modulePath, parentModulePath); + + // Instantiate a Node Module class + currentModule = new Module(modulePath, parentModulePath); + + if (this.opts.internal) { + currentModule.load(currentModule.id); + + return currentModule.exports; + } + + // Grab the original modules require function + nodeRequire = currentModule.require; + + // Set a new proxy require function + currentModule.require = function requireProxy(module) { + // check whitelist, plugin config, etc. + if (_.includes(self.opts.blacklist, module)) { + throw new Error(i18n.t('errors.apps.unsafeAppRequire.error', {msg: module})); + } + + var firstTwo = module.slice(0, 2), + resolvedPath, + relPath, + innerBox, + newOpts; + + // Load relative modules with their own sandbox + if (firstTwo === './' || firstTwo === '..') { + // Get the path relative to the modules directory + resolvedPath = path.resolve(moduleDir, module); + + // Check relative path from the appRoot for outside requires + relPath = path.relative(appRoot, resolvedPath); + if (relPath.slice(0, 2) === '..') { + throw new Error(i18n.t('errors.apps.unsafeAppRequire.error', {msg: relPath})); + } + + // Assign as new module path + module = resolvedPath; + + // Pass down the same options + newOpts = _.extend({}, self.opts); + + // Make sure the appRoot and parent are appropriate + newOpts.appRoot = appRoot; + newOpts.parent = currentModule.parent; + + // Create the inner sandbox for loading this module. + innerBox = new AppSandbox(newOpts); + + return innerBox.loadModule(module); + } + + // Call the original require method for white listed named modules + return nodeRequire.call(currentModule, module); + }; + + currentModule.load(currentModule.id); + + return currentModule.exports; +}; + +AppSandbox.defaults = { + blacklist: ['knex', 'fs', 'http', 'sqlite3', 'pg', 'mysql', 'ghost'] +}; + +module.exports = AppSandbox; diff --git a/core/server/apps/subscribers/index.js b/core/server/apps/subscribers/index.js new file mode 100644 index 0000000..234c234 --- /dev/null +++ b/core/server/apps/subscribers/index.js @@ -0,0 +1,81 @@ +var _ = require('lodash'), + path = require('path'), + hbs = require('express-hbs'), + router = require('./lib/router'), + + // Dirty requires + config = require('../../config'), + errors = require('../../errors'), + i18n = require('../../i18n'), + labs = require('../../utils/labs'), + template = require('../../helpers/template'), + utils = require('../../helpers/utils'), + + params = ['error', 'success', 'email'], + + /** + * This helper script sets the referrer and current location if not existent. + * + * document.querySelector['.location']['value'] = document.querySelector('.location')['value'] || window.location.href; + */ + subscribeScript = + ''; + +function makeHidden(name, extras) { + return utils.inputTemplate({ + type: 'hidden', + name: name, + className: name, + extras: extras + }); +} + +function subscribeFormHelper(options) { + var root = options.data.root, + data = _.merge({}, options.hash, _.pick(root, params), { + action: path.join('/', config.paths.subdir, config.routeKeywords.subscribe, '/'), + script: new hbs.handlebars.SafeString(subscribeScript), + hidden: new hbs.handlebars.SafeString( + makeHidden('confirm') + + makeHidden('location', root.subscribed_url ? 'value=' + root.subscribed_url : '') + + makeHidden('referrer', root.subscribed_referrer ? 'value=' + root.subscribed_referrer : '') + ) + }); + + return template.execute('subscribe_form', data, options); +} + +module.exports = { + activate: function activate(ghost) { + var errorMessages = [ + i18n.t('warnings.helpers.helperNotAvailable', {helperName: 'subscribe_form'}), + i18n.t('warnings.helpers.apiMustBeEnabled', {helperName: 'subscribe_form', flagName: 'subscribers'}), + i18n.t('warnings.helpers.seeLink', {url: 'https://help.ghost.org/hc/en-us/articles/224089787-Subscribers-Beta'}) + ]; + + // Correct way to register a helper from an app + ghost.helpers.register('subscribe_form', function labsEnabledHelper() { + if (labs.isSet('subscribers') === true) { + return subscribeFormHelper.apply(this, arguments); + } + + errors.logError.apply(this, errorMessages); + return new hbs.handlebars.SafeString(''); + }); + }, + + setupRoutes: function setupRoutes(blogRouter) { + blogRouter.use('/' + config.routeKeywords.subscribe + '/', function labsEnabledRouter(req, res, next) { + if (labs.isSet('subscribers') === true) { + return router.apply(this, arguments); + } + + next(); + }); + } +}; diff --git a/core/server/apps/subscribers/lib/router.js b/core/server/apps/subscribers/lib/router.js new file mode 100644 index 0000000..f76c602 --- /dev/null +++ b/core/server/apps/subscribers/lib/router.js @@ -0,0 +1,120 @@ +var path = require('path'), + express = require('express'), + _ = require('lodash'), + subscribeRouter = express.Router(), + + // Dirty requires + api = require('../../../api'), + errors = require('../../../errors'), + validator = require('../../../data/validation').validator, + templates = require('../../../controllers/frontend/templates'), + postlookup = require('../../../controllers/frontend/post-lookup'), + setResponseContext = require('../../../controllers/frontend/context'); + +function controller(req, res) { + var defaultView = path.resolve(__dirname, 'views', 'subscribe.hbs'), + paths = templates.getActiveThemePaths(req.app.get('activeTheme')), + data = req.body; + + setResponseContext(req, res); + if (paths.hasOwnProperty('subscribe.hbs')) { + return res.render('subscribe', data); + } else { + return res.render(defaultView, data); + } +} + +/** + * Takes care of sanitizing the email input. + * XSS prevention. + * For success cases, we don't have to worry, because then the input contained a valid email address. + */ +function errorHandler(error, req, res, next) { + /*jshint unused:false */ + + req.body.email = ''; + + if (error.statusCode !== 404) { + res.locals.error = error; + return controller(req, res); + } + + next(error); +} + +function honeyPot(req, res, next) { + if (!req.body.hasOwnProperty('confirm') || req.body.confirm !== '') { + return next(new Error('Oops, something went wrong!')); + } + + // we don't need this anymore + delete req.body.confirm; + next(); +} + +function validateUrl(url) { + return validator.isEmptyOrURL(url || '') ? url : ''; +} + +function handleSource(req, res, next) { + req.body.subscribed_url = validateUrl(req.body.location); + req.body.subscribed_referrer = validateUrl(req.body.referrer); + delete req.body.location; + delete req.body.referrer; + + postlookup(req.body.subscribed_url) + .then(function (result) { + if (result && result.post) { + req.body.post_id = result.post.id; + } + + next(); + }) + .catch(function (err) { + if (err instanceof errors.NotFoundError) { + return next(); + } + + next(err); + }); +} + +function storeSubscriber(req, res, next) { + req.body.status = 'subscribed'; + + if (_.isEmpty(req.body.email)) { + return next(new errors.ValidationError('Email cannot be blank.')); + } else if (!validator.isEmail(req.body.email)) { + return next(new errors.ValidationError('Invalid email.')); + } + + return api.subscribers.add({subscribers: [req.body]}, {context: {external: true}}) + .then(function () { + res.locals.success = true; + next(); + }) + .catch(function () { + // we do not expose any information + res.locals.success = true; + next(); + }); +} + +// subscribe frontend route +subscribeRouter.route('/') + .get( + controller + ) + .post( + honeyPot, + handleSource, + storeSubscriber, + controller + ); + +// configure an error handler just for subscribe problems +subscribeRouter.use(errorHandler); + +module.exports = subscribeRouter; +module.exports.controller = controller; +module.exports.storeSubscriber = storeSubscriber; diff --git a/core/server/apps/subscribers/lib/views/subscribe.hbs b/core/server/apps/subscribers/lib/views/subscribe.hbs new file mode 100644 index 0000000..7b963be --- /dev/null +++ b/core/server/apps/subscribers/lib/views/subscribe.hbs @@ -0,0 +1,62 @@ + + + + + + + + Ghost - Subscribe + + + + + + + + + + + + +
+
+
+
+
+ +
+ +
+
+ {{^if success}} +
+

Subscribe to {{@blog.title}}

+
+ + {{subscribe_form + form_class="gh-signin" + input_class="gh-input" + button_class="btn btn-blue btn-block" + placeholder="Your email address" + autofocus="true" + }} + {{else}} +
+

Subscribed!

+
+ +

+ You've successfully subscribed to {{@blog.title}} + with the email address {{email}}. +

+ {{/if}} +
+
+
+
+
+
+ + diff --git a/core/server/apps/subscribers/tests/routing_spec.js b/core/server/apps/subscribers/tests/routing_spec.js new file mode 100644 index 0000000..9f53066 --- /dev/null +++ b/core/server/apps/subscribers/tests/routing_spec.js @@ -0,0 +1,113 @@ +var request = require('supertest'), + should = require('should'), + sinon = require('sinon'), + testUtils = require('../../../../test/utils'), + ghost = require('../../../../../core'), + labs = require('../../../utils/labs'), + sandbox = sinon.sandbox.create(); + +describe('Subscriber: Routing', function () { + before(testUtils.teardown); + before(testUtils.setup); + after(testUtils.teardown); + + before(function (done) { + ghost().then(function (ghostServer) { + // Setup the request object with the ghost express app + request = request(ghostServer.rootApp); + + done(); + }).catch(function (e) { + console.log('Ghost Error: ', e); + console.log(e.stack); + done(e); + }); + }); + + before(function () { + sandbox.stub(labs, 'isSet', function (key) { + if (key === 'subscribers') { + return true; + } + }); + }); + + after(function () { + sandbox.restore(); + }); + + describe('GET', function () { + it('[success]', function (done) { + request.get('/subscribe/') + .expect(200) + .end(function (err) { + should.not.exist(err); + done(); + }); + }); + }); + + describe('POST', function () { + it('[success]', function (done) { + request.post('/subscribe/') + .send({ + email: 'test@ghost.org', + location: 'http://localhost:2368', + confirm: '' + }) + .expect(200) + .end(function (err, res) { + should.not.exist(err); + res.text.should.containEql('Subscribed!'); + res.text.should.containEql('test@ghost.org'); + done(); + }); + }); + + it('[error] email is invalid', function (done) { + request.post('/subscribe/') + .send({ + email: 'alphabetazeta', + location: 'http://localhost:2368', + confirm: '' + }) + .expect(200) + .end(function (err, res) { + should.not.exist(err); + res.text.should.not.containEql('Subscribed!'); + res.text.should.not.containEql('alphabetazeta'); + done(); + }); + }); + + it('[error] location is not defined', function (done) { + request.post('/subscribe/') + .send({ + email: 'test@ghost.org', + confirm: '' + }) + .expect(200) + .end(function (err, res) { + should.not.exist(err); + res.text.should.not.containEql('Subscribed!'); + res.text.should.not.containEql('test@ghost.org'); + done(); + }); + }); + + it('[error] confirm is not defined', function (done) { + request.post('/subscribe/') + .send({ + email: 'test@ghost.org', + location: 'http://localhost:2368' + }) + .expect(200) + .end(function (err, res) { + should.not.exist(err); + res.text.should.not.containEql('Subscribed!'); + res.text.should.not.containEql('test@ghost.org'); + done(); + }); + }); + }); +}); diff --git a/core/server/config/index.js b/core/server/config/index.js new file mode 100644 index 0000000..74cd689 --- /dev/null +++ b/core/server/config/index.js @@ -0,0 +1,515 @@ +// # Config +// General entry point for all configuration data +var path = require('path'), + Promise = require('bluebird'), + chalk = require('chalk'), + fs = require('fs'), + url = require('url'), + _ = require('lodash'), + + validator = require('validator'), + generateAssetHash = require('../utils/asset-hash'), + readThemes = require('../utils/read-themes'), + errors = require('../errors'), + configUrl = require('./url'), + packageInfo = require('../../../package.json'), + i18n = require('../i18n'), + appRoot = path.resolve(__dirname, '../../../'), + corePath = path.resolve(appRoot, 'core/'), + testingEnvs = ['testing', 'testing-mysql', 'testing-pg'], + defaultConfig = {}; + +function ConfigManager(config) { + /** + * Our internal true representation of our current config object. + * @private + * @type {Object} + */ + this._config = {}; + + // Allow other modules to be externally accessible. + this.urlJoin = configUrl.urlJoin; + this.urlFor = configUrl.urlFor; + this.urlPathForPost = configUrl.urlPathForPost; + this.apiUrl = configUrl.apiUrl; + this.getBaseUrl = configUrl.getBaseUrl; + + // If we're given an initial config object then we can set it. + if (config && _.isObject(config)) { + this.set(config); + } +} + +// Are we using sockets? Custom socket or the default? +ConfigManager.prototype.getSocket = function () { + var socketConfig, + values = { + path: path.join(this._config.paths.contentPath, process.env.NODE_ENV + '.socket'), + permissions: '660' + }; + + if (this._config.server.hasOwnProperty('socket')) { + socketConfig = this._config.server.socket; + + if (_.isString(socketConfig)) { + values.path = socketConfig; + + return values; + } + + if (_.isObject(socketConfig)) { + values.path = socketConfig.path || values.path; + values.permissions = socketConfig.permissions || values.permissions; + + return values; + } + } + + return false; +}; + +ConfigManager.prototype.init = function (rawConfig) { + var self = this; + + // Cache the config.js object's environment + // object so we can later refer to it. + // Note: this is not the entirety of config.js, + // just the object appropriate for this NODE_ENV + self.set(rawConfig); + + return Promise.resolve(self._config); +}; + +ConfigManager.prototype.loadExtras = function () { + var self = this; + + return self.loadThemes() + .then(function () { + return self._config; + }); +}; + +ConfigManager.prototype.loadThemes = function () { + var self = this; + + return readThemes(self._config.paths.themePath) + .then(function (result) { + self._config.paths.availableThemes = result; + }); +}; + +/** + * Allows you to set the config object. + * @param {Object} config Only accepts an object at the moment. + */ +ConfigManager.prototype.set = function (config) { + var localPath = '', + defaultStorageAdapter = 'local-file-store', + defaultSchedulingAdapter = 'SchedulingDefault', + activeStorageAdapter, + activeSchedulingAdapter, + contentPath, + schedulingPath, + subdir, + assetHash, + timezone = 'Etc/UTC'; + + // CASE: remember existing timezone + if (this._config.theme && this._config.theme.timezone) { + timezone = this._config.theme.timezone; + } + + // CASE: override existing timezone + if (config && config.theme && config.theme.timezone) { + timezone = config.theme.timezone; + } + + // Merge passed in config object onto our existing config object. + // We're using merge here as it doesn't assign `undefined` properties + // onto our cached config object. This allows us to only update our + // local copy with properties that have been explicitly set. + _.merge(this._config, config); + + // Special case for the database config, which should be overridden not merged + + if (config && config.database) { + this._config.database = config.database; + } + + // Special case for the them.navigation JSON object, which should be overridden not merged + if (config && config.theme && config.theme.navigation) { + this._config.theme.navigation = config.theme.navigation; + } + + // Protect against accessing a non-existant object. + // This ensures there's always at least a paths object + // because it's referenced in multiple places. + this._config.paths = this._config.paths || {}; + + // Parse local path location + if (this._config.url) { + localPath = url.parse(this._config.url).path; + // Remove trailing slash + if (localPath !== '/') { + localPath = localPath.replace(/\/$/, ''); + } + } + + subdir = localPath === '/' ? '' : localPath; + + if (!_.isEmpty(subdir)) { + this._config.slugs.protected.push(subdir.split('/').pop()); + } + + // Allow contentPath to be over-written by passed in config object + // Otherwise default to default content path location + contentPath = this._config.paths.contentPath || path.resolve(appRoot, 'content'); + + assetHash = this._config.assetHash || generateAssetHash(); + + // read storage adapter from config file or attach default adapter + this._config.storage = this._config.storage || {}; + activeStorageAdapter = this._config.storage.active || defaultStorageAdapter; + + // read scheduling adapter(s) from config file or attach default adapter + this._config.scheduling = this._config.scheduling || {}; + activeSchedulingAdapter = this._config.scheduling.active || defaultSchedulingAdapter; + + // storage.active can be an object like {images: 'my-custom-image-storage-adapter', themes: 'local-file-storage'} + // we ensure that passing a string to storage.active still works, but internal it's always an object + if (_.isString(activeStorageAdapter)) { + this._config.storage = _.merge(this._config.storage, { + active: { + images: activeStorageAdapter, + themes: defaultStorageAdapter + } + }); + } else { + // ensure there is a default image storage adapter + if (!this._config.storage.active.images) { + this._config.storage.active.images = defaultStorageAdapter; + } + + // ensure there is a default theme storage adapter + // @TODO: right now we only support theme uploads to local file storage + // @TODO: we need to change reading themes from disk on bootstrap (see loadThemes) + this._config.storage.active.themes = defaultStorageAdapter; + } + + if (activeSchedulingAdapter === defaultSchedulingAdapter) { + schedulingPath = path.join(corePath, '/server/scheduling/'); + } else { + schedulingPath = path.join(contentPath, '/scheduling/'); + } + + this._config.times = _.merge({ + cannotScheduleAPostBeforeInMinutes: 2, + publishAPostBySchedulerToleranceInMinutes: 2, + getImageSizeTimeoutInMS: 5000 + }, this._config.times || {}); + + _.merge(this._config, { + ghostVersion: packageInfo.version, + paths: { + appRoot: appRoot, + subdir: subdir, + config: this._config.paths.config || path.join(appRoot, 'config.js'), + configExample: path.join(appRoot, 'config.example.js'), + corePath: corePath, + + storagePath: { + default: path.join(corePath, '/server/storage/'), + custom: path.join(contentPath, 'storage/') + }, + + contentPath: contentPath, + themePath: path.resolve(contentPath, 'themes'), + appPath: path.resolve(contentPath, 'apps'), + dataPath: path.resolve(contentPath, 'data'), + imagesPath: path.resolve(contentPath, 'images'), + internalAppPath: path.join(corePath, '/server/apps/'), + imagesRelPath: 'content/images', + + adminViews: path.join(corePath, '/server/views/'), + helperTemplates: path.join(corePath, '/server/helpers/tpl/'), + + availableThemes: this._config.paths.availableThemes || {}, + clientAssets: path.join(corePath, '/built/assets/') + }, + maintenance: {}, + scheduling: { + active: activeSchedulingAdapter, + path: schedulingPath + }, + theme: { + // normalise the URL by removing any trailing slash + url: this._config.url ? this._config.url.replace(/\/$/, '') : '', + timezone: timezone + }, + routeKeywords: { + tag: 'tag', + author: 'author', + page: 'page', + preview: 'p', + private: 'private', + subscribe: 'subscribe', + amp: 'amp' + }, + internalApps: ['private-blogging', 'subscribers', 'amp'], + slugs: { + // Used by generateSlug to generate slugs for posts, tags, users, .. + // reserved slugs are reserved but can be extended/removed by apps + // protected slugs cannot be changed or removed + reserved: ['admin', 'app', 'apps', 'archive', 'archives', 'categories', + 'category', 'dashboard', 'feed', 'ghost-admin', 'login', 'logout', + 'page', 'pages', 'post', 'posts', 'public', 'register', 'setup', + 'signin', 'signout', 'signup', 'user', 'users', 'wp-admin', 'wp-login'], + protected: ['ghost', 'rss', 'amp'] + }, + // used in middleware/validation/upload.js + // if we finish the data/importer logic, each type selects an importer + uploads: { + subscribers: { + extensions: ['.csv'], + contentTypes: ['text/csv', 'application/csv', 'application/octet-stream'] + }, + images: { + extensions: ['.jpg', '.jpeg', '.gif', '.png', '.svg', '.svgz'], + contentTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'] + }, + db: { + extensions: ['.json', '.zip'], + contentTypes: ['application/octet-stream', 'application/json', 'application/zip', 'application/x-zip-compressed'] + }, + themes: { + extensions: ['.zip'], + contentTypes: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'] + } + }, + deprecatedItems: ['updateCheck', 'mail.fromaddress'], + // create a hash for cache busting assets + assetHash: assetHash, + preloadHeaders: this._config.preloadHeaders || false + }); + + // Also pass config object to + // configUrl object to maintain + // clean dependency tree + configUrl.setConfig(this._config); + + // For now we're going to copy the current state of this._config + // so it's directly accessible on the instance. + // @TODO: perhaps not do this? Put access of the config object behind + // a function? + _.extend(this, this._config); +}; + +/** + * Allows you to read the config object. + * @return {Object} The config object. + */ +ConfigManager.prototype.get = function () { + return this._config; +}; + +ConfigManager.prototype.load = function (configFilePath) { + var self = this; + + self._config.paths.config = process.env.GHOST_CONFIG || configFilePath || self._config.paths.config; + + /* Check for config file and copy from config.example.js + if one doesn't exist. After that, start the server. */ + return new Promise(function (resolve, reject) { + fs.stat(self._config.paths.config, function (err) { + var exists = (err) ? false : true, + pendingConfig; + + if (!exists) { + pendingConfig = self.writeFile(); + } + + Promise.resolve(pendingConfig).then(function () { + return self.validate(); + }).then(function (rawConfig) { + return self.init(rawConfig); + }).then(resolve) + .catch(reject); + }); + }); +}; + +/* Check for config file and copy from config.example.js + if one doesn't exist. After that, start the server. */ +ConfigManager.prototype.writeFile = function () { + var configPath = this._config.paths.config, + configExamplePath = this._config.paths.configExample; + + return new Promise(function (resolve, reject) { + fs.stat(configExamplePath, function checkTemplate(err) { + var templateExists = (err) ? false : true, + read, + write, + error; + + if (!templateExists) { + error = new Error(i18n.t('errors.config.couldNotLocateConfigFile.error')); + error.context = appRoot; + error.help = i18n.t('errors.config.couldNotLocateConfigFile.help'); + + return reject(error); + } + + // Copy config.example.js => config.js + read = fs.createReadStream(configExamplePath); + read.on('error', function (err) { + errors.logError( + new Error(i18n.t('errors.config.couldNotOpenForReading.error', {file: 'config.example.js'})), + appRoot, + i18n.t('errors.config.couldNotOpenForReading.help')); + + reject(err); + }); + + write = fs.createWriteStream(configPath); + write.on('error', function (err) { + errors.logError( + new Error(i18n.t('errors.config.couldNotOpenForWriting.error', {file: 'config.js'})), + appRoot, + i18n.t('errors.config.couldNotOpenForWriting.help')); + + reject(err); + }); + + write.on('finish', resolve); + + read.pipe(write); + }); + }); +}; + +/** + * Read config.js file from file system using node's require + * @param {String} envVal Which environment we're in. + * @return {Object} The config object. + */ +ConfigManager.prototype.readFile = function (envVal) { + return require(this._config.paths.config)[envVal]; +}; + +/** + * Validates the config object has everything we want and in the form we want. + * @return {Promise.} Returns a promise that resolves to the config object. + */ +ConfigManager.prototype.validate = function () { + var envVal = process.env.NODE_ENV || undefined, + hasHostAndPort, + hasSocket, + config, + parsedUrl; + + try { + config = this.readFile(envVal); + } + catch (e) { + return Promise.reject(e); + } + + // Check that our url is valid + if (!validator.isURL(config.url, {protocols: ['http', 'https'], require_protocol: true})) { + errors.logError( + new Error(i18n.t('errors.config.invalidUrlInConfig.description'), + config.url, + i18n.t('errors.config.invalidUrlInConfig.help'))); + + return Promise.reject(new Error(i18n.t('errors.config.invalidUrlInConfig.error'))); + } + + parsedUrl = url.parse(config.url || 'invalid', false, true); + + if (/\/ghost(\/|$)/.test(parsedUrl.pathname)) { + errors.logError( + new Error(i18n.t('errors.config.urlCannotContainGhostSubdir.description'), + config.url, + i18n.t('errors.config.urlCannotContainGhostSubdir.help'))); + + return Promise.reject(new Error(i18n.t('errors.config.urlCannotContainGhostSubdir.error'))); + } + + // Check that we have database values + if (!config.database || !config.database.client) { + errors.logError( + new Error(i18n.t('errors.config.dbConfigInvalid.description')), + JSON.stringify(config.database), + i18n.t('errors.config.dbConfigInvalid.help')); + + return Promise.reject(new Error(i18n.t('errors.config.dbConfigInvalid.error'))); + } + + hasHostAndPort = config.server && !!config.server.host && !!config.server.port; + hasSocket = config.server && !!config.server.socket; + + // Check for valid server host and port values + if (!config.server || !(hasHostAndPort || hasSocket)) { + errors.logError( + new Error(i18n.t('errors.config.invalidServerValues.description')), + JSON.stringify(config.server), + i18n.t('errors.config.invalidServerValues.help')); + + return Promise.reject(new Error(i18n.t('errors.config.invalidServerValues.error'))); + } + + return Promise.resolve(config); +}; + +/** + * Helper method for checking the state of a particular privacy flag + * @param {String} privacyFlag The flag to check + * @returns {boolean} + */ +ConfigManager.prototype.isPrivacyDisabled = function (privacyFlag) { + if (!this.privacy) { + return false; + } + + if (this.privacy.useTinfoil === true) { + return true; + } + + return this.privacy[privacyFlag] === false; +}; + +/** + * Check if any of the currently set config items are deprecated, and issues a warning. + */ +ConfigManager.prototype.checkDeprecated = function () { + var self = this; + _.each(this.deprecatedItems, function (property) { + self.displayDeprecated(self._config, property.split('.'), []); + }); +}; + +ConfigManager.prototype.displayDeprecated = function (item, properties, address) { + var self = this, + property = properties.shift(), + errorText, + explanationText, + helpText; + + address.push(property); + + if (item.hasOwnProperty(property)) { + if (properties.length) { + return self.displayDeprecated(item[property], properties, address); + } + errorText = i18n.t('errors.config.deprecatedProperty.error', {property: chalk.bold(address.join('.'))}); + explanationText = i18n.t('errors.config.deprecatedProperty.explanation'); + helpText = i18n.t('errors.config.deprecatedProperty.help', {url: 'https://docs.ghost.org/v0.11.9/docs/configuring-ghost'}); + errors.logWarn(errorText, explanationText, helpText); + } +}; + +if (testingEnvs.indexOf(process.env.NODE_ENV) > -1) { + defaultConfig = require('../../../config.example')[process.env.NODE_ENV]; +} + +module.exports = new ConfigManager(defaultConfig); diff --git a/core/server/config/url.js b/core/server/config/url.js new file mode 100644 index 0000000..b484c27 --- /dev/null +++ b/core/server/config/url.js @@ -0,0 +1,270 @@ +// Contains all path information to be used throughout +// the codebase. + +var moment = require('moment-timezone'), + _ = require('lodash'), + ghostConfig = '', + // @TODO: unify this with routes.apiBaseUrl + apiPath = '/ghost/api/v0.1'; + +// ## setConfig +// Simple utility function to allow +// passing of the ghostConfig +// object here to be used locally +// to ensure clean dependency graph +// (i.e. no circular dependencies). +function setConfig(config) { + ghostConfig = config; +} + +function getBaseUrl(secure) { + if (secure && ghostConfig.urlSSL) { + return ghostConfig.urlSSL; + } else { + if (secure) { + return ghostConfig.url.replace('http://', 'https://'); + } else { + return ghostConfig.url; + } + } +} + +function urlJoin() { + var args = Array.prototype.slice.call(arguments), + prefixDoubleSlash = false, + subdir = ghostConfig.paths.subdir.replace(/^\/|\/+$/, ''), + subdirRegex, + url; + + // Remove empty item at the beginning + if (args[0] === '') { + args.shift(); + } + + // Handle schemeless protocols + if (args[0].indexOf('//') === 0) { + prefixDoubleSlash = true; + } + + // join the elements using a slash + url = args.join('/'); + + // Fix multiple slashes + url = url.replace(/(^|[^:])\/\/+/g, '$1/'); + + // Put the double slash back at the beginning if this was a schemeless protocol + if (prefixDoubleSlash) { + url = url.replace(/^\//, '//'); + } + + // Deduplicate subdirectory + if (subdir) { + subdirRegex = new RegExp(subdir + '\/' + subdir + '\/'); + url = url.replace(subdirRegex, subdir + '/'); + } + + return url; +} + +// ## createUrl +// Simple url creation from a given path +// Ensures that our urls contain the subdirectory if there is one +// And are correctly formatted as either relative or absolute +// Usage: +// createUrl('/', true) -> http://my-ghost-blog.com/ +// E.g. /blog/ subdir +// createUrl('/welcome-to-ghost/') -> /blog/welcome-to-ghost/ +// Parameters: +// - urlPath - string which must start and end with a slash +// - absolute (optional, default:false) - boolean whether or not the url should be absolute +// - secure (optional, default:false) - boolean whether or not to use urlSSL or url config +// Returns: +// - a URL which always ends with a slash +function createUrl(urlPath, absolute, secure) { + urlPath = urlPath || '/'; + absolute = absolute || false; + var base; + + // create base of url, always ends without a slash + if (absolute) { + base = getBaseUrl(secure); + } else { + base = ghostConfig.paths.subdir; + } + + return urlJoin(base, urlPath); +} + +/** + * creates the url path for a post based on blog timezone and permalink pattern + * + * @param {JSON} post + * @returns {string} + */ +function urlPathForPost(post) { + var output = '', + permalinks = ghostConfig.theme.permalinks, + publishedAtMoment = moment.tz(post.published_at || Date.now(), ghostConfig.theme.timezone), + tags = { + year: function () { return publishedAtMoment.format('YYYY'); }, + month: function () { return publishedAtMoment.format('MM'); }, + day: function () { return publishedAtMoment.format('DD'); }, + author: function () { return post.author.slug; }, + slug: function () { return post.slug; }, + id: function () { return post.id; } + }; + + if (post.page) { + output += '/:slug/'; + } else { + output += permalinks; + } + + // replace tags like :slug or :year with actual values + output = output.replace(/(:[a-z]+)/g, function (match) { + if (_.has(tags, match.substr(1))) { + return tags[match.substr(1)](); + } + }); + + return output; +} + +// ## urlFor +// Synchronous url creation for a given context +// Can generate a url for a named path, given path, or known object (post) +// Determines what sort of context it has been given, and delegates to the correct generation method, +// Finally passing to createUrl, to ensure any subdirectory is honoured, and the url is absolute if needed +// Usage: +// urlFor('home', true) -> http://my-ghost-blog.com/ +// E.g. /blog/ subdir +// urlFor({relativeUrl: '/my-static-page/'}) -> /blog/my-static-page/ +// E.g. if post object represents welcome post, and slugs are set to standard +// urlFor('post', {...}) -> /welcome-to-ghost/ +// E.g. if post object represents welcome post, and slugs are set to date +// urlFor('post', {...}) -> /2014/01/01/welcome-to-ghost/ +// Parameters: +// - context - a string, or json object describing the context for which you need a url +// - data (optional) - a json object containing data needed to generate a url +// - absolute (optional, default:false) - boolean whether or not the url should be absolute +// This is probably not the right place for this, but it's the best place for now +function urlFor(context, data, absolute) { + var urlPath = '/', + secure, imagePathRe, + knownObjects = ['post', 'tag', 'author', 'image', 'nav'], baseUrl, + hostname, + + // this will become really big + knownPaths = { + home: '/', + rss: '/rss/', + api: apiPath, + sitemap_xsl: '/sitemap.xsl' + }; + + // Make data properly optional + if (_.isBoolean(data)) { + absolute = data; + data = null; + } + + // Can pass 'secure' flag in either context or data arg + secure = (context && context.secure) || (data && data.secure); + + if (_.isObject(context) && context.relativeUrl) { + urlPath = context.relativeUrl; + } else if (_.isString(context) && _.indexOf(knownObjects, context) !== -1) { + // trying to create a url for an object + if (context === 'post' && data.post) { + urlPath = data.post.url; + secure = data.secure; + } else if (context === 'tag' && data.tag) { + urlPath = urlJoin('/', ghostConfig.routeKeywords.tag, data.tag.slug, '/'); + secure = data.tag.secure; + } else if (context === 'author' && data.author) { + urlPath = urlJoin('/', ghostConfig.routeKeywords.author, data.author.slug, '/'); + secure = data.author.secure; + } else if (context === 'image' && data.image) { + urlPath = data.image; + imagePathRe = new RegExp('^' + ghostConfig.paths.subdir + '/' + ghostConfig.paths.imagesRelPath); + absolute = imagePathRe.test(data.image) ? absolute : false; + secure = data.image.secure; + + if (absolute) { + // Remove the sub-directory from the URL because ghostConfig will add it back. + urlPath = urlPath.replace(new RegExp('^' + ghostConfig.paths.subdir), ''); + baseUrl = getBaseUrl(secure).replace(/\/$/, ''); + urlPath = baseUrl + urlPath; + } + + return urlPath; + } else if (context === 'nav' && data.nav) { + urlPath = data.nav.url; + secure = data.nav.secure || secure; + baseUrl = getBaseUrl(secure); + hostname = baseUrl.split('//')[1] + ghostConfig.paths.subdir; + if (urlPath.indexOf(hostname) > -1 + && !urlPath.split(hostname)[0].match(/\.|mailto:/) + && urlPath.split(hostname)[1].substring(0,1) !== ':') { + // make link relative to account for possible + // mismatch in http/https etc, force absolute + // do not do so if link is a subdomain of blog url + // or if hostname is inside of the slug + // or if slug is a port + urlPath = urlPath.split(hostname)[1]; + if (urlPath.substring(0, 1) !== '/') { + urlPath = '/' + urlPath; + } + absolute = true; + } + } + // other objects are recognised but not yet supported + } else if (_.isString(context) && _.indexOf(_.keys(knownPaths), context) !== -1) { + // trying to create a url for a named path + urlPath = knownPaths[context] || '/'; + } + + // This url already has a protocol so is likely an external url to be returned + // or it is an alternative scheme, protocol-less, or an anchor-only path + if (urlPath && (urlPath.indexOf('://') !== -1 || urlPath.match(/^(\/\/|#|[a-zA-Z0-9\-]+:)/))) { + return urlPath; + } + + return createUrl(urlPath, absolute, secure); +} + +/** + * CASE: generate api url for CORS + * - we delete the http protocol if your blog runs with http and https (configured by nginx) + * - in that case your config.js configures Ghost with http and no admin ssl force + * - the browser then reads the protocol dynamically + */ +function apiUrl(options) { + options = options || {cors: false}; + + // @TODO unify this with urlFor + var url; + + if (ghostConfig.forceAdminSSL) { + url = (ghostConfig.urlSSL || ghostConfig.url).replace(/^.*?:\/\//g, 'https://'); + } else if (ghostConfig.urlSSL) { + url = ghostConfig.urlSSL.replace(/^.*?:\/\//g, 'https://'); + } else if (ghostConfig.url.match(/^https:/)) { + url = ghostConfig.url; + } else { + if (options.cors === false) { + url = ghostConfig.url; + } else { + url = ghostConfig.url.replace(/^.*?:\/\//g, '//'); + } + } + + return url.replace(/\/$/, '') + apiPath + '/'; +} + +module.exports.setConfig = setConfig; +module.exports.urlJoin = urlJoin; +module.exports.urlFor = urlFor; +module.exports.urlPathForPost = urlPathForPost; +module.exports.apiUrl = apiUrl; +module.exports.getBaseUrl = getBaseUrl; diff --git a/core/server/controllers/admin.js b/core/server/controllers/admin.js new file mode 100644 index 0000000..5875df2 --- /dev/null +++ b/core/server/controllers/admin.js @@ -0,0 +1,61 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + api = require('../api'), + errors = require('../errors'), + updateCheck = require('../update-check'), + i18n = require('../i18n'), + adminControllers; + +adminControllers = { + // Route: index + // Path: /ghost/ + // Method: GET + index: function index(req, res) { + /*jslint unparam:true*/ + + function renderIndex() { + var configuration, + fetch = { + configuration: api.configuration.read().then(function (res) { return res.configuration[0]; }), + client: api.clients.read({slug: 'ghost-admin'}).then(function (res) { return res.clients[0]; }) + }; + + return Promise.props(fetch).then(function renderIndex(result) { + configuration = result.configuration; + + configuration.clientId = {value: result.client.slug, type: 'string'}; + configuration.clientSecret = {value: result.client.secret, type: 'string'}; + + res.render('default', { + configuration: configuration + }); + }); + } + + updateCheck().then(function then() { + return updateCheck.showUpdateNotification(); + }).then(function then(updateVersion) { + if (!updateVersion) { + return; + } + + var notification = { + status: 'alert', + type: 'info', + location: 'upgrade.new-version-available', + dismissible: false, + message: i18n.t('notices.controllers.newVersionAvailable', + {version: updateVersion, link: 'Click here'})}; + + return api.notifications.browse({context: {internal: true}}).then(function then(results) { + if (!_.some(results.notifications, {message: notification.message})) { + return api.notifications.add({notifications: [notification]}, {context: {internal: true}}); + } + }); + }).finally(function noMatterWhat() { + renderIndex(); + }).catch(errors.logError); + } +}; + +module.exports = adminControllers; diff --git a/core/server/controllers/frontend/channel-config.js b/core/server/controllers/frontend/channel-config.js new file mode 100644 index 0000000..ac0f98e --- /dev/null +++ b/core/server/controllers/frontend/channel-config.js @@ -0,0 +1,55 @@ +var _ = require('lodash'), + config = require('../../config'), + channelConfig; + +channelConfig = function channelConfig() { + var defaults = { + index: { + name: 'index', + route: '/', + frontPageTemplate: 'home' + }, + tag: { + name: 'tag', + route: '/' + config.routeKeywords.tag + '/:slug/', + postOptions: { + filter: 'tags:\'%s\'' + }, + data: { + tag: { + type: 'read', + resource: 'tags', + options: {slug: '%s'} + } + }, + slugTemplate: true, + editRedirect: '/ghost/settings/tags/:slug/' + }, + author: { + name: 'author', + route: '/' + config.routeKeywords.author + '/:slug/', + postOptions: { + filter: 'author:\'%s\'' + }, + data: { + author: { + type: 'read', + resource: 'users', + options: {slug: '%s'} + } + }, + slugTemplate: true, + editRedirect: '/ghost/team/:slug/' + } + }; + + return defaults; +}; + +module.exports.list = function list() { + return channelConfig(); +}; + +module.exports.get = function get(name) { + return _.cloneDeep(channelConfig()[name]); +}; diff --git a/core/server/controllers/frontend/channels.js b/core/server/controllers/frontend/channels.js new file mode 100644 index 0000000..44244d7 --- /dev/null +++ b/core/server/controllers/frontend/channels.js @@ -0,0 +1,93 @@ +var express = require('express'), + _ = require('lodash'), + config = require('../../config'), + errors = require('../../errors'), + rss = require('../../data/xml/rss'), + utils = require('../../utils'), + + channelConfig = require('./channel-config'), + renderChannel = require('./render-channel'), + + rssRouter, + channelRouter; + +function handlePageParam(req, res, next, page) { + var pageRegex = new RegExp('/' + config.routeKeywords.page + '/(.*)?/'), + rssRegex = new RegExp('/rss/(.*)?/'); + + page = parseInt(page, 10); + + if (page === 1) { + // Page 1 is an alias, do a permanent 301 redirect + if (rssRegex.test(req.url)) { + return utils.redirect301(res, req.originalUrl.replace(rssRegex, '/rss/')); + } else { + return utils.redirect301(res, req.originalUrl.replace(pageRegex, '/')); + } + } else if (page < 1 || isNaN(page)) { + // Nothing less than 1 is a valid page number, go straight to a 404 + return next(new errors.NotFoundError()); + } else { + // Set req.params.page to the already parsed number, and continue + req.params.page = page; + return next(); + } +} + +rssRouter = function rssRouter(channelConfig) { + function rssConfigMiddleware(req, res, next) { + req.channelConfig.isRSS = true; + next(); + } + + // @TODO move this to an RSS module + var router = express.Router({mergeParams: true}), + stack = [channelConfig, rssConfigMiddleware, rss], + baseRoute = '/rss/'; + + router.get(baseRoute, stack); + router.get(baseRoute + ':page/', stack); + router.get('/feed/', function redirectToRSS(req, res) { + return utils.redirect301(res, config.paths.subdir + req.baseUrl + baseRoute); + }); + router.param('page', handlePageParam); + + return router; +}; + +channelRouter = function router() { + function channelConfigMiddleware(channel) { + return function doChannelConfig(req, res, next) { + req.channelConfig = _.cloneDeep(channel); + next(); + }; + } + + var channelsRouter = express.Router({mergeParams: true}), + baseRoute = '/', + pageRoute = '/' + config.routeKeywords.page + '/:page/'; + + _.each(channelConfig.list(), function (channel) { + var channelRouter = express.Router({mergeParams: true}), + configChannel = channelConfigMiddleware(channel); + + // @TODO figure out how to collapse this into a single rule + channelRouter.get(baseRoute, configChannel, renderChannel); + channelRouter.get(pageRoute, configChannel, renderChannel); + channelRouter.param('page', handlePageParam); + channelRouter.use(rssRouter(configChannel)); + + if (channel.editRedirect) { + channelRouter.get('/edit/', function redirect(req, res) { + res.redirect(config.paths.subdir + channel.editRedirect.replace(':slug', req.params.slug)); + }); + } + + // Mount this channel router on the parent channels router + channelsRouter.use(channel.route, channelRouter); + }); + + return channelsRouter; +}; + +module.exports.router = channelRouter; diff --git a/core/server/controllers/frontend/context.js b/core/server/controllers/frontend/context.js new file mode 100644 index 0000000..0ab4b7d --- /dev/null +++ b/core/server/controllers/frontend/context.js @@ -0,0 +1,68 @@ +/** + * # Response context + * + * Figures out which context we are currently serving. The biggest challenge with determining this + * is that the only way to determine whether or not we are a post, or a page, is with data after all the + * data for the template has been retrieved. + * + * Contexts are determined based on 3 pieces of information + * 1. res.locals.relativeUrl - which never includes the subdirectory + * 2. req.params.page - always has the page parameter, regardless of if the URL contains a keyword (RSS pages don't) + * 3. data - used for telling the difference between posts and pages + */ + +var config = require('../../config'), + + // Context patterns, should eventually come from Channel configuration + privatePattern = new RegExp('^\\/' + config.routeKeywords.private + '\\/'), + subscribePattern = new RegExp('^\\/' + config.routeKeywords.subscribe + '\\/'), + ampPattern = new RegExp('\\/' + config.routeKeywords.amp + '\\/$'), + rssPattern = new RegExp('^\\/rss\\/'), + homePattern = new RegExp('^\\/$'); + +function setResponseContext(req, res, data) { + var pageParam = req.params && req.params.page !== undefined ? parseInt(req.params.page, 10) : 1; + + res.locals = res.locals || {}; + res.locals.context = []; + + // If we don't have a relativeUrl, we can't detect the context, so return + if (!res.locals.relativeUrl) { + return; + } + + // Paged context - special rule + if (!isNaN(pageParam) && pageParam > 1) { + res.locals.context.push('paged'); + } + + // Home context - special rule + if (homePattern.test(res.locals.relativeUrl)) { + res.locals.context.push('home'); + } + + // This is not currently used, as setRequestContext is not called for RSS feeds + if (rssPattern.test(res.locals.relativeUrl)) { + res.locals.context.push('rss'); + } + + // Add context 'amp' to either post or page, if we have an `*/amp` route + if (ampPattern.test(res.locals.relativeUrl) && data.post) { + res.locals.context.push('amp'); + } + + // Each page can only have at most one of these + if (req.channelConfig) { + res.locals.context.push(req.channelConfig.name); + } else if (privatePattern.test(res.locals.relativeUrl)) { + res.locals.context.push('private'); + } else if (subscribePattern.test(res.locals.relativeUrl)) { + res.locals.context.push('subscribe'); + } else if (data && data.post && data.post.page) { + res.locals.context.push('page'); + } else if (data && data.post) { + res.locals.context.push('post'); + } +} + +module.exports = setResponseContext; diff --git a/core/server/controllers/frontend/error.js b/core/server/controllers/frontend/error.js new file mode 100644 index 0000000..bb84c78 --- /dev/null +++ b/core/server/controllers/frontend/error.js @@ -0,0 +1,12 @@ +function handleError(next) { + return function handleError(err) { + // If we've thrown an error message of type: 'NotFound' then we found no path match. + if (err.errorType === 'NotFoundError') { + return next(); + } + + return next(err); + }; +} + +module.exports = handleError; diff --git a/core/server/controllers/frontend/fetch-data.js b/core/server/controllers/frontend/fetch-data.js new file mode 100644 index 0000000..2ecda45 --- /dev/null +++ b/core/server/controllers/frontend/fetch-data.js @@ -0,0 +1,122 @@ +/** + * # Fetch Data + * Dynamically build and execute queries on the API for channels + */ +var api = require('../../api'), + _ = require('lodash'), + config = require('../../config'), + Promise = require('bluebird'), + queryDefaults, + defaultPostQuery = {}; + +// The default settings for a default post query +queryDefaults = { + type: 'browse', + resource: 'posts', + options: {} +}; + +// Default post query needs to always include author & tags +_.extend(defaultPostQuery, queryDefaults, { + options: { + include: 'author,tags' + } +}); + +/** + * ## Fetch Posts Per page + * Grab the postsPerPage setting + * + * @param {Object} options + * @returns {Object} postOptions + */ +function fetchPostsPerPage(options) { + options = options || {}; + + var postsPerPage = parseInt(config.theme.postsPerPage); + + // No negative posts per page, must be number + if (!isNaN(postsPerPage) && postsPerPage > 0) { + options.limit = postsPerPage; + } + + // Ensure the options key is present, so this can be merged with other options + return {options: options}; +} + +/** + * @typedef query + * @ + */ + +/** + * ## Process Query + * Takes a 'query' object, ensures that type, resource and options are set + * Replaces occurrences of `%s` in options with slugParam + * Converts the query config to a promise for the result + * + * @param {{type: String, resource: String, options: Object}} query + * @param {String} slugParam + * @returns {Promise} promise for an API call + */ +function processQuery(query, slugParam) { + query = _.cloneDeep(query); + + // Ensure that all the properties are filled out + _.defaultsDeep(query, queryDefaults); + + // Replace any slugs + _.each(query.options, function (option, name) { + query.options[name] = _.isString(option) ? option.replace(/%s/g, slugParam) : option; + }); + + // Return a promise for the api query + return api[query.resource][query.type](query.options); +} + +/** + * ## Fetch Data + * Calls out to get posts per page, builds the final posts query & builds any additional queries + * Wraps the queries using Promise.props to ensure it gets named responses + * Does a first round of formatting on the response, and returns + * + * @param {Object} channelOptions + * @returns {Promise} response + */ +function fetchData(channelOptions) { + // @TODO improve this further + var pageOptions = channelOptions.isRSS ? + {options: channelOptions.postOptions} : fetchPostsPerPage(channelOptions.postOptions), + postQuery, + props = {}; + + // All channels must have a posts query, use the default if not provided + postQuery = _.defaultsDeep({}, pageOptions, defaultPostQuery); + props.posts = processQuery(postQuery, channelOptions.slugParam); + + _.each(channelOptions.data, function (query, name) { + props[name] = processQuery(query, channelOptions.slugParam); + }); + + return Promise.props(props).then(function formatResponse(results) { + var response = _.cloneDeep(results.posts); + delete results.posts; + + // process any remaining data + if (!_.isEmpty(results)) { + response.data = {}; + + _.each(results, function (result, name) { + if (channelOptions.data[name].type === 'browse') { + response.data[name] = result; + } else { + response.data[name] = result[channelOptions.data[name].resource]; + } + }); + } + + return response; + }); +} + +module.exports = fetchData; diff --git a/core/server/controllers/frontend/format-response.js b/core/server/controllers/frontend/format-response.js new file mode 100644 index 0000000..48a7de8 --- /dev/null +++ b/core/server/controllers/frontend/format-response.js @@ -0,0 +1,42 @@ +var _ = require('lodash'); + +/** + * formats variables for handlebars in multi-post contexts. + * If extraValues are available, they are merged in the final value + * @return {Object} containing page variables + */ +function formatPageResponse(result) { + var response = { + posts: result.posts, + pagination: result.meta.pagination + }; + + _.each(result.data, function (data, name) { + if (data.meta) { + // Move pagination to be a top level key + response[name] = data; + response[name].pagination = data.meta.pagination; + delete response[name].meta; + } else { + // This is a single object, don't wrap it in an array + response[name] = data[0]; + } + }); + + return response; +} + +/** + * similar to formatPageResponse, but for single post pages + * @return {Object} containing page variables + */ +function formatResponse(post) { + return { + post: post + }; +} + +module.exports = { + channel: formatPageResponse, + single: formatResponse +}; diff --git a/core/server/controllers/frontend/index.js b/core/server/controllers/frontend/index.js new file mode 100644 index 0000000..c7e94b4 --- /dev/null +++ b/core/server/controllers/frontend/index.js @@ -0,0 +1,92 @@ +/** + * Main controller for Ghost frontend + */ + +/*global require, module */ + +var api = require('../../api'), + config = require('../../config'), + filters = require('../../filters'), + templates = require('./templates'), + handleError = require('./error'), + formatResponse = require('./format-response'), + postLookup = require('./post-lookup'), + setResponseContext = require('./context'), + setRequestIsSecure = require('./secure'), + + frontendControllers; + +/* +* Sets the response context around a post and renders it +* with the current theme's post view. Used by post preview +* and single post methods. +* Returns a function that takes the post to be rendered. +*/ +function renderPost(req, res) { + return function renderPost(post) { + var view = templates.single(req.app.get('activeTheme'), post), + response = formatResponse.single(post); + + setResponseContext(req, res, response); + res.render(view, response); + }; +} + +frontendControllers = { + preview: function preview(req, res, next) { + var params = { + uuid: req.params.uuid, + status: 'all', + include: 'author,tags' + }; + + api.posts.read(params).then(function then(result) { + var post = result.posts[0]; + + if (!post) { + return next(); + } + + if (post.status === 'published') { + return res.redirect(301, config.urlFor('post', {post: post})); + } + + setRequestIsSecure(req, post); + + filters.doFilter('prePostsRender', post, res.locals) + .then(renderPost(req, res)); + }).catch(handleError(next)); + }, + single: function single(req, res, next) { + // Query database to find post + return postLookup(req.path).then(function then(lookup) { + var post = lookup ? lookup.post : false; + + if (!post) { + return next(); + } + + // CASE: postlookup can detect options for example /edit, unknown options get ignored and end in 404 + if (lookup.isUnknownOption) { + return next(); + } + + // CASE: last param is of url is /edit, redirect to admin + if (lookup.isEditURL) { + return res.redirect(config.paths.subdir + '/ghost/editor/' + post.id + '/'); + } + + // CASE: permalink is not valid anymore, we redirect him permanently to the correct one + if (post.url !== req.path) { + return res.redirect(301, post.url); + } + + setRequestIsSecure(req, post); + + filters.doFilter('prePostsRender', post, res.locals) + .then(renderPost(req, res)); + }).catch(handleError(next)); + } +}; + +module.exports = frontendControllers; diff --git a/core/server/controllers/frontend/post-lookup.js b/core/server/controllers/frontend/post-lookup.js new file mode 100644 index 0000000..7dd0d45 --- /dev/null +++ b/core/server/controllers/frontend/post-lookup.js @@ -0,0 +1,70 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + url = require('url'), + routeMatch = require('path-match')(), + api = require('../../api'), + config = require('../../config'), + + optionsFormat = '/:options?'; + +function getOptionsFormat(linkStructure) { + return linkStructure.replace(/\/$/, '') + optionsFormat; +} + +function postLookup(postUrl) { + var postPath = url.parse(postUrl).path, + postPermalink = config.theme.permalinks, + pagePermalink = '/:slug/', + isEditURL = false, + matchFuncPost, + matchFuncPage, + postParams, + params; + + // Convert saved permalink into a path-match function + matchFuncPost = routeMatch(getOptionsFormat(postPermalink)); + matchFuncPage = routeMatch(getOptionsFormat(pagePermalink)); + + postParams = matchFuncPost(postPath); + + // Check if the path matches the permalink structure. + // If there are no matches found, test to see if this is a page instead + params = postParams || matchFuncPage(postPath); + + // if there are no matches for either then return empty + if (params === false) { + return Promise.resolve(); + } + + // If params contains options, and it is equal to 'edit', this is an edit URL + if (params.options && params.options.toLowerCase() === 'edit') { + isEditURL = true; + } + + // Query database to find post + return api.posts.read(_.extend(_.pick(params, 'slug', 'id'), {include: 'author,tags'})).then(function then(result) { + var post = result.posts[0]; + + if (!post) { + return Promise.resolve(); + } + + // CASE: we originally couldn't match the post based on date permalink and we tried to check if its a page + if (!post.page && !postParams) { + return Promise.resolve(); + } + + // CASE: we only support /:slug format for pages + if (post.page && matchFuncPage(postPath) === false) { + return Promise.resolve(); + } + + return { + post: post, + isEditURL: isEditURL, + isUnknownOption: isEditURL ? false : params.options ? true : false + }; + }); +} + +module.exports = postLookup; diff --git a/core/server/controllers/frontend/render-channel.js b/core/server/controllers/frontend/render-channel.js new file mode 100644 index 0000000..66d0141 --- /dev/null +++ b/core/server/controllers/frontend/render-channel.js @@ -0,0 +1,61 @@ +var _ = require('lodash'), + errors = require('../../errors'), + filters = require('../../filters'), + safeString = require('../../utils/index').safeString, + labs = require('../../utils/labs'), + handleError = require('./error'), + fetchData = require('./fetch-data'), + formatResponse = require('./format-response'), + setResponseContext = require('./context'), + setRequestIsSecure = require('./secure'), + templates = require('./templates'); + +function renderChannel(req, res, next) { + // Parse the parameters we need from the URL + var channelOpts = req.channelConfig, + pageParam = req.params.page !== undefined ? req.params.page : 1, + slugParam = req.params.slug ? safeString(req.params.slug) : undefined; + + // Ensure we at least have an empty object for postOptions + channelOpts.postOptions = channelOpts.postOptions || {}; + // Set page on postOptions for the query made later + channelOpts.postOptions.page = pageParam; + channelOpts.slugParam = slugParam; + + // this is needed here because the channel config is cloned, + // and thus changes to labs flags don't update the config + // Once internal tags is moved out of labs the functionality can be + // moved back into the channel config + if (labs.isSet('internalTags') && channelOpts.name === 'tag') { + channelOpts.postOptions.filter = 'tags:\'%s\'+tags.visibility:\'public\''; + channelOpts.data.tag.options = {slug: '%s', visibility: 'public'}; + } + + // Call fetchData to get everything we need from the API + return fetchData(channelOpts).then(function handleResult(result) { + // If page is greater than number of pages we have, go straight to 404 + if (pageParam > result.meta.pagination.pages) { + return next(new errors.NotFoundError()); + } + + // @TODO: figure out if this can be removed, it's supposed to ensure that absolutely URLs get generated + // correctly for the various objects, but I believe it doesn't work and a different approach is needed. + setRequestIsSecure(req, result.posts); + _.each(result.data, function (data) { + setRequestIsSecure(req, data); + }); + + // @TODO: properly design these filters + filters.doFilter('prePostsRender', result.posts, res.locals).then(function then(posts) { + var view = templates.channel(req.app.get('activeTheme'), channelOpts); + + // Do final data formatting and then render + result.posts = posts; + result = formatResponse.channel(result); + setResponseContext(req, res); + res.render(view, result); + }); + }).catch(handleError(next)); +} + +module.exports = renderChannel; diff --git a/core/server/controllers/frontend/secure.js b/core/server/controllers/frontend/secure.js new file mode 100644 index 0000000..c91e4b2 --- /dev/null +++ b/core/server/controllers/frontend/secure.js @@ -0,0 +1,10 @@ +// TODO: figure out how to remove the need for this +// Add Request context parameter to the data object +// to be passed down to the templates +function setRequestIsSecure(req, data) { + (Array.isArray(data) ? data : [data]).forEach(function forEach(d) { + d.secure = req.secure; + }); +} + +module.exports = setRequestIsSecure; diff --git a/core/server/controllers/frontend/templates.js b/core/server/controllers/frontend/templates.js new file mode 100644 index 0000000..d69f74d --- /dev/null +++ b/core/server/controllers/frontend/templates.js @@ -0,0 +1,100 @@ +// # Templates +// +// Figure out which template should be used to render a request +// based on the templates which are allowed, and what is available in the theme +var _ = require('lodash'), + config = require('../../config'); + +function getActiveThemePaths(activeTheme) { + return config.paths.availableThemes[activeTheme]; +} + +/** + * ## Get Channel Template Hierarchy + * + * Fetch the ordered list of templates that can be used to render this request. + * 'index' is the default / fallback + * For channels with slugs: [:channelName-:slug, :channelName, index] + * For channels without slugs: [:channelName, index] + * Channels can also have a front page template which is used if this is the first page of the channel, e.g. 'home.hbs' + * + * @param {Object} channelOpts + * @returns {String[]} + */ +function getChannelTemplateHierarchy(channelOpts) { + var templateList = ['index']; + + if (channelOpts.name && channelOpts.name !== 'index') { + templateList.unshift(channelOpts.name); + + if (channelOpts.slugTemplate && channelOpts.slugParam) { + templateList.unshift(channelOpts.name + '-' + channelOpts.slugParam); + } + } + + if (channelOpts.frontPageTemplate && channelOpts.postOptions.page === 1) { + templateList.unshift(channelOpts.frontPageTemplate); + } + + return templateList; +} + +/** + * ## Get Single Template Hierarchy + * + * Fetch the ordered list of templates that can be used to render this request. + * 'post' is the default / fallback + * For posts: [post-:slug, post] + * For pages: [page-:slug, page, post] + * + * @param {Object} single + * @returns {String[]} + */ +function getSingleTemplateHierarchy(single) { + var templateList = ['post'], + type = 'post'; + + if (single.page) { + templateList.unshift('page'); + type = 'page'; + } + + templateList.unshift(type + '-' + single.slug); + + return templateList; +} + +/** + * ## Pick Template + * + * Taking the ordered list of allowed templates for this request + * Cycle through and find the first one which has a match in the theme + * + * @param {Object} themePaths + * @param {Array} templateList + */ +function pickTemplate(themePaths, templateList) { + var template = _.find(templateList, function (template) { + return themePaths.hasOwnProperty(template + '.hbs'); + }); + + if (!template) { + template = templateList[templateList.length - 1]; + } + + return template; +} + +function getTemplateForSingle(activeTheme, single) { + return pickTemplate(getActiveThemePaths(activeTheme), getSingleTemplateHierarchy(single)); +} + +function getTemplateForChannel(activeTheme, channelOpts) { + return pickTemplate(getActiveThemePaths(activeTheme), getChannelTemplateHierarchy(channelOpts)); +} + +module.exports = { + getActiveThemePaths: getActiveThemePaths, + channel: getTemplateForChannel, + single: getTemplateForSingle +}; diff --git a/core/server/data/db/connection.js b/core/server/data/db/connection.js new file mode 100644 index 0000000..6a26cb5 --- /dev/null +++ b/core/server/data/db/connection.js @@ -0,0 +1,58 @@ +var knex = require('knex'), + _ = require('lodash'), + config = require('../../config'), + knexInstance; + +// @TODO: +// - if you require this file before config file was loaded, +// - then this file is cached and you have no chance to connect to the db anymore +// - bring dynamic into this file (db.connect()) +function configure(dbConfig) { + var client = dbConfig.client, + pg; + + dbConfig.isPostgreSQL = function () { + return client === 'pg' || client === 'postgres' || client === 'postgresql'; + }; + + if (dbConfig.isPostgreSQL()) { + try { + pg = require('pg'); + } catch (e) { + pg = require('pg.js'); + } + + // By default PostgreSQL returns data as strings along with an OID that identifies + // its type. We're setting the parser to convert OID 20 (int8) into a javascript + // integer. + pg.types.setTypeParser(20, function (val) { + return val === null ? null : parseInt(val, 10); + }); + + // https://github.com/tgriesser/knex/issues/97 + // this sets the timezone to UTC only for the connection! + dbConfig.pool = _.defaults({ + afterCreate: function (connection, callback) { + connection.query('set timezone=\'UTC\'', function (err) { + callback(err, connection); + }); + } + }, dbConfig.pool); + } + + if (client === 'sqlite3') { + dbConfig.useNullAsDefault = dbConfig.useNullAsDefault || false; + } + + if (client === 'mysql') { + dbConfig.connection.timezone = 'UTC'; + } + + return dbConfig; +} + +if (!knexInstance && config.database && config.database.client) { + knexInstance = knex(configure(config.database)); +} + +module.exports = knexInstance; diff --git a/core/server/data/db/index.js b/core/server/data/db/index.js new file mode 100644 index 0000000..b1d2d03 --- /dev/null +++ b/core/server/data/db/index.js @@ -0,0 +1,10 @@ +var connection; + +Object.defineProperty(exports, 'knex', { + enumerable: true, + configurable: true, + get: function get() { + connection = connection || require('./connection'); + return connection; + } +}); diff --git a/core/server/data/export/index.js b/core/server/data/export/index.js new file mode 100644 index 0000000..fac2618 --- /dev/null +++ b/core/server/data/export/index.js @@ -0,0 +1,84 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + db = require('../../data/db'), + commands = require('../schema').commands, + versioning = require('../schema').versioning, + serverUtils = require('../../utils'), + errors = require('../../errors'), + settings = require('../../api/settings'), + i18n = require('../../i18n'), + + excludedTables = ['accesstokens', 'refreshtokens', 'clients', 'client_trusted_domains'], + modelOptions = {context: {internal: true}}, + + // private + getVersionAndTables, + exportTable, + + // public + doExport, + exportFileName; + +exportFileName = function exportFileName() { + var datetime = (new Date()).toJSON().substring(0, 10), + title = ''; + + return settings.read(_.extend({}, {key: 'title'}, modelOptions)).then(function (result) { + if (result) { + title = serverUtils.safeString(result.settings[0].value) + '.'; + } + return title + 'ghost.' + datetime + '.json'; + }).catch(function (err) { + errors.logError(err); + return 'ghost.' + datetime + '.json'; + }); +}; + +getVersionAndTables = function getVersionAndTables() { + var props = { + version: versioning.getDatabaseVersion(), + tables: commands.getTables() + }; + + return Promise.props(props); +}; + +exportTable = function exportTable(tableName) { + if (excludedTables.indexOf(tableName) < 0) { + return db.knex(tableName).select(); + } +}; + +doExport = function doExport() { + var tables, version; + + return getVersionAndTables().then(function exportAllTables(result) { + tables = result.tables; + version = result.version; + + return Promise.mapSeries(tables, exportTable); + }).then(function formatData(tableData) { + var exportData = { + meta: { + exported_on: new Date().getTime(), + version: version + }, + data: { + // Filled below + } + }; + + _.each(tables, function (name, i) { + exportData.data[name] = tableData[i]; + }); + + return exportData; + }).catch(function (err) { + errors.logAndThrowError(err, i18n.t('errors.data.export.errorExportingData'), ''); + }); +}; + +module.exports = { + doExport: doExport, + fileName: exportFileName +}; diff --git a/core/server/data/import/data-importer.js b/core/server/data/import/data-importer.js new file mode 100644 index 0000000..e33e9fc --- /dev/null +++ b/core/server/data/import/data-importer.js @@ -0,0 +1,178 @@ +var Promise = require('bluebird'), + _ = require('lodash'), + models = require('../../models'), + utils = require('./utils'), + i18n = require('../../i18n'), + + internal = utils.internal, + + DataImporter; + +DataImporter = function () {}; + +DataImporter.prototype.importData = function (data) { + return this.doImport(data); +}; + +DataImporter.prototype.loadRoles = function () { + var options = _.extend({}, internal); + + return models.Role.findAll(options).then(function (roles) { + return roles.toJSON(); + }); +}; + +DataImporter.prototype.loadUsers = function () { + var users = {all: {}}, + options = _.extend({}, {include: ['roles']}, internal); + + return models.User.findAll(options).then(function (_users) { + _users.forEach(function (user) { + users.all[user.get('email')] = {realId: user.get('id')}; + if (user.related('roles').toJSON(options)[0] && user.related('roles').toJSON(options)[0].name === 'Owner') { + users.owner = user.toJSON(options); + } + }); + + if (!users.owner) { + return Promise.reject(i18n.t('errors.data.import.dataImporter.unableToFindOwner')); + } + + return users; + }); +}; + +DataImporter.prototype.doUserImport = function (t, tableData, owner, users, errors, roles) { + var userOps = [], + imported = []; + + if (tableData.users && tableData.users.length) { + if (tableData.roles_users && tableData.roles_users.length) { + tableData = utils.preProcessRolesUsers(tableData, owner, roles); + } + + // Import users, deduplicating with already present users + userOps = utils.importUsers(tableData.users, users, t).map(function (userImport) { + return userImport.reflect(); + }); + + return Promise.all(userOps).then(function (descriptors) { + descriptors.forEach(function (d) { + if (!d.isFulfilled()) { + if (!d.reason().raw || (d.reason().raw.errno !== 19 && d.reason().raw.errno !== 1062)) { + errors = errors.concat(d.reason()); + } + } else { + imported.push(d.value().toJSON(internal)); + } + }); + + // If adding the users fails, + if (errors.length > 0) { + t.rollback(errors); + } else { + return imported; + } + }); + } + + return Promise.resolve({}); +}; + +DataImporter.prototype.doImport = function (data) { + var self = this, + tableData = data.data, + imported = {}, + errors = [], + users = {}, + owner = {}, roles = {}; + + return self.loadRoles().then(function (_roles) { + roles = _roles; + + return self.loadUsers().then(function (result) { + owner = result.owner; + users = result.all; + + return models.Base.transaction(function (t) { + // Step 1: Attempt to handle adding new users + self.doUserImport(t, tableData, owner, users, errors, roles).then(function (result) { + var importResults = []; + + imported.users = result; + + _.each(imported.users, function (user) { + users[user.email] = {realId: user.id}; + }); + + // process user data - need to figure out what users we have available for assigning stuff to etc + try { + tableData = utils.processUsers(tableData, owner, users, ['posts', 'tags']); + } catch (error) { + return t.rollback([error]); + } + + // Do any pre-processing of relationships (we can't depend on ids) + if (tableData.posts_tags && tableData.posts && tableData.tags) { + tableData = utils.preProcessPostTags(tableData); + } + + // Import things in the right order + + return utils.importTags(tableData.tags, t).then(function (results) { + if (results) { + importResults = importResults.concat(results); + } + + return utils.importPosts(tableData.posts, t); + }).then(function (results) { + if (results) { + importResults = importResults.concat(results); + } + + return utils.importSettings(tableData.settings, t); + }).then(function (results) { + if (results) { + importResults = importResults.concat(results); + } + + return utils.importSubscribers(tableData.subscribers, t); + }).then(function (results) { + if (results) { + importResults = importResults.concat(results); + } + }).then(function () { + importResults.forEach(function (p) { + if (!p.isFulfilled()) { + errors = errors.concat(p.reason()); + } + }); + + if (errors.length === 0) { + t.commit(); + } else { + t.rollback(errors); + } + }); + + /** do nothing with these tables, the data shouldn't have changed from the fixtures + * permissions + * roles + * permissions_roles + * permissions_users + */ + }); + }).then(function () { + // TODO: could return statistics of imported items + return Promise.resolve(); + }); + }); + }); +}; + +module.exports = { + DataImporter: DataImporter, + importData: function (data) { + return new DataImporter().importData(data); + } +}; diff --git a/core/server/data/import/index.js b/core/server/data/import/index.js new file mode 100644 index 0000000..5ba5e06 --- /dev/null +++ b/core/server/data/import/index.js @@ -0,0 +1,207 @@ +var Promise = require('bluebird'), + _ = require('lodash'), + validation = require('../validation'), + errors = require('../../errors'), + uuid = require('uuid'), + importer = require('./data-importer'), + tables = require('../schema').tables, + i18n = require('../../i18n'), + validate, + handleErrors, + checkDuplicateAttributes, + sanitize, + cleanError, + doImport; + +cleanError = function cleanError(error) { + var temp, + message, + offendingProperty, + value; + + if (error.raw.message.toLowerCase().indexOf('unique') !== -1) { + // This is a unique constraint failure + if (error.raw.message.indexOf('ER_DUP_ENTRY') !== -1) { + temp = error.raw.message.split('\''); + if (temp.length === 5) { + value = temp[1]; + temp = temp[3].split('_'); + offendingProperty = temp.length === 3 ? temp[0] + '.' + temp[1] : error.model; + } + } else if (error.raw.message.indexOf('SQLITE_CONSTRAINT') !== -1) { + temp = error.raw.message.split('failed: '); + offendingProperty = temp.length === 2 ? temp[1] : error.model; + temp = offendingProperty.split('.'); + value = temp.length === 2 ? error.data[temp[1]] : 'unknown'; + } else if (error.raw.detail) { + value = error.raw.detail; + offendingProperty = error.model; + } + message = i18n.t('errors.data.import.index.duplicateEntryFound', {value: value, offendingProperty: offendingProperty}); + } + + offendingProperty = offendingProperty || error.model; + value = value || 'unknown'; + message = message || error.raw.message; + + return new errors.DataImportError(message, offendingProperty, value); +}; + +handleErrors = function handleErrors(errorList) { + var processedErrors = []; + + if (!_.isArray(errorList)) { + return Promise.reject(errorList); + } + + _.each(errorList, function (error) { + if (!error.raw) { + // These are validation errors + processedErrors.push(error); + } else if (_.isArray(error.raw)) { + processedErrors = processedErrors.concat(error.raw); + } else { + processedErrors.push(cleanError(error)); + } + }); + + return Promise.reject(processedErrors); +}; + +checkDuplicateAttributes = function checkDuplicateAttributes(data, comparedValue, attribs) { + // Check if any objects in data have the same attribute values + return _.find(data, function (datum) { + return _.every(attribs, function (attrib) { + return datum[attrib] === comparedValue[attrib]; + }); + }); +}; + +sanitize = function sanitize(data) { + var allProblems = {}, + tablesInData = _.keys(data.data), + tableNames = _.sortBy(_.keys(tables), function (tableName) { + // We want to guarantee posts and tags go first + if (tableName === 'posts') { + return 1; + } else if (tableName === 'tags') { + return 2; + } + + return 3; + }); + + tableNames = _.intersection(tableNames, tablesInData); + + _.each(tableNames, function (tableName) { + // Sanitize the table data for duplicates and valid uuid and created_at values + var sanitizedTableData = _.transform(data.data[tableName], function (memo, importValues) { + var uuidMissing = (!importValues.uuid && tables[tableName].uuid) ? true : false, + uuidMalformed = (importValues.uuid && !validation.validator.isUUID(importValues.uuid)) ? true : false, + isDuplicate, + problemTag; + + // Check for correct UUID and fix if necessary + if (uuidMissing || uuidMalformed) { + importValues.uuid = uuid.v4(); + } + + // Custom sanitize for posts, tags and users + if (tableName === 'posts') { + // Check if any previously added posts have the same + // title and slug + isDuplicate = checkDuplicateAttributes(memo.data, importValues, ['title', 'slug']); + + // If it's a duplicate add to the problems and continue on + if (isDuplicate) { + // TODO: Put the reason why it was a problem? + memo.problems.push(importValues); + return; + } + } else if (tableName === 'tags') { + // Check if any previously added posts have the same + // name and slug + isDuplicate = checkDuplicateAttributes(memo.data, importValues, ['name', 'slug']); + + // If it's a duplicate add to the problems and continue on + if (isDuplicate) { + // TODO: Put the reason why it was a problem? + // Remember this tag so it can be updated later + importValues.duplicate = isDuplicate; + memo.problems.push(importValues); + + return; + } + } else if (tableName === 'posts_tags') { + // Fix up removed tags associations + problemTag = _.find(allProblems.tags, function (tag) { + return tag.id === importValues.tag_id; + }); + + // Update the tag id to the original "duplicate" id + if (problemTag) { + importValues.tag_id = problemTag.duplicate.id; + } + } + + memo.data.push(importValues); + }, { + data: [], + problems: [] + }); + + // Store the table data to return + data.data[tableName] = sanitizedTableData.data; + + // Keep track of all problems for all tables + if (!_.isEmpty(sanitizedTableData.problems)) { + allProblems[tableName] = sanitizedTableData.problems; + } + }); + + return { + data: data, + problems: allProblems + }; +}; + +validate = function validate(data) { + var validateOps = []; + + _.each(_.keys(data.data), function (tableName) { + _.each(data.data[tableName], function (importValues) { + validateOps.push(validation. + validateSchema(tableName, importValues).reflect()); + }); + }); + + return Promise.all(validateOps).then(function (descriptors) { + var errorList = []; + + _.each(descriptors, function (d) { + if (!d.isFulfilled()) { + errorList = errorList.concat(d.reason()); + } + }); + + if (!_.isEmpty(errorList)) { + return Promise.reject(errorList); + } + }); +}; + +doImport = function (data) { + var sanitizeResults = sanitize(data); + + data = sanitizeResults.data; + + return validate(data).then(function () { + return importer.importData(data); + }).then(function () { + return sanitizeResults; + }).catch(function (result) { + return handleErrors(result); + }); +}; + +module.exports.doImport = doImport; diff --git a/core/server/data/import/utils.js b/core/server/data/import/utils.js new file mode 100644 index 0000000..ad7b18b --- /dev/null +++ b/core/server/data/import/utils.js @@ -0,0 +1,355 @@ +var Promise = require('bluebird'), + _ = require('lodash'), + models = require('../../models'), + errors = require('../../errors'), + globalUtils = require('../../utils'), + i18n = require('../../i18n'), + + internalContext = {context: {internal: true}}, + utils, + areEmpty, + updatedSettingKeys, + stripProperties; + +updatedSettingKeys = { + activePlugins: 'activeApps', + installedPlugins: 'installedApps' +}; + +areEmpty = function (object) { + var fields = _.toArray(arguments).slice(1), + areEmpty = _.every(fields, function (field) { + return _.isEmpty(object[field]); + }); + + return areEmpty; +}; + +stripProperties = function stripProperties(properties, data) { + data = _.cloneDeep(data); + _.each(data, function (obj) { + _.each(properties, function (property) { + delete obj[property]; + }); + }); + return data; +}; + +utils = { + internal: internalContext, + + processUsers: function preProcessUsers(tableData, owner, existingUsers, objs) { + // We need to: + // 1. figure out who the owner of the blog is + // 2. figure out what users we have + // 3. figure out what users the import data refers to in foreign keys + // 4. try to map each one to a user + var userKeys = ['created_by', 'updated_by', 'published_by', 'author_id'], + userMap = {}; + + // Search the passed in objects for any user foreign keys + _.each(objs, function (obj) { + if (tableData[obj]) { + // For each object in the tableData that matches + _.each(tableData[obj], function (data) { + // For each possible user foreign key + _.each(userKeys, function (key) { + if (_.has(data, key) && data[key] !== null) { + userMap[data[key]] = {}; + } + }); + }); + } + }); + + // We now have a list of users we need to figure out what their email addresses are + _.each(_.keys(userMap), function (userToMap) { + userToMap = parseInt(userToMap, 10); + var foundUser = _.find(tableData.users, function (tableDataUser) { + return tableDataUser.id === userToMap; + }); + + // we now know that userToMap's email is foundUser.email - look them up in existing users + if (foundUser && _.has(foundUser, 'email') && _.has(existingUsers, foundUser.email)) { + existingUsers[foundUser.email].importId = userToMap; + userMap[userToMap] = existingUsers[foundUser.email].realId; + } else if (userToMap === 1) { + // if we don't have user data and the id is 1, we assume this means the owner + existingUsers[owner.email].importId = userToMap; + userMap[userToMap] = existingUsers[owner.email].realId; + } else if (userToMap === 0) { + // CASE: external context + userMap[userToMap] = '0'; + } else { + throw new errors.DataImportError( + i18n.t('errors.data.import.utils.dataLinkedToUnknownUser', {userToMap: userToMap}), 'user.id', userToMap + ); + } + }); + + // now replace any user foreign keys + _.each(objs, function (obj) { + if (tableData[obj]) { + // For each object in the tableData that matches + _.each(tableData[obj], function (data) { + // For each possible user foreign key + _.each(userKeys, function (key) { + if (_.has(data, key) && data[key] !== null) { + data[key] = userMap[data[key]]; + } + }); + }); + } + }); + + return tableData; + }, + + preProcessPostTags: function preProcessPostTags(tableData) { + var postTags, + postsWithTags = {}; + + postTags = tableData.posts_tags; + _.each(postTags, function (postTag) { + if (!postsWithTags.hasOwnProperty(postTag.post_id)) { + postsWithTags[postTag.post_id] = []; + } + postsWithTags[postTag.post_id].push(postTag.tag_id); + }); + + _.each(postsWithTags, function (tagIds, postId) { + var post, tags; + post = _.find(tableData.posts, function (post) { + return post.id === parseInt(postId, 10); + }); + if (post) { + tags = _.filter(tableData.tags, function (tag) { + return _.indexOf(tagIds, tag.id) !== -1; + }); + post.tags = []; + _.each(tags, function (tag) { + // names are unique.. this should get the right tags added + // as long as tags are added first; + post.tags.push({name: tag.name}); + }); + } + }); + + return tableData; + }, + + preProcessRolesUsers: function preProcessRolesUsers(tableData, owner, roles) { + var validRoles = _.map(roles, 'name'); + if (!tableData.roles || !tableData.roles.length) { + tableData.roles = roles; + } + + _.each(tableData.roles, function (_role) { + var match = false; + // Check import data does not contain unknown roles + _.each(validRoles, function (validRole) { + if (_role.name === validRole) { + match = true; + _role.oldId = _role.id; + _role.id = _.find(roles, {name: validRole}).id; + } + }); + // If unknown role is found then remove role to force down to Author + if (!match) { + _role.oldId = _role.id; + _role.id = _.find(roles, {name: 'Author'}).id; + } + }); + + _.each(tableData.roles_users, function (roleUser) { + var user = _.find(tableData.users, function (user) { + return user.id === parseInt(roleUser.user_id, 10); + }); + + // Map role_id to updated roles id + roleUser.role_id = _.find(tableData.roles, {oldId: roleUser.role_id}).id; + + // Check for owner users that do not match current owner and change role to administrator + if (roleUser.role_id === owner.roles[0].id && user && user.email && user.email !== owner.email) { + roleUser.role_id = _.find(roles, {name: 'Administrator'}).id; + user.roles = [roleUser.role_id]; + } + + // just the one role for now + if (user && !user.roles) { + user.roles = [roleUser.role_id]; + } + }); + + return tableData; + }, + + importTags: function importTags(tableData, transaction) { + if (!tableData) { + return Promise.resolve(); + } + + var ops = []; + + tableData = stripProperties(['id'], tableData); + _.each(tableData, function (tag) { + // Validate minimum tag fields + if (areEmpty(tag, 'name', 'slug')) { + return; + } + + ops.push(models.Tag.findOne({name: tag.name}, {transacting: transaction}).then(function (_tag) { + if (!_tag) { + return models.Tag.add(tag, _.extend({}, internalContext, {transacting: transaction})) + .catch(function (error) { + return Promise.reject({raw: error, model: 'tag', data: tag}); + }); + } + + return _tag; + }).reflect()); + }); + + return Promise.all(ops); + }, + + importPosts: function importPosts(tableData, transaction) { + if (!tableData) { + return Promise.resolve(); + } + + var ops = []; + + tableData = stripProperties(['id'], tableData); + _.each(tableData, function (post) { + // Validate minimum post fields + if (areEmpty(post, 'title', 'slug', 'markdown')) { + return; + } + + // The post importer has auto-timestamping disabled + if (!post.created_at) { + post.created_at = Date.now(); + } + + ops.push(models.Post.add(post, _.extend({}, internalContext, {transacting: transaction, importing: true})) + .catch(function (error) { + return Promise.reject({raw: error, model: 'post', data: post}); + }).reflect() + ); + }); + + return Promise.all(ops); + }, + + importUsers: function importUsers(tableData, existingUsers, transaction) { + var ops = []; + tableData = stripProperties(['id'], tableData); + _.each(tableData, function (user) { + // Validate minimum user fields + if (areEmpty(user, 'name', 'slug', 'email')) { + return; + } + + if (_.has(existingUsers, user.email)) { + // User is already present, ignore + return; + } + + // Set password to a random password, and lock the account + user.password = globalUtils.uid(50); + user.status = 'locked'; + + ops.push(models.User.add(user, _.extend({}, internalContext, {transacting: transaction})) + .catch(function (error) { + return Promise.reject({raw: error, model: 'user', data: user}); + })); + }); + + return ops; + }, + + importSettings: function importSettings(tableData, transaction) { + if (!tableData) { + return Promise.resolve(); + } + + // for settings we need to update individual settings, and insert any missing ones + // settings we MUST NOT update are 'core' and 'theme' settings + // as all of these will cause side effects which don't make sense for an import + var blackList = ['core', 'theme'], + ops = []; + + tableData = stripProperties(['id'], tableData); + tableData = _.filter(tableData, function (data) { + return blackList.indexOf(data.type) === -1; + }); + + // Clean up legacy plugin setting references + _.each(tableData, function (datum) { + datum.key = updatedSettingKeys[datum.key] || datum.key; + }); + + ops.push(models.Settings.edit(tableData, _.extend({}, internalContext, {transacting: transaction})).catch(function (error) { + // Ignore NotFound errors + if (!(error instanceof errors.NotFoundError)) { + return Promise.reject({raw: error, model: 'setting', data: tableData}); + } + }).reflect()); + + return Promise.all(ops); + }, + + importSubscribers: function importSubscribers(tableData, transaction) { + if (!tableData) { + return Promise.resolve(); + } + + var ops = []; + tableData = stripProperties(['id', 'post_id'], tableData); + + _.each(tableData, function (subscriber) { + ops.push(models.Subscriber.add(subscriber, _.extend({}, internalContext, {transacting: transaction})) + .catch(function (error) { + // ignore duplicates + if (error.code && error.message.toLowerCase().indexOf('unique') === -1) { + return Promise.reject({ + raw: error, + model: 'subscriber', + data: subscriber + }); + } + }).reflect()); + }); + + return Promise.all(ops); + }, + + /** For later **/ + importApps: function importApps(tableData, transaction) { + if (!tableData) { + return Promise.resolve(); + } + + var ops = []; + + tableData = stripProperties(['id'], tableData); + _.each(tableData, function (app) { + // Avoid duplicates + ops.push(models.App.findOne({name: app.name}, {transacting: transaction}).then(function (_app) { + if (!_app) { + return models.App.add(app, _.extend({}, internalContext, {transacting: transaction})) + .catch(function (error) { + return Promise.reject({raw: error, model: 'app', data: app}); + }); + } + + return _app; + }).reflect()); + }); + + return Promise.all(ops); + } +}; + +module.exports = utils; diff --git a/core/server/data/importer/handlers/image.js b/core/server/data/importer/handlers/image.js new file mode 100644 index 0000000..e77c038 --- /dev/null +++ b/core/server/data/importer/handlers/image.js @@ -0,0 +1,48 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + path = require('path'), + config = require('../../../config'), + storage = require('../../../storage'), + + ImageHandler; + +ImageHandler = { + type: 'images', + extensions: config.uploads.images.extensions, + contentTypes: config.uploads.images.contentTypes, + directories: ['images', 'content'], + + loadFile: function (files, baseDir) { + var store = storage.getStorage(), + baseDirRegex = baseDir ? new RegExp('^' + baseDir + '/') : new RegExp(''), + imageFolderRegexes = _.map(config.paths.imagesRelPath.split('/'), function (dir) { + return new RegExp('^' + dir + '/'); + }); + + // normalize the directory structure + files = _.map(files, function (file) { + var noBaseDir = file.name.replace(baseDirRegex, ''), + noGhostDirs = noBaseDir; + + _.each(imageFolderRegexes, function (regex) { + noGhostDirs = noGhostDirs.replace(regex, ''); + }); + + file.originalPath = noBaseDir; + file.name = noGhostDirs; + file.targetDir = path.join(config.paths.imagesPath, path.dirname(noGhostDirs)); + return file; + }); + + return Promise.map(files, function (image) { + return store.getUniqueFileName(store, image, image.targetDir).then(function (targetFilename) { + image.newPath = (config.paths.subdir + '/' + + config.paths.imagesRelPath + '/' + path.relative(config.paths.imagesPath, targetFilename)) + .replace(new RegExp('\\' + path.sep, 'g'), '/'); + return image; + }); + }); + } +}; + +module.exports = ImageHandler; diff --git a/core/server/data/importer/handlers/json.js b/core/server/data/importer/handlers/json.js new file mode 100644 index 0000000..a781e00 --- /dev/null +++ b/core/server/data/importer/handlers/json.js @@ -0,0 +1,43 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + fs = require('fs-extra'), + errors = require('../../../errors'), + i18n = require('../../../i18n'), + JSONHandler; + +JSONHandler = { + type: 'data', + extensions: ['.json'], + contentTypes: ['application/octet-stream', 'application/json'], + directories: [], + + loadFile: function (files, startDir) { + /*jshint unused:false */ + // @TODO: Handle multiple JSON files + var filePath = files[0].path; + + return Promise.promisify(fs.readFile)(filePath).then(function (fileData) { + var importData; + try { + importData = JSON.parse(fileData); + + // if importData follows JSON-API format `{ db: [exportedData] }` + if (_.keys(importData).length === 1) { + if (!importData.db || !Array.isArray(importData.db)) { + throw new Error(i18n.t('errors.data.importer.handlers.json.invalidJsonFormat')); + } + + importData = importData.db[0]; + } + + return importData; + } catch (e) { + errors.logError(e, i18n.t('errors.data.importer.handlers.json.apiDbImportContent'), + i18n.t('errors.data.importer.handlers.json.checkImportJsonIsValid')); + return Promise.reject(new errors.BadRequestError(i18n.t('errors.data.importer.handlers.json.failedToParseImportJson'))); + } + }); + } +}; + +module.exports = JSONHandler; diff --git a/core/server/data/importer/handlers/markdown.js b/core/server/data/importer/handlers/markdown.js new file mode 100644 index 0000000..38fb376 --- /dev/null +++ b/core/server/data/importer/handlers/markdown.js @@ -0,0 +1,112 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + fs = require('fs-extra'), + moment = require('moment'), + + featuredImageRegex = /^(!\[]\(([^)]*?)\)\s+)(?=#)/, + titleRegex = /^#\s?([\w\W]*?)(?=\n)/, + statusRegex = /(published||draft)-/, + dateRegex = /(\d{4}-\d{2}-\d{2})-/, + + processDateTime, + processFileName, + processMarkdownFile, + MarkdownHandler; + +// Takes a date from the filename in y-m-d-h-m form, and converts it into a Date ready to import +processDateTime = function (post, datetime) { + var format = 'YYYY-MM-DD-HH-mm'; + datetime = moment.utc(datetime, format).valueOf(); + + if (post.status && post.status === 'published') { + post.published_at = datetime; + } else { + post.created_at = datetime; + } + + return post; +}; + +processFileName = function (filename) { + var post = {}, + name = filename.split('.')[0], + match; + + // Parse out the status + match = name.match(statusRegex); + if (match) { + post.status = match[1]; + name = name.replace(match[0], ''); + } + + // Parse out the date + match = name.match(dateRegex); + if (match) { + name = name.replace(match[0], ''); + // Default to middle of the day + post = processDateTime(post, match[1] + '-12-00'); + } + + post.slug = name; + post.title = name; + + return post; +}; + +processMarkdownFile = function (filename, content) { + var post = processFileName(filename), + match; + + content = content.replace(/\r\n/gm, '\n'); + + // parse out any image which appears before the title + match = content.match(featuredImageRegex); + if (match) { + content = content.replace(match[1], ''); + post.image = match[2]; + } + + // try to parse out a heading 1 for the title + match = content.match(titleRegex); + if (match) { + content = content.replace(titleRegex, ''); + post.title = match[1]; + } + + content = content.replace(/^\n+/, ''); + + post.markdown = content; + + return post; +}; + +MarkdownHandler = { + type: 'data', + extensions: ['.md', '.markdown'], + contentTypes: ['application/octet-stream', 'text/plain'], + directories: [], + + loadFile: function (files, startDir) { + /*jshint unused:false */ + var startDirRegex = startDir ? new RegExp('^' + startDir + '/') : new RegExp(''), + posts = [], + ops = []; + + _.each(files, function (file) { + ops.push(Promise.promisify(fs.readFile)(file.path).then(function (content) { + // normalize the file name + file.name = file.name.replace(startDirRegex, ''); + // don't include deleted posts + if (!/^deleted/.test(file.name)) { + posts.push(processMarkdownFile(file.name, content.toString())); + } + })); + }); + + return Promise.all(ops).then(function () { + return {meta: {}, data: {posts: posts}}; + }); + } +}; + +module.exports = MarkdownHandler; diff --git a/core/server/data/importer/importers/data.js b/core/server/data/importer/importers/data.js new file mode 100644 index 0000000..56847a5 --- /dev/null +++ b/core/server/data/importer/importers/data.js @@ -0,0 +1,15 @@ +var importer = require('../../import'), + DataImporter; + +DataImporter = { + type: 'data', + preProcess: function (importData) { + importData.preProcessedByData = true; + return importData; + }, + doImport: function (importData) { + return importer.doImport(importData); + } +}; + +module.exports = DataImporter; diff --git a/core/server/data/importer/importers/image.js b/core/server/data/importer/importers/image.js new file mode 100644 index 0000000..81bfe6b --- /dev/null +++ b/core/server/data/importer/importers/image.js @@ -0,0 +1,73 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + storage = require('../../../storage'), + replaceImage, + ImageImporter, + preProcessPosts, + preProcessTags, + preProcessUsers; + +replaceImage = function (markdown, image) { + // Normalizes to include a trailing slash if there was one + var regex = new RegExp('(/)?' + image.originalPath, 'gm'); + + return markdown.replace(regex, image.newPath); +}; + +preProcessPosts = function (data, image) { + _.each(data.posts, function (post) { + post.markdown = replaceImage(post.markdown, image); + if (post.html) { + post.html = replaceImage(post.html, image); + } + if (post.image) { + post.image = replaceImage(post.image, image); + } + }); +}; + +preProcessTags = function (data, image) { + _.each(data.tags, function (tag) { + if (tag.image) { + tag.image = replaceImage(tag.image, image); + } + }); +}; + +preProcessUsers = function (data, image) { + _.each(data.users, function (user) { + if (user.cover) { + user.cover = replaceImage(user.cover, image); + } + if (user.image) { + user.image = replaceImage(user.image, image); + } + }); +}; + +ImageImporter = { + type: 'images', + preProcess: function (importData) { + if (importData.images && importData.data) { + _.each(importData.images, function (image) { + preProcessPosts(importData.data.data, image); + preProcessTags(importData.data.data, image); + preProcessUsers(importData.data.data, image); + }); + } + + importData.preProcessedByImage = true; + return importData; + }, + doImport: function (imageData) { + var store = storage.getStorage(); + + return Promise.map(imageData, function (image) { + return store.save(image, image.targetDir).then(function (result) { + return {originalPath: image.originalPath, newPath: image.newPath, stored: result}; + }); + }); + } +}; + +module.exports = ImageImporter; diff --git a/core/server/data/importer/index.js b/core/server/data/importer/index.js new file mode 100644 index 0000000..e5fbea6 --- /dev/null +++ b/core/server/data/importer/index.js @@ -0,0 +1,377 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + sequence = require('../../utils/sequence'), + pipeline = require('../../utils/pipeline'), + fs = require('fs-extra'), + path = require('path'), + os = require('os'), + glob = require('glob'), + uuid = require('uuid'), + extract = require('extract-zip-fork'), + errors = require('../../errors'), + ImageHandler = require('./handlers/image'), + JSONHandler = require('./handlers/json'), + MarkdownHandler = require('./handlers/markdown'), + ImageImporter = require('./importers/image'), + DataImporter = require('./importers/data'), + i18n = require('../../i18n'), + + // Glob levels + ROOT_ONLY = 0, + ROOT_OR_SINGLE_DIR = 1, + ALL_DIRS = 2, + + defaults; + +defaults = { + extensions: ['.zip'], + contentTypes: ['application/zip', 'application/x-zip-compressed'], + directories: [] +}; + +function ImportManager() { + this.importers = [ImageImporter, DataImporter]; + this.handlers = [ImageHandler, JSONHandler, MarkdownHandler]; + // Keep track of files to cleanup at the end + this.filesToDelete = []; +} + +/** + * A number, or a string containing a number. + * @typedef {Object} ImportData + * @property [Object] data + * @property [Array] images + */ + +_.extend(ImportManager.prototype, { + /** + * Get an array of all the file extensions for which we have handlers + * @returns {string[]} + */ + getExtensions: function () { + return _.flatten(_.union(_.map(this.handlers, 'extensions'), defaults.extensions)); + }, + /** + * Get an array of all the mime types for which we have handlers + * @returns {string[]} + */ + getContentTypes: function () { + return _.flatten(_.union(_.map(this.handlers, 'contentTypes'), defaults.contentTypes)); + }, + /** + * Get an array of directories for which we have handlers + * @returns {string[]} + */ + getDirectories: function () { + return _.flatten(_.union(_.map(this.handlers, 'directories'), defaults.directories)); + }, + /** + * Convert items into a glob string + * @param {String[]} items + * @returns {String} + */ + getGlobPattern: function (items) { + return '+(' + _.reduce(items, function (memo, ext) { + return memo !== '' ? memo + '|' + ext : ext; + }, '') + ')'; + }, + /** + * @param {String[]} extensions + * @param {Number} level + * @returns {String} + */ + getExtensionGlob: function (extensions, level) { + var prefix = level === ALL_DIRS ? '**/*' : + (level === ROOT_OR_SINGLE_DIR ? '{*/*,*}' : '*'); + + return prefix + this.getGlobPattern(extensions); + }, + /** + * + * @param {String[]} directories + * @param {Number} level + * @returns {String} + */ + getDirectoryGlob: function (directories, level) { + var prefix = level === ALL_DIRS ? '**/' : + (level === ROOT_OR_SINGLE_DIR ? '{*/,}' : ''); + + return prefix + this.getGlobPattern(directories); + }, + /** + * Remove files after we're done (abstracted into a function for easier testing) + * @returns {Function} + */ + cleanUp: function () { + var filesToDelete = this.filesToDelete; + return function (result) { + _.each(filesToDelete, function (fileToDelete) { + fs.remove(fileToDelete, function (err) { + if (err) { + errors.logError(err, i18n.t('errors.data.importer.index.couldNotCleanUpFile.error'), + i18n.t('errors.data.importer.index.couldNotCleanUpFile.context')); + } + }); + }); + + return result; + }; + }, + /** + * Return true if the given file is a Zip + * @returns Boolean + */ + isZip: function (ext) { + return _.includes(defaults.extensions, ext); + }, + /** + * Checks the content of a zip folder to see if it is valid. + * Importable content includes any files or directories which the handlers can process + * Importable content must be found either in the root, or inside one base directory + * + * @param {String} directory + * @returns {Promise} + */ + isValidZip: function (directory) { + // Globs match content in the root or inside a single directory + var extMatchesBase = glob.sync( + this.getExtensionGlob(this.getExtensions(), ROOT_OR_SINGLE_DIR), {cwd: directory} + ), + extMatchesAll = glob.sync( + this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory} + ), + dirMatches = glob.sync( + this.getDirectoryGlob(this.getDirectories(), ROOT_OR_SINGLE_DIR), {cwd: directory} + ), + oldRoonMatches = glob.sync(this.getDirectoryGlob(['drafts', 'published', 'deleted'], ROOT_OR_SINGLE_DIR), + {cwd: directory}); + + // This is a temporary extra message for the old format roon export which doesn't work with Ghost + if (oldRoonMatches.length > 0) { + throw new errors.UnsupportedMediaTypeError( + i18n.t('errors.data.importer.index.unsupportedRoonExport') + ); + } + + // If this folder contains importable files or a content or images directory + if (extMatchesBase.length > 0 || (dirMatches.length > 0 && extMatchesAll.length > 0)) { + return true; + } + + if (extMatchesAll.length < 1) { + throw new errors.UnsupportedMediaTypeError( + i18n.t('errors.data.importer.index.noContentToImport')); + } + + throw new errors.UnsupportedMediaTypeError( + i18n.t('errors.data.importer.index.invalidZipStructure')); + }, + /** + * Use the extract module to extract the given zip file to a temp directory & return the temp directory path + * @param {String} filePath + * @returns {Promise[]} Files + */ + extractZip: function (filePath) { + var tmpDir = path.join(os.tmpdir(), uuid.v4()); + this.filesToDelete.push(tmpDir); + return Promise.promisify(extract)(filePath, {dir: tmpDir}).then(function () { + return tmpDir; + }); + }, + /** + * Use the handler extensions to get a globbing pattern, then use that to fetch all the files from the zip which + * are relevant to the given handler, and return them as a name and path combo + * @param {Object} handler + * @param {String} directory + * @returns [] Files + */ + getFilesFromZip: function (handler, directory) { + var globPattern = this.getExtensionGlob(handler.extensions, ALL_DIRS); + return _.map(glob.sync(globPattern, {cwd: directory}), function (file) { + return {name: file, path: path.join(directory, file)}; + }); + }, + /** + * Get the name of the single base directory if there is one, else return an empty string + * @param {String} directory + * @returns {Promise (String)} + */ + getBaseDirectory: function (directory) { + // Globs match root level only + var extMatches = glob.sync(this.getExtensionGlob(this.getExtensions(), ROOT_ONLY), {cwd: directory}), + dirMatches = glob.sync(this.getDirectoryGlob(this.getDirectories(), ROOT_ONLY), {cwd: directory}), + extMatchesAll; + + // There is no base directory + if (extMatches.length > 0 || dirMatches.length > 0) { + return; + } + // There is a base directory, grab it from any ext match + extMatchesAll = glob.sync( + this.getExtensionGlob(this.getExtensions(), ALL_DIRS), {cwd: directory} + ); + if (extMatchesAll.length < 1 || extMatchesAll[0].split('/') < 1) { + throw new errors.ValidationError(i18n.t('errors.data.importer.index.invalidZipFileBaseDirectory')); + } + return extMatchesAll[0].split('/')[0]; + }, + /** + * Process Zip + * Takes a reference to a zip file, extracts it, sends any relevant files from inside to the right handler, and + * returns an object in the importData format: {data: {}, images: []} + * The data key contains JSON representing any data that should be imported + * The image key contains references to images that will be stored (and where they will be stored) + * @param {File} file + * @returns {Promise(ImportData)} + */ + processZip: function (file) { + var self = this; + + return this.extractZip(file.path).then(function (zipDirectory) { + var ops = [], + importData = {}, + baseDir; + + self.isValidZip(zipDirectory); + baseDir = self.getBaseDirectory(zipDirectory); + + _.each(self.handlers, function (handler) { + if (importData.hasOwnProperty(handler.type)) { + // This limitation is here to reduce the complexity of the importer for now + return Promise.reject(new errors.UnsupportedMediaTypeError( + i18n.t('errors.data.importer.index.zipContainsMultipleDataFormats') + )); + } + + var files = self.getFilesFromZip(handler, zipDirectory); + + if (files.length > 0) { + ops.push(function () { + return handler.loadFile(files, baseDir).then(function (data) { + importData[handler.type] = data; + }); + }); + } + }); + + if (ops.length === 0) { + return Promise.reject(new errors.UnsupportedMediaTypeError( + i18n.t('errors.data.importer.index.noContentToImport') + )); + } + + return sequence(ops).then(function () { + return importData; + }); + }); + }, + /** + * Process File + * Takes a reference to a single file, sends it to the relevant handler to be loaded and returns an object in the + * importData format: {data: {}, images: []} + * The data key contains JSON representing any data that should be imported + * The image key contains references to images that will be stored (and where they will be stored) + * @param {File} file + * @returns {Promise(ImportData)} + */ + processFile: function (file, ext) { + var fileHandler = _.find(this.handlers, function (handler) { + return _.includes(handler.extensions, ext); + }); + + return fileHandler.loadFile([_.pick(file, 'name', 'path')]).then(function (loadedData) { + // normalize the returned data + var importData = {}; + importData[fileHandler.type] = loadedData; + return importData; + }); + }, + /** + * Import Step 1: + * Load the given file into usable importData in the format: {data: {}, images: []}, regardless of + * whether the file is a single importable file like a JSON file, or a zip file containing loads of files. + * @param {File} file + * @returns {Promise} + */ + loadFile: function (file) { + var self = this, + ext = path.extname(file.name).toLowerCase(); + + this.filesToDelete.push(file.path); + + return this.isZip(ext) ? self.processZip(file) : self.processFile(file, ext); + }, + /** + * Import Step 2: + * Pass the prepared importData through the preProcess function of the various importers, so that the importers can + * make any adjustments to the data based on relationships between it + * @param {ImportData} importData + * @returns {Promise(ImportData)} + */ + preProcess: function (importData) { + var ops = []; + _.each(this.importers, function (importer) { + ops.push(function () { + return importer.preProcess(importData); + }); + }); + + return pipeline(ops); + }, + /** + * Import Step 3: + * Each importer gets passed the data from importData which has the key matching its type - i.e. it only gets the + * data that it should import. Each importer then handles actually importing that data into Ghost + * @param {ImportData} importData + * @returns {Promise(ImportData)} + */ + doImport: function (importData) { + var ops = []; + _.each(this.importers, function (importer) { + if (importData.hasOwnProperty(importer.type)) { + ops.push(function () { + return importer.doImport(importData[importer.type]); + }); + } + }); + + return sequence(ops).then(function (importResult) { + return importResult; + }); + }, + /** + * Import Step 4: + * Report on what was imported, currently a no-op + * @param {ImportData} importData + * @returns {Promise(ImportData)} + */ + generateReport: function (importData) { + return Promise.resolve(importData); + }, + /** + * Import From File + * The main method of the ImportManager, call this to kick everything off! + * @param {File} file + * @returns {Promise} + */ + importFromFile: function (file) { + var self = this; + + // Step 1: Handle converting the file to usable data + return this.loadFile(file).then(function (importData) { + // Step 2: Let the importers pre-process the data + return self.preProcess(importData); + }).then(function (importData) { + // Step 3: Actually do the import + // @TODO: It would be cool to have some sort of dry run flag here + return self.doImport(importData); + }).then(function (importData) { + // Step 4: Report on the import + return self.generateReport(importData) + // Step 5: Cleanup any files + .finally(self.cleanUp()); + }); + } +}); + +module.exports = new ImportManager(); diff --git a/core/server/data/meta/amp_url.js b/core/server/data/meta/amp_url.js new file mode 100644 index 0000000..782a3da --- /dev/null +++ b/core/server/data/meta/amp_url.js @@ -0,0 +1,15 @@ +var config = require('../../config'), + getUrl = require('./url'), + _ = require('lodash'); + +function getAmplUrl(data) { + var context = data.context ? data.context : null; + + if (_.includes(context, 'post') && !_.includes(context, 'amp')) { + return config.urlJoin(config.getBaseUrl(false), + getUrl(data, false)) + 'amp/'; + } + return null; +} + +module.exports = getAmplUrl; diff --git a/core/server/data/meta/asset_url.js b/core/server/data/meta/asset_url.js new file mode 100644 index 0000000..7a5adb0 --- /dev/null +++ b/core/server/data/meta/asset_url.js @@ -0,0 +1,38 @@ +var config = require('../../config'), + generateAssetHash = require('../../utils/asset-hash'); + +function getAssetUrl(path, isAdmin, minify) { + var output = ''; + + output += config.paths.subdir + '/'; + + if (!path.match(/^favicon\.ico$/) && !path.match(/^shared/) && !path.match(/^asset/)) { + if (isAdmin) { + output += 'ghost/'; + } else { + output += 'assets/'; + } + } + + // Get rid of any leading slash on the path + path = path.replace(/^\//, ''); + + // replace ".foo" with ".min.foo" in production + if (minify) { + path = path.replace(/\.([^\.]*)$/, '.min.$1'); + } + + output += path; + + if (!path.match(/^favicon\.ico$/)) { + if (!config.assetHash) { + config.set({assetHash: generateAssetHash()}); + } + + output = output + '?v=' + config.assetHash; + } + + return output; +} + +module.exports = getAssetUrl; diff --git a/core/server/data/meta/author_fb_url.js b/core/server/data/meta/author_fb_url.js new file mode 100644 index 0000000..a76f7ac --- /dev/null +++ b/core/server/data/meta/author_fb_url.js @@ -0,0 +1,16 @@ +var getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getAuthorFacebookUrl(data) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if ((_.includes(context, 'post') || _.includes(context, 'page')) && contextObject.author && contextObject.author.facebook) { + return contextObject.author.facebook; + } else if (_.includes(context, 'author') && contextObject.facebook) { + return contextObject.facebook; + } + return null; +} + +module.exports = getAuthorFacebookUrl; diff --git a/core/server/data/meta/author_image.js b/core/server/data/meta/author_image.js new file mode 100644 index 0000000..0b96c47 --- /dev/null +++ b/core/server/data/meta/author_image.js @@ -0,0 +1,15 @@ +var config = require('../../config'), + getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getAuthorImage(data, absolute) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if ((_.includes(context, 'post') || _.includes(context, 'page')) && contextObject.author && contextObject.author.image) { + return config.urlFor('image', {image: contextObject.author.image}, absolute); + } + return null; +} + +module.exports = getAuthorImage; diff --git a/core/server/data/meta/author_url.js b/core/server/data/meta/author_url.js new file mode 100644 index 0000000..f2be5a8 --- /dev/null +++ b/core/server/data/meta/author_url.js @@ -0,0 +1,17 @@ +var config = require('../../config'); + +function getAuthorUrl(data, absolute) { + var context = data.context ? data.context[0] : null; + + context = context === 'amp' ? 'post' : context; + + if (data.author) { + return config.urlFor('author', {author: data.author}, absolute); + } + if (data[context] && data[context].author) { + return config.urlFor('author', {author: data[context].author}, absolute); + } + return null; +} + +module.exports = getAuthorUrl; diff --git a/core/server/data/meta/canonical_url.js b/core/server/data/meta/canonical_url.js new file mode 100644 index 0000000..9b2b3bb --- /dev/null +++ b/core/server/data/meta/canonical_url.js @@ -0,0 +1,14 @@ +var config = require('../../config'), + getUrl = require('./url'); + +function getCanonicalUrl(data) { + var url = config.urlJoin(config.getBaseUrl(false), + getUrl(data, false)); + + if (url.indexOf('/amp/')) { + url = url.replace(/\/amp\/$/i, '/'); + } + return url; +} + +module.exports = getCanonicalUrl; diff --git a/core/server/data/meta/context_object.js b/core/server/data/meta/context_object.js new file mode 100644 index 0000000..d52e3ed --- /dev/null +++ b/core/server/data/meta/context_object.js @@ -0,0 +1,13 @@ +var config = require('../../config'), + _ = require('lodash'); + +function getContextObject(data, context) { + var blog = config.theme, + contextObject; + + context = _.includes(context, 'page') || _.includes(context, 'amp') ? 'post' : context; + contextObject = data[context] || blog; + return contextObject; +} + +module.exports = getContextObject; diff --git a/core/server/data/meta/cover_image.js b/core/server/data/meta/cover_image.js new file mode 100644 index 0000000..ee40031 --- /dev/null +++ b/core/server/data/meta/cover_image.js @@ -0,0 +1,21 @@ +var config = require('../../config'), + getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getCoverImage(data) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if (_.includes(context, 'home') || _.includes(context, 'author')) { + if (contextObject.cover) { + return config.urlFor('image', {image: contextObject.cover}, true); + } + } else { + if (contextObject.image) { + return config.urlFor('image', {image: contextObject.image}, true); + } + } + return null; +} + +module.exports = getCoverImage; diff --git a/core/server/data/meta/creator_url.js b/core/server/data/meta/creator_url.js new file mode 100644 index 0000000..d0fb61c --- /dev/null +++ b/core/server/data/meta/creator_url.js @@ -0,0 +1,16 @@ +var getContextObject = require('./context_object.js'), + _ = require('lodash'); + +function getCreatorTwitterUrl(data) { + var context = data.context ? data.context : null, + contextObject = getContextObject(data, context); + + if ((_.includes(context, 'post') || _.includes(context, 'page')) && contextObject.author && contextObject.author.twitter) { + return contextObject.author.twitter; + } else if (_.includes(context, 'author') && contextObject.twitter) { + return contextObject.twitter; + } + return null; +} + +module.exports = getCreatorTwitterUrl; diff --git a/core/server/data/meta/description.js b/core/server/data/meta/description.js new file mode 100644 index 0000000..2bd0355 --- /dev/null +++ b/core/server/data/meta/description.js @@ -0,0 +1,25 @@ +var _ = require('lodash'), + config = require('../../config'); + +function getDescription(data, root) { + var description = '', + context = root ? root.context : null; + + if (data.meta_description) { + description = data.meta_description; + } else if (_.includes(context, 'paged')) { + description = ''; + } else if (_.includes(context, 'home')) { + description = config.theme.description; + } else if (_.includes(context, 'author') && data.author) { + description = data.author.meta_description || data.author.bio; + } else if (_.includes(context, 'tag') && data.tag) { + description = data.tag.meta_description || data.tag.description; + } else if ((_.includes(context, 'post') || _.includes(context, 'page')) && data.post) { + description = data.post.meta_description; + } + + return (description || '').trim(); +} + +module.exports = getDescription; diff --git a/core/server/data/meta/excerpt.js b/core/server/data/meta/excerpt.js new file mode 100644 index 0000000..f6e3158 --- /dev/null +++ b/core/server/data/meta/excerpt.js @@ -0,0 +1,20 @@ +var downsize = require('downsize'); + +function getExcerpt(html, truncateOptions) { + truncateOptions = truncateOptions || {}; + // Strip inline and bottom footnotes + var excerpt = html.replace(/.*?<\/a>/gi, ''); + excerpt = excerpt.replace(/
    .*?<\/ol><\/div>/, ''); + // Strip other html + excerpt = excerpt.replace(/<\/?[^>]+>/gi, ''); + excerpt = excerpt.replace(/(\r\n|\n|\r)+/gm, ' '); + /*jslint regexp:false */ + + if (!truncateOptions.words && !truncateOptions.characters) { + truncateOptions.words = 50; + } + + return downsize(excerpt, truncateOptions); +} + +module.exports = getExcerpt; diff --git a/core/server/data/meta/image-dimensions.js b/core/server/data/meta/image-dimensions.js new file mode 100644 index 0000000..4b778ff --- /dev/null +++ b/core/server/data/meta/image-dimensions.js @@ -0,0 +1,58 @@ +var getCachedImageSizeFromUrl = require('../../utils/cached-image-size-from-url'), + Promise = require('bluebird'), + _ = require('lodash'); + +/** + * Get Image dimensions + * @param {object} metaData + * @returns {object} metaData + * @description for image properties in meta data (coverImage, authorImage and blog.logo), `getCachedImageSizeFromUrl` is + * called to receive image width and height + */ +function getImageDimensions(metaData) { + var fetch = { + coverImage: getCachedImageSizeFromUrl(metaData.coverImage.url), + authorImage: getCachedImageSizeFromUrl(metaData.authorImage.url), + logo: getCachedImageSizeFromUrl(metaData.blog.logo.url) + }; + + return Promise.props(fetch).then(function (resolve) { + var imageObj = {}; + + imageObj = { + coverImage: resolve.coverImage, + authorImage: resolve.authorImage, + logo: resolve.logo + }; + + _.forEach(imageObj, function (key, value) { + if (_.has(key, 'width') && _.has(key, 'height')) { + // We have some restrictions for publisher.logo: + // The image needs to be <=600px wide and <=60px high (ideally exactly 600px x 60px). + // Unless we have proper image-handling (see https://github.com/TryGhost/Ghost/issues/4453), + // we will not output an ImageObject if the logo doesn't fit in the dimensions. + if (value === 'logo') { + if (key.height <= 60 && key.width <= 600) { + _.assign(metaData.blog[value], { + dimensions: { + width: key.width, + height: key.height + } + }); + } + } else { + _.assign(metaData[value], { + dimensions: { + width: key.width, + height: key.height + } + }); + } + } + }); + + return metaData; + }); +} + +module.exports = getImageDimensions; diff --git a/core/server/data/meta/index.js b/core/server/data/meta/index.js new file mode 100644 index 0000000..e5b73ca --- /dev/null +++ b/core/server/data/meta/index.js @@ -0,0 +1,72 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + config = require('../../config'), + getUrl = require('./url'), + getImageDimensions = require('./image-dimensions'), + getCanonicalUrl = require('./canonical_url'), + getAmpUrl = require('./amp_url'), + getPaginatedUrl = require('./paginated_url'), + getAuthorUrl = require('./author_url'), + getRssUrl = require('./rss_url'), + getTitle = require('./title'), + getDescription = require('./description'), + getCoverImage = require('./cover_image'), + getAuthorImage = require('./author_image'), + getAuthorFacebook = require('./author_fb_url'), + getCreatorTwitter = require('./creator_url'), + getKeywords = require('./keywords'), + getPublishedDate = require('./published_date'), + getModifiedDate = require('./modified_date'), + getOgType = require('./og_type'), + getStructuredData = require('./structured_data'), + getSchema = require('./schema'), + getExcerpt = require('./excerpt'); + +function getMetaData(data, root) { + var metaData = { + url: getUrl(data, true), + canonicalUrl: getCanonicalUrl(data), + ampUrl: getAmpUrl(data), + previousUrl: getPaginatedUrl('prev', data, true), + nextUrl: getPaginatedUrl('next', data, true), + authorUrl: getAuthorUrl(data, true), + rssUrl: getRssUrl(data, true), + metaTitle: getTitle(data, root), + metaDescription: getDescription(data, root), + coverImage: { + url: getCoverImage(data, true) + }, + authorImage: { + url: getAuthorImage(data, true) + }, + authorFacebook: getAuthorFacebook(data), + creatorTwitter: getCreatorTwitter(data), + keywords: getKeywords(data), + publishedDate: getPublishedDate(data), + modifiedDate: getModifiedDate(data), + ogType: getOgType(data), + blog: _.cloneDeep(config.theme) + }; + + metaData.blog.logo = {}; + metaData.blog.logo.url = config.theme.logo ? + config.urlFor('image', {image: config.theme.logo}, true) : config.urlFor({relativeUrl: '/ghost/img/ghosticon.jpg'}, {}, true); + + // TODO: cleanup these if statements + if (data.post && data.post.html) { + metaData.excerpt = getExcerpt(data.post.html, {words: 50}); + } + + if (data.post && data.post.author && data.post.author.name) { + metaData.authorName = data.post.author.name; + } + + return Promise.props(getImageDimensions(metaData)).then(function () { + metaData.structuredData = getStructuredData(metaData); + metaData.schema = getSchema(metaData, data); + + return metaData; + }); +} + +module.exports = getMetaData; diff --git a/core/server/data/meta/keywords.js b/core/server/data/meta/keywords.js new file mode 100644 index 0000000..077d411 --- /dev/null +++ b/core/server/data/meta/keywords.js @@ -0,0 +1,16 @@ +var labs = require('../../utils/labs'); + +function getKeywords(data) { + if (data.post && data.post.tags && data.post.tags.length > 0) { + return data.post.tags.reduce(function (tags, tag) { + if (tag.visibility !== 'internal' || !labs.isSet('internalTags')) { + tags.push(tag.name); + } + return tags; + }, []); + } + return null; +} + +module.exports = getKeywords; + diff --git a/core/server/data/meta/modified_date.js b/core/server/data/meta/modified_date.js new file mode 100644 index 0000000..b4efcef --- /dev/null +++ b/core/server/data/meta/modified_date.js @@ -0,0 +1,18 @@ +var _ = require('lodash'); + +function getModifiedDate(data) { + var context = data.context ? data.context : null, + modDate; + + context = _.includes(context, 'amp') ? 'post' : context; + + if (data[context]) { + modDate = data[context].updated_at || null; + if (modDate) { + return new Date(modDate).toISOString(); + } + } + return null; +} + +module.exports = getModifiedDate; diff --git a/core/server/data/meta/og_type.js b/core/server/data/meta/og_type.js new file mode 100644 index 0000000..2ccb056 --- /dev/null +++ b/core/server/data/meta/og_type.js @@ -0,0 +1,15 @@ +function getOgType(data) { + var context = data.context ? data.context[0] : null; + + context = context === 'amp' ? 'post' : context; + + if (context === 'author') { + return 'profile'; + } + if (context === 'post') { + return 'article'; + } + return 'website'; +} + +module.exports = getOgType; diff --git a/core/server/data/meta/paginated_url.js b/core/server/data/meta/paginated_url.js new file mode 100644 index 0000000..0158303 --- /dev/null +++ b/core/server/data/meta/paginated_url.js @@ -0,0 +1,35 @@ +var _ = require('lodash'), + config = require('../../config'); + +function getPaginatedUrl(page, data, absolute) { + // If we don't have enough information, return null right away + if (!data || !data.relativeUrl || !data.pagination) { + return null; + } + + var pagePath = '/' + config.routeKeywords.page + '/', + // Try to match the base url, as whatever precedes the pagePath + baseUrlPattern = new RegExp('(.+)?(/' + config.routeKeywords.page + '/\\d+/)'), + baseUrlMatch = data.relativeUrl.match(baseUrlPattern), + // If there is no match for pagePath, use the original url, without the trailing slash + baseUrl = baseUrlMatch ? baseUrlMatch[1] : data.relativeUrl.slice(0, -1), + newRelativeUrl; + + if (page === 'next' && data.pagination.next) { + newRelativeUrl = pagePath + data.pagination.next + '/'; + } else if (page === 'prev' && data.pagination.prev) { + newRelativeUrl = data.pagination.prev > 1 ? pagePath + data.pagination.prev + '/' : '/'; + } else if (_.isNumber(page)) { + newRelativeUrl = page > 1 ? pagePath + page + '/' : '/'; + } else { + // If none of the cases match, return null right away + return null; + } + + // baseUrl can be undefined, if there was nothing preceding the pagePath (e.g. first page of the index channel) + newRelativeUrl = baseUrl ? baseUrl + newRelativeUrl : newRelativeUrl; + + return config.urlFor({relativeUrl: newRelativeUrl, secure: data.secure}, absolute); +} + +module.exports = getPaginatedUrl; diff --git a/core/server/data/meta/published_date.js b/core/server/data/meta/published_date.js new file mode 100644 index 0000000..f1cc1c1 --- /dev/null +++ b/core/server/data/meta/published_date.js @@ -0,0 +1,12 @@ +function getPublishedDate(data) { + var context = data.context ? data.context[0] : null; + + context = context === 'amp' ? 'post' : context; + + if (data[context] && data[context].published_at) { + return new Date(data[context].published_at).toISOString(); + } + return null; +} + +module.exports = getPublishedDate; diff --git a/core/server/data/meta/rss_url.js b/core/server/data/meta/rss_url.js new file mode 100644 index 0000000..4c65056 --- /dev/null +++ b/core/server/data/meta/rss_url.js @@ -0,0 +1,7 @@ +var config = require('../../config'); + +function getRssUrl(data, absolute) { + return config.urlFor('rss', {secure: data.secure}, absolute); +} + +module.exports = getRssUrl; diff --git a/core/server/data/meta/schema.js b/core/server/data/meta/schema.js new file mode 100644 index 0000000..4aad906 --- /dev/null +++ b/core/server/data/meta/schema.js @@ -0,0 +1,189 @@ +var config = require('../../config'), + hbs = require('express-hbs'), + socialUrls = require('../../utils/social-urls'), + escapeExpression = hbs.handlebars.Utils.escapeExpression, + _ = require('lodash'); + +function schemaImageObject(metaDataVal) { + var imageObject; + if (!metaDataVal) { + return null; + } + if (!metaDataVal.dimensions) { + return metaDataVal.url; + } + + imageObject = { + '@type': 'ImageObject', + url: metaDataVal.url, + width: metaDataVal.dimensions.width, + height: metaDataVal.dimensions.height + }; + + return imageObject; +} + +// Creates the final schema object with values that are not null +function trimSchema(schema) { + var schemaObject = {}; + + _.each(schema, function (value, key) { + if (value !== null && typeof value !== 'undefined') { + schemaObject[key] = value; + } + }); + + return schemaObject; +} + +function trimSameAs(data, context) { + var sameAs = []; + + if (context === 'post') { + if (data.post.author.website) { + sameAs.push(escapeExpression(data.post.author.website)); + } + if (data.post.author.facebook) { + sameAs.push(socialUrls.facebookUrl(data.post.author.facebook)); + } + if (data.post.author.twitter) { + sameAs.push(socialUrls.twitterUrl(data.post.author.twitter)); + } + } else if (context === 'author') { + if (data.author.website) { + sameAs.push(escapeExpression(data.author.website)); + } + if (data.author.facebook) { + sameAs.push(socialUrls.facebookUrl(data.author.facebook)); + } + if (data.author.twitter) { + sameAs.push(socialUrls.twitterUrl(data.author.twitter)); + } + } + + return sameAs; +} + +function getPostSchema(metaData, data) { + var description = metaData.metaDescription ? escapeExpression(metaData.metaDescription) : + (metaData.excerpt ? escapeExpression(metaData.excerpt) : null), + schema; + + schema = { + '@context': 'https://schema.org', + '@type': 'Article', + publisher: { + '@type': 'Organization', + name: escapeExpression(metaData.blog.title), + logo: schemaImageObject(metaData.blog.logo) || null + }, + author: { + '@type': 'Person', + name: escapeExpression(data.post.author.name), + image: schemaImageObject(metaData.authorImage), + url: metaData.authorUrl, + sameAs: trimSameAs(data, 'post'), + description: data.post.author.bio ? + escapeExpression(data.post.author.bio) : + null + }, + headline: escapeExpression(metaData.metaTitle), + url: metaData.url, + datePublished: metaData.publishedDate, + dateModified: metaData.modifiedDate, + image: schemaImageObject(metaData.coverImage), + keywords: metaData.keywords && metaData.keywords.length > 0 ? + metaData.keywords.join(', ') : null, + description: description, + mainEntityOfPage: { + '@type': 'WebPage', + '@id': metaData.blog.url || null + } + }; + schema.author = trimSchema(schema.author); + return trimSchema(schema); +} + +function getHomeSchema(metaData) { + var schema = { + '@context': 'https://schema.org', + '@type': 'Website', + publisher: { + '@type': 'Organization', + name: escapeExpression(metaData.blog.title), + logo: schemaImageObject(metaData.blog.logo) || null + }, + url: metaData.url, + image: schemaImageObject(metaData.coverImage), + mainEntityOfPage: { + '@type': 'WebPage', + '@id': metaData.blog.url || null + }, + description: metaData.metaDescription ? + escapeExpression(metaData.metaDescription) : + null + }; + return trimSchema(schema); +} + +function getTagSchema(metaData, data) { + var schema = { + '@context': 'https://schema.org', + '@type': 'Series', + publisher: { + '@type': 'Organization', + name: escapeExpression(metaData.blog.title), + logo: schemaImageObject(metaData.blog.logo) || null + }, + url: metaData.url, + image: schemaImageObject(metaData.coverImage), + name: data.tag.name, + mainEntityOfPage: { + '@type': 'WebPage', + '@id': metaData.blog.url || null + }, + description: metaData.metaDescription ? + escapeExpression(metaData.metaDescription) : + null + }; + + return trimSchema(schema); +} + +function getAuthorSchema(metaData, data) { + var schema = { + '@context': 'https://schema.org', + '@type': 'Person', + sameAs: trimSameAs(data, 'author'), + name: escapeExpression(data.author.name), + url: metaData.authorUrl, + image: schemaImageObject(metaData.coverImage), + mainEntityOfPage: { + '@type': 'WebPage', + '@id': metaData.blog.url || null + }, + description: metaData.metaDescription ? + escapeExpression(metaData.metaDescription) : + null + }; + + return trimSchema(schema); +} + +function getSchema(metaData, data) { + if (!config.isPrivacyDisabled('useStructuredData')) { + var context = data.context ? data.context : null; + if (_.includes(context, 'post') || _.includes(context, 'page') || _.includes(context, 'amp')) { + return getPostSchema(metaData, data); + } else if (_.includes(context, 'home')) { + return getHomeSchema(metaData); + } else if (_.includes(context, 'tag')) { + return getTagSchema(metaData, data); + } else if (_.includes(context, 'author')) { + return getAuthorSchema(metaData, data); + } + } + return null; +} + +module.exports = getSchema; diff --git a/core/server/data/meta/structured_data.js b/core/server/data/meta/structured_data.js new file mode 100644 index 0000000..ffeddd6 --- /dev/null +++ b/core/server/data/meta/structured_data.js @@ -0,0 +1,51 @@ +var socialUrls = require('../../utils/social-urls'); + +function getStructuredData(metaData) { + var structuredData, + card = 'summary'; + + if (metaData.coverImage.url) { + card = 'summary_large_image'; + } + + structuredData = { + 'og:site_name': metaData.blog.title, + 'og:type': metaData.ogType, + 'og:title': metaData.metaTitle, + 'og:description': metaData.metaDescription || metaData.excerpt, + 'og:url': metaData.canonicalUrl, + 'og:image': metaData.coverImage.url, + 'article:published_time': metaData.publishedDate, + 'article:modified_time': metaData.modifiedDate, + 'article:tag': metaData.keywords, + 'article:publisher': metaData.blog.facebook ? socialUrls.facebookUrl(metaData.blog.facebook) : undefined, + 'article:author': metaData.authorFacebook ? socialUrls.facebookUrl(metaData.authorFacebook) : undefined, + 'twitter:card': card, + 'twitter:title': metaData.metaTitle, + 'twitter:description': metaData.metaDescription || metaData.excerpt, + 'twitter:url': metaData.canonicalUrl, + 'twitter:image': metaData.coverImage.url, + 'twitter:label1': metaData.authorName ? 'Written by' : undefined, + 'twitter:data1': metaData.authorName, + 'twitter:label2': metaData.keywords ? 'Filed under' : undefined, + 'twitter:data2': metaData.keywords ? metaData.keywords.join(', ') : undefined, + 'twitter:site': metaData.blog.twitter || undefined, + 'twitter:creator': metaData.creatorTwitter || undefined + }; + + if (metaData.coverImage.dimensions) { + structuredData['og:image:width'] = metaData.coverImage.dimensions.width; + structuredData['og:image:height'] = metaData.coverImage.dimensions.height; + } + + // return structured data removing null or undefined keys + return Object.keys(structuredData).reduce(function (data, key) { + var content = structuredData[key]; + if (content !== null && typeof content !== 'undefined') { + data[key] = content; + } + return data; + }, {}); +} + +module.exports = getStructuredData; diff --git a/core/server/data/meta/title.js b/core/server/data/meta/title.js new file mode 100644 index 0000000..8ac0b6d --- /dev/null +++ b/core/server/data/meta/title.js @@ -0,0 +1,31 @@ +var _ = require('lodash'), + config = require('../../config'); + +function getTitle(data, root) { + var title = '', + context = root ? root.context : null, + blog = config.theme, + pagination = root ? root.pagination : null, + pageString = ''; + + if (pagination && pagination.total > 1) { + pageString = ' - Page ' + pagination.page; + } + if (data.meta_title) { + title = data.meta_title; + } else if (_.includes(context, 'home')) { + title = blog.title; + } else if (_.includes(context, 'author') && data.author) { + title = data.author.name + pageString + ' - ' + blog.title; + } else if (_.includes(context, 'tag') && data.tag) { + title = data.tag.meta_title || data.tag.name + pageString + ' - ' + blog.title; + } else if ((_.includes(context, 'post') || _.includes(context, 'page')) && data.post) { + title = data.post.meta_title || data.post.title; + } else { + title = blog.title + pageString; + } + + return (title || '').trim(); +} + +module.exports = getTitle; diff --git a/core/server/data/meta/url.js b/core/server/data/meta/url.js new file mode 100644 index 0000000..9df714a --- /dev/null +++ b/core/server/data/meta/url.js @@ -0,0 +1,35 @@ +var schema = require('../schema').checks, + config = require('../../config'); + +// This cleans the url from any `/amp` postfixes, so we'll never +// output a url with `/amp` in the end, except for the needed `amphtml` +// canonical link, which is rendered by `getAmpUrl`. +function sanitizeAmpUrl(url) { + if (url.indexOf('/amp/') !== -1) { + url = url.replace(/\/amp\/$/i, '/'); + } + return url; +} + +function getUrl(data, absolute) { + if (schema.isPost(data)) { + return config.urlFor('post', {post: data, secure: data.secure}, absolute); + } + + if (schema.isTag(data)) { + return config.urlFor('tag', {tag: data, secure: data.secure}, absolute); + } + + if (schema.isUser(data)) { + return config.urlFor('author', {author: data, secure: data.secure}, absolute); + } + + if (schema.isNav(data)) { + return config.urlFor('nav', {nav: data, secure: data.secure}, absolute); + } + + // sanitize any trailing `/amp` in the url + return sanitizeAmpUrl(config.urlFor(data, {}, absolute)); +} + +module.exports = getUrl; diff --git a/core/server/data/migration/004/01-add-tour-column-to-users.js b/core/server/data/migration/004/01-add-tour-column-to-users.js new file mode 100644 index 0000000..3e16671 --- /dev/null +++ b/core/server/data/migration/004/01-add-tour-column-to-users.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'users', + column = 'tour', + message = 'Adding column: ' + table + '.' + column; + +module.exports = function addTourColumnToUsers(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/004/02-add-sortorder-column-to-poststags.js b/core/server/data/migration/004/02-add-sortorder-column-to-poststags.js new file mode 100644 index 0000000..5641924 --- /dev/null +++ b/core/server/data/migration/004/02-add-sortorder-column-to-poststags.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'posts_tags', + column = 'sort_order', + message = 'Adding column: ' + table + '.' + column; + +module.exports = function addSortOrderColumnToPostsTags(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/004/03-add-many-columns-to-clients.js b/core/server/data/migration/004/03-add-many-columns-to-clients.js new file mode 100644 index 0000000..107e3b7 --- /dev/null +++ b/core/server/data/migration/004/03-add-many-columns-to-clients.js @@ -0,0 +1,29 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'clients', + columns = ['redirection_uri', 'logo', 'status', 'type', 'description']; + +module.exports = function addManyColumnsToClients(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return Promise.mapSeries(columns, function (column) { + var message = 'Adding column: ' + table + '.' + column; + + return transaction.schema.hasColumn(table, column) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); + }); + }); +}; diff --git a/core/server/data/migration/004/04-add-clienttrusteddomains-table.js b/core/server/data/migration/004/04-add-clienttrusteddomains-table.js new file mode 100644 index 0000000..1167dc1 --- /dev/null +++ b/core/server/data/migration/004/04-add-clienttrusteddomains-table.js @@ -0,0 +1,17 @@ +var commands = require('../../schema').commands, + table = 'client_trusted_domains', + message = 'Creating table: ' + table; + +module.exports = function addClientTrustedDomainsTable(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.createTable(table, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/004/05-drop-unique-on-clients-secret.js b/core/server/data/migration/004/05-drop-unique-on-clients-secret.js new file mode 100644 index 0000000..9329986 --- /dev/null +++ b/core/server/data/migration/004/05-drop-unique-on-clients-secret.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'clients', + column = 'secret', + message = 'Dropping unique on: ' + table + '.' + column; + +module.exports = function dropUniqueOnClientsSecret(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return commands.getIndexes(table, transaction); + }) + .then(function (indexes) { + if (indexes.indexOf(table + '_' + column + '_unique') > -1) { + logger.info(message); + return commands.dropUnique(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/004/index.js b/core/server/data/migration/004/index.js new file mode 100644 index 0000000..72970d5 --- /dev/null +++ b/core/server/data/migration/004/index.js @@ -0,0 +1,12 @@ +module.exports = [ + // Added tour column to users + require('./01-add-tour-column-to-users'), + // Added sort_order to posts_tags + require('./02-add-sortorder-column-to-poststags'), + // Added redirection_uri, logo, status, type & description columns to clients + require('./03-add-many-columns-to-clients'), + // Added client_trusted_domains table + require('./04-add-clienttrusteddomains-table'), + // Dropped unique index on client secret + require('./05-drop-unique-on-clients-secret') +]; diff --git a/core/server/data/migration/005/01-drop-hidden-column-from-tags.js b/core/server/data/migration/005/01-drop-hidden-column-from-tags.js new file mode 100644 index 0000000..3932ae4 --- /dev/null +++ b/core/server/data/migration/005/01-drop-hidden-column-from-tags.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'tags', + column = 'hidden', + message = 'Removing column: ' + table + '.' + column; + +module.exports = function dropHiddenColumnFromTags(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (exists) { + logger.info(message); + return commands.dropColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/005/02-add-visibility-column-to-key-tables.js b/core/server/data/migration/005/02-add-visibility-column-to-key-tables.js new file mode 100644 index 0000000..8ea9918 --- /dev/null +++ b/core/server/data/migration/005/02-add-visibility-column-to-key-tables.js @@ -0,0 +1,29 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + tables = ['posts', 'tags', 'users'], + column = 'visibility'; + +module.exports = function addVisibilityColumnToKeyTables(options, logger) { + var transaction = options.transacting; + + return Promise.mapSeries(tables, function (table) { + var message = 'Adding column: ' + table + '.' + column; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); + }); +}; diff --git a/core/server/data/migration/005/03-add-mobiledoc-column-to-posts.js b/core/server/data/migration/005/03-add-mobiledoc-column-to-posts.js new file mode 100644 index 0000000..b6a6849 --- /dev/null +++ b/core/server/data/migration/005/03-add-mobiledoc-column-to-posts.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'posts', + column = 'mobiledoc', + message = 'Adding column: ' + table + '.' + column; + +module.exports = function addMobiledocColumnToPosts(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/005/04-add-social-media-columns-to-users.js b/core/server/data/migration/005/04-add-social-media-columns-to-users.js new file mode 100644 index 0000000..849f5a9 --- /dev/null +++ b/core/server/data/migration/005/04-add-social-media-columns-to-users.js @@ -0,0 +1,28 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'users', + columns = ['facebook', 'twitter']; + +module.exports = function addSocialMediaColumnsToUsers(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return Promise.mapSeries(columns, function (column) { + var message = 'Adding column: ' + table + '.' + column; + + return transaction.schema.hasColumn(table, column).then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); + }); + }); +}; diff --git a/core/server/data/migration/005/05-add-subscribers-table.js b/core/server/data/migration/005/05-add-subscribers-table.js new file mode 100644 index 0000000..31a85ca --- /dev/null +++ b/core/server/data/migration/005/05-add-subscribers-table.js @@ -0,0 +1,16 @@ +var commands = require('../../schema').commands, + table = 'subscribers', + message = 'Creating table: ' + table; + +module.exports = function addSubscribersTable(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table).then(function (exists) { + if (!exists) { + logger.info(message); + return commands.createTable(table, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/005/index.js b/core/server/data/migration/005/index.js new file mode 100644 index 0000000..7fc0494 --- /dev/null +++ b/core/server/data/migration/005/index.js @@ -0,0 +1,12 @@ +module.exports = [ + // Drop hidden column from tags table + require('./01-drop-hidden-column-from-tags'), + // Add visibility column to posts, tags, and users tables + require('./02-add-visibility-column-to-key-tables'), + // Add mobiledoc column to posts + require('./03-add-mobiledoc-column-to-posts'), + // Add social media columns to users + require('./04-add-social-media-columns-to-users'), + // Add subscribers table + require('./05-add-subscribers-table') +]; diff --git a/core/server/data/migration/006/index.js b/core/server/data/migration/006/index.js new file mode 100644 index 0000000..e0a30c5 --- /dev/null +++ b/core/server/data/migration/006/index.js @@ -0,0 +1 @@ +module.exports = []; diff --git a/core/server/data/migration/008/01-add-amp-column-to-posts.js b/core/server/data/migration/008/01-add-amp-column-to-posts.js new file mode 100644 index 0000000..8ce293d --- /dev/null +++ b/core/server/data/migration/008/01-add-amp-column-to-posts.js @@ -0,0 +1,26 @@ +var Promise = require('bluebird'), + commands = require('../../schema').commands, + table = 'posts', + column = 'amp', + message = 'Adding column: ' + table + '.' + column; + +module.exports = function addAmpColumnToPosts(options, logger) { + var transaction = options.transacting; + + return transaction.schema.hasTable(table) + .then(function (exists) { + if (!exists) { + return Promise.reject(new Error('Table does not exist!')); + } + + return transaction.schema.hasColumn(table, column); + }) + .then(function (exists) { + if (!exists) { + logger.info(message); + return commands.addColumn(table, column, transaction); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/008/index.js b/core/server/data/migration/008/index.js new file mode 100644 index 0000000..5c3de05 --- /dev/null +++ b/core/server/data/migration/008/index.js @@ -0,0 +1,4 @@ +module.exports = [ + // Add amp column to posts + require('./01-add-amp-column-to-posts') +]; diff --git a/core/server/data/migration/backup.js b/core/server/data/migration/backup.js new file mode 100644 index 0000000..64b304f --- /dev/null +++ b/core/server/data/migration/backup.js @@ -0,0 +1,44 @@ +// # Backup Database +// Provides for backing up the database before making potentially destructive changes +var _ = require('lodash'), + fs = require('fs'), + path = require('path'), + Promise = require('bluebird'), + config = require('../../config'), + exporter = require('../export'), + + writeExportFile, + backup; + +writeExportFile = function writeExportFile(exportResult) { + var filename = path.resolve(config.paths.contentPath + '/data/' + exportResult.filename); + + return Promise.promisify(fs.writeFile)(filename, JSON.stringify(exportResult.data)).return(filename); +}; + +/** + * ## Backup + * does an export, and stores this in a local file + * + * @param {{info: logger.info, warn: logger.warn}} [logger] + * @returns {Promise<*>} + */ +backup = function backup(logger) { + // If we get passed a function, use it to output notices, else don't do anything + logger = logger && _.isFunction(logger.info) ? logger : {info: _.noop}; + + logger.info('Creating database backup'); + + var props = { + data: exporter.doExport(), + filename: exporter.fileName() + }; + + return Promise.props(props) + .then(writeExportFile) + .then(function successMessage(filename) { + logger.info('Database backup written to: ' + filename); + }); +}; + +module.exports = backup; diff --git a/core/server/data/migration/fixtures/004/01-move-jquery-with-alert.js b/core/server/data/migration/fixtures/004/01-move-jquery-with-alert.js new file mode 100644 index 0000000..b81e457 --- /dev/null +++ b/core/server/data/migration/fixtures/004/01-move-jquery-with-alert.js @@ -0,0 +1,57 @@ +// Moves jQuery inclusion to code injection via ghost_foot +var _ = require('lodash'), + Promise = require('bluebird'), + serverPath = '../../../../', + config = require(serverPath + 'config'), + models = require(serverPath + 'models'), + notifications = require(serverPath + 'api/notifications'), + i18n = require(serverPath + 'i18n'), + + // These messages are shown in the admin UI, not the console, and should therefore be translated + jquery = [ + i18n.t('notices.data.fixtures.canSafelyDelete'), + '\n\n' + ], + privacyMessage = [ + i18n.t('notices.data.fixtures.jQueryRemoved'), + i18n.t('notices.data.fixtures.canBeChanged') + ], + + message = 'Adding jQuery link to ghost_foot'; + +module.exports = function moveJQuery(options, logger) { + var value; + + return models.Settings.findOne('ghost_foot', options) + .then(function (setting) { + if (setting) { + value = setting.get('value'); + + // Only add jQuery if it's not already in there + if (value.indexOf(jquery.join('')) === -1) { + logger.info(message); + value = jquery.join('') + value; + + return models.Settings.edit({key: 'ghost_foot', value: value}, options); + } else { + logger.warn(message); + } + } else { + logger.warn(message); + } + }) + .then(function () { + if (_.isEmpty(config.privacy)) { + return Promise.resolve(); + } + + logger.info(privacyMessage.join(' ').replace(/<\/?strong>/g, '')); + + return notifications.add({ + notifications: [{ + type: 'info', + message: privacyMessage.join(' ') + }] + }, options); + }); +}; diff --git a/core/server/data/migration/fixtures/004/02-update-private-setting-type.js b/core/server/data/migration/fixtures/004/02-update-private-setting-type.js new file mode 100644 index 0000000..38d6705 --- /dev/null +++ b/core/server/data/migration/fixtures/004/02-update-private-setting-type.js @@ -0,0 +1,15 @@ +// Update the `isPrivate` setting, so that it has a type of `private` rather than `blog` +var models = require('../../../../models'), + + message = 'Update isPrivate setting'; + +module.exports = function updatePrivateSetting(options, logger) { + return models.Settings.findOne('isPrivate', options).then(function (setting) { + if (setting && setting.get('type') !== 'private') { + logger.info(message); + return models.Settings.edit({key: 'isPrivate', type: 'private'}, options); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/03-update-password-setting-type.js b/core/server/data/migration/fixtures/004/03-update-password-setting-type.js new file mode 100644 index 0000000..9f374f2 --- /dev/null +++ b/core/server/data/migration/fixtures/004/03-update-password-setting-type.js @@ -0,0 +1,14 @@ +// Update the `password` setting, so that it has a type of `private` rather than `blog` +var models = require('../../../../models'), + message = 'Update password setting'; + +module.exports = function updatePasswordSetting(options, logger) { + return models.Settings.findOne('password', options).then(function (setting) { + if (setting && setting.get('type') !== 'private') { + logger.info(message); + return models.Settings.edit({key: 'password', type: 'private'}, options); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/04-update-ghost-admin-client.js b/core/server/data/migration/fixtures/004/04-update-ghost-admin-client.js new file mode 100644 index 0000000..67ce681 --- /dev/null +++ b/core/server/data/migration/fixtures/004/04-update-ghost-admin-client.js @@ -0,0 +1,27 @@ +// Update the `ghost-admin` client so that it has a proper secret +var _ = require('lodash'), + Promise = require('bluebird'), + crypto = require('crypto'), + models = require('../../../../models'), + adminClient = require('../utils').findModelFixtureEntry('Client', {slug: 'ghost-admin'}), + message = 'Update ghost-admin client fixture'; + +module.exports = function updateGhostAdminClient(options, logger) { + // ghost-admin should already exist from 003 version + return models.Client.findOne({slug: adminClient.slug}, options) + .then(function (client) { + if (!client) { + return Promise.reject(new Error('Admin client does not exist!')); + } + + if (client.get('secret') === 'not_available' || client.get('status') !== 'enabled') { + logger.info(message); + return models.Client.edit( + _.extend({}, adminClient, {secret: crypto.randomBytes(6).toString('hex')}), + _.extend({}, options, {id: client.id}) + ); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/05-add-ghost-frontend-client.js b/core/server/data/migration/fixtures/004/05-add-ghost-frontend-client.js new file mode 100644 index 0000000..4ab7025 --- /dev/null +++ b/core/server/data/migration/fixtures/004/05-add-ghost-frontend-client.js @@ -0,0 +1,16 @@ +// Create a new `ghost-frontend` client for use in themes +var models = require('../../../../models'), + frontendClient = require('../utils').findModelFixtureEntry('Client', {slug: 'ghost-frontend'}), + message = 'Add ghost-frontend client fixture'; + +module.exports = function addGhostFrontendClient(options, logger) { + return models.Client.findOne({slug: frontendClient.slug}, options) + .then(function (client) { + if (!client) { + logger.info(message); + return models.Client.add(frontendClient, options); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/06-clean-broken-tags.js b/core/server/data/migration/fixtures/004/06-clean-broken-tags.js new file mode 100644 index 0000000..741c28a --- /dev/null +++ b/core/server/data/migration/fixtures/004/06-clean-broken-tags.js @@ -0,0 +1,32 @@ +// Clean tags which start with commas, the only illegal char in tags +var models = require('../../../../models'), + Promise = require('bluebird'), + message = 'Cleaning malformed tags'; + +module.exports = function cleanBrokenTags(options, logger) { + return models.Tag.findAll(options).then(function (tags) { + var tagOps = []; + + if (tags) { + tags.each(function (tag) { + var name = tag.get('name'), + updated = name.replace(/^(,+)/, '').trim(); + + // If we've ended up with an empty string, default to just 'tag' + updated = updated === '' ? 'tag' : updated; + + if (name !== updated) { + tagOps.push(tag.save({name: updated}, options)); + } + }); + if (tagOps.length > 0) { + logger.info(message + '(' + tagOps.length + ')'); + return Promise.all(tagOps); + } else { + logger.warn(message); + } + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/07-add-post-tag-order.js b/core/server/data/migration/fixtures/004/07-add-post-tag-order.js new file mode 100644 index 0000000..1b8d73b --- /dev/null +++ b/core/server/data/migration/fixtures/004/07-add-post-tag-order.js @@ -0,0 +1,65 @@ +// Add a new order value to posts_tags based on the existing info +var models = require('../../../../models'), + _ = require('lodash'), + sequence = require('../../../../utils/sequence'), + migrationHasRunFlag, + modelOptions; + +function loadTagsForEachPost(posts) { + if (!posts) { + return []; + } + + return posts.mapThen(function loadTagsForPost(post) { + return post.load(['tags'], modelOptions); + }); +} + +function updatePostTagsSortOrder(post, tagId, order) { + var sortOrder = order; + return function doUpdatePivot() { + return post.tags().updatePivot( + {sort_order: sortOrder}, _.extend({}, modelOptions, {query: {where: {tag_id: tagId}}}) + ); + }; +} + +function buildTagOpsArray(tagOps, post) { + var order = 0; + + return post.related('tags').reduce(function processTag(tagOps, tag) { + if (tag.pivot.get('sort_order') > 0) { + // if any entry in the posts_tags table has already run, we shouldn't run this again + migrationHasRunFlag = true; + } + + tagOps.push(updatePostTagsSortOrder(post, tag.id, order)); + order += 1; + + return tagOps; + }, tagOps); +} + +function processPostsArray(postsArray) { + return postsArray.reduce(buildTagOpsArray, []); +} + +module.exports = function addPostTagOrder(options, logger) { + modelOptions = options; + migrationHasRunFlag = false; + + logger.info('Collecting data on tag order for posts...'); + return models.Post.findAll(_.extend({}, modelOptions)) + .then(loadTagsForEachPost) + .then(processPostsArray) + .then(function (tagOps) { + if (tagOps.length > 0 && !migrationHasRunFlag) { + logger.info('Updating order on ' + tagOps.length + ' tag relationships (could take a while)...'); + return sequence(tagOps).then(function () { + logger.info('Tag order successfully updated'); + }); + } else { + logger.warn('Updating order on tag relationships'); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/08-add-post-fixture.js b/core/server/data/migration/fixtures/004/08-add-post-fixture.js new file mode 100644 index 0000000..dae463c --- /dev/null +++ b/core/server/data/migration/fixtures/004/08-add-post-fixture.js @@ -0,0 +1,31 @@ +// Adds a new draft post with information about the new design +var models = require('../../../../models'), + + newPost = { + title: 'You\'ve been upgraded to the latest version of Ghost', + slug: 'ghost-0-7', + markdown: 'You\'ve just upgraded to the latest version of Ghost and we\'ve made a few changes that you should probably know about!\n\n## Woah, why does everything look different?\n\nAfter two years and hundreds of thousands of users, we learned a great deal about what was (and wasn\'t) working in the old Ghost admin user interface. What you\'re looking at is Ghost\'s first major UI refresh, with a strong focus on being more usable and robust all round.\n\n![New Design](https://ghost.org/images/zelda.png)\n\nThe main navigation menu, previously located at the top of your screen, has now moved over to the left. This makes it way easier to work with on mobile devices, and has the added benefit of providing ample space for upcoming features!\n\n## Lost and found: Your old posts\n\nFrom talking to many of you we understand that finding old posts in the admin area was a real pain; so we\'ve added a new magical search bar which lets you quickly find posts for editing, without having to scroll endlessly. Take it for a spin!\n\n![Search](https://ghost.org/images/search.gif)\n\nQuestions? Comments? Send us a tweet [@TryGhost](https://twitter.com/tryghost)\n\nOh, and yes – you can safely delete this draft post!', + image: null, + featured: false, + page: false, + status: 'draft', + language: 'en_US', + meta_title: null, + meta_description: null + }, + message = 'Adding 0.7 upgrade post fixture'; + +module.exports = function addNewPostFixture(options, logger) { + return models.Post.findOne({slug: newPost.slug, status: 'all'}, options).then(function (post) { + if (!post) { + logger.info(message); + // Set the published_at timestamp, but keep the post as a draft so doesn't appear on the frontend + // This is a hack to ensure that this post appears at the very top of the drafts list, because + // unpublished posts always appear first + newPost.published_at = Date.now(); + return models.Post.add(newPost, options); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/004/index.js b/core/server/data/migration/fixtures/004/index.js new file mode 100644 index 0000000..8c13411 --- /dev/null +++ b/core/server/data/migration/fixtures/004/index.js @@ -0,0 +1,25 @@ +module.exports = [ + // add jquery setting and privacy info + require('./01-move-jquery-with-alert'), + + // change `type` for protected blog `isPrivate` setting + require('./02-update-private-setting-type'), + + // change `type` for protected blog `password` setting + require('./03-update-password-setting-type'), + + // Update ghost-admin client fixture + require('./04-update-ghost-admin-client'), + + // add ghost-frontend client if missing + require('./05-add-ghost-frontend-client'), + + // clean up broken tags + require('./06-clean-broken-tags'), + + // Add post_tag order + require('./07-add-post-tag-order'), + + // Add a new draft post + require('./08-add-post-fixture') +]; diff --git a/core/server/data/migration/fixtures/005/01-update-ghost-client-secrets.js b/core/server/data/migration/fixtures/005/01-update-ghost-client-secrets.js new file mode 100644 index 0000000..e4f1368 --- /dev/null +++ b/core/server/data/migration/fixtures/005/01-update-ghost-client-secrets.js @@ -0,0 +1,26 @@ +// Update the `ghost-*` clients so that they definitely have a proper secret +var models = require('../../../../models'), + _ = require('lodash'), + Promise = require('bluebird'), + crypto = require('crypto'), + + message = 'Updating client secret'; + +module.exports = function updateGhostClientsSecrets(options, logger) { + return models.Clients.forge().query('where', 'secret', '=', 'not_available').fetch(options).then(function (results) { + if (results.models.length === 0) { + logger.warn(message); + return; + } + + return Promise.map(results.models, function mapper(client) { + logger.info(message + ' (' + client.slug + ')'); + client.secret = crypto.randomBytes(6).toString('hex'); + + return models.Client.edit( + _.extend({}, client, {secret: crypto.randomBytes(6).toString('hex')}), + _.extend({}, options, {id: client.id}) + ); + }); + }); +}; diff --git a/core/server/data/migration/fixtures/005/02-add-ghost-scheduler-client.js b/core/server/data/migration/fixtures/005/02-add-ghost-scheduler-client.js new file mode 100644 index 0000000..c30e200 --- /dev/null +++ b/core/server/data/migration/fixtures/005/02-add-ghost-scheduler-client.js @@ -0,0 +1,16 @@ +// Create a new `ghost-scheduler` client for use in themes +var models = require('../../../../models'), + + schedulerClient = require('../utils').findModelFixtureEntry('Client', {slug: 'ghost-scheduler'}), + message = 'Add ghost-scheduler client fixture'; + +module.exports = function addGhostFrontendClient(options, logger) { + return models.Client.findOne({slug: schedulerClient.slug}, options).then(function (client) { + if (!client) { + logger.info(message); + return models.Client.add(schedulerClient, options); + } else { + logger.warn(message); + } + }); +}; diff --git a/core/server/data/migration/fixtures/005/03-add-client-permissions.js b/core/server/data/migration/fixtures/005/03-add-client-permissions.js new file mode 100644 index 0000000..bf6d812 --- /dev/null +++ b/core/server/data/migration/fixtures/005/03-add-client-permissions.js @@ -0,0 +1,31 @@ +// Update the permissions & permissions_roles tables to add entries for clients +var utils = require('../utils'), + resource = 'client'; + +function getPermissions() { + return utils.findModelFixtures('Permission', {object_type: resource}); +} + +function getRelations() { + return utils.findPermissionRelationsForObject(resource); +} + +function printResult(logger, result, message) { + if (result.done === result.expected) { + logger.info(message); + } else { + logger.warn('(' + result.done + '/' + result.expected + ') ' + message); + } +} + +module.exports = function addClientPermissions(options, logger) { + var modelToAdd = getPermissions(), + relationToAdd = getRelations(); + + return utils.addFixturesForModel(modelToAdd, options).then(function (result) { + printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's'); + return utils.addFixturesForRelation(relationToAdd, options); + }).then(function (result) { + printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's'); + }); +}; diff --git a/core/server/data/migration/fixtures/005/04-add-subscriber-permissions.js b/core/server/data/migration/fixtures/005/04-add-subscriber-permissions.js new file mode 100644 index 0000000..89e2fda --- /dev/null +++ b/core/server/data/migration/fixtures/005/04-add-subscriber-permissions.js @@ -0,0 +1,31 @@ +// Update the permissions & permissions_roles tables to add entries for subscribers +var utils = require('../utils'), + resource = 'subscriber'; + +function getPermissions() { + return utils.findModelFixtures('Permission', {object_type: resource}); +} + +function getRelations() { + return utils.findPermissionRelationsForObject(resource); +} + +function printResult(logger, result, message) { + if (result.done === result.expected) { + logger.info(message); + } else { + logger.warn('(' + result.done + '/' + result.expected + ') ' + message); + } +} + +module.exports = function addSubscriberPermissions(options, logger) { + var modelToAdd = getPermissions(), + relationToAdd = getRelations(); + + return utils.addFixturesForModel(modelToAdd, options).then(function (result) { + printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's'); + return utils.addFixturesForRelation(relationToAdd, options); + }).then(function (result) { + printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's'); + }); +}; diff --git a/core/server/data/migration/fixtures/005/index.js b/core/server/data/migration/fixtures/005/index.js new file mode 100644 index 0000000..eb89fb2 --- /dev/null +++ b/core/server/data/migration/fixtures/005/index.js @@ -0,0 +1,10 @@ +module.exports = [ + // add jquery setting and privacy info + require('./01-update-ghost-client-secrets'), + // add ghost-scheduler client + require('./02-add-ghost-scheduler-client'), + // add client permissions and permission_role relations + require('./03-add-client-permissions'), + // add subscriber permissions and permission_role relations + require('./04-add-subscriber-permissions') +]; diff --git a/core/server/data/migration/fixtures/006/01-transform-dates-into-utc.js b/core/server/data/migration/fixtures/006/01-transform-dates-into-utc.js new file mode 100644 index 0000000..bc81a66 --- /dev/null +++ b/core/server/data/migration/fixtures/006/01-transform-dates-into-utc.js @@ -0,0 +1,214 @@ +var config = require('../../../../config'), + models = require(config.paths.corePath + '/server/models'), + api = require(config.paths.corePath + '/server/api'), + sequence = require(config.paths.corePath + '/server/utils/sequence'), + moment = require('moment-timezone'), + _ = require('lodash'), + Promise = require('bluebird'), + messagePrefix = 'Transforming dates to UTC: ', + settingsKey = '006/01', + _private = {}; + +_private.getTZOffset = function getTZOffset(date) { + return date.getTimezoneOffset(); +}; + +_private.getTZOffsetMax = function getTZOffsetMax() { + return Math.max(Math.abs(new Date('2015-07-01').getTimezoneOffset()), Math.abs(new Date('2015-01-01').getTimezoneOffset())); +}; + +_private.addOffset = function addOffset(date) { + if (_private.noOffset) { + return moment(date).toDate(); + } + + return moment(date).add(_private.getTZOffset(date), 'minutes').toDate(); +}; + +/** + * postgres: stores dates with offset, so it's enough to force timezone UTC in the db connection (see data/db/connection.js) + * sqlite: stores UTC timestamps, but we will normalize the format to YYYY-MM-DD HH:mm:ss + */ +module.exports = function transformDatesIntoUTC(options, logger) { + var ServerTimezoneOffset = _private.getTZOffsetMax(), + settingsMigrations = null; + + // will ensure updated_at fields will not be updated, we take them from the original models + options.importing = true; + + return sequence([ + function databaseCheck() { + // we have to change the sqlite format, because it stores dates as integer + if (ServerTimezoneOffset === 0 && config.database.client === 'mysql') { + return Promise.reject(new Error('skip')); + } + + if (config.database.isPostgreSQL()) { + _private.noOffset = true; + } else if (config.database.client === 'mysql') { + _private.noOffset = false; + } else if (config.database.client === 'sqlite3') { + _private.noOffset = true; + } + + logger.info(messagePrefix + '(could take a while)...'); + return Promise.resolve(); + }, + function checkIfMigrationAlreadyRan() { + return models.Settings.findOne({key: 'migrations'}, options) + .then(function (result) { + try { + settingsMigrations = JSON.parse(result.attributes.value) || {}; + } catch (err) { + return Promise.reject(err); + } + + // CASE: migration ran already + if (settingsMigrations.hasOwnProperty(settingsKey)) { + return Promise.reject(new Error('skip')); + } + + return Promise.resolve(); + }); + }, + function updatePosts() { + return models.Post.findAll(options).then(function (result) { + if (result.models.length === 0) { + logger.warn(messagePrefix + 'No Posts found'); + return; + } + + return Promise.mapSeries(result.models, function mapper(post) { + if (post.get('published_at')) { + post.set('published_at', _private.addOffset(post.get('published_at'))); + } + + if (post.get('updated_at')) { + post.set('updated_at', _private.addOffset(post.get('updated_at'))); + } + + post.set('created_at', _private.addOffset(post.get('created_at'))); + return models.Post.edit(post.toJSON(), _.merge({}, options, {id: post.get('id')})); + }).then(function () { + logger.info(messagePrefix + 'Updated datetime fields for Posts'); + }); + }); + }, + function updateUsers() { + return models.User.findAll(options).then(function (result) { + if (result.models.length === 0) { + logger.warn(messagePrefix + 'No Users found'); + return; + } + + return Promise.mapSeries(result.models, function mapper(user) { + if (user.get('last_login')) { + user.set('last_login', _private.addOffset(user.get('last_login'))); + } + + if (user.get('updated_at')) { + user.set('updated_at', _private.addOffset(user.get('updated_at'))); + } + + user.set('created_at', _private.addOffset(user.get('created_at'))); + return models.User.edit(user.toJSON(), _.merge({}, options, {id: user.get('id')})); + }).then(function () { + logger.info(messagePrefix + 'Updated datetime fields for Users'); + }); + }); + }, + function updateSubscribers() { + return models.Subscriber.findAll(options).then(function (result) { + if (result.models.length === 0) { + logger.warn(messagePrefix + 'No Subscribers found'); + return; + } + + return Promise.mapSeries(result.models, function mapper(subscriber) { + if (subscriber.get('unsubscribed_at')) { + subscriber.set('unsubscribed_at', _private.addOffset(subscriber.get('unsubscribed_at'))); + } + + if (subscriber.get('updated_at')) { + subscriber.set('updated_at', _private.addOffset(subscriber.get('updated_at'))); + } + + subscriber.set('created_at', _private.addOffset(subscriber.get('created_at'))); + return models.Subscriber.edit(subscriber.toJSON(), _.merge({}, options, {id: subscriber.get('id')})); + }).then(function () { + logger.info(messagePrefix + 'Updated datetime fields for Subscribers'); + }); + }); + }, + function updateSettings() { + return models.Settings.findAll(options).then(function (result) { + if (result.models.length === 0) { + logger.warn(messagePrefix + 'No Settings found'); + return; + } + + return Promise.mapSeries(result.models, function mapper(settings) { + // migrations was new created, so it already is in UTC + if (settings.get('key') === 'migrations') { + return Promise.resolve(); + } + + if (settings.get('updated_at')) { + settings.set('updated_at', _private.addOffset(settings.get('updated_at'))); + } + + settings.set('created_at', _private.addOffset(settings.get('created_at'))); + return models.Settings.edit(settings.toJSON(), _.merge({}, options, {id: settings.get('id')})); + }).then(function () { + logger.info(messagePrefix + 'Updated datetime fields for Settings'); + }); + }); + }, + function updateAllOtherModels() { + return Promise.mapSeries(['Role', 'Permission', 'Tag', 'App', 'AppSetting', 'AppField', 'Client'], function (model) { + return models[model].findAll(options).then(function (result) { + if (result.models.length === 0) { + logger.warn(messagePrefix + 'No {model} found'.replace('{model}', model)); + return; + } + + return Promise.mapSeries(result.models, function mapper(object) { + object.set('created_at', _private.addOffset(object.get('created_at'))); + + if (object.get('updated_at')) { + object.set('updated_at', _private.addOffset(object.get('updated_at'))); + } + + return models[model].edit(object.toJSON(), _.merge({}, options, {id: object.get('id')})); + }).then(function () { + logger.info(messagePrefix + 'Updated datetime fields for {model}'.replace('{model}', model)); + }); + }); + }); + }, + function setActiveTimezone() { + var timezone = config.forceTimezoneOnMigration || moment.tz.guess(); + return models.Settings.edit({ + key: 'activeTimezone', + value: timezone + }, options); + }, + function addMigrationSettingsEntry() { + settingsMigrations[settingsKey] = moment().format(); + return models.Settings.edit({ + key: 'migrations', + value: JSON.stringify(settingsMigrations) + }, options); + }, + function updateSettingsCache() { + return api.settings.updateSettingsCache(null, options); + }] + ).catch(function (err) { + if (err.message === 'skip') { + logger.warn(messagePrefix + 'Your databases uses UTC datetimes, skip!'); + return Promise.resolve(); + } + + return Promise.reject(err); + }); +}; diff --git a/core/server/data/migration/fixtures/006/index.js b/core/server/data/migration/fixtures/006/index.js new file mode 100644 index 0000000..52b386f --- /dev/null +++ b/core/server/data/migration/fixtures/006/index.js @@ -0,0 +1,3 @@ +module.exports = [ + require('./01-transform-dates-into-utc') +]; diff --git a/core/server/data/migration/fixtures/007/01-add-themes-permissions.js b/core/server/data/migration/fixtures/007/01-add-themes-permissions.js new file mode 100644 index 0000000..9d9df65 --- /dev/null +++ b/core/server/data/migration/fixtures/007/01-add-themes-permissions.js @@ -0,0 +1,33 @@ +var utils = require('../utils'), + permissions = require('../../../../permissions'), + resource = 'theme'; + +function getPermissions() { + return utils.findModelFixtures('Permission', {object_type: resource}); +} + +function getRelations() { + return utils.findPermissionRelationsForObject(resource); +} + +function printResult(logger, result, message) { + if (result.done === result.expected) { + logger.info(message); + } else { + logger.warn('(' + result.done + '/' + result.expected + ') ' + message); + } +} + +module.exports = function addThemePermissions(options, logger) { + var modelToAdd = getPermissions(), + relationToAdd = getRelations(); + + return utils.addFixturesForModel(modelToAdd, options).then(function (result) { + printResult(logger, result, 'Adding permissions fixtures for ' + resource + 's'); + return utils.addFixturesForRelation(relationToAdd, options); + }).then(function (result) { + printResult(logger, result, 'Adding permissions_roles fixtures for ' + resource + 's'); + }).then(function () { + return permissions.init(options); + }); +}; diff --git a/core/server/data/migration/fixtures/007/index.js b/core/server/data/migration/fixtures/007/index.js new file mode 100644 index 0000000..b75de82 --- /dev/null +++ b/core/server/data/migration/fixtures/007/index.js @@ -0,0 +1,3 @@ +module.exports = [ + require('./01-add-themes-permissions') +]; diff --git a/core/server/data/migration/fixtures/008/01-fix-sqlite-pg-format.js b/core/server/data/migration/fixtures/008/01-fix-sqlite-pg-format.js new file mode 100644 index 0000000..7824877 --- /dev/null +++ b/core/server/data/migration/fixtures/008/01-fix-sqlite-pg-format.js @@ -0,0 +1,38 @@ +var config = require('../../../../config'), + models = require(config.paths.corePath + '/server/models'), + transfomDatesIntoUTC = require(config.paths.corePath + '/server/data/migration/fixtures/006/01-transform-dates-into-utc'), + Promise = require('bluebird'), + messagePrefix = 'Fix sqlite/pg format: '; + +/** + * this migration script is a very special one for people who run their server in UTC and use sqlite3 or run their server in any TZ and use postgres + * 006/01-transform-dates-into-utc had a bug for this case, see what happen because of this bug https://github.com/TryGhost/Ghost/issues/7192 + */ +module.exports = function fixSqliteFormat(options, logger) { + var settingsMigrations, settingsKey = '006/01'; + + // CASE: skip this script when using mysql + if (config.database.client === 'mysql') { + logger.warn(messagePrefix + 'This script only runs, when using sqlite/postgres as database.'); + return Promise.resolve(); + } + + // CASE: sqlite3 and postgres need's to re-run 006 date migrations + // because we had a bug that both database types were skipped when their server was running in UTC + // but we need to change the date format in that case as well, but without offset! + return models.Settings.findOne({key: 'migrations'}, options) + .then(function fetchedMigrationsSettings(result) { + try { + settingsMigrations = JSON.parse(result.attributes.value) || {}; + } catch (err) { + return Promise.reject(err); + } + + if (settingsMigrations.hasOwnProperty(settingsKey)) { + logger.warn(messagePrefix + 'Your dates are in correct format.'); + return; + } + + return transfomDatesIntoUTC(options, logger); + }); +}; diff --git a/core/server/data/migration/fixtures/008/index.js b/core/server/data/migration/fixtures/008/index.js new file mode 100644 index 0000000..aaae3bb --- /dev/null +++ b/core/server/data/migration/fixtures/008/index.js @@ -0,0 +1,3 @@ +module.exports = [ + require('./01-fix-sqlite-pg-format') +]; diff --git a/core/server/data/migration/fixtures/fixtures.json b/core/server/data/migration/fixtures/fixtures.json new file mode 100644 index 0000000..db5b36c --- /dev/null +++ b/core/server/data/migration/fixtures/fixtures.json @@ -0,0 +1,360 @@ +{ + "models": [ + { + "name": "Post", + "entries": [ + { + "title": "Welcome to Ghost", + "slug": "welcome-to-ghost", + "markdown": "You're live! Nice. We've put together a little post to introduce you to the Ghost editor and get you started. You can manage your content by signing in to the admin area at `/ghost/`. When you arrive, you can select this post from a list on the left and see a preview of it on the right. Click the little pencil icon at the top of the preview to edit this post and read the next section!\n\n## Getting Started\n\nGhost uses something called Markdown for writing. Essentially, it's a shorthand way to manage your post formatting as you write!\n\nWriting in Markdown is really easy. In the left hand panel of Ghost, you simply write as you normally would. Where appropriate, you can use *shortcuts* to **style** your content. For example, a list:\n\n* Item number one\n* Item number two\n * A nested item\n* A final item\n\nor with numbers!\n\n1. Remember to buy some milk\n2. Drink the milk\n3. Tweet that I remembered to buy the milk, and drank it\n\n### Links\n\nWant to link to a source? No problem. If you paste in a URL, like http://ghost.org - it'll automatically be linked up. But if you want to customise your anchor text, you can do that too! Here's a link to [the Ghost website](http://ghost.org). Neat.\n\n### What about Images?\n\nImages work too! Already know the URL of the image you want to include in your article? Simply paste it in like this to make it show up:\n\n![The Ghost Logo](https://ghost.org/images/ghost.png)\n\nNot sure which image you want to use yet? That's ok too. Leave yourself a descriptive placeholder and keep writing. Come back later and drag and drop the image in to upload:\n\n![A bowl of bananas]\n\n\n### Quoting\n\nSometimes a link isn't enough, you want to quote someone on what they've said. Perhaps you've started using a new blogging platform and feel the sudden urge to share their slogan? A quote might be just the way to do it!\n\n> Ghost - Just a blogging platform\n\n### Working with Code\n\nGot a streak of geek? We've got you covered there, too. You can write inline `` blocks really easily with back ticks. Want to show off something more comprehensive? 4 spaces of indentation gets you there.\n\n .awesome-thing {\n display: block;\n width: 100%;\n }\n\n### Ready for a Break? \n\nThrow 3 or more dashes down on any new line and you've got yourself a fancy new divider. Aw yeah.\n\n---\n\n### Advanced Usage\n\nThere's one fantastic secret about Markdown. If you want, you can write plain old HTML and it'll still work! Very flexible.\n\n\n\nThat should be enough to get you started. Have fun - and let us know what you think :)", + "image": null, + "featured": false, + "page": false, + "status": "published", + "language": "en_US", + "meta_title": null, + "meta_description": null + } + ] + }, + { + "name": "Tag", + "entries": [ + { + "name": "Getting Started", + "slug": "getting-started", + "description": null, + "parent_id": null, + "meta_title": null, + "meta_description": null + } + ] + }, + { + "name": "Client", + "entries": [ + { + "name": "Ghost Admin", + "slug": "ghost-admin", + "status": "enabled" + }, + { + "name": "Ghost Frontend", + "slug": "ghost-frontend", + "status": "enabled" + }, + { + "name": "Ghost Scheduler", + "slug": "ghost-scheduler", + "status": "enabled", + "type": "web" + } + ] + }, + { + "name": "Role", + "entries": [ + { + "name": "Administrator", + "description": "Administrators" + }, + { + "name": "Editor", + "description": "Editors" + }, + { + "name": "Author", + "description": "Authors" + }, + { + "name": "Owner", + "description": "Blog Owner" + } + ] + }, + { + "name": "Permission", + "entries": [ + { + "name": "Export database", + "action_type": "exportContent", + "object_type": "db" + }, + { + "name": "Import database", + "action_type": "importContent", + "object_type": "db" + }, + { + "name": "Delete all content", + "action_type": "deleteAllContent", + "object_type": "db" + }, + { + "name": "Send mail", + "action_type": "send", + "object_type": "mail" + }, + { + "name": "Browse notifications", + "action_type": "browse", + "object_type": "notification" + }, + { + "name": "Add notifications", + "action_type": "add", + "object_type": "notification" + }, + { + "name": "Delete notifications", + "action_type": "destroy", + "object_type": "notification" + }, + { + "name": "Browse posts", + "action_type": "browse", + "object_type": "post" + }, + { + "name": "Read posts", + "action_type": "read", + "object_type": "post" + }, + { + "name": "Edit posts", + "action_type": "edit", + "object_type": "post" + }, + { + "name": "Add posts", + "action_type": "add", + "object_type": "post" + }, + { + "name": "Delete posts", + "action_type": "destroy", + "object_type": "post" + }, + { + "name": "Browse settings", + "action_type": "browse", + "object_type": "setting" + }, + { + "name": "Read settings", + "action_type": "read", + "object_type": "setting" + }, + { + "name": "Edit settings", + "action_type": "edit", + "object_type": "setting" + }, + { + "name": "Generate slugs", + "action_type": "generate", + "object_type": "slug" + }, + { + "name": "Browse tags", + "action_type": "browse", + "object_type": "tag" + }, + { + "name": "Read tags", + "action_type": "read", + "object_type": "tag" + }, + { + "name": "Edit tags", + "action_type": "edit", + "object_type": "tag" + }, + { + "name": "Add tags", + "action_type": "add", + "object_type": "tag" + }, + { + "name": "Delete tags", + "action_type": "destroy", + "object_type": "tag" + }, + { + "name": "Browse themes", + "action_type": "browse", + "object_type": "theme" + }, + { + "name": "Edit themes", + "action_type": "edit", + "object_type": "theme" + }, + { + "name": "Upload themes", + "action_type": "add", + "object_type": "theme" + }, + { + "name": "Download themes", + "action_type": "read", + "object_type": "theme" + }, + { + "name": "Delete themes", + "action_type": "destroy", + "object_type": "theme" + }, + { + "name": "Browse users", + "action_type": "browse", + "object_type": "user" + }, + { + "name": "Read users", + "action_type": "read", + "object_type": "user" + }, + { + "name": "Edit users", + "action_type": "edit", + "object_type": "user" + }, + { + "name": "Add users", + "action_type": "add", + "object_type": "user" + }, + { + "name": "Delete users", + "action_type": "destroy", + "object_type": "user" + }, + { + "name": "Assign a role", + "action_type": "assign", + "object_type": "role" + }, + { + "name": "Browse roles", + "action_type": "browse", + "object_type": "role" + }, + { + "name": "Browse clients", + "action_type": "browse", + "object_type": "client" + }, + { + "name": "Read clients", + "action_type": "read", + "object_type": "client" + }, + { + "name": "Edit clients", + "action_type": "edit", + "object_type": "client" + }, + { + "name": "Add clients", + "action_type": "add", + "object_type": "client" + }, + { + "name": "Delete clients", + "action_type": "destroy", + "object_type": "client" + }, + { + "name": "Browse subscribers", + "action_type": "browse", + "object_type": "subscriber" + }, + { + "name": "Read subscribers", + "action_type": "read", + "object_type": "subscriber" + }, + { + "name": "Edit subscribers", + "action_type": "edit", + "object_type": "subscriber" + }, + { + "name": "Add subscribers", + "action_type": "add", + "object_type": "subscriber" + }, + { + "name": "Delete subscribers", + "action_type": "destroy", + "object_type": "subscriber" + } + ] + } + + ], + "relations": [ + { + "from": { + "model": "Role", + "match": "name", + "relation": "permissions" + }, + "to": { + "model": "Permission", + "match": ["object_type", "action_type"] + }, + "entries": { + "Administrator": { + "db": "all", + "mail": "all", + "notification": "all", + "post": "all", + "setting": "all", + "slug": "all", + "tag": "all", + "theme": "all", + "user": "all", + "role": "all", + "client": "all", + "subscriber": "all" + }, + "Editor": { + "post": "all", + "setting": ["browse", "read"], + "slug": "all", + "tag": "all", + "user": "all", + "role": "all", + "client": "all", + "subscriber": ["add"] + }, + "Author": { + "post": ["browse", "read", "add"], + "setting": ["browse", "read"], + "slug": "all", + "tag": ["browse", "read", "add"], + "user": ["browse", "read"], + "role": ["browse"], + "client": "all", + "subscriber": ["add"] + } + } + }, + { + "from": { + "model": "Post", + "match": "title", + "relation": "tags" + }, + "to": { + "model": "Tag", + "match": "name" + }, + "entries": { + "Welcome to Ghost": ["Getting Started"] + } + } + ] +} diff --git a/core/server/data/migration/fixtures/index.js b/core/server/data/migration/fixtures/index.js new file mode 100644 index 0000000..e421ac7 --- /dev/null +++ b/core/server/data/migration/fixtures/index.js @@ -0,0 +1,9 @@ +var populate = require('./populate'), + update = require('./update'), + fixtures = require('./fixtures'); + +module.exports = { + populate: populate, + update: update, + fixtures: fixtures +}; diff --git a/core/server/data/migration/fixtures/populate.js b/core/server/data/migration/fixtures/populate.js new file mode 100644 index 0000000..e1815b5 --- /dev/null +++ b/core/server/data/migration/fixtures/populate.js @@ -0,0 +1,89 @@ +// # Populate Fixtures +// This module handles populating fixtures on a fresh install. +// This is done automatically, by reading the fixtures.json file +// All models, and relationships inside the file are then setup. +var Promise = require('bluebird'), + models = require('../../../models'), + coreUtils = require('../../../utils'), + fixtureUtils = require('./utils'), + fixtures = require('./fixtures'), + + // private + addAllModels, + addAllRelations, + createOwner, + + // public + populate; + +/** + * ### Add All Models + * Sequentially calls add on all the models specified in fixtures.json + * + * @returns {Promise<*>} + */ +addAllModels = function addAllModels(modelOptions) { + return Promise.mapSeries(fixtures.models, function (model) { + return fixtureUtils.addFixturesForModel(model, modelOptions); + }); +}; + +/** + * ### Add All Relations + * Sequentially calls add on all the relations specified in fixtures.json + * + * @returns {Promise|Array} + */ +addAllRelations = function addAllRelations(modelOptions) { + return Promise.mapSeries(fixtures.relations, function (model) { + return fixtureUtils.addFixturesForRelation(model, modelOptions); + }); +}; + +/** + * ### Create Owner + * Creates the user fixture and gives it the owner role. + * By default, users are given the Author role, making it hard to do this using the fixture system + * + * @param {{info: logger.info, warn: logger.warn}} logger + * @returns {Promise<*>} + */ +createOwner = function createOwner(logger, modelOptions) { + var user = { + name: 'Ghost Owner', + email: 'ghost@ghost.org', + status: 'inactive', + password: coreUtils.uid(50) + }; + + return models.Role.findOne({name: 'Owner'}, modelOptions).then(function (ownerRole) { + if (ownerRole) { + user.roles = [ownerRole.id]; + + logger.info('Creating owner'); + return models.User.add(user, modelOptions); + } + }); +}; + +/** + * ## Populate + * Sequentially creates all models, in the order they are specified, and then + * creates all the relationships, also maintaining order. + * + * @param {{info: logger.info, warn: logger.warn}} logger + * @returns {Promise<*>} + */ +populate = function populate(logger, modelOptions) { + logger.info('Running fixture populations'); + + return addAllModels(modelOptions) + .then(function () { + return addAllRelations(modelOptions); + }) + .then(function () { + return createOwner(logger, modelOptions); + }); +}; + +module.exports = populate; diff --git a/core/server/data/migration/fixtures/update.js b/core/server/data/migration/fixtures/update.js new file mode 100644 index 0000000..79b9f01 --- /dev/null +++ b/core/server/data/migration/fixtures/update.js @@ -0,0 +1,34 @@ +// # Update Fixtures +// This module handles updating fixtures. +// This is done manually, through a series of files stored in an adjacent folder +// E.g. if we update to version 004, all the tasks in /004/ are executed + +var Promise = require('bluebird'), + _ = require('lodash'), + sequence = function sequence(tasks, modelOptions, logger) { + // utils/sequence.js does not offer an option to pass cloned arguments + return Promise.reduce(tasks, function (results, task) { + return task(_.cloneDeep(modelOptions), logger) + .then(function (result) { + results.push(result); + return results; + }); + }, []); + }, + update; + +/** + * Handles doing subsequent update for one version + */ +update = function update(tasks, logger, modelOptions) { + logger.info('Running fixture updates'); + + if (!tasks.length) { + logger.info('No fixture migration tasks found for this version'); + return Promise.resolve(); + } + + return sequence(tasks, modelOptions, logger); +}; + +module.exports = update; diff --git a/core/server/data/migration/fixtures/utils.js b/core/server/data/migration/fixtures/utils.js new file mode 100644 index 0000000..9a34da0 --- /dev/null +++ b/core/server/data/migration/fixtures/utils.js @@ -0,0 +1,230 @@ +// # Fixture Utils +// Standalone file which can be required to help with advanced operations on the fixtures.json file +var _ = require('lodash'), + Promise = require('bluebird'), + models = require('../../../models'), + sequence = require('../../../utils/sequence'), + + fixtures = require('./fixtures'), + + // Private + matchFunc, + matchObj, + fetchRelationData, + findRelationFixture, + findModelFixture, + + addFixturesForModel, + addFixturesForRelation, + findModelFixtureEntry, + findModelFixtures, + findPermissionRelationsForObject; + +/** + * ### Match Func + * Figures out how to match across various combinations of keys and values. + * Match can be a string or an array containing 2 strings + * Key and Value are the values to be found + * Value can also be an array, in which case we look for a match in the array. + * @api private + * @param {String|Array} match + * @param {String|Integer} key + * @param {String|Array} [value] + * @returns {Function} matching function + */ +matchFunc = function matchFunc(match, key, value) { + if (_.isArray(match)) { + return function (item) { + var valueTest = true; + + if (_.isArray(value)) { + valueTest = value.indexOf(item.get(match[1])) > -1; + } else if (value !== 'all') { + valueTest = item.get(match[1]) === value; + } + + return item.get(match[0]) === key && valueTest; + }; + } + + return function (item) { + key = key === 0 && value ? value : key; + return item.get(match) === key; + }; +}; + +matchObj = function matchObj(match, item) { + var matchObj = {}; + + if (_.isArray(match)) { + _.each(match, function (matchProp) { + matchObj[matchProp] = item.get(matchProp); + }); + } else { + matchObj[match] = item.get(match); + } + + return matchObj; +}; + +/** + * ### Fetch Relation Data + * Before we build relations we need to fetch all of the models from both sides so that we can + * use filter and find to quickly locate the correct models. + * @api private + * @param {{from, to, entries}} relation + * @returns {Promise<*>} + */ +fetchRelationData = function fetchRelationData(relation, options) { + var fromOptions = _.extend({}, options, {withRelated: [relation.from.relation]}), + props = { + from: models[relation.from.model].findAll(fromOptions), + to: models[relation.to.model].findAll(options) + }; + + return Promise.props(props); +}; + +/** + * ### Add Fixtures for Model + * Takes a model fixture, with a name and some entries and processes these + * into a sequence of promises to get each fixture added. + * + * @param {{name, entries}} modelFixture + * @returns {Promise.<*>} + */ +addFixturesForModel = function addFixturesForModel(modelFixture, options) { + return Promise.mapSeries(modelFixture.entries, function (entry) { + return models[modelFixture.name].findOne(entry, options).then(function (found) { + if (!found) { + return models[modelFixture.name].add(entry, options); + } + }); + }).then(function (results) { + return {expected: modelFixture.entries.length, done: _.compact(results).length}; + }); +}; + +/** + * ## Add Fixtures for Relation + * Takes a relation fixtures object, with a from, to and some entries and processes these + * into a sequence of promises, to get each fixture added. + * + * @param {{from, to, entries}} relationFixture + * @returns {Promise.<*>} + */ +addFixturesForRelation = function addFixturesForRelation(relationFixture, options) { + var ops = [], max = 0; + + return fetchRelationData(relationFixture, options).then(function getRelationOps(data) { + _.each(relationFixture.entries, function processEntries(entry, key) { + var fromItem = data.from.find(matchFunc(relationFixture.from.match, key)); + + _.each(entry, function processEntryValues(value, key) { + var toItems = data.to.filter(matchFunc(relationFixture.to.match, key, value)); + max += toItems.length; + + // Remove any duplicates that already exist in the collection + toItems = _.reject(toItems, function (item) { + return fromItem + .related(relationFixture.from.relation) + .findWhere(matchObj(relationFixture.to.match, item)); + }); + + if (toItems && toItems.length > 0) { + ops.push(function addRelationItems() { + return fromItem[relationFixture.from.relation]().attach(toItems, options); + }); + } + }); + }); + + return sequence(ops); + }).then(function (result) { + return {expected: max, done: _(result).map('length').sum()}; + }); +}; + +/** + * ### Find Model Fixture + * Finds a model fixture based on model name + * @api private + * @param {String} modelName + * @returns {Object} model fixture + */ +findModelFixture = function findModelFixture(modelName) { + return _.find(fixtures.models, function (modelFixture) { + return modelFixture.name === modelName; + }); +}; + +/** + * ### Find Model Fixture Entry + * Find a single model fixture entry by model name & a matching expression for the FIND function + * @param {String} modelName + * @param {String|Object|Function} matchExpr + * @returns {Object} model fixture entry + */ +findModelFixtureEntry = function findModelFixtureEntry(modelName, matchExpr) { + return _.find(findModelFixture(modelName).entries, matchExpr); +}; + +/** + * ### Find Model Fixtures + * Find a model fixture name & a matching expression for the FILTER function + * @param {String} modelName + * @param {String|Object|Function} matchExpr + * @returns {Object} model fixture + */ +findModelFixtures = function findModelFixtures(modelName, matchExpr) { + var foundModel = _.cloneDeep(findModelFixture(modelName)); + foundModel.entries = _.filter(foundModel.entries, matchExpr); + return foundModel; +}; + +/** + * ### Find Relation Fixture + * Find a relation fixture by from & to models + * @api private + * @param {String} from + * @param {String} to + * @returns {Object} relation fixture + */ +findRelationFixture = function findRelationFixture(from, to) { + return _.find(fixtures.relations, function (relation) { + return relation.from.model === from && relation.to.model === to; + }); +}; + +/** + * ### Find Permission Relations For Object + * Specialist function can return the permission relation fixture with only entries for a particular object.model + * @param {String} objName + * @returns {Object} fixture relation + */ +findPermissionRelationsForObject = function findPermissionRelationsForObject(objName) { + // Make a copy and delete any entries we don't want + var foundRelation = _.cloneDeep(findRelationFixture('Role', 'Permission')); + + _.each(foundRelation.entries, function (entry, role) { + _.each(entry, function (perm, obj) { + if (obj !== objName) { + delete entry[obj]; + } + }); + + if (_.isEmpty(entry)) { + delete foundRelation.entries[role]; + } + }); + + return foundRelation; +}; + +module.exports = { + addFixturesForModel: addFixturesForModel, + addFixturesForRelation: addFixturesForRelation, + findModelFixtureEntry: findModelFixtureEntry, + findModelFixtures: findModelFixtures, + findPermissionRelationsForObject: findPermissionRelationsForObject +}; diff --git a/core/server/data/migration/index.js b/core/server/data/migration/index.js new file mode 100644 index 0000000..7e426af --- /dev/null +++ b/core/server/data/migration/index.js @@ -0,0 +1,4 @@ +exports.update = require('./update'); +exports.populate = require('./populate'); +exports.reset = require('./reset'); +exports.backupDatabase = require('./backup'); diff --git a/core/server/data/migration/populate.js b/core/server/data/migration/populate.js new file mode 100644 index 0000000..a94f074 --- /dev/null +++ b/core/server/data/migration/populate.js @@ -0,0 +1,58 @@ +// # Populate +// Create a brand new database for a new install of ghost +var Promise = require('bluebird'), + _ = require('lodash'), + commands = require('../schema').commands, + fixtures = require('./fixtures'), + errors = require('../../errors'), + models = require('../../models'), + db = require('../../data/db'), + schema = require('../schema').tables, + schemaTables = Object.keys(schema), + populate, logger; + +// @TODO: remove me asap! +logger = { + info: function info(message) { + errors.logComponentInfo('Migrations', message); + }, + warn: function warn(message) { + errors.logComponentWarn('Skipping Migrations', message); + } +}; + +/** + * ## Populate + * Uses the schema to determine table structures, and automatically creates each table in order + */ +populate = function populate(options) { + options = options || {}; + + var tablesOnly = options.tablesOnly, + modelOptions = { + context: { + internal: true + } + }; + + logger.info('Creating tables...'); + return db.knex.transaction(function populateDatabaseInTransaction(transaction) { + return Promise.mapSeries(schemaTables, function createTable(table) { + logger.info('Creating table: ' + table); + return commands.createTable(table, transaction); + }).then(function populateFixtures() { + if (tablesOnly) { + return; + } + + return fixtures.populate(logger, _.merge({}, {transacting: transaction}, modelOptions)); + }).then(function () { + return models.Settings.populateDefaults({transacting: transaction}); + }); + }).catch(function populateDatabaseError(err) { + logger.warn('rolling back...'); + return Promise.reject(new errors.InternalServerError('Unable to populate database: ' + err.message)); + }); +}; + +module.exports = populate; diff --git a/core/server/data/migration/reset.js b/core/server/data/migration/reset.js new file mode 100644 index 0000000..a26e66e --- /dev/null +++ b/core/server/data/migration/reset.js @@ -0,0 +1,23 @@ +// ### Reset +// Delete all tables from the database in reverse order +var Promise = require('bluebird'), + commands = require('../schema').commands, + schema = require('../schema').tables, + + schemaTables = Object.keys(schema).reverse(), + reset; + +/** + * # Reset + * Deletes all the tables defined in the schema + * Uses reverse order, which ensures that foreign keys are removed before the parent table + * + * @returns {Promise<*>} + */ +reset = function reset() { + return Promise.mapSeries(schemaTables, function (table) { + return commands.deleteTable(table); + }); +}; + +module.exports = reset; diff --git a/core/server/data/migration/update.js b/core/server/data/migration/update.js new file mode 100644 index 0000000..b4d3e4b --- /dev/null +++ b/core/server/data/migration/update.js @@ -0,0 +1,149 @@ +// # Update Database +// Handles migrating a database between two different database versions +var Promise = require('bluebird'), + _ = require('lodash'), + backup = require('./backup'), + fixtures = require('./fixtures'), + errors = require('../../errors'), + i18n = require('../../i18n'), + db = require('../../data/db'), + versioning = require('../schema').versioning, + sequence = function sequence(tasks, modelOptions, logger) { + // utils/sequence.js does not offer an option to pass cloned arguments + return Promise.reduce(tasks, function (results, task) { + return task(_.cloneDeep(modelOptions), logger) + .then(function (result) { + results.push(result); + return results; + }); + }, []); + }, + updateDatabaseSchema, + migrateToDatabaseVersion, + execute, logger, isDatabaseOutOfDate; + +// @TODO: remove me asap! +logger = { + info: function info(message) { + errors.logComponentInfo('Migrations', message); + }, + warn: function warn(message) { + errors.logComponentWarn('Skipping Migrations', message); + } +}; + +/** + * update database schema for one single version + */ +updateDatabaseSchema = function (tasks, logger, modelOptions) { + if (!tasks.length) { + logger.info('No database migration tasks found for this version'); + return Promise.resolve(); + } + + return sequence(tasks, modelOptions, logger); +}; + +/** + * update each database version as one transaction + * if a version fails, rollback + * if a version fails, stop updating more versions + */ +migrateToDatabaseVersion = function migrateToDatabaseVersion(version, logger, modelOptions) { + return new Promise(function (resolve, reject) { + db.knex.transaction(function (transaction) { + var migrationTasks = versioning.getUpdateDatabaseTasks(version, logger), + fixturesTasks = versioning.getUpdateFixturesTasks(version, logger); + + logger.info('Updating database to ' + version); + + modelOptions.transacting = transaction; + + updateDatabaseSchema(migrationTasks, logger, modelOptions) + .then(function () { + return fixtures.update(fixturesTasks, logger, modelOptions); + }) + .then(function () { + return versioning.setDatabaseVersion(transaction, version); + }) + .then(function () { + transaction.commit(); + resolve(); + }) + .catch(function (err) { + logger.warn('rolling back because of an Error:\n' + err.message + '\n' + err.stack); + + transaction.rollback(); + }); + }).catch(function () { + reject(); + }); + }); +}; + +/** + * ## Update + * Does a backup, then updates the database and fixtures + */ +execute = function execute(options) { + options = options || {}; + + var fromVersion = options.fromVersion, + toVersion = options.toVersion, + forceMigration = options.forceMigration, + versionsToUpdate, + modelOptions = { + context: { + internal: true + } + }; + + fromVersion = forceMigration ? versioning.canMigrateFromVersion : fromVersion; + + // Figure out which versions we're updating through. + // This shouldn't include the from/current version (which we're already on) + versionsToUpdate = versioning.getMigrationVersions(fromVersion, toVersion).slice(1); + + return backup(logger) + .then(function () { + logger.info('Migration required from ' + fromVersion + ' to ' + toVersion); + return Promise.mapSeries(versionsToUpdate, function (versionToUpdate) { + return migrateToDatabaseVersion(versionToUpdate, logger, modelOptions); + }); + }) + .then(function () { + logger.info('Finished!'); + }); +}; + +isDatabaseOutOfDate = function isDatabaseOutOfDate(options) { + options = options || {}; + + var fromVersion = options.fromVersion, + toVersion = options.toVersion, + forceMigration = options.forceMigration; + + // CASE: current database version is lower then we support + if (fromVersion < versioning.canMigrateFromVersion) { + return {error: new errors.DatabaseVersion( + i18n.t('errors.data.versioning.index.cannotMigrate.error'), + i18n.t('errors.data.versioning.index.cannotMigrate.context'), + i18n.t('common.seeLinkForInstructions', {link: 'https://docs.ghost.org/v0.11.9/docs/how-to-upgrade-ghost'}) + )}; + } + // CASE: the database exists but is out of date + else if (fromVersion < toVersion || forceMigration) { + return {migrate: true}; + } + // CASE: database is up-to-date + else if (fromVersion === toVersion) { + return {migrate: false}; + } + // CASE: we don't understand the version + else { + return {error: new errors.DatabaseVersion(i18n.t('errors.data.versioning.index.dbVersionNotRecognized'))}; + } +}; + +exports.execute = execute; +exports.isDatabaseOutOfDate = isDatabaseOutOfDate; diff --git a/core/server/data/schema/checks.js b/core/server/data/schema/checks.js new file mode 100644 index 0000000..59eaba8 --- /dev/null +++ b/core/server/data/schema/checks.js @@ -0,0 +1,26 @@ +function isPost(jsonData) { + return jsonData.hasOwnProperty('html') && jsonData.hasOwnProperty('markdown') && + jsonData.hasOwnProperty('title') && jsonData.hasOwnProperty('slug'); +} + +function isTag(jsonData) { + return jsonData.hasOwnProperty('name') && jsonData.hasOwnProperty('slug') && + jsonData.hasOwnProperty('description') && jsonData.hasOwnProperty('parent'); +} + +function isUser(jsonData) { + return jsonData.hasOwnProperty('bio') && jsonData.hasOwnProperty('website') && + jsonData.hasOwnProperty('status') && jsonData.hasOwnProperty('location'); +} + +function isNav(jsonData) { + return jsonData.hasOwnProperty('label') && jsonData.hasOwnProperty('url') && + jsonData.hasOwnProperty('slug') && jsonData.hasOwnProperty('current'); +} + +module.exports = { + isPost: isPost, + isTag: isTag, + isUser: isUser, + isNav: isNav +}; diff --git a/core/server/data/schema/clients/index.js b/core/server/data/schema/clients/index.js new file mode 100644 index 0000000..8c8df7c --- /dev/null +++ b/core/server/data/schema/clients/index.js @@ -0,0 +1,11 @@ +var sqlite3 = require('./sqlite3'), + mysql = require('./mysql'), + pg = require('./pg'); + +module.exports = { + sqlite3: sqlite3, + mysql: mysql, + pg: pg, + postgres: pg, + postgresql: pg +}; diff --git a/core/server/data/schema/clients/mysql.js b/core/server/data/schema/clients/mysql.js new file mode 100644 index 0000000..e91ad58 --- /dev/null +++ b/core/server/data/schema/clients/mysql.js @@ -0,0 +1,56 @@ +var _ = require('lodash'), + db = require('../../../data/db'), + + // private + doRawAndFlatten, + + // public + getTables, + getIndexes, + getColumns, + checkPostTable; + +doRawAndFlatten = function doRaw(query, transaction, flattenFn) { + return (transaction || db.knex).raw(query).then(function (response) { + return _.flatten(flattenFn(response)); + }); +}; + +getTables = function getTables(transaction) { + return doRawAndFlatten('show tables', transaction, function (response) { + return _.map(response[0], function (entry) { return _.values(entry); }); + }); +}; + +getIndexes = function getIndexes(table, transaction) { + return doRawAndFlatten('SHOW INDEXES from ' + table, transaction, function (response) { + return _.map(response[0], 'Key_name'); + }); +}; + +getColumns = function getColumns(table, transaction) { + return doRawAndFlatten('SHOW COLUMNS FROM ' + table, transaction, function (response) { + return _.map(response[0], 'Field'); + }); +}; + +// This function changes the type of posts.html and posts.markdown columns to mediumtext. Due to +// a wrong datatype in schema.js some installations using mysql could have been created using the +// data type text instead of mediumtext. +// For details see: https://github.com/TryGhost/Ghost/issues/1947 +checkPostTable = function checkPostTable(transaction) { + return (transaction || db.knex).raw('SHOW FIELDS FROM posts where Field ="html" OR Field = "markdown"').then(function (response) { + return _.flatten(_.map(response[0], function (entry) { + if (entry.Type.toLowerCase() !== 'mediumtext') { + return (transaction || db.knex).raw('ALTER TABLE posts MODIFY ' + entry.Field + ' MEDIUMTEXT'); + } + })); + }); +}; + +module.exports = { + checkPostTable: checkPostTable, + getTables: getTables, + getIndexes: getIndexes, + getColumns: getColumns +}; diff --git a/core/server/data/schema/clients/pg.js b/core/server/data/schema/clients/pg.js new file mode 100644 index 0000000..2b67828 --- /dev/null +++ b/core/server/data/schema/clients/pg.js @@ -0,0 +1,45 @@ +var _ = require('lodash'), + db = require('../../../data/db'), + + // private + doRawFlattenAndPluck, + + // public + getTables, + getIndexes, + getColumns; + +doRawFlattenAndPluck = function doRaw(query, name, transaction) { + return (transaction || db.knex).raw(query).then(function (response) { + return _.flatten(_.map(response.rows, name)); + }); +}; + +getTables = function getTables(transaction) { + return doRawFlattenAndPluck( + 'SELECT table_name FROM information_schema.tables WHERE table_schema = CURRENT_SCHEMA()', + 'table_name', + transaction + ); +}; + +getIndexes = function getIndexes(table, transaction) { + var selectIndexes = 'SELECT t.relname as table_name, i.relname as index_name, a.attname as column_name' + + ' FROM pg_class t, pg_class i, pg_index ix, pg_attribute a' + + ' WHERE t.oid = ix.indrelid and i.oid = ix.indexrelid and' + + ' a.attrelid = t.oid and a.attnum = ANY(ix.indkey) and t.relname = \'' + table + '\''; + + return doRawFlattenAndPluck(selectIndexes, 'index_name', transaction); +}; + +getColumns = function getColumns(table, transaction) { + var selectIndexes = 'SELECT column_name FROM information_schema.columns WHERE table_name = \'' + table + '\''; + + return doRawFlattenAndPluck(selectIndexes, 'column_name', transaction); +}; + +module.exports = { + getTables: getTables, + getIndexes: getIndexes, + getColumns: getColumns +}; diff --git a/core/server/data/schema/clients/sqlite3.js b/core/server/data/schema/clients/sqlite3.js new file mode 100644 index 0000000..3920c17 --- /dev/null +++ b/core/server/data/schema/clients/sqlite3.js @@ -0,0 +1,47 @@ +var _ = require('lodash'), + db = require('../../../data/db'), + + // private + doRaw, + + // public + getTables, + getIndexes, + getColumns; + +doRaw = function doRaw(query, transaction, fn) { + if (!fn) { + fn = transaction; + transaction = null; + } + + return (transaction || db.knex).raw(query).then(function (response) { + return fn(response); + }); +}; + +getTables = function getTables(transaction) { + return doRaw('select * from sqlite_master where type = "table"', transaction, function (response) { + return _.reject(_.map(response, 'tbl_name'), function (name) { + return name === 'sqlite_sequence'; + }); + }); +}; + +getIndexes = function getIndexes(table, transaction) { + return doRaw('pragma index_list("' + table + '")', transaction, function (response) { + return _.flatten(_.map(response, 'name')); + }); +}; + +getColumns = function getColumns(table, transaction) { + return doRaw('pragma table_info("' + table + '")', transaction, function (response) { + return _.flatten(_.map(response, 'name')); + }); +}; + +module.exports = { + getTables: getTables, + getIndexes: getIndexes, + getColumns: getColumns +}; diff --git a/core/server/data/schema/commands.js b/core/server/data/schema/commands.js new file mode 100644 index 0000000..23f124b --- /dev/null +++ b/core/server/data/schema/commands.js @@ -0,0 +1,130 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + i18n = require('../../i18n'), + db = require('../db'), + schema = require('./schema'), + clients = require('./clients'); + +function addTableColumn(tableName, table, columnName) { + var column, + columnSpec = schema[tableName][columnName]; + + // creation distinguishes between text with fieldtype, string with maxlength and all others + if (columnSpec.type === 'text' && columnSpec.hasOwnProperty('fieldtype')) { + column = table[columnSpec.type](columnName, columnSpec.fieldtype); + } else if (columnSpec.type === 'string' && columnSpec.hasOwnProperty('maxlength')) { + column = table[columnSpec.type](columnName, columnSpec.maxlength); + } else { + column = table[columnSpec.type](columnName); + } + + if (columnSpec.hasOwnProperty('nullable') && columnSpec.nullable === true) { + column.nullable(); + } else { + column.notNullable(); + } + if (columnSpec.hasOwnProperty('primary') && columnSpec.primary === true) { + column.primary(); + } + if (columnSpec.hasOwnProperty('unique') && columnSpec.unique) { + column.unique(); + } + if (columnSpec.hasOwnProperty('unsigned') && columnSpec.unsigned) { + column.unsigned(); + } + if (columnSpec.hasOwnProperty('references')) { + // check if table exists? + column.references(columnSpec.references); + } + if (columnSpec.hasOwnProperty('defaultTo')) { + column.defaultTo(columnSpec.defaultTo); + } +} + +function addColumn(tableName, column, transaction) { + return (transaction || db.knex).schema.table(tableName, function (table) { + addTableColumn(tableName, table, column); + }); +} + +function dropColumn(table, column, transaction) { + return (transaction || db.knex).schema.table(table, function (table) { + table.dropColumn(column); + }); +} + +function addUnique(table, column, transaction) { + return (transaction || db.knex).schema.table(table, function (table) { + table.unique(column); + }); +} + +function dropUnique(table, column, transaction) { + return (transaction || db.knex).schema.table(table, function (table) { + table.dropUnique(column); + }); +} + +function createTable(table, transaction) { + return (transaction || db.knex).schema.createTableIfNotExists(table, function (t) { + var columnKeys = _.keys(schema[table]); + _.each(columnKeys, function (column) { + return addTableColumn(table, t, column); + }); + }); +} + +function deleteTable(table, transaction) { + return (transaction || db.knex).schema.dropTableIfExists(table); +} + +function getTables(transaction) { + var client = (transaction || db.knex).client.config.client; + + if (_.includes(_.keys(clients), client)) { + return clients[client].getTables(); + } + + return Promise.reject(i18n.t('notices.data.utils.index.noSupportForDatabase', {client: client})); +} + +function getIndexes(table, transaction) { + var client = (transaction || db.knex).client.config.client; + + if (_.includes(_.keys(clients), client)) { + return clients[client].getIndexes(table, transaction); + } + + return Promise.reject(i18n.t('notices.data.utils.index.noSupportForDatabase', {client: client})); +} + +function getColumns(table, transaction) { + var client = (transaction || db.knex).client.config.client; + + if (_.includes(_.keys(clients), client)) { + return clients[client].getColumns(table); + } + + return Promise.reject(i18n.t('notices.data.utils.index.noSupportForDatabase', {client: client})); +} + +function checkTables(transaction) { + var client = (transaction || db.knex).client.config.client; + + if (client === 'mysql') { + return clients[client].checkPostTable(); + } +} + +module.exports = { + checkTables: checkTables, + createTable: createTable, + deleteTable: deleteTable, + getTables: getTables, + getIndexes: getIndexes, + addUnique: addUnique, + dropUnique: dropUnique, + addColumn: addColumn, + dropColumn: dropColumn, + getColumns: getColumns +}; diff --git a/core/server/data/schema/default-settings.json b/core/server/data/schema/default-settings.json new file mode 100644 index 0000000..9640bc7 --- /dev/null +++ b/core/server/data/schema/default-settings.json @@ -0,0 +1,117 @@ +{ + "core": { + "databaseVersion": { + "defaultValue": "009" + }, + "dbHash": { + "defaultValue": null + }, + "nextUpdateCheck": { + "defaultValue": null + }, + "displayUpdateNotification": { + "defaultValue": null + }, + "seenNotifications": { + "defaultValue": "[]" + }, + "migrations": { + "defaultValue": "{}" + } + }, + "blog": { + "title": { + "defaultValue": "Ghost" + }, + "description": { + "defaultValue": "Just a blogging platform." + }, + "logo": { + "defaultValue": "" + }, + "cover": { + "defaultValue": "" + }, + "defaultLang": { + "defaultValue": "en_US", + "validations": { + "isEmpty": false + } + }, + "postsPerPage": { + "defaultValue": "5", + "validations": { + "isEmpty": false, + "isInt": true, + "isLength": [1, 1000] + } + }, + "activeTimezone": { + "defaultValue": "Etc/UTC", + "validations": { + "isTimezone": true, + "isEmpty": false + } + }, + "forceI18n": { + "defaultValue": "true", + "validations": { + "isEmpty": false, + "isIn": [["true", "false"]] + } + }, + "permalinks": { + "defaultValue": "/:slug/", + "validations": { + "matches": "^(\/:?[a-z0-9_-]+){1,5}\/$", + "matches": "(:id|:slug|:year|:month|:day|:author)", + "notContains": "/ghost/" + } + }, + "amp": { + "defaultValue": "true" + }, + "ghost_head": { + "defaultValue" : "" + }, + "ghost_foot": { + "defaultValue" : "" + }, + "facebook": { + "defaultValue" : "" + }, + "twitter": { + "defaultValue" : "" + }, + "labs": { + "defaultValue": "{}" + }, + "navigation": { + "defaultValue": "[{\"label\":\"Home\", \"url\":\"/\"}]" + }, + "slack": { + "defaultValue": "[{\"url\":\"\"}]" + } + }, + "theme": { + "activeTheme": { + "defaultValue": "casper" + } + }, + "app": { + "activeApps": { + "defaultValue": "[]" + }, + "installedApps": { + "defaultValue": "[]" + } + }, + "private": { + "isPrivate": { + "defaultValue": "false" + }, + "password": { + "defaultValue": "" + } + } +} diff --git a/core/server/data/schema/index.js b/core/server/data/schema/index.js new file mode 100644 index 0000000..f641d95 --- /dev/null +++ b/core/server/data/schema/index.js @@ -0,0 +1,11 @@ +var schema = require('./schema'), + checks = require('./checks'), + commands = require('./commands'), + versioning = require('./versioning'), + defaultSettings = require('./default-settings'); + +module.exports.tables = schema; +module.exports.checks = checks; +module.exports.commands = commands; +module.exports.versioning = versioning; +module.exports.defaultSettings = defaultSettings; diff --git a/core/server/data/schema/schema.js b/core/server/data/schema/schema.js new file mode 100644 index 0000000..4cb405a --- /dev/null +++ b/core/server/data/schema/schema.js @@ -0,0 +1,219 @@ +module.exports = { + posts: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + title: {type: 'string', maxlength: 150, nullable: false}, + slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, + markdown: {type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: true}, + mobiledoc: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, + html: {type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: true}, + amp: {type: 'text', maxlength: 16777215, fieldtype: 'medium', nullable: true}, + image: {type: 'text', maxlength: 2000, nullable: true}, + featured: {type: 'bool', nullable: false, defaultTo: false}, + page: {type: 'bool', nullable: false, defaultTo: false}, + status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'draft'}, + language: {type: 'string', maxlength: 6, nullable: false, defaultTo: 'en_US'}, + visibility: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'public', validations: {isIn: [['public']]}}, + meta_title: {type: 'string', maxlength: 150, nullable: true}, + meta_description: {type: 'string', maxlength: 200, nullable: true}, + author_id: {type: 'integer', nullable: false}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true}, + published_at: {type: 'dateTime', nullable: true}, + published_by: {type: 'integer', nullable: true} + }, + users: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: false}, + slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, + password: {type: 'string', maxlength: 60, nullable: false}, + email: {type: 'string', maxlength: 254, nullable: false, unique: true, validations: {isEmail: true}}, + image: {type: 'text', maxlength: 2000, nullable: true}, + cover: {type: 'text', maxlength: 2000, nullable: true}, + bio: {type: 'string', maxlength: 200, nullable: true}, + website: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}}, + location: {type: 'text', maxlength: 65535, nullable: true}, + facebook: {type: 'text', maxlength: 2000, nullable: true}, + twitter: {type: 'text', maxlength: 2000, nullable: true}, + accessibility: {type: 'text', maxlength: 65535, nullable: true}, + status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'active'}, + language: {type: 'string', maxlength: 6, nullable: false, defaultTo: 'en_US'}, + visibility: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'public', validations: {isIn: [['public']]}}, + meta_title: {type: 'string', maxlength: 150, nullable: true}, + meta_description: {type: 'string', maxlength: 200, nullable: true}, + tour: {type: 'text', maxlength: 65535, nullable: true}, + last_login: {type: 'dateTime', nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + roles: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: false}, + description: {type: 'string', maxlength: 200, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + roles_users: { + id: {type: 'increments', nullable: false, primary: true}, + role_id: {type: 'integer', nullable: false}, + user_id: {type: 'integer', nullable: false} + }, + permissions: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: false}, + object_type: {type: 'string', maxlength: 150, nullable: false}, + action_type: {type: 'string', maxlength: 150, nullable: false}, + object_id: {type: 'integer', nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + permissions_users: { + id: {type: 'increments', nullable: false, primary: true}, + user_id: {type: 'integer', nullable: false}, + permission_id: {type: 'integer', nullable: false} + }, + permissions_roles: { + id: {type: 'increments', nullable: false, primary: true}, + role_id: {type: 'integer', nullable: false}, + permission_id: {type: 'integer', nullable: false} + }, + permissions_apps: { + id: {type: 'increments', nullable: false, primary: true}, + app_id: {type: 'integer', nullable: false}, + permission_id: {type: 'integer', nullable: false} + }, + settings: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + key: {type: 'string', maxlength: 150, nullable: false, unique: true}, + value: {type: 'text', maxlength: 65535, nullable: true}, + type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'core', validations: {isIn: [['core', 'blog', 'theme', 'app', 'plugin', 'private']]}}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + tags: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: false, validations: {matches: /^([^,]|$)/}}, + slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, + description: {type: 'string', maxlength: 200, nullable: true}, + image: {type: 'text', maxlength: 2000, nullable: true}, + parent_id: {type: 'integer', nullable: true}, + visibility: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'public', validations: {isIn: [['public', 'internal']]}}, + meta_title: {type: 'string', maxlength: 150, nullable: true}, + meta_description: {type: 'string', maxlength: 200, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + posts_tags: { + id: {type: 'increments', nullable: false, primary: true}, + post_id: {type: 'integer', nullable: false, unsigned: true, references: 'posts.id'}, + tag_id: {type: 'integer', nullable: false, unsigned: true, references: 'tags.id'}, + sort_order: {type: 'integer', nullable: false, unsigned: true, defaultTo: 0} + }, + apps: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, + version: {type: 'string', maxlength: 150, nullable: false}, + status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'inactive'}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + app_settings: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + key: {type: 'string', maxlength: 150, nullable: false, unique: true}, + value: {type: 'text', maxlength: 65535, nullable: true}, + app_id: {type: 'integer', nullable: false, unsigned: true, references: 'apps.id'}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + app_fields: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + key: {type: 'string', maxlength: 150, nullable: false}, + value: {type: 'text', maxlength: 65535, nullable: true}, + type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'html'}, + app_id: {type: 'integer', nullable: false, unsigned: true, references: 'apps.id'}, + relatable_id: {type: 'integer', nullable: false, unsigned: true}, + relatable_type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'posts'}, + active: {type: 'bool', nullable: false, defaultTo: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + clients: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false}, + name: {type: 'string', maxlength: 150, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 150, nullable: false, unique: true}, + secret: {type: 'string', maxlength: 150, nullable: false}, + redirection_uri: {type: 'string', maxlength: 2000, nullable: true}, + logo: {type: 'string', maxlength: 2000, nullable: true}, + status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'development'}, + type: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'ua', validations: {isIn: [['ua', 'web', 'native']]}}, + description: {type: 'string', maxlength: 200, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + }, + client_trusted_domains: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false}, + client_id: {type: 'integer', nullable: false, unsigned: true, references: 'clients.id'}, + trusted_domain: {type: 'string', maxlength: 2000, nullable: true} + }, + accesstokens: { + id: {type: 'increments', nullable: false, primary: true}, + token: {type: 'string', nullable: false, unique: true}, + user_id: {type: 'integer', nullable: false, unsigned: true, references: 'users.id'}, + client_id: {type: 'integer', nullable: false, unsigned: true, references: 'clients.id'}, + expires: {type: 'bigInteger', nullable: false} + }, + refreshtokens: { + id: {type: 'increments', nullable: false, primary: true}, + token: {type: 'string', nullable: false, unique: true}, + user_id: {type: 'integer', nullable: false, unsigned: true, references: 'users.id'}, + client_id: {type: 'integer', nullable: false, unsigned: true, references: 'clients.id'}, + expires: {type: 'bigInteger', nullable: false} + }, + subscribers: { + id: {type: 'increments', nullable: false, primary: true}, + uuid: {type: 'string', maxlength: 36, nullable: false, validations: {isUUID: true}}, + name: {type: 'string', maxlength: 150, nullable: true}, + email: {type: 'string', maxlength: 254, nullable: false, unique: true, validations: {isEmail: true}}, + status: {type: 'string', maxlength: 150, nullable: false, defaultTo: 'pending', validations: {isIn: [['subscribed', 'pending', 'unsubscribed']]}}, + post_id: {type: 'integer', nullable: true, unsigned: true, references: 'posts.id'}, + subscribed_url: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}}, + subscribed_referrer: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}}, + unsubscribed_url: {type: 'text', maxlength: 2000, nullable: true, validations: {isEmptyOrURL: true}}, + unsubscribed_at: {type: 'dateTime', nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + created_by: {type: 'integer', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + updated_by: {type: 'integer', nullable: true} + } +}; diff --git a/core/server/data/schema/versioning.js b/core/server/data/schema/versioning.js new file mode 100644 index 0000000..7d5c68a --- /dev/null +++ b/core/server/data/schema/versioning.js @@ -0,0 +1,105 @@ +var path = require('path'), + Promise = require('bluebird'), + db = require('../db'), + errors = require('../../errors'), + i18n = require('../../i18n'), + defaultSettings = require('./default-settings'), + + defaultDatabaseVersion; + +// Newest Database Version +// The migration version number according to the hardcoded default settings +// This is the version the database should be at or migrated to +function getNewestDatabaseVersion() { + if (!defaultDatabaseVersion) { + // This be the current version according to the software + defaultDatabaseVersion = defaultSettings.core.databaseVersion.defaultValue; + } + + return defaultDatabaseVersion; +} + +// Database Current Version +// The migration version number according to the database +// This is what the database is currently at and may need to be updated +function getDatabaseVersion() { + return db.knex.schema.hasTable('settings').then(function (exists) { + // Check for the current version from the settings table + if (exists) { + // Temporary code to deal with old databases with currentVersion settings + return db.knex('settings') + .where('key', 'databaseVersion') + .first('value') + .then(function (version) { + if (!version || isNaN(version.value)) { + return Promise.reject(new errors.DatabaseVersion(i18n.t('errors.data.versioning.index.dbVersionNotRecognized'))); + } + + return version.value; + }); + } + + return Promise.reject(new errors.DatabaseNotPopulated(i18n.t('errors.data.versioning.index.databaseNotPopulated'))); + }); +} + +function setDatabaseVersion(transaction, version) { + return (transaction || db.knex)('settings') + .where('key', 'databaseVersion') + .update({value: version || defaultDatabaseVersion}); +} + +function pad(num, width) { + return Array(Math.max(width - String(num).length + 1, 0)).join(0) + num; +} + +function getMigrationVersions(fromVersion, toVersion) { + var versions = [], + i; + + for (i = parseInt(fromVersion, 10); i <= toVersion; i += 1) { + versions.push(pad(i, 3)); + } + + return versions; +} + +/** + * ### Get Version Tasks + * Tries to require a directory matching the version number + * + * This was split from update to make testing easier + * + * @param {String} version + * @param {String} relPath + * @returns {Array} + */ +function getVersionTasks(version, relPath) { + var tasks = []; + + try { + tasks = require(path.join(relPath, version)); + } catch (e) { + // ignore + } + + return tasks; +} + +function getUpdateDatabaseTasks(version, logger) { + return getVersionTasks(version, '../migration/', logger); +} + +function getUpdateFixturesTasks(version, logger) { + return getVersionTasks(version, '../migration/fixtures/', logger); +} + +module.exports = { + canMigrateFromVersion: '003', + getNewestDatabaseVersion: getNewestDatabaseVersion, + getDatabaseVersion: getDatabaseVersion, + setDatabaseVersion: setDatabaseVersion, + getMigrationVersions: getMigrationVersions, + getUpdateDatabaseTasks: getUpdateDatabaseTasks, + getUpdateFixturesTasks: getUpdateFixturesTasks +}; diff --git a/core/server/data/slack/index.js b/core/server/data/slack/index.js new file mode 100644 index 0000000..336f3c5 --- /dev/null +++ b/core/server/data/slack/index.js @@ -0,0 +1,110 @@ +var https = require('https'), + errors = require('../../errors'), + url = require('url'), + Promise = require('bluebird'), + config = require('../../config'), + events = require('../../events'), + api = require('../../api/settings'), + i18n = require('../../i18n'), + schema = require('../schema').checks, + options, + req, + slackData = {}; + +function getSlackSettings() { + return api.read({context: {internal: true}, key: 'slack'}).then(function (response) { + var slackSetting = response.settings[0].value; + + try { + slackSetting = JSON.parse(slackSetting); + } catch (e) { + return Promise.reject(e); + } + + return slackSetting[0]; + }); +} + +function makeRequest(reqOptions, reqPayload) { + req = https.request(reqOptions); + + reqPayload = JSON.stringify(reqPayload); + + req.write(reqPayload); + req.on('error', function (error) { + errors.logError( + error, + i18n.t('errors.data.xml.xmlrpc.pingUpdateFailed.error'), + i18n.t('errors.data.xml.xmlrpc.pingUpdateFailed.help', {url: 'http://docs.ghost.org/v0.11.9'}) + ); + }); + + req.end(); +} + +function ping(post) { + var message; + + // If this is a post, we want to send the link of the post + if (schema.isPost(post)) { + message = config.urlFor('post', {post: post}, true); + } else { + message = post.message; + } + + return getSlackSettings().then(function (slackSettings) { + // Quit here if slack integration is not activated + + if (slackSettings.url && slackSettings.url !== '') { + // Only ping when not a page + if (post.page) { + return; + } + + // Don't ping for the welcome to ghost post. + // This also handles the case where during ghost's first run + // models.init() inserts this post but permissions.init() hasn't + // (can't) run yet. + if (post.slug === 'welcome-to-ghost') { + return; + } + + slackData = { + text: message, + unfurl_links: true, + icon_url: config.urlFor({relativeUrl: '/ghost/img/ghosticon.jpg'}, {}, true), + username: 'Ghost' + }; + + // fill the options for https request + options = url.parse(slackSettings.url); + options.method = 'POST'; + options.headers = {'Content-type': 'application/json'}; + + // with all the data we have, we're doing the request now + makeRequest(options, slackData); + } else { + return; + } + }); +} + +function listener(model) { + ping(model.toJSON()); +} + +function testPing() { + ping({ + message: 'Heya! This is a test notification from your Ghost blog :simple_smile:. Seems to work fine!' + }); +} + +function listen() { + events.on('post.published', listener); + events.on('slack.test', testPing); +} + +// Public API +module.exports = { + listen: listen +}; diff --git a/core/server/data/timezones.json b/core/server/data/timezones.json new file mode 100644 index 0000000..98e6d6c --- /dev/null +++ b/core/server/data/timezones.json @@ -0,0 +1,268 @@ +{ + "timezones": [ + { + "name": "Pacific/Pago_Pago", + "label": "(GMT -11:00) Midway Island, Samoa" + }, + { + "name": "Pacific/Honolulu", + "label": "(GMT -10:00) Hawaii" + }, + { + "name": "America/Anchorage", + "label": "(GMT -9:00) Alaska" + }, + { + "name": "America/Tijuana", + "label": "(GMT -8:00) Chihuahua, La Paz, Mazatlan" + }, + { + "name": "America/Los_Angeles", + "label": "(GMT -8:00) Pacific Time (US & Canada); Tijuana" + }, + { + "name": "America/Phoenix", + "label": "(GMT -7:00) Arizona" + }, + { + "name": "America/Denver", + "label": "(GMT -7:00) Mountain Time (US & Canada)" + }, + { + "name": "America/Costa_Rica", + "label": "(GMT -6:00) Central America" + }, + { + "name": "America/Chicago", + "label": "(GMT -6:00) Central Time (US & Canada)" + }, + { + "name": "America/Mexico_City", + "label": "(GMT -6:00) Guadalajara, Mexico City, Monterrey" + }, + { + "name": "America/Regina", + "label": "(GMT -6:00) Saskatchewan" + }, + { + "name": "America/Bogota", + "label": "(GMT -5:00) Bogota, Lima, Quito" + }, + { + "name": "America/New_York", + "label": "(GMT -5:00) Eastern Time (US & Canada)" + }, + { + "name": "America/Fort_Wayne", + "label": "(GMT -5:00) Indiana (East)" + }, + { + "name": "America/Caracas", + "label": "(GMT -4:00) Caracas, La Paz" + }, + { + "name": "America/Halifax", + "label": "(GMT -4:00) Atlantic Time (Canada); Brasilia, Greenland" + }, + { + "name": "America/Santiago", + "label": "(GMT -4:00) Santiago" + }, + { + "name": "America/St_Johns", + "label": "(GMT -3:30) Newfoundland" + }, + { + "name": "America/Argentina/Buenos_Aires", + "label": "(GMT -3:00) Buenos Aires, Georgetown" + }, + { + "name": "America/Noronha", + "label": "(GMT -2:00) Fernando de Noronha" + }, + { + "name": "Atlantic/Azores", + "label": "(GMT -1:00) Azores" + }, + { + "name": "Atlantic/Cape_Verde", + "label": "(GMT -1:00) Cape Verde Is." + }, + { + "name": "Etc/UTC", + "label": "(GMT) UTC" + }, + { + "name": "Africa/Casablanca", + "label": "(GMT +0:00) Casablanca, Monrovia" + }, + { + "name": "Europe/Dublin", + "label": "(GMT +0:00) Dublin, Edinburgh, London" + }, + { + "name": "Europe/Amsterdam", + "label": "(GMT +1:00) Amsterdam, Berlin, Rome, Stockholm, Vienna" + }, + { + "name": "Europe/Prague", + "label": "(GMT +1:00) Belgrade, Bratislava, Budapest, Prague" + }, + { + "name": "Europe/Paris", + "label": "(GMT +1:00) Brussels, Copenhagen, Madrid, Paris" + }, + { + "name": "Europe/Warsaw", + "label": "(GMT +1:00) Sarajevo, Skopje, Warsaw, Zagreb" + }, + { + "name": "Africa/Lagos", + "label": "(GMT +1:00) West Central Africa" + }, + { + "name": "Europe/Istanbul", + "label": "(GMT +2:00) Athens, Beirut, Bucharest, Istanbul" + }, + { + "name": "Africa/Cairo", + "label": "(GMT +2:00) Cairo, Egypt" + }, + { + "name": "Africa/Maputo", + "label": "(GMT +2:00) Harare" + }, + { + "name": "Europe/Kiev", + "label": "(GMT +2:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius" + }, + { + "name": "Asia/Jerusalem", + "label": "(GMT +2:00) Jerusalem" + }, + { + "name": "Africa/Johannesburg", + "label": "(GMT +2:00) Pretoria" + }, + { + "name": "Asia/Baghdad", + "label": "(GMT +3:00) Baghdad" + }, + { + "name": "Asia/Riyadh", + "label": "(GMT +3:00) Kuwait, Nairobi, Riyadh" + }, + { + "name": "Europe/Moscow", + "label": "(GMT +3:00) Moscow, St. Petersburg, Volgograd" + }, + { + "name": "Asia/Tehran", + "label": "(GMT +3:30) Tehran" + }, + { + "name": "Asia/Dubai", + "label": "(GMT +4:00) Abu Dhabi, Muscat" + }, + { + "name": "Asia/Baku", + "label": "(GMT +4:00) Baku, Tbilisi, Yerevan" + }, + { + "name": "Asia/Kabul", + "label": "(GMT +4:30) Kabul" + }, + { + "name": "Asia/Karachi", + "label": "(GMT +5:00) Islamabad, Karachi, Tashkent" + }, + { + "name": "Asia/Yekaterinburg", + "label": "(GMT +5:00) Yekaterinburg" + }, + { + "name": "Asia/Kolkata", + "label": "(GMT +5:30) Chennai, Calcutta, Mumbai, New Delhi" + }, + { + "name": "Asia/Kathmandu", + "label": "(GMT +5:45) Katmandu" + }, + { + "name": "Asia/Almaty", + "label": "(GMT +6:00) Almaty, Novosibirsk" + }, + { + "name": "Asia/Dhaka", + "label": "(GMT +6:00) Astana, Dhaka, Sri Jayawardenepura" + }, + { + "name": "Asia/Rangoon", + "label": "(GMT +6:30) Rangoon" + }, + { + "name": "Asia/Bangkok", + "label": "(GMT +7:00) Bangkok, Hanoi, Jakarta" + }, + { + "name": "Asia/Krasnoyarsk", + "label": "(GMT +7:00) Krasnoyarsk" + }, + { + "name": "Asia/Hong_Kong", + "label": "(GMT +8:00) Beijing, Chongqing, Hong Kong, Urumqi" + }, + { + "name": "Asia/Irkutsk", + "label": "(GMT +8:00) Irkutsk, Ulaan Bataar" + }, + { + "name": "Asia/Singapore", + "label": "(GMT +8:00) Kuala Lumpur, Perth, Singapore, Taipei" + }, + { + "name": "Asia/Tokyo", + "label": "(GMT +9:00) Osaka, Sapporo, Tokyo" + }, + { + "name": "Asia/Seoul", + "label": "(GMT +9:00) Seoul" + }, + { + "name": "Asia/Yakutsk", + "label": "(GMT +9:00) Yakutsk" + }, + { + "name": "Australia/Adelaide", + "label": "(GMT +9:30) Adelaide" + }, + { + "name": "Australia/Darwin", + "label": "(GMT +9:30) Darwin" + }, + { + "name": "Australia/Brisbane", + "label": "(GMT +10:00) Brisbane, Guam, Port Moresby" + }, + { + "name": "Australia/Sydney", + "label": "(GMT +10:00) Canberra, Hobart, Melbourne, Sydney, Vladivostok" + }, + { + "name": "Asia/Magadan", + "label": "(GMT +11:00) Magadan, Soloman Is., New Caledonia" + }, + { + "name": "Pacific/Auckland", + "label": "(GMT +12:00) Auckland, Wellington" + }, + { + "name": "Pacific/Fiji", + "label": "(GMT +12:00) Fiji, Kamchatka, Marshall Is." + }, + { + "name": "Pacific/Kwajalein", + "label": "(GMT +12:00) International Date Line West" + } + ] +} diff --git a/core/server/data/validation/index.js b/core/server/data/validation/index.js new file mode 100644 index 0000000..575e589 --- /dev/null +++ b/core/server/data/validation/index.js @@ -0,0 +1,211 @@ +var schema = require('../schema').tables, + _ = require('lodash'), + validator = require('validator'), + moment = require('moment-timezone'), + assert = require('assert'), + Promise = require('bluebird'), + errors = require('../../errors'), + config = require('../../config'), + readThemes = require('../../utils/read-themes'), + i18n = require('../../i18n'), + + validateSchema, + validateSettings, + validateActiveTheme, + validate, + + availableThemes; + +function assertString(input) { + assert(typeof input === 'string', 'Validator js validates strings only'); +} + +// extends has been removed in validator >= 5.0.0, need to monkey-patch it back in +validator.extend = function (name, fn) { + validator[name] = function () { + var args = Array.prototype.slice.call(arguments); + assertString(args[0]); + return fn.apply(validator, args); + }; +}; + +// Provide a few custom validators +validator.extend('empty', function empty(str) { + return _.isEmpty(str); +}); + +validator.extend('notContains', function notContains(str, badString) { + return !_.includes(str, badString); +}); + +validator.extend('isTimezone', function isTimezone(str) { + return moment.tz.zone(str) ? true : false; +}); + +validator.extend('isEmptyOrURL', function isEmptyOrURL(str) { + return (_.isEmpty(str) || validator.isURL(str, {require_protocol: false})); +}); + +validator.extend('isSlug', function isSlug(str) { + return validator.matches(str, /^[a-z0-9\-_]+$/); +}); + +// Validation against schema attributes +// values are checked against the validation objects from schema.js +validateSchema = function validateSchema(tableName, model) { + var columns = _.keys(schema[tableName]), + validationErrors = []; + + _.each(columns, function each(columnKey) { + var message = '', + strVal = _.toString(model[columnKey]); + + // check nullable + if (model.hasOwnProperty(columnKey) && schema[tableName][columnKey].hasOwnProperty('nullable') + && schema[tableName][columnKey].nullable !== true) { + if (validator.empty(strVal)) { + message = i18n.t('notices.data.validation.index.valueCannotBeBlank', {tableName: tableName, columnKey: columnKey}); + validationErrors.push(new errors.ValidationError(message, tableName + '.' + columnKey)); + } + } + + // validate boolean columns + if (model.hasOwnProperty(columnKey) && schema[tableName][columnKey].hasOwnProperty('type') + && schema[tableName][columnKey].type === 'bool') { + if (!(validator.isBoolean(strVal) || validator.empty(strVal))) { + message = i18n.t('notices.data.validation.index.valueMustBeBoolean', {tableName: tableName, columnKey: columnKey}); + validationErrors.push(new errors.ValidationError(message, tableName + '.' + columnKey)); + } + } + + // TODO: check if mandatory values should be enforced + if (model[columnKey] !== null && model[columnKey] !== undefined) { + // check length + if (schema[tableName][columnKey].hasOwnProperty('maxlength')) { + if (!validator.isLength(strVal, 0, schema[tableName][columnKey].maxlength)) { + message = i18n.t('notices.data.validation.index.valueExceedsMaxLength', + {tableName: tableName, columnKey: columnKey, maxlength: schema[tableName][columnKey].maxlength}); + validationErrors.push(new errors.ValidationError(message, tableName + '.' + columnKey)); + } + } + + // check validations objects + if (schema[tableName][columnKey].hasOwnProperty('validations')) { + validationErrors = validationErrors.concat(validate(strVal, columnKey, schema[tableName][columnKey].validations)); + } + + // check type + if (schema[tableName][columnKey].hasOwnProperty('type')) { + if (schema[tableName][columnKey].type === 'integer' && !validator.isInt(strVal)) { + message = i18n.t('notices.data.validation.index.valueIsNotInteger', {tableName: tableName, columnKey: columnKey}); + validationErrors.push(new errors.ValidationError(message, tableName + '.' + columnKey)); + } + } + } + }); + + if (validationErrors.length !== 0) { + return Promise.reject(validationErrors); + } + + return Promise.resolve(); +}; + +// Validation for settings +// settings are checked against the validation objects +// form default-settings.json +validateSettings = function validateSettings(defaultSettings, model) { + var values = model.toJSON(), + validationErrors = [], + matchingDefault = defaultSettings[values.key]; + + if (matchingDefault && matchingDefault.validations) { + validationErrors = validationErrors.concat(validate(values.value, values.key, matchingDefault.validations)); + } + + if (validationErrors.length !== 0) { + return Promise.reject(validationErrors); + } + + return Promise.resolve(); +}; + +validateActiveTheme = function validateActiveTheme(themeName, options) { + // If Ghost is running and its availableThemes collection exists + // give it priority. + if (config.paths.availableThemes && Object.keys(config.paths.availableThemes).length > 0) { + availableThemes = Promise.resolve(config.paths.availableThemes); + } + + if (!availableThemes) { + // A Promise that will resolve to an object with a property for each installed theme. + // This is necessary because certain configuration data is only available while Ghost + // is running and at times the validations are used when it's not (e.g. tests) + availableThemes = readThemes(config.paths.themePath); + } + + return availableThemes.then(function then(themes) { + if (themes.hasOwnProperty(themeName)) { + return; + } + + if (options && options.showWarning) { + errors.logWarn(i18n.t('errors.middleware.themehandler.missingTheme', {theme: themeName})); + return; + } + return Promise.reject(new errors.ValidationError(i18n.t('notices.data.validation.index.themeCannotBeActivated', {themeName: themeName}), 'activeTheme')); + }); +}; + +// Validate default settings using the validator module. +// Each validation's key is a method name and its value is an array of options +// +// eg: +// validations: { isURL: true, isLength: [20, 40] } +// +// will validate that a setting's length is a URL between 20 and 40 chars. +// +// If you pass a boolean as the value, it will specify the "good" result. By default +// the "good" result is assumed to be true. +// +// eg: +// validations: { isNull: false } // means the "good" result would +// // fail the `isNull` check, so +// // not null. +// +// available validators: https://github.com/chriso/validator.js#validators +validate = function validate(value, key, validations) { + var validationErrors = []; + value = _.toString(value); + + _.each(validations, function each(validationOptions, validationName) { + var goodResult = true; + + if (_.isBoolean(validationOptions)) { + goodResult = validationOptions; + validationOptions = []; + } else if (!_.isArray(validationOptions)) { + validationOptions = [validationOptions]; + } + + validationOptions.unshift(value); + + // equivalent of validator.isSomething(option1, option2) + if (validator[validationName].apply(validator, validationOptions) !== goodResult) { + validationErrors.push(new errors.ValidationError(i18n.t('notices.data.validation.index.validationFailed', + {validationName: validationName, key: key}))); + } + + validationOptions.shift(); + }, this); + + return validationErrors; +}; + +module.exports = { + validate: validate, + validator: validator, + validateSchema: validateSchema, + validateSettings: validateSettings, + validateActiveTheme: validateActiveTheme +}; diff --git a/core/server/data/xml/rss/index.js b/core/server/data/xml/rss/index.js new file mode 100644 index 0000000..147b83c --- /dev/null +++ b/core/server/data/xml/rss/index.js @@ -0,0 +1,190 @@ +var crypto = require('crypto'), + downsize = require('downsize'), + RSS = require('rss'), + config = require('../../../config'), + errors = require('../../../errors'), + filters = require('../../../filters'), + processUrls = require('../../../utils/make-absolute-urls'), + labs = require('../../../utils/labs'), + + // Really ugly temporary hack for location of things + fetchData = require('../../../controllers/frontend/fetch-data'), + + generate, + generateFeed, + generateTags, + getFeedXml, + feedCache = {}; + +function isTag(req) { + return req.originalUrl.indexOf('/' + config.routeKeywords.tag + '/') !== -1; +} + +function isAuthor(req) { + return req.originalUrl.indexOf('/' + config.routeKeywords.author + '/') !== -1; +} + +function handleError(next) { + return function handleError(err) { + return next(err); + }; +} + +function getData(channelOpts, slugParam) { + channelOpts.data = channelOpts.data || {}; + + return fetchData(channelOpts, slugParam).then(function (result) { + var response = {}, + titleStart = ''; + + if (result.data && result.data.tag) { titleStart = result.data.tag[0].name + ' - ' || ''; } + if (result.data && result.data.author) { titleStart = result.data.author[0].name + ' - ' || ''; } + + response.title = titleStart + config.theme.title; + response.description = config.theme.description; + response.results = { + posts: result.posts, + meta: result.meta + }; + + return response; + }); +} + +function getBaseUrl(req, slugParam) { + var baseUrl = config.paths.subdir; + + if (isTag(req)) { + baseUrl += '/' + config.routeKeywords.tag + '/' + slugParam + '/rss/'; + } else if (isAuthor(req)) { + baseUrl += '/' + config.routeKeywords.author + '/' + slugParam + '/rss/'; + } else { + baseUrl += '/rss/'; + } + + return baseUrl; +} + +getFeedXml = function getFeedXml(path, data) { + var dataHash = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex'); + if (!feedCache[path] || feedCache[path].hash !== dataHash) { + // We need to regenerate + feedCache[path] = { + hash: dataHash, + xml: generateFeed(data) + }; + } + + return feedCache[path].xml; +}; + +generateTags = function generateTags(data) { + if (data.tags) { + return data.tags.reduce(function (tags, tag) { + if (tag.visibility !== 'internal' || !labs.isSet('internalTags')) { + tags.push(tag.name); + } + return tags; + }, []); + } + + return []; +}; + +generateFeed = function generateFeed(data) { + var feed = new RSS({ + title: data.title, + description: data.description, + generator: 'Ghost ' + data.version, + feed_url: data.feedUrl, + site_url: data.siteUrl, + ttl: '60', + custom_namespaces: { + content: 'http://purl.org/rss/1.0/modules/content/', + media: 'http://search.yahoo.com/mrss/' + } + }); + + data.results.posts.forEach(function forEach(post) { + var itemUrl = config.urlFor('post', {post: post, secure: data.secure}, true), + htmlContent = processUrls(post.html, data.siteUrl, itemUrl), + item = { + title: post.title, + description: post.meta_description || downsize(htmlContent.html(), {words: 50}), + guid: post.uuid, + url: itemUrl, + date: post.published_at, + categories: generateTags(post), + author: post.author ? post.author.name : null, + custom_elements: [] + }, + imageUrl; + + if (post.image) { + imageUrl = config.urlFor('image', {image: post.image, secure: data.secure}, true); + + // Add a media content tag + item.custom_elements.push({ + 'media:content': { + _attr: { + url: imageUrl, + medium: 'image' + } + } + }); + + // Also add the image to the content, because not all readers support media:content + htmlContent('p').first().before(''); + htmlContent('img').attr('alt', post.title); + } + + item.custom_elements.push({ + 'content:encoded': { + _cdata: htmlContent.html() + } + }); + + filters.doFilter('rss.item', item, post).then(function then(item) { + feed.item(item); + }); + }); + + return filters.doFilter('rss.feed', feed).then(function then(feed) { + return feed.xml(); + }); +}; + +generate = function generate(req, res, next) { + // Initialize RSS + var pageParam = req.params.page !== undefined ? req.params.page : 1, + slugParam = req.params.slug, + baseUrl = getBaseUrl(req, slugParam); + + // Ensure we at least have an empty object for postOptions + req.channelConfig.postOptions = req.channelConfig.postOptions || {}; + // Set page on postOptions for the query made later + req.channelConfig.postOptions.page = pageParam; + + req.channelConfig.slugParam = slugParam; + + return getData(req.channelConfig).then(function then(data) { + var maxPage = data.results.meta.pagination.pages; + + // If page is greater than number of pages we have, redirect to last page + if (pageParam > maxPage) { + return next(new errors.NotFoundError()); + } + + data.version = res.locals.safeVersion; + data.siteUrl = config.urlFor('home', {secure: req.secure}, true); + data.feedUrl = config.urlFor({relativeUrl: baseUrl, secure: req.secure}, true); + data.secure = req.secure; + + return getFeedXml(req.originalUrl, data).then(function then(feedXml) { + res.set('Content-Type', 'text/xml; charset=UTF-8'); + res.send(feedXml); + }); + }).catch(handleError(next)); +}; + +module.exports = generate; diff --git a/core/server/data/xml/sitemap/base-generator.js b/core/server/data/xml/sitemap/base-generator.js new file mode 100644 index 0000000..48d48e8 --- /dev/null +++ b/core/server/data/xml/sitemap/base-generator.js @@ -0,0 +1,239 @@ +var _ = require('lodash'), + xml = require('xml'), + moment = require('moment'), + config = require('../../../config'), + events = require('../../../events'), + utils = require('./utils'), + Promise = require('bluebird'), + path = require('path'), + CHANGE_FREQ = 'weekly', + XMLNS_DECLS; + +// Sitemap specific xml namespace declarations that should not change +XMLNS_DECLS = { + _attr: { + xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9', + 'xmlns:image': 'http://www.google.com/schemas/sitemap-image/1.1' + } +}; + +function BaseSiteMapGenerator() { + this.lastModified = 0; + this.nodeLookup = {}; + this.nodeTimeLookup = {}; + this.siteMapContent = ''; + this.dataEvents = events; +} + +_.extend(BaseSiteMapGenerator.prototype, { + init: function () { + var self = this; + return this.refreshAll().then(function () { + return self.bindEvents(); + }); + }, + + bindEvents: _.noop, + + getData: function () { + return Promise.resolve([]); + }, + + refreshAll: function () { + var self = this; + + // Load all data + return this.getData().then(function (data) { + // Generate SiteMap from data + return self.generateXmlFromData(data); + }).then(function (generatedXml) { + self.siteMapContent = generatedXml; + }); + }, + + generateXmlFromData: function (data) { + // Create all the url elements in JSON + var self = this, + nodes; + + nodes = _.reduce(data, function (nodeArr, datum) { + var node = self.createUrlNodeFromDatum(datum); + + if (node) { + self.updateLastModified(datum); + self.updateLookups(datum, node); + nodeArr.push(node); + } + + return nodeArr; + }, []); + + return this.generateXmlFromNodes(nodes); + }, + + generateXmlFromNodes: function () { + var self = this, + // Get a mapping of node to timestamp + timedNodes = _.map(this.nodeLookup, function (node, id) { + return { + id: id, + // Using negative here to sort newest to oldest + ts: -(self.nodeTimeLookup[id] || 0), + node: node + }; + }, []), + // Sort nodes by timestamp + sortedNodes = _.sortBy(timedNodes, 'ts'), + // Grab just the nodes + urlElements = _.map(sortedNodes, 'node'), + data = { + // Concat the elements to the _attr declaration + urlset: [XMLNS_DECLS].concat(urlElements) + }; + + // Return the xml + return utils.getDeclarations() + xml(data); + }, + + updateXmlFromNodes: function (urlElements) { + var content = this.generateXmlFromNodes(urlElements); + + this.setSiteMapContent(content); + + return content; + }, + + addOrUpdateUrl: function (model) { + var datum = model.toJSON(), + node = this.createUrlNodeFromDatum(datum); + + if (node) { + this.updateLastModified(datum); + // TODO: Check if the node values changed, and if not don't regenerate + this.updateLookups(datum, node); + this.updateXmlFromNodes(); + } + }, + + removeUrl: function (model) { + var datum = model.toJSON(); + // When the model is destroyed we need to fetch previousAttributes + if (!datum.id) { + datum = model.previousAttributes(); + } + this.removeFromLookups(datum); + + this.lastModified = Date.now(); + + this.updateXmlFromNodes(); + }, + + validateDatum: function () { + return true; + }, + + getUrlForDatum: function () { + return config.urlFor('home', true); + }, + + getUrlForImage: function (image) { + return config.urlFor('image', {image: image}, true); + }, + + getPriorityForDatum: function () { + return 1.0; + }, + + getLastModifiedForDatum: function (datum) { + return datum.updated_at || datum.published_at || datum.created_at; + }, + + createUrlNodeFromDatum: function (datum) { + if (!this.validateDatum(datum)) { + return false; + } + + var url = this.getUrlForDatum(datum), + priority = this.getPriorityForDatum(datum), + node, + imgNode; + + node = { + url: [ + {loc: url}, + {lastmod: moment(this.getLastModifiedForDatum(datum)).toISOString()}, + {changefreq: CHANGE_FREQ}, + {priority: priority} + ] + }; + + imgNode = this.createImageNodeFromDatum(datum); + + if (imgNode) { + node.url.push(imgNode); + } + + return node; + }, + + createImageNodeFromDatum: function (datum) { + // Check for cover first because user has cover but the rest only have image + var image = datum.cover || datum.image, + imageUrl, + imageEl; + + if (!image) { + return; + } + + // Grab the image url + imageUrl = this.getUrlForImage(image); + + // Verify the url structure + if (!this.validateImageUrl(imageUrl)) { + return; + } + + // Create the weird xml node syntax structure that is expected + imageEl = [ + {'image:loc': imageUrl}, + {'image:caption': path.basename(imageUrl)} + ]; + + // Return the node to be added to the url xml node + return { + 'image:image': imageEl + }; + }, + + validateImageUrl: function (imageUrl) { + return !!imageUrl; + }, + + setSiteMapContent: function (content) { + this.siteMapContent = content; + }, + + updateLastModified: function (datum) { + var lastModified = this.getLastModifiedForDatum(datum); + + if (lastModified > this.lastModified) { + this.lastModified = lastModified; + } + }, + + updateLookups: function (datum, node) { + this.nodeLookup[datum.id] = node; + this.nodeTimeLookup[datum.id] = this.getLastModifiedForDatum(datum); + }, + + removeFromLookups: function (datum) { + var lookup = this.nodeLookup; + delete lookup[datum.id]; + + lookup = this.nodeTimeLookup; + delete lookup[datum.id]; + } +}); + +module.exports = BaseSiteMapGenerator; diff --git a/core/server/data/xml/sitemap/handler.js b/core/server/data/xml/sitemap/handler.js new file mode 100644 index 0000000..cc7d99a --- /dev/null +++ b/core/server/data/xml/sitemap/handler.js @@ -0,0 +1,66 @@ +var _ = require('lodash'), + utils = require('../../../utils'), + sitemap = require('./index'); + +// Responsible for handling requests for sitemap files +module.exports = function handler(blogApp) { + var resourceTypes = ['posts', 'authors', 'tags', 'pages'], + verifyResourceType = function verifyResourceType(req, res, next) { + if (!_.includes(resourceTypes, req.params.resource)) { + return res.sendStatus(404); + } + + next(); + }, + getResourceSiteMapXml = function getResourceSiteMapXml(type, page) { + return sitemap.getSiteMapXml(type, page); + }; + + blogApp.get('/sitemap.xml', function sitemapXML(req, res, next) { + var siteMapXml = sitemap.getIndexXml(); + + res.set({ + 'Cache-Control': 'public, max-age=' + utils.ONE_HOUR_S, + 'Content-Type': 'text/xml' + }); + + // CASE: returns null if sitemap is not initialized as below + if (!siteMapXml) { + sitemap.init() + .then(function () { + siteMapXml = sitemap.getIndexXml(); + res.send(siteMapXml); + }) + .catch(function (err) { + next(err); + }); + } else { + res.send(siteMapXml); + } + }); + + blogApp.get('/sitemap-:resource.xml', verifyResourceType, function sitemapResourceXML(req, res, next) { + var type = req.params.resource, + page = 1, + siteMapXml = getResourceSiteMapXml(type, page); + + res.set({ + 'Cache-Control': 'public, max-age=' + utils.ONE_HOUR_S, + 'Content-Type': 'text/xml' + }); + + // CASE: returns null if sitemap is not initialized + if (!siteMapXml) { + sitemap.init() + .then(function () { + siteMapXml = getResourceSiteMapXml(type, page); + res.send(siteMapXml); + }) + .catch(function (err) { + next(err); + }); + } else { + res.send(siteMapXml); + } + }); +}; diff --git a/core/server/data/xml/sitemap/index-generator.js b/core/server/data/xml/sitemap/index-generator.js new file mode 100644 index 0000000..16518ca --- /dev/null +++ b/core/server/data/xml/sitemap/index-generator.js @@ -0,0 +1,53 @@ +var _ = require('lodash'), + xml = require('xml'), + moment = require('moment'), + config = require('../../../config'), + utils = require('./utils'), + RESOURCES, + XMLNS_DECLS; + +RESOURCES = ['pages', 'posts', 'authors', 'tags']; + +XMLNS_DECLS = { + _attr: { + xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' + } +}; + +function SiteMapIndexGenerator(opts) { + // Grab the other site map generators from the options + _.extend(this, _.pick(opts, RESOURCES)); +} + +_.extend(SiteMapIndexGenerator.prototype, { + getIndexXml: function () { + var urlElements = this.generateSiteMapUrlElements(), + data = { + // Concat the elements to the _attr declaration + sitemapindex: [XMLNS_DECLS].concat(urlElements) + }; + + // Return the xml + return utils.getDeclarations() + xml(data); + }, + + generateSiteMapUrlElements: function () { + var self = this; + + return _.map(RESOURCES, function (resourceType) { + var url = config.urlFor({ + relativeUrl: '/sitemap-' + resourceType + '.xml' + }, true), + lastModified = self[resourceType].lastModified; + + return { + sitemap: [ + {loc: url}, + {lastmod: moment(lastModified).toISOString()} + ] + }; + }); + } +}); + +module.exports = SiteMapIndexGenerator; diff --git a/core/server/data/xml/sitemap/index.js b/core/server/data/xml/sitemap/index.js new file mode 100644 index 0000000..be8e865 --- /dev/null +++ b/core/server/data/xml/sitemap/index.js @@ -0,0 +1,3 @@ +var SiteMapManager = require('./manager'); + +module.exports = new SiteMapManager(); diff --git a/core/server/data/xml/sitemap/manager.js b/core/server/data/xml/sitemap/manager.js new file mode 100644 index 0000000..852a6bc --- /dev/null +++ b/core/server/data/xml/sitemap/manager.js @@ -0,0 +1,75 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + IndexMapGenerator = require('./index-generator'), + PagesMapGenerator = require('./page-generator'), + PostsMapGenerator = require('./post-generator'), + UsersMapGenerator = require('./user-generator'), + TagsMapGenerator = require('./tag-generator'), + SiteMapManager; + +SiteMapManager = function (opts) { + opts = opts || {}; + + this.initialized = false; + + this.pages = opts.pages || this.createPagesGenerator(opts); + this.posts = opts.posts || this.createPostsGenerator(opts); + this.authors = opts.authors || this.createUsersGenerator(opts); + this.tags = opts.tags || this.createTagsGenerator(opts); + + this.index = opts.index || this.createIndexGenerator(opts); +}; + +_.extend(SiteMapManager.prototype, { + createIndexGenerator: function () { + return new IndexMapGenerator(_.pick(this, 'pages', 'posts', 'authors', 'tags')); + }, + + createPagesGenerator: function (opts) { + return new PagesMapGenerator(opts); + }, + + createPostsGenerator: function (opts) { + return new PostsMapGenerator(opts); + }, + + createUsersGenerator: function (opts) { + return new UsersMapGenerator(opts); + }, + + createTagsGenerator: function (opts) { + return new TagsMapGenerator(opts); + }, + + init: function () { + var self = this, + initOps = [ + this.pages.init(), + this.posts.init(), + this.authors.init(), + this.tags.init() + ]; + + return Promise.all(initOps).then(function () { + self.initialized = true; + }); + }, + + getIndexXml: function () { + if (!this.initialized) { + return ''; + } + + return this.index.getIndexXml(); + }, + + getSiteMapXml: function (type) { + if (!this.initialized || !this[type]) { + return null; + } + + return this[type].siteMapContent; + } +}); + +module.exports = SiteMapManager; diff --git a/core/server/data/xml/sitemap/page-generator.js b/core/server/data/xml/sitemap/page-generator.js new file mode 100644 index 0000000..1e2df61 --- /dev/null +++ b/core/server/data/xml/sitemap/page-generator.js @@ -0,0 +1,61 @@ +var _ = require('lodash'), + api = require('../../../api'), + config = require('../../../config'), + BaseMapGenerator = require('./base-generator'); + +// A class responsible for generating a sitemap from posts and keeping it updated +function PageMapGenerator(opts) { + _.extend(this, opts); + + BaseMapGenerator.apply(this, arguments); +} + +// Inherit from the base generator class +_.extend(PageMapGenerator.prototype, BaseMapGenerator.prototype); + +_.extend(PageMapGenerator.prototype, { + bindEvents: function () { + var self = this; + this.dataEvents.on('page.published', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('page.published.edited', self.addOrUpdateUrl.bind(self)); + // Note: This is called if a published post is deleted + this.dataEvents.on('page.unpublished', self.removeUrl.bind(self)); + }, + + getData: function () { + return api.posts.browse({ + context: { + internal: true + }, + filter: 'visibility:public', + status: 'published', + staticPages: true, + limit: 'all' + }).then(function (resp) { + var homePage = { + id: 0, + name: 'home' + }; + return [homePage].concat(resp.posts); + }); + }, + + validateDatum: function (datum) { + return datum.name === 'home' || (datum.page === true && datum.visibility === 'public'); + }, + + getUrlForDatum: function (post) { + if (post.id === 0 && !_.isEmpty(post.name)) { + return config.urlFor(post.name, true); + } + + return config.urlFor('post', {post: post}, true); + }, + + getPriorityForDatum: function (post) { + // TODO: We could influence this with priority or meta information + return post && post.name === 'home' ? 1.0 : 0.8; + } +}); + +module.exports = PageMapGenerator; diff --git a/core/server/data/xml/sitemap/post-generator.js b/core/server/data/xml/sitemap/post-generator.js new file mode 100644 index 0000000..096a22c --- /dev/null +++ b/core/server/data/xml/sitemap/post-generator.js @@ -0,0 +1,54 @@ +var _ = require('lodash'), + api = require('../../../api'), + config = require('../../../config'), + BaseMapGenerator = require('./base-generator'); + +// A class responsible for generating a sitemap from posts and keeping it updated +function PostMapGenerator(opts) { + _.extend(this, opts); + + BaseMapGenerator.apply(this, arguments); +} + +// Inherit from the base generator class +_.extend(PostMapGenerator.prototype, BaseMapGenerator.prototype); + +_.extend(PostMapGenerator.prototype, { + bindEvents: function () { + var self = this; + this.dataEvents.on('post.published', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('post.published.edited', self.addOrUpdateUrl.bind(self)); + // Note: This is called if a published post is deleted + this.dataEvents.on('post.unpublished', self.removeUrl.bind(self)); + }, + + getData: function () { + return api.posts.browse({ + context: { + internal: true + }, + filter: 'visibility:public', + status: 'published', + staticPages: false, + limit: 'all', + include: 'author' + }).then(function (resp) { + return resp.posts; + }); + }, + + validateDatum: function (datum) { + return datum.page === false && datum.visibility === 'public'; + }, + + getUrlForDatum: function (post) { + return config.urlFor('post', {post: post}, true); + }, + + getPriorityForDatum: function (post) { + // give a slightly higher priority to featured posts + return post.featured ? 0.9 : 0.8; + } +}); + +module.exports = PostMapGenerator; diff --git a/core/server/data/xml/sitemap/tag-generator.js b/core/server/data/xml/sitemap/tag-generator.js new file mode 100644 index 0000000..eaf4fe7 --- /dev/null +++ b/core/server/data/xml/sitemap/tag-generator.js @@ -0,0 +1,50 @@ +var _ = require('lodash'), + api = require('../../../api'), + config = require('../../../config'), + BaseMapGenerator = require('./base-generator'); + +// A class responsible for generating a sitemap from posts and keeping it updated +function TagsMapGenerator(opts) { + _.extend(this, opts); + + BaseMapGenerator.apply(this, arguments); +} + +// Inherit from the base generator class +_.extend(TagsMapGenerator.prototype, BaseMapGenerator.prototype); + +_.extend(TagsMapGenerator.prototype, { + bindEvents: function () { + var self = this; + this.dataEvents.on('tag.added', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('tag.edited', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('tag.deleted', self.removeUrl.bind(self)); + }, + + getData: function () { + return api.tags.browse({ + context: { + internal: true + }, + filter: 'visibility:public', + limit: 'all' + }).then(function (resp) { + return resp.tags; + }); + }, + + validateDatum: function (datum) { + return datum.visibility === 'public'; + }, + + getUrlForDatum: function (tag) { + return config.urlFor('tag', {tag: tag}, true); + }, + + getPriorityForDatum: function () { + // TODO: We could influence this with meta information + return 0.6; + } +}); + +module.exports = TagsMapGenerator; diff --git a/core/server/data/xml/sitemap/user-generator.js b/core/server/data/xml/sitemap/user-generator.js new file mode 100644 index 0000000..5539ab9 --- /dev/null +++ b/core/server/data/xml/sitemap/user-generator.js @@ -0,0 +1,58 @@ +var _ = require('lodash'), + api = require('../../../api'), + config = require('../../../config'), + validator = require('validator'), + BaseMapGenerator = require('./base-generator'), + // @TODO: figure out a way to get rid of this + activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked']; + +// A class responsible for generating a sitemap from posts and keeping it updated +function UserMapGenerator(opts) { + _.extend(this, opts); + + BaseMapGenerator.apply(this, arguments); +} + +// Inherit from the base generator class +_.extend(UserMapGenerator.prototype, BaseMapGenerator.prototype); + +_.extend(UserMapGenerator.prototype, { + bindEvents: function () { + var self = this; + this.dataEvents.on('user.activated', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('user.activated.edited', self.addOrUpdateUrl.bind(self)); + this.dataEvents.on('user.deactivated', self.removeUrl.bind(self)); + }, + + getData: function () { + return api.users.browse({ + context: { + internal: true + }, + filter: 'visibility:public', + status: 'active', + limit: 'all' + }).then(function (resp) { + return resp.users; + }); + }, + + validateDatum: function (datum) { + return datum.visibility === 'public' && _.includes(activeStates, datum.status); + }, + + getUrlForDatum: function (user) { + return config.urlFor('author', {author: user}, true); + }, + + getPriorityForDatum: function () { + // TODO: We could influence this with meta information + return 0.6; + }, + + validateImageUrl: function (imageUrl) { + return imageUrl && validator.isURL(imageUrl, {protocols: ['http', 'https'], require_protocol: true}); + } +}); + +module.exports = UserMapGenerator; diff --git a/core/server/data/xml/sitemap/utils.js b/core/server/data/xml/sitemap/utils.js new file mode 100644 index 0000000..27427dc --- /dev/null +++ b/core/server/data/xml/sitemap/utils.js @@ -0,0 +1,13 @@ +var config = require('../../../config'), + utils; + +utils = { + getDeclarations: function () { + var baseUrl = config.urlFor('sitemap_xsl', true); + baseUrl = baseUrl.replace(/^(http:|https:)/, ''); + return '' + + ''; + } +}; + +module.exports = utils; diff --git a/core/server/data/xml/xmlrpc.js b/core/server/data/xml/xmlrpc.js new file mode 100644 index 0000000..014a5fb --- /dev/null +++ b/core/server/data/xml/xmlrpc.js @@ -0,0 +1,91 @@ +var _ = require('lodash'), + http = require('http'), + xml = require('xml'), + config = require('../../config'), + errors = require('../../errors'), + events = require('../../events'), + i18n = require('../../i18n'), + pingList; + +// ToDo: Make this configurable +pingList = [{ + host: 'blogsearch.google.com', + path: '/ping/RPC2' +}, { + host: 'rpc.pingomatic.com', + path: '/' +}]; + +function ping(post) { + var pingXML, + title = post.title, + url = config.urlFor('post', {post: post}, true); + + // Only ping when in production and not a page + if (process.env.NODE_ENV !== 'production' || post.page || config.isPrivacyDisabled('useRpcPing')) { + return; + } + + // Don't ping for the welcome to ghost post. + // This also handles the case where during ghost's first run + // models.init() inserts this post but permissions.init() hasn't + // (can't) run yet. + if (post.slug === 'welcome-to-ghost') { + return; + } + + // Build XML object. + pingXML = xml({ + methodCall: [{ + methodName: 'weblogUpdate.ping' + }, { + params: [{ + param: [{ + value: [{ + string: title + }] + }] + }, { + param: [{ + value: [{ + string: url + }] + }] + }] + }] + }, {declaration: true}); + + // Ping each of the defined services. + _.each(pingList, function (pingHost) { + var options = { + hostname: pingHost.host, + path: pingHost.path, + method: 'POST' + }, + req; + + req = http.request(options); + req.write(pingXML); + req.on('error', function handleError(error) { + errors.logError( + error, + i18n.t('errors.data.xml.xmlrpc.pingUpdateFailed.error'), + i18n.t('errors.data.xml.xmlrpc.pingUpdateFailed.help', {url: 'http://docs.ghost.org/v0.11.9'}) + ); + } + ); + req.end(); + }); +} + +function listener(model) { + ping(model.toJSON()); +} + +function listen() { + events.on('post.published', listener); +} + +module.exports = { + listen: listen +}; diff --git a/core/server/errors/bad-request-error.js b/core/server/errors/bad-request-error.js new file mode 100644 index 0000000..bdb1825 --- /dev/null +++ b/core/server/errors/bad-request-error.js @@ -0,0 +1,14 @@ +// # Bad request error +// Custom error class with status code and type prefilled. + +function BadRequestError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 400; + this.errorType = this.name; +} + +BadRequestError.prototype = Object.create(Error.prototype); +BadRequestError.prototype.name = 'BadRequestError'; + +module.exports = BadRequestError; diff --git a/core/server/errors/data-import-error.js b/core/server/errors/data-import-error.js new file mode 100644 index 0000000..4834343 --- /dev/null +++ b/core/server/errors/data-import-error.js @@ -0,0 +1,16 @@ +// # Data import error +// Custom error class with status code and type prefilled. + +function DataImportError(message, offendingProperty, value) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 500; + this.errorType = this.name; + this.property = offendingProperty || undefined; + this.value = value || undefined; +} + +DataImportError.prototype = Object.create(Error.prototype); +DataImportError.prototype.name = 'DataImportError'; + +module.exports = DataImportError; diff --git a/core/server/errors/database-not-populated.js b/core/server/errors/database-not-populated.js new file mode 100644 index 0000000..30030c8 --- /dev/null +++ b/core/server/errors/database-not-populated.js @@ -0,0 +1,11 @@ +function DatabaseNotPopulated(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 500; + this.errorType = this.name; +} + +DatabaseNotPopulated.prototype = Object.create(Error.prototype); +DatabaseNotPopulated.prototype.name = 'DatabaseNotPopulated'; + +module.exports = DatabaseNotPopulated; diff --git a/core/server/errors/database-version.js b/core/server/errors/database-version.js new file mode 100644 index 0000000..4b7156d --- /dev/null +++ b/core/server/errors/database-version.js @@ -0,0 +1,13 @@ +function DatabaseVersion(message, context, help) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 500; + this.errorType = this.name; + this.context = context; + this.help = help; +} + +DatabaseVersion.prototype = Object.create(Error.prototype); +DatabaseVersion.prototype.name = 'DatabaseVersion'; + +module.exports = DatabaseVersion; diff --git a/core/server/errors/email-error.js b/core/server/errors/email-error.js new file mode 100644 index 0000000..4bf1d67 --- /dev/null +++ b/core/server/errors/email-error.js @@ -0,0 +1,14 @@ +// # Email error +// Custom error class with status code and type prefilled. + +function EmailError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 500; + this.errorType = this.name; +} + +EmailError.prototype = Object.create(Error.prototype); +EmailError.prototype.name = 'EmailError'; + +module.exports = EmailError; diff --git a/core/server/errors/incorrect-usage.js b/core/server/errors/incorrect-usage.js new file mode 100644 index 0000000..24a851a --- /dev/null +++ b/core/server/errors/incorrect-usage.js @@ -0,0 +1,11 @@ +function IncorrectUsage(message, context) { + this.name = 'IncorrectUsage'; + this.stack = new Error().stack; + this.statusCode = 400; + this.errorType = this.name; + this.message = message; + this.context = context; +} + +IncorrectUsage.prototype = Object.create(Error.prototype); +module.exports = IncorrectUsage; diff --git a/core/server/errors/index.js b/core/server/errors/index.js new file mode 100644 index 0000000..75c1103 --- /dev/null +++ b/core/server/errors/index.js @@ -0,0 +1,473 @@ +// # Errors +/*jslint regexp: true */ +var _ = require('lodash'), + chalk = require('chalk'), + path = require('path'), + Promise = require('bluebird'), + hbs = require('express-hbs'), + NotFoundError = require('./not-found-error'), + BadRequestError = require('./bad-request-error'), + InternalServerError = require('./internal-server-error'), + NoPermissionError = require('./no-permission-error'), + MethodNotAllowedError = require('./method-not-allowed-error'), + RequestEntityTooLargeError = require('./request-too-large-error'), + UnauthorizedError = require('./unauthorized-error'), + ValidationError = require('./validation-error'), + ThemeValidationError = require('./theme-validation-error'), + UnsupportedMediaTypeError = require('./unsupported-media-type-error'), + EmailError = require('./email-error'), + DataImportError = require('./data-import-error'), + TooManyRequestsError = require('./too-many-requests-error'), + TokenRevocationError = require('./token-revocation-error'), + VersionMismatchError = require('./version-mismatch-error'), + IncorrectUsage = require('./incorrect-usage'), + Maintenance = require('./maintenance'), + DatabaseNotPopulated = require('./database-not-populated'), + DatabaseVersion = require('./database-version'), + i18n = require('../i18n'), + config, + errors, + + // Paths for views + userErrorTemplateExists = false; + +// Shim right now to deal with circular dependencies. +// @TODO(hswolff): remove circular dependency and lazy require. +function getConfigModule() { + if (!config) { + config = require('../config'); + } + + return config; +} + +function isValidErrorStatus(status) { + return _.isNumber(status) && status >= 400 && status < 600; +} + +function getStatusCode(error) { + if (error.statusCode) { + return error.statusCode; + } + + if (error.status && isValidErrorStatus(error.status)) { + error.statusCode = error.status; + return error.statusCode; + } + + if (error.code && isValidErrorStatus(error.code)) { + error.statusCode = error.code; + return error.statusCode; + } + + error.statusCode = 500; + return error.statusCode; +} + +/** + * Basic error handling helpers + */ +errors = { + updateActiveTheme: function (activeTheme) { + userErrorTemplateExists = getConfigModule().paths.availableThemes[activeTheme].hasOwnProperty('error.hbs'); + }, + + throwError: function (err) { + if (!err) { + err = new Error(i18n.t('errors.errors.anErrorOccurred')); + } + + if (_.isString(err)) { + throw new Error(err); + } + + throw err; + }, + + // ## Reject Error + // Used to pass through promise errors when we want to handle them at a later time + rejectError: function (err) { + return Promise.reject(err); + }, + + logComponentInfo: function (component, info) { + if (process.env.NODE_LEVEL === 'DEBUG' || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'staging' || + process.env.NODE_ENV === 'production') { + console.info(chalk.cyan(component + ':', info)); + } + }, + + logComponentWarn: function (component, warning) { + if (process.env.NODE_LEVEL === 'DEBUG' || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'staging' || + process.env.NODE_ENV === 'production') { + console.info(chalk.yellow(component + ':', warning)); + } + }, + + logWarn: function (warn, context, help) { + if (process.env.NODE_LEVEL === 'DEBUG' || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'staging' || + process.env.NODE_ENV === 'production') { + warn = warn || i18n.t('errors.errors.noMessageSupplied'); + var msgs = [chalk.yellow(i18n.t('errors.errors.warning'), warn), '\n']; + + if (context) { + msgs.push(chalk.white(context), '\n'); + } + + if (help) { + msgs.push(chalk.green(help)); + } + + // add a new line + msgs.push('\n'); + + console.log.apply(console, msgs); + } + }, + + logError: function (err, context, help) { + var self = this, + origArgs = _.toArray(arguments).slice(1), + stack, + msgs; + + if (_.isArray(err)) { + _.each(err, function (e) { + var newArgs = [e].concat(origArgs); + errors.logError.apply(self, newArgs); + }); + return; + } + + stack = err ? err.stack : null; + + if (!_.isString(err)) { + if (_.isObject(err) && _.isString(err.message)) { + err = err.message; + } else { + err = i18n.t('errors.errors.unknownErrorOccurred'); + } + } + + // Overwrite error to provide information that this is probably a permission problem + // TODO: https://github.com/TryGhost/Ghost/issues/3687 + if (err.indexOf('SQLITE_READONLY') !== -1) { + context = i18n.t('errors.errors.databaseIsReadOnly'); + help = i18n.t('errors.errors.checkDatabase'); + } + + // TODO: Logging framework hookup + // Eventually we'll have better logging which will know about envs + // you can use DEBUG=true when running tests and need error stdout + if ((process.env.NODE_LEVEL === 'DEBUG' || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'staging' || + process.env.NODE_ENV === 'production')) { + msgs = [chalk.red(i18n.t('errors.errors.error'), err), '\n']; + + if (context) { + msgs.push(chalk.white(context), '\n'); + } + + if (help) { + msgs.push(chalk.green(help)); + } + + // add a new line + msgs.push('\n'); + + if (stack) { + msgs.push(stack, '\n'); + } + + console.error.apply(console, msgs); + } + }, + + logErrorAndExit: function (err, context, help) { + this.logError(err, context, help); + // Exit with 0 to prevent npm errors as we have our own + process.exit(0); + }, + + logAndThrowError: function (err, context, help) { + this.logError(err, context, help); + + this.throwError(err, context, help); + }, + + logAndRejectError: function (err, context, help) { + this.logError(err, context, help); + + return this.rejectError(err, context, help); + }, + + logErrorWithRedirect: function (msg, context, help, redirectTo, req, res) { + /*jshint unused:false*/ + var self = this; + + return function () { + self.logError(msg, context, help); + + if (_.isFunction(res.redirect)) { + res.redirect(redirectTo); + } + }; + }, + + /** + * ### Format HTTP Errors + * Converts the error response from the API into a format which can be returned over HTTP + * + * @private + * @param {Array} error + * @return {{errors: Array, statusCode: number}} + */ + formatHttpErrors: function formatHttpErrors(error) { + var statusCode = 500, + errors = []; + + if (!_.isArray(error)) { + error = [].concat(error); + } + + _.each(error, function each(errorItem) { + var errorContent = {}; + + // TODO: add logic to set the correct status code + statusCode = getStatusCode(errorItem); + + errorContent.message = _.isString(errorItem) ? errorItem : + (_.isObject(errorItem) ? errorItem.message : i18n.t('errors.errors.unknownApiError')); + + errorContent.errorType = errorItem.errorType || 'InternalServerError'; + + if (errorItem.code) { + errorContent.code = errorItem.code; + } + + if (errorItem.errorType === 'ThemeValidationError' && errorItem.errorDetails) { + errorContent.errorDetails = errorItem.errorDetails; + } + + errors.push(errorContent); + }); + + return {errors: errors, statusCode: statusCode}; + }, + + formatAndRejectAPIError: function (error, permsMessage) { + if (!error) { + return this.rejectError( + new this.NoPermissionError(permsMessage || i18n.t('errors.errors.notEnoughPermission')) + ); + } + + if (_.isString(error)) { + return this.rejectError(new this.NoPermissionError(error)); + } + + if (error.errorType) { + return this.rejectError(error); + } + + // handle database errors + if (error.code && (error.errno || error.detail)) { + error.db_error_code = error.code; + error.errorType = 'DatabaseError'; + error.statusCode = 500; + + return this.rejectError(error); + } + + return this.rejectError(new this.InternalServerError(error)); + }, + + handleAPIError: function errorHandler(err, req, res, next) { + /*jshint unused:false */ + var httpErrors = this.formatHttpErrors(err); + this.logError(err); + // Send a properly formatted HTTP response containing the errors + res.status(httpErrors.statusCode).json({errors: httpErrors.errors}); + }, + + renderErrorPage: function (statusCode, err, req, res, next) { + /*jshint unused:false*/ + var self = this, + defaultErrorTemplatePath = path.resolve(getConfigModule().paths.adminViews, 'user-error.hbs'); + + function parseStack(stack) { + if (!_.isString(stack)) { + return stack; + } + + // TODO: split out line numbers + var stackRegex = /\s*at\s*(\w+)?\s*\(([^\)]+)\)\s*/i; + + return ( + stack + .split(/[\r\n]+/) + .slice(1) + .map(function (line) { + var parts = line.match(stackRegex); + if (!parts) { + return null; + } + + return { + function: parts[1], + at: parts[2] + }; + }) + .filter(function (line) { + return !!line; + }) + ); + } + + // Render the error! + function renderErrorInt(errorView) { + var stack = null; + + // Not Found and Maintenance Errors don't need a stack trace + if (statusCode !== 404 && statusCode !== 503 && process.env.NODE_ENV !== 'production' && err.stack) { + stack = parseStack(err.stack); + } + + res.status(statusCode).render((errorView || 'error'), { + message: err.message || err, + // We have to use code here, as it's the variable passed to the template + // And error templates can be customised... therefore this constitutes API + // In future I recommend we make this be used for a combo-version of statusCode & errorCode + code: statusCode, + // Adding this as being distinctly, the status code, as opposed to any other code see #6526 + statusCode: statusCode, + stack: stack + }, function (templateErr, html) { + if (!templateErr) { + return res.status(statusCode).send(html); + } + // There was an error trying to render the error page, output the error + self.logError(templateErr, i18n.t('errors.errors.errorWhilstRenderingError'), i18n.t('errors.errors.errorTemplateHasError')); + + // And then try to explain things to the user... + // Cheat and output the error using handlebars escapeExpression + return res.status(500).send( + '

    ' + i18n.t('errors.errors.oopsErrorTemplateHasError') + '

    ' + + '

    ' + i18n.t('errors.errors.encounteredError') + '

    ' + + '
    ' + hbs.handlebars.Utils.escapeExpression(templateErr.message || templateErr) + '
    ' + + '

    ' + i18n.t('errors.errors.whilstTryingToRender') + '

    ' + + statusCode + ' ' + '
    '  + hbs.handlebars.Utils.escapeExpression(err.message || err) + '
    ' + ); + }); + } + + if (statusCode >= 500) { + this.logError(err, i18n.t('errors.errors.renderingErrorPage'), i18n.t('errors.errors.caughtProcessingError')); + } + + // Are we admin? If so, don't worry about the user template + if ((res.isAdmin && req.user && req.user.id) || userErrorTemplateExists === true) { + return renderErrorInt(); + } + + // We're not admin and the template doesn't exist. Render the default. + return renderErrorInt(defaultErrorTemplatePath); + }, + + error404: function (req, res, next) { + var message = i18n.t('errors.errors.pageNotFound'); + + // do not cache 404 error + res.set({'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'}); + if (req.method === 'GET') { + this.renderErrorPage(404, message, req, res, next); + } else { + res.status(404).send(message); + } + }, + + error500: function (err, req, res, next) { + var statusCode = getStatusCode(err), + returnErrors = []; + + // 500 errors should never be cached + res.set({'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'}); + + if (statusCode === 404) { + return this.error404(req, res, next); + } + + if (req.method === 'GET') { + if (!err || !(err instanceof Error)) { + next(); + } + errors.renderErrorPage(statusCode, err, req, res, next); + } else { + if (!_.isArray(err)) { + err = [].concat(err); + } + + _.each(err, function (errorItem) { + var errorContent = {}; + + errorContent.message = _.isString(errorItem) ? errorItem : + (_.isObject(errorItem) ? errorItem.message : i18n.t('errors.errors.unknownError')); + errorContent.errorType = errorItem.errorType || 'InternalServerError'; + returnErrors.push(errorContent); + }); + + res.status(statusCode).json({errors: returnErrors}); + } + } +}; + +// Ensure our 'this' context for methods and preserve method arity by +// using Function#bind for expressjs +_.each([ + 'logWarn', + 'logComponentInfo', + 'logComponentWarn', + 'rejectError', + 'throwError', + 'logError', + 'logAndThrowError', + 'logAndRejectError', + 'logErrorAndExit', + 'logErrorWithRedirect', + 'handleAPIError', + 'formatAndRejectAPIError', + 'formatHttpErrors', + 'renderErrorPage', + 'error404', + 'error500' +], function (funcName) { + errors[funcName] = errors[funcName].bind(errors); +}); + +module.exports = errors; +module.exports.NotFoundError = NotFoundError; +module.exports.BadRequestError = BadRequestError; +module.exports.InternalServerError = InternalServerError; +module.exports.NoPermissionError = NoPermissionError; +module.exports.UnauthorizedError = UnauthorizedError; +module.exports.ValidationError = ValidationError; +module.exports.ThemeValidationError = ThemeValidationError; +module.exports.RequestEntityTooLargeError = RequestEntityTooLargeError; +module.exports.UnsupportedMediaTypeError = UnsupportedMediaTypeError; +module.exports.EmailError = EmailError; +module.exports.DataImportError = DataImportError; +module.exports.MethodNotAllowedError = MethodNotAllowedError; +module.exports.TooManyRequestsError = TooManyRequestsError; +module.exports.TokenRevocationError = TokenRevocationError; +module.exports.VersionMismatchError = VersionMismatchError; +module.exports.IncorrectUsage = IncorrectUsage; +module.exports.Maintenance = Maintenance; +module.exports.DatabaseNotPopulated = DatabaseNotPopulated; +module.exports.DatabaseVersion = DatabaseVersion; diff --git a/core/server/errors/internal-server-error.js b/core/server/errors/internal-server-error.js new file mode 100644 index 0000000..08a18f9 --- /dev/null +++ b/core/server/errors/internal-server-error.js @@ -0,0 +1,14 @@ +// # Internal Server Error +// Custom error class with status code and type prefilled. + +function InternalServerError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 500; + this.errorType = this.name; +} + +InternalServerError.prototype = Object.create(Error.prototype); +InternalServerError.prototype.name = 'InternalServerError'; + +module.exports = InternalServerError; diff --git a/core/server/errors/maintenance.js b/core/server/errors/maintenance.js new file mode 100644 index 0000000..309f61f --- /dev/null +++ b/core/server/errors/maintenance.js @@ -0,0 +1,11 @@ +function Maintenance(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 503; + this.errorType = this.name; +} + +Maintenance.prototype = Object.create(Error.prototype); +Maintenance.prototype.name = 'Maintenance'; + +module.exports = Maintenance; diff --git a/core/server/errors/method-not-allowed-error.js b/core/server/errors/method-not-allowed-error.js new file mode 100644 index 0000000..8efe5e3 --- /dev/null +++ b/core/server/errors/method-not-allowed-error.js @@ -0,0 +1,14 @@ +// # Not found error +// Custom error class with status code and type prefilled. + +function MethodNotAllowedError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 405; + this.errorType = this.name; +} + +MethodNotAllowedError.prototype = Object.create(Error.prototype); +MethodNotAllowedError.prototype.name = 'MethodNotAllowedError'; + +module.exports = MethodNotAllowedError; diff --git a/core/server/errors/no-permission-error.js b/core/server/errors/no-permission-error.js new file mode 100644 index 0000000..c60dacc --- /dev/null +++ b/core/server/errors/no-permission-error.js @@ -0,0 +1,14 @@ +// # No Permission Error +// Custom error class with status code and type prefilled. + +function NoPermissionError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 403; + this.errorType = this.name; +} + +NoPermissionError.prototype = Object.create(Error.prototype); +NoPermissionError.prototype.name = 'NoPermissionError'; + +module.exports = NoPermissionError; diff --git a/core/server/errors/not-found-error.js b/core/server/errors/not-found-error.js new file mode 100644 index 0000000..ee5bd39 --- /dev/null +++ b/core/server/errors/not-found-error.js @@ -0,0 +1,14 @@ +// # Not found error +// Custom error class with status code and type prefilled. + +function NotFoundError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 404; + this.errorType = this.name; +} + +NotFoundError.prototype = Object.create(Error.prototype); +NotFoundError.prototype.name = 'NotFoundError'; + +module.exports = NotFoundError; diff --git a/core/server/errors/request-too-large-error.js b/core/server/errors/request-too-large-error.js new file mode 100644 index 0000000..71c2848 --- /dev/null +++ b/core/server/errors/request-too-large-error.js @@ -0,0 +1,14 @@ +// # Request Entity Too Large Error +// Custom error class with status code and type prefilled. + +function RequestEntityTooLargeError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 413; + this.errorType = this.name; +} + +RequestEntityTooLargeError.prototype = Object.create(Error.prototype); +RequestEntityTooLargeError.prototype.name = 'RequestEntityTooLargeError'; + +module.exports = RequestEntityTooLargeError; diff --git a/core/server/errors/theme-validation-error.js b/core/server/errors/theme-validation-error.js new file mode 100644 index 0000000..93ddd7c --- /dev/null +++ b/core/server/errors/theme-validation-error.js @@ -0,0 +1,18 @@ +// # Theme Validation Error +// Custom error class with status code and type prefilled. + +function ThemeValidationError(message, details) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 422; + if (details) { + this.errorDetails = details; + } + + this.errorType = this.name; +} + +ThemeValidationError.prototype = Object.create(Error.prototype); +ThemeValidationError.prototype.name = 'ThemeValidationError'; + +module.exports = ThemeValidationError; diff --git a/core/server/errors/token-revocation-error.js b/core/server/errors/token-revocation-error.js new file mode 100644 index 0000000..445db37 --- /dev/null +++ b/core/server/errors/token-revocation-error.js @@ -0,0 +1,14 @@ +// # Token Revocation ERror +// Custom error class with status code and type prefilled. + +function TokenRevocationError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 503; + this.errorType = this.name; +} + +TokenRevocationError.prototype = Object.create(Error.prototype); +TokenRevocationError.prototype.name = 'TokenRevocationError'; + +module.exports = TokenRevocationError; diff --git a/core/server/errors/too-many-requests-error.js b/core/server/errors/too-many-requests-error.js new file mode 100644 index 0000000..183a063 --- /dev/null +++ b/core/server/errors/too-many-requests-error.js @@ -0,0 +1,14 @@ +// # Too Many Requests Error +// Custom error class with status code and type prefilled. + +function TooManyRequestsError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 429; + this.errorType = this.name; +} + +TooManyRequestsError.prototype = Object.create(Error.prototype); +TooManyRequestsError.prototype.name = 'TooManyRequestsError'; + +module.exports = TooManyRequestsError; diff --git a/core/server/errors/unauthorized-error.js b/core/server/errors/unauthorized-error.js new file mode 100644 index 0000000..d03723a --- /dev/null +++ b/core/server/errors/unauthorized-error.js @@ -0,0 +1,14 @@ +// # Unauthorized error +// Custom error class with status code and type prefilled. + +function UnauthorizedError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 401; + this.errorType = this.name; +} + +UnauthorizedError.prototype = Object.create(Error.prototype); +UnauthorizedError.prototype.name = 'UnauthorizedError'; + +module.exports = UnauthorizedError; diff --git a/core/server/errors/unsupported-media-type-error.js b/core/server/errors/unsupported-media-type-error.js new file mode 100644 index 0000000..1d16691 --- /dev/null +++ b/core/server/errors/unsupported-media-type-error.js @@ -0,0 +1,14 @@ +// # Unsupported Media Type +// Custom error class with status code and type prefilled. + +function UnsupportedMediaTypeError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 415; + this.errorType = this.name; +} + +UnsupportedMediaTypeError.prototype = Object.create(Error.prototype); +UnsupportedMediaTypeError.prototype.name = 'UnsupportedMediaTypeError'; + +module.exports = UnsupportedMediaTypeError; diff --git a/core/server/errors/validation-error.js b/core/server/errors/validation-error.js new file mode 100644 index 0000000..999f88a --- /dev/null +++ b/core/server/errors/validation-error.js @@ -0,0 +1,17 @@ +// # Validation Error +// Custom error class with status code and type prefilled. + +function ValidationError(message, offendingProperty) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 422; + if (offendingProperty) { + this.property = offendingProperty; + } + this.errorType = this.name; +} + +ValidationError.prototype = Object.create(Error.prototype); +ValidationError.prototype.name = 'ValidationError'; + +module.exports = ValidationError; diff --git a/core/server/errors/version-mismatch-error.js b/core/server/errors/version-mismatch-error.js new file mode 100644 index 0000000..7ab4956 --- /dev/null +++ b/core/server/errors/version-mismatch-error.js @@ -0,0 +1,14 @@ +// # Version mismatch error +// Custom error class with status code and type prefilled. + +function VersionMismatchError(message) { + this.message = message; + this.stack = new Error().stack; + this.statusCode = 400; + this.errorType = this.name; +} + +VersionMismatchError.prototype = Object.create(Error.prototype); +VersionMismatchError.prototype.name = 'VersionMismatchError'; + +module.exports = VersionMismatchError; diff --git a/core/server/events/index.js b/core/server/events/index.js new file mode 100644 index 0000000..61b40d7 --- /dev/null +++ b/core/server/events/index.js @@ -0,0 +1,23 @@ +var events = require('events'), + util = require('util'), + EventRegistry, + EventRegistryInstance; + +EventRegistry = function () { + events.EventEmitter.call(this); +}; + +util.inherits(EventRegistry, events.EventEmitter); + +EventRegistry.prototype.onMany = function (arr, onEvent) { + var self = this; + + arr.forEach(function (eventName) { + self.on(eventName, onEvent); + }); +}; + +EventRegistryInstance = new EventRegistry(); +EventRegistryInstance.setMaxListeners(100); + +module.exports = EventRegistryInstance; diff --git a/core/server/filters.js b/core/server/filters.js new file mode 100644 index 0000000..9bce4e8 --- /dev/null +++ b/core/server/filters.js @@ -0,0 +1,95 @@ +// # Filters +// Filters are not yet properly used, this system is intended to allow Apps to extend Ghost in various ways. +var Promise = require('bluebird'), + pipeline = require('./utils/pipeline'), + _ = require('lodash'), + defaults; + +// ## Default values +/** + * A hash of default values to use instead of 'magic' numbers/strings. + * @type {Object} + */ +defaults = { + filterPriority: 5, + maxPriority: 9 +}; + +function Filters() { + // Holds the filters + this.filterCallbacks = []; + + // Holds the filter hooks (that are built in to Ghost Core) + this.filters = []; +} + +// Register a new filter callback function +Filters.prototype.registerFilter = function (name, priority, fn) { + // Carry the priority optional parameter to a default of 5 + if (_.isFunction(priority)) { + fn = priority; + priority = null; + } + + // Null priority should be set to default + if (priority === null) { + priority = defaults.filterPriority; + } + + this.filterCallbacks[name] = this.filterCallbacks[name] || {}; + this.filterCallbacks[name][priority] = this.filterCallbacks[name][priority] || []; + + this.filterCallbacks[name][priority].push(fn); +}; + +// Unregister a filter callback function +Filters.prototype.deregisterFilter = function (name, priority, fn) { + // Curry the priority optional parameter to a default of 5 + if (_.isFunction(priority)) { + fn = priority; + priority = defaults.filterPriority; + } + + // Check if it even exists + if (this.filterCallbacks[name] && this.filterCallbacks[name][priority]) { + // Remove the function from the list of filter funcs + this.filterCallbacks[name][priority] = _.without(this.filterCallbacks[name][priority], fn); + } +}; + +// Execute filter functions in priority order +Filters.prototype.doFilter = function (name, args, context) { + var callbacks = this.filterCallbacks[name], + priorityCallbacks = []; + + // Bug out early if no callbacks by that name + if (!callbacks) { + return Promise.resolve(args); + } + + // For each priorityLevel + _.times(defaults.maxPriority + 1, function (priority) { + // Add a function that runs its priority level callbacks in a pipeline + priorityCallbacks.push(function (currentArgs) { + var callables; + + // Bug out if no handlers on this priority + if (!_.isArray(callbacks[priority])) { + return Promise.resolve(currentArgs); + } + + callables = _.map(callbacks[priority], function (callback) { + return function (args) { + return callback(args, context); + }; + }); + // Call each handler for this priority level, allowing for promises or values + return pipeline(callables, currentArgs); + }); + }); + + return pipeline(priorityCallbacks, args); +}; + +module.exports = new Filters(); +module.exports.Filters = Filters; diff --git a/core/server/ghost-server.js b/core/server/ghost-server.js new file mode 100644 index 0000000..d7c3319 --- /dev/null +++ b/core/server/ghost-server.js @@ -0,0 +1,216 @@ +// # Ghost Server +// Handles the creation of an HTTP Server for Ghost +var Promise = require('bluebird'), + chalk = require('chalk'), + fs = require('fs'), + errors = require('./errors'), + events = require('./events'), + config = require('./config'), + i18n = require('./i18n'), + moment = require('moment'); + +/** + * ## GhostServer + * @constructor + * @param {Object} rootApp - parent express instance + */ +function GhostServer(rootApp) { + this.rootApp = rootApp; + this.httpServer = null; + this.connections = {}; + this.connectionId = 0; + + // Expose config module for use externally. + this.config = config; +} + +/** + * ## Public API methods + * + * ### Start + * Starts the ghost server listening on the configured port. + * Alternatively you can pass in your own express instance and let Ghost + * start listening for you. + * @param {Object} externalApp - Optional express app instance. + * @return {Promise} Resolves once Ghost has started + */ +GhostServer.prototype.start = function (externalApp) { + var self = this, + rootApp = externalApp ? externalApp : self.rootApp; + + return new Promise(function (resolve) { + var socketConfig = config.getSocket(); + + if (socketConfig) { + // Make sure the socket is gone before trying to create another + try { + fs.unlinkSync(socketConfig.path); + } catch (e) { + // We can ignore this. + } + + self.httpServer = rootApp.listen(socketConfig.path); + + fs.chmod(socketConfig.path, socketConfig.permissions); + } else { + self.httpServer = rootApp.listen( + config.server.port, + config.server.host + ); + } + + self.httpServer.on('error', function (error) { + if (error.errno === 'EADDRINUSE') { + errors.logError( + i18n.t('errors.httpServer.addressInUse.error'), + i18n.t('errors.httpServer.addressInUse.context', {port: config.server.port}), + i18n.t('errors.httpServer.addressInUse.help') + ); + } else { + errors.logError( + i18n.t('errors.httpServer.otherError.error', {errorNumber: error.errno}), + i18n.t('errors.httpServer.otherError.context'), + i18n.t('errors.httpServer.otherError.help') + ); + } + process.exit(-1); + }); + self.httpServer.on('connection', self.connection.bind(self)); + self.httpServer.on('listening', function () { + events.emit('server:start'); + self.logStartMessages(); + resolve(self); + }); + }); +}; + +/** + * ### Stop + * Returns a promise that will be fulfilled when the server stops. If the server has not been started, + * the promise will be fulfilled immediately + * @returns {Promise} Resolves once Ghost has stopped + */ +GhostServer.prototype.stop = function () { + var self = this; + + return new Promise(function (resolve) { + if (self.httpServer === null) { + resolve(self); + } else { + self.httpServer.close(function () { + self.httpServer = null; + self.logShutdownMessages(); + resolve(self); + }); + + self.closeConnections(); + } + }); +}; + +/** + * ### Restart + * Restarts the ghost application + * @returns {Promise} Resolves once Ghost has restarted + */ +GhostServer.prototype.restart = function () { + return this.stop().then(function (ghostServer) { + return ghostServer.start(); + }); +}; + +/** + * ### Hammertime + * To be called after `stop` + */ +GhostServer.prototype.hammertime = function () { + console.log(chalk.green(i18n.t('notices.httpServer.cantTouchThis'))); + + return Promise.resolve(this); +}; + +/** + * ## Private (internal) methods + * + * ### Connection + * @param {Object} socket + */ +GhostServer.prototype.connection = function (socket) { + var self = this; + + self.connectionId += 1; + socket._ghostId = self.connectionId; + + socket.on('close', function () { + delete self.connections[this._ghostId]; + }); + + self.connections[socket._ghostId] = socket; +}; + +/** + * ### Close Connections + * Most browsers keep a persistent connection open to the server, which prevents the close callback of + * httpServer from returning. We need to destroy all connections manually. + */ +GhostServer.prototype.closeConnections = function () { + var self = this; + + Object.keys(self.connections).forEach(function (socketId) { + var socket = self.connections[socketId]; + + if (socket) { + socket.destroy(); + } + }); +}; + +/** + * ### Log Start Messages + */ +GhostServer.prototype.logStartMessages = function () { + // Startup & Shutdown messages + if (process.env.NODE_ENV === 'production') { + console.log( + chalk.green(i18n.t('notices.httpServer.ghostIsRunningIn', {env: process.env.NODE_ENV})), + i18n.t('notices.httpServer.yourBlogIsAvailableOn', {url: config.url}), + chalk.gray(i18n.t('notices.httpServer.ctrlCToShutDown')) + ); + } else { + console.log( + chalk.green(i18n.t('notices.httpServer.ghostIsRunningIn', {env: process.env.NODE_ENV})), + i18n.t('notices.httpServer.listeningOn'), + config.getSocket() || config.server.host + ':' + config.server.port, + i18n.t('notices.httpServer.urlConfiguredAs', {url: config.url}), + chalk.gray(i18n.t('notices.httpServer.ctrlCToShutDown')) + ); + } + + function shutdown() { + console.log(chalk.red(i18n.t('notices.httpServer.ghostHasShutdown'))); + if (process.env.NODE_ENV === 'production') { + console.log( + i18n.t('notices.httpServer.yourBlogIsNowOffline') + ); + } else { + console.log( + i18n.t('notices.httpServer.ghostWasRunningFor'), + moment.duration(process.uptime(), 'seconds').humanize() + ); + } + process.exit(0); + } + // ensure that Ghost exits correctly on Ctrl+C and SIGTERM + process. + removeAllListeners('SIGINT').on('SIGINT', shutdown). + removeAllListeners('SIGTERM').on('SIGTERM', shutdown); +}; + +/** + * ### Log Shutdown Messages + */ +GhostServer.prototype.logShutdownMessages = function () { + console.log(chalk.red(i18n.t('notices.httpServer.ghostIsClosingConnections'))); +}; + +module.exports = GhostServer; diff --git a/core/server/helpers/asset.js b/core/server/helpers/asset.js new file mode 100644 index 0000000..00e6c10 --- /dev/null +++ b/core/server/helpers/asset.js @@ -0,0 +1,25 @@ +// # Asset helper +// Usage: `{{asset "css/screen.css"}}`, `{{asset "css/screen.css" ghost="true"}}` +// +// Returns the path to the specified asset. The ghost flag outputs the asset path for the Ghost admin + +var getAssetUrl = require('../data/meta/asset_url'), + hbs = require('express-hbs'); + +function asset(path, options) { + var isAdmin, + minify; + + if (options && options.hash) { + isAdmin = options.hash.ghost; + minify = options.hash.minifyInProduction; + } + if (process.env.NODE_ENV !== 'production') { + minify = false; + } + return new hbs.handlebars.SafeString( + getAssetUrl(path, isAdmin, minify) + ); +} + +module.exports = asset; diff --git a/core/server/helpers/author.js b/core/server/helpers/author.js new file mode 100644 index 0000000..b889cc5 --- /dev/null +++ b/core/server/helpers/author.js @@ -0,0 +1,41 @@ +// # Author Helper +// Usage: `{{author}}` OR `{{#author}}{{/author}}` +// +// Can be used as either an output or a block helper +// +// Output helper: `{{author}}` +// Returns the full name of the author of a given post, or a blank string +// if the author could not be determined. +// +// Block helper: `{{#author}}{{/author}}` +// This is the default handlebars behaviour of dropping into the author object scope + +var hbs = require('express-hbs'), + _ = require('lodash'), + config = require('../config'), + utils = require('./utils'), + author; + +author = function (options) { + if (options.fn) { + return hbs.handlebars.helpers.with.call(this, this.author, options); + } + + var autolink = _.isString(options.hash.autolink) && options.hash.autolink === 'false' ? false : true, + output = ''; + + if (this.author && this.author.name) { + if (autolink) { + output = utils.linkTemplate({ + url: config.urlFor('author', {author: this.author}), + text: _.escape(this.author.name) + }); + } else { + output = _.escape(this.author.name); + } + } + + return new hbs.handlebars.SafeString(output); +}; + +module.exports = author; diff --git a/core/server/helpers/body_class.js b/core/server/helpers/body_class.js new file mode 100644 index 0000000..8dd5070 --- /dev/null +++ b/core/server/helpers/body_class.js @@ -0,0 +1,71 @@ +// # Body Class Helper +// Usage: `{{body_class}}` +// +// Output classes for the body element +// +// We use the name body_class to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var hbs = require('express-hbs'), + _ = require('lodash'), + // @TODO Fix this + template = require('../controllers/frontend/templates'), + body_class; + +body_class = function (options) { + var classes = [], + context = options.data.root.context, + post = this.post, + tags = this.post && this.post.tags ? this.post.tags : this.tags || [], + page = this.post && this.post.page ? this.post.page : this.page || false, + activeTheme = options.data.root.settings.activeTheme, + view; + + if (post) { + // To be removed from pages by #2597 when we're ready to deprecate this + // i.e. this should be if (_.includes(context, 'post') && post) { ... } + classes.push('post-template'); + } + + if (_.includes(context, 'home')) { + classes.push('home-template'); + } else if (_.includes(context, 'page') && page) { + classes.push('page-template'); + // To be removed by #2597 when we're ready to deprecate this + classes.push('page'); + } else if (_.includes(context, 'tag') && this.tag) { + classes.push('tag-template'); + classes.push('tag-' + this.tag.slug); + } else if (_.includes(context, 'author') && this.author) { + classes.push('author-template'); + classes.push('author-' + this.author.slug); + } else if (_.includes(context, 'private')) { + classes.push('private-template'); + } + + if (tags) { + classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); + } + + if (_.includes(context, 'paged')) { + classes.push('paged'); + // To be removed from pages by #2597 when we're ready to deprecate this + classes.push('archive-template'); + } + + if (post && page) { + view = template.single(activeTheme, post).split('-'); + + if (view[0] === 'page' && view.length > 1) { + classes.push(view.join('-')); + // To be removed by #2597 when we're ready to deprecate this + view.splice(1, 0, 'template'); + classes.push(view.join('-')); + } + } + + classes = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classes.trim()); +}; + +module.exports = body_class; diff --git a/core/server/helpers/content.js b/core/server/helpers/content.js new file mode 100644 index 0000000..ce29b42 --- /dev/null +++ b/core/server/helpers/content.js @@ -0,0 +1,38 @@ +// # Content Helper +// Usage: `{{content}}`, `{{content words="20"}}`, `{{content characters="256"}}` +// +// Turns content html into a safestring so that the user doesn't have to +// escape it or tell handlebars to leave it alone with a triple-brace. +// +// Enables tag-safe truncation of content by characters or words. + +var hbs = require('express-hbs'), + _ = require('lodash'), + downsize = require('downsize'), + downzero = require('../utils/downzero'), + content; + +content = function (options) { + var truncateOptions = (options || {}).hash || {}; + truncateOptions = _.pick(truncateOptions, ['words', 'characters']); + _.keys(truncateOptions).map(function (key) { + truncateOptions[key] = parseInt(truncateOptions[key], 10); + }); + + if (truncateOptions.hasOwnProperty('words') || truncateOptions.hasOwnProperty('characters')) { + // Legacy function: {{content words="0"}} should return leading tags. + if (truncateOptions.hasOwnProperty('words') && truncateOptions.words === 0) { + return new hbs.handlebars.SafeString( + downzero(this.html) + ); + } + + return new hbs.handlebars.SafeString( + downsize(this.html, truncateOptions) + ); + } + + return new hbs.handlebars.SafeString(this.html); +}; + +module.exports = content; diff --git a/core/server/helpers/date.js b/core/server/helpers/date.js new file mode 100644 index 0000000..17327c4 --- /dev/null +++ b/core/server/helpers/date.js @@ -0,0 +1,39 @@ +// # Date Helper +// Usage: `{{date format="DD MM, YYYY"}}`, `{{date updated_at format="DD MM, YYYY"}}` +// +// Formats a date using moment-timezone.js. Formats published_at by default but will also take a date as a parameter + +var moment = require('moment-timezone'), + date, + timezone; + +date = function (date, options) { + if (!options && date.hasOwnProperty('hash')) { + options = date; + date = undefined; + timezone = options.data.blog.timezone; + + // set to published_at by default, if it's available + // otherwise, this will print the current date + if (this.published_at) { + date = moment(this.published_at).tz(timezone).format(); + } + } + + // ensure that context is undefined, not null, as that can cause errors + date = date === null ? undefined : date; + + var f = options.hash.format || 'MMM DD, YYYY', + timeago = options.hash.timeago, + timeNow = moment().tz(timezone); + + if (timeago) { + date = timezone ? moment(date).tz(timezone).from(timeNow) : moment(date).fromNow(); + } else { + date = timezone ? moment(date).tz(timezone).format(f) : moment(date).format(f); + } + + return date; +}; + +module.exports = date; diff --git a/core/server/helpers/encode.js b/core/server/helpers/encode.js new file mode 100644 index 0000000..661a92d --- /dev/null +++ b/core/server/helpers/encode.js @@ -0,0 +1,15 @@ +// # Encode Helper +// +// Usage: `{{encode uri}}` +// +// Returns URI encoded string + +var hbs = require('express-hbs'), + encode; + +encode = function (string, options) { + var uri = string || options; + return new hbs.handlebars.SafeString(encodeURIComponent(uri)); +}; + +module.exports = encode; diff --git a/core/server/helpers/excerpt.js b/core/server/helpers/excerpt.js new file mode 100644 index 0000000..8819680 --- /dev/null +++ b/core/server/helpers/excerpt.js @@ -0,0 +1,25 @@ +// # Excerpt Helper +// Usage: `{{excerpt}}`, `{{excerpt words="50"}}`, `{{excerpt characters="256"}}` +// +// Attempts to remove all HTML from the string, and then shortens the result according to the provided option. +// +// Defaults to words="50" + +var hbs = require('express-hbs'), + _ = require('lodash'), + getMetaDataExcerpt = require('../data/meta/excerpt'); + +function excerpt(options) { + var truncateOptions = (options || {}).hash || {}; + + truncateOptions = _.pick(truncateOptions, ['words', 'characters']); + _.keys(truncateOptions).map(function (key) { + truncateOptions[key] = parseInt(truncateOptions[key], 10); + }); + + return new hbs.handlebars.SafeString( + getMetaDataExcerpt(String(this.html), truncateOptions) + ); +} + +module.exports = excerpt; diff --git a/core/server/helpers/facebook_url.js b/core/server/helpers/facebook_url.js new file mode 100644 index 0000000..d3030c1 --- /dev/null +++ b/core/server/helpers/facebook_url.js @@ -0,0 +1,26 @@ +// # Facebook URL Helper +// Usage: `{{facebook_url}}` or `{{facebook_url author.facebook}}` +// +// Output a url for a twitter username +// +// We use the name facebook_url to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var socialUrls = require('../utils/social-urls'), + findKey = require('./utils').findKey, + facebook_url; + +facebook_url = function (username, options) { + if (!options) { + options = username; + username = findKey('facebook', this, options.data.blog); + } + + if (username) { + return socialUrls.facebookUrl(username); + } + + return null; +}; + +module.exports = facebook_url; diff --git a/core/server/helpers/foreach.js b/core/server/helpers/foreach.js new file mode 100644 index 0000000..dcd3df5 --- /dev/null +++ b/core/server/helpers/foreach.js @@ -0,0 +1,126 @@ +// # Foreach Helper +// Usage: `{{#foreach data}}{{/foreach}}` +// +// Block helper designed for looping through posts +var hbs = require('express-hbs'), + _ = require('lodash'), + errors = require('../errors'), + i18n = require('../i18n'), + labs = require('../utils/labs'), + utils = require('./utils'), + + hbsUtils = hbs.handlebars.Utils, + foreach; + +function filterItemsByVisibility(items, options) { + var visibility = utils.parseVisibility(options); + + if (!labs.isSet('internalTags') || _.includes(visibility, 'all')) { + return items; + } + + function visibilityFilter(item) { + // If the item doesn't have a visibility property && options.hash.visibility wasn't set + // We return the item, else we need to be sure that this item has the property + if (!item.visibility && !options.hash.visibility || _.includes(visibility, item.visibility)) { + return item; + } + } + + // We don't want to change the structure of what is returned + return _.isArray(items) ? _.filter(items, visibilityFilter) : _.pickBy(items, visibilityFilter); +} + +foreach = function (items, options) { + if (!options) { + errors.logWarn(i18n.t('warnings.helpers.foreach.iteratorNeeded')); + } + + if (hbsUtils.isFunction(items)) { + items = items.call(this); + } + + // Exclude items which should not be visible in the theme + items = filterItemsByVisibility(items, options); + + // Initial values set based on parameters sent through. If nothing sent, set to defaults + var fn = options.fn, + inverse = options.inverse, + columns = options.hash.columns, + length = _.size(items), + limit = parseInt(options.hash.limit, 10) || length, + from = parseInt(options.hash.from, 10) || 1, + to = parseInt(options.hash.to, 10) || length, + output = '', + data, + contextPath; + + // If a limit option was sent through (aka not equal to default (length)) + // and from plus limit is less than the length, set to to the from + limit + if ((limit < length) && ((from + limit) <= length)) { + to = (from - 1) + limit; + } + + if (options.data && options.ids) { + contextPath = hbsUtils.appendContextPath(options.data.contextPath, options.ids[0]) + '.'; + } + + if (options.data) { + data = hbs.handlebars.createFrame(options.data); + } + + function execIteration(field, index, last) { + if (data) { + data.key = field; + data.index = index; + data.number = index + 1; + data.first = index === from - 1; // From uses 1-indexed, but array uses 0-indexed + data.last = !!last; + data.even = index % 2 === 1; + data.odd = !data.even; + data.rowStart = index % columns === 0; + data.rowEnd = index % columns === (columns - 1); + if (contextPath) { + data.contextPath = contextPath + field; + } + } + + output = output + fn(items[field], { + data: data, + blockParams: hbsUtils.blockParams([items[field], field], [contextPath + field, null]) + }); + } + + function iterateCollection(context) { + // Context is all posts on the blog + var count = 1, + current = 1; + + // For each post, if it is a post number that fits within the from and to + // send the key to execIteration to set to be added to the page + _.each(context, function (item, key) { + if (current < from) { + current += 1; + return; + } + + if (current <= to) { + execIteration(key, current - 1, current === to); + } + count += 1; + current += 1; + }); + } + + if (items && typeof items === 'object') { + iterateCollection(items); + } + + if (length === 0) { + output = inverse(this); + } + + return output; +}; + +module.exports = foreach; diff --git a/core/server/helpers/get.js b/core/server/helpers/get.js new file mode 100644 index 0000000..d7dece2 --- /dev/null +++ b/core/server/helpers/get.js @@ -0,0 +1,163 @@ +// # Get Helper +// Usage: `{{#get "posts" limit="5"}}`, `{{#get "tags" limit="all"}}` +// Fetches data from the API +var _ = require('lodash'), + hbs = require('express-hbs'), + Promise = require('bluebird'), + errors = require('../errors'), + api = require('../api'), + jsonpath = require('jsonpath'), + labs = require('../utils/labs'), + i18n = require('../i18n'), + resources, + pathAliases, + get; + +// Endpoints that the helper is able to access +resources = ['posts', 'tags', 'users']; + +// Short forms of paths which we should understand +pathAliases = { + 'post.tags': 'post.tags[*].slug', + 'post.author': 'post.author.slug' +}; + +/** + * ## Is Browse + * Is this a Browse request or a Read request? + * @param {Object} resource + * @param {Object} options + * @returns {boolean} + */ +function isBrowse(resource, options) { + var browse = true; + + if (options.id || options.slug) { + browse = false; + } + + return browse; +} + +/** + * ## Resolve Paths + * Find and resolve path strings + * + * @param {Object} data + * @param {String} value + * @returns {String} + */ +function resolvePaths(data, value) { + var regex = /\{\{(.*?)\}\}/g; + + value = value.replace(regex, function (match, path) { + var result; + + // Handle aliases + path = pathAliases[path] ? pathAliases[path] : path; + // Handle Handlebars .[] style arrays + path = path.replace(/\.\[/g, '['); + + // Do the query, and convert from array to string + result = jsonpath.query(data, path).join(','); + + return result; + }); + + return value; +} + +/** + * ## Parse Options + * Ensure options passed in make sense + * + * @param {Object} data + * @param {Object} options + * @returns {*} + */ +function parseOptions(data, options) { + if (_.isString(options.filter)) { + options.filter = resolvePaths(data, options.filter); + } + + return options; +} + +/** + * ## Get + * @param {Object} resource + * @param {Object} options + * @returns {Promise} + */ +get = function get(resource, options) { + options = options || {}; + options.hash = options.hash || {}; + options.data = options.data || {}; + + var self = this, + data = hbs.handlebars.createFrame(options.data), + apiOptions = options.hash, + apiMethod; + + if (!options.fn) { + data.error = i18n.t('warnings.helpers.get.mustBeCalledAsBlock'); + errors.logWarn(data.error); + return Promise.resolve(); + } + + if (!_.includes(resources, resource)) { + data.error = i18n.t('warnings.helpers.get.invalidResource'); + errors.logWarn(data.error); + return Promise.resolve(options.inverse(self, {data: data})); + } + + // Determine if this is a read or browse + apiMethod = isBrowse(resource, apiOptions) ? api[resource].browse : api[resource].read; + // Parse the options we're going to pass to the API + apiOptions = parseOptions(this, apiOptions); + + return apiMethod(apiOptions).then(function success(result) { + var blockParams; + + // If no result is found, call the inverse or `{{else}}` function + if (_.isEmpty(result[resource])) { + return options.inverse(self, {data: data}); + } + + // block params allows the theme developer to name the data using something like + // `{{#get "posts" as |result pageInfo|}}` + blockParams = [result[resource]]; + if (result.meta && result.meta.pagination) { + result.pagination = result.meta.pagination; + blockParams.push(result.meta.pagination); + } + + // Call the main template function + return options.fn(result, { + data: data, + blockParams: blockParams + }); + }).catch(function error(err) { + data.error = err.message; + return options.inverse(self, {data: data}); + }); +}; + +module.exports = function getWithLabs(resource, options) { + var self = this, + errorMessages = [ + i18n.t('warnings.helpers.get.helperNotAvailable'), + i18n.t('warnings.helpers.get.apiMustBeEnabled'), + i18n.t('warnings.helpers.get.seeLink', {url: 'https://help.ghost.org/hc/en-us/articles/115000301672-Public-API-Beta'}) + ]; + + if (labs.isSet('publicAPI') === true) { + // get helper is active + return get.call(self, resource, options); + } else { + errors.logError.apply(this, errorMessages); + return Promise.resolve(function noGetHelper() { + return ''; + }); + } +}; diff --git a/core/server/helpers/ghost_foot.js b/core/server/helpers/ghost_foot.js new file mode 100644 index 0000000..9c59c11 --- /dev/null +++ b/core/server/helpers/ghost_foot.js @@ -0,0 +1,28 @@ +// # Ghost Foot Helper +// Usage: `{{ghost_foot}}` +// +// Outputs scripts and other assets at the bottom of a Ghost theme +// +// We use the name ghost_foot to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var hbs = require('express-hbs'), + _ = require('lodash'), + filters = require('../filters'), + api = require('../api'), + ghost_foot; + +ghost_foot = function (options) { + /*jshint unused:false*/ + var foot = []; + + return api.settings.read({key: 'ghost_foot'}).then(function (response) { + foot.push(response.settings[0].value); + return filters.doFilter('ghost_foot', foot); + }).then(function (foot) { + var footString = _.reduce(foot, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(footString.trim()); + }); +}; + +module.exports = ghost_foot; diff --git a/core/server/helpers/ghost_head.js b/core/server/helpers/ghost_head.js new file mode 100644 index 0000000..77e2160 --- /dev/null +++ b/core/server/helpers/ghost_head.js @@ -0,0 +1,152 @@ +// # Ghost Head Helper +// Usage: `{{ghost_head}}` +// +// Outputs scripts and other assets at the top of a Ghost theme +// +// We use the name ghost_head to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var getMetaData = require('../data/meta'), + hbs = require('express-hbs'), + escapeExpression = hbs.handlebars.Utils.escapeExpression, + SafeString = hbs.handlebars.SafeString, + _ = require('lodash'), + filters = require('../filters'), + assetHelper = require('./asset'), + config = require('../config'), + Promise = require('bluebird'), + labs = require('../utils/labs'), + api = require('../api'); + +function getClient() { + if (labs.isSet('publicAPI') === true) { + return api.clients.read({slug: 'ghost-frontend'}).then(function (client) { + client = client.clients[0]; + if (client.status === 'enabled') { + return { + id: client.slug, + secret: client.secret + }; + } + return {}; + }); + } + return Promise.resolve({}); +} + +function writeMetaTag(property, content, type) { + type = type || property.substring(0, 7) === 'twitter' ? 'name' : 'property'; + return ''; +} + +function finaliseStructuredData(metaData) { + var head = []; + _.each(metaData.structuredData, function (content, property) { + if (property === 'article:tag') { + _.each(metaData.keywords, function (keyword) { + if (keyword !== '') { + keyword = escapeExpression(keyword); + head.push(writeMetaTag(property, + escapeExpression(keyword))); + } + }); + head.push(''); + } else if (content !== null && content !== undefined) { + head.push(writeMetaTag(property, + escapeExpression(content))); + } + }); + return head; +} + +function getAjaxHelper(clientId, clientSecret) { + return '\n' + + ''; +} + +function ghost_head(options) { + // if error page do nothing + if (this.statusCode >= 400) { + return; + } + + var metaData, + client, + head = [], + context = this.context ? this.context : null, + useStructuredData = !config.isPrivacyDisabled('useStructuredData'), + safeVersion = this.safeVersion, + referrerPolicy = config.referrerPolicy ? config.referrerPolicy : 'no-referrer-when-downgrade', + fetch = { + metaData: getMetaData(this, options.data.root), + client: getClient() + }; + + return Promise.props(fetch).then(function (response) { + client = response.client; + metaData = response.metaData; + + if (context) { + // head is our main array that holds our meta data + head.push(''); + head.push(''); + + // show amp link in post when 1. we are not on the amp page and 2. amp is enabled + if (_.includes(context, 'post') && !_.includes(context, 'amp') && config.theme.amp) { + head.push(''); + } + + if (metaData.previousUrl) { + head.push(''); + } + + if (metaData.nextUrl) { + head.push(''); + } + + if (!_.includes(context, 'paged') && useStructuredData) { + head.push(''); + head.push.apply(head, finaliseStructuredData(metaData)); + head.push(''); + + if (metaData.schema) { + head.push('\n'); + } + } + + if (client && client.id && client.secret && !_.includes(context, 'amp')) { + head.push(getAjaxHelper(client.id, client.secret)); + } + } + + head.push(''); + head.push(''); + + return api.settings.read({key: 'ghost_head'}); + }).then(function (response) { + // no code injection for amp context!!! + if (!_.includes(context, 'amp')) { + head.push(response.settings[0].value); + } + return filters.doFilter('ghost_head', head); + }).then(function (head) { + return new SafeString(head.join('\n ').trim()); + }); +} + +module.exports = ghost_head; diff --git a/core/server/helpers/has.js b/core/server/helpers/has.js new file mode 100644 index 0000000..3d616eb --- /dev/null +++ b/core/server/helpers/has.js @@ -0,0 +1,57 @@ +// # Has Helper +// Usage: `{{#has tag="video, music"}}`, `{{#has author="sam, pat"}}` +// +// Checks if a post has a particular property + +var _ = require('lodash'), + errors = require('../errors'), + i18n = require('../i18n'), + has; + +has = function (options) { + options = options || {}; + options.hash = options.hash || {}; + + var tags = _.map(this.tags, 'name'), + author = this.author ? this.author.name : null, + tagList = options.hash.tag || false, + authorList = options.hash.author || false, + tagsOk, + authorOk; + + function evaluateTagList(expr, tags) { + return expr.split(',').map(function (v) { + return v.trim(); + }).reduce(function (p, c) { + return p || (_.findIndex(tags, function (item) { + // Escape regex special characters + item = item.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'); + item = new RegExp('^' + item + '$', 'i'); + return item.test(c); + }) !== -1); + }, false); + } + + function evaluateAuthorList(expr, author) { + var authorList = expr.split(',').map(function (v) { + return v.trim().toLocaleLowerCase(); + }); + + return _.includes(authorList, author.toLocaleLowerCase()); + } + + if (!tagList && !authorList) { + errors.logWarn(i18n.t('warnings.helpers.has.invalidAttribute')); + return; + } + + tagsOk = tagList && evaluateTagList(tagList, tags) || false; + authorOk = authorList && evaluateAuthorList(authorList, author) || false; + + if (tagsOk || authorOk) { + return options.fn(this); + } + return options.inverse(this); +}; + +module.exports = has; diff --git a/core/server/helpers/image.js b/core/server/helpers/image.js new file mode 100644 index 0000000..6e57843 --- /dev/null +++ b/core/server/helpers/image.js @@ -0,0 +1,18 @@ + +// Usage: `{{image}}`, `{{image absolute="true"}}` +// +// Returns the URL for the current object scope i.e. If inside a post scope will return image permalink +// `absolute` flag outputs absolute URL, else URL is relative. + +var config = require('../config'), + image; + +image = function (options) { + var absolute = options && options.hash.absolute; + + if (this.image) { + return config.urlFor('image', {image: this.image}, absolute); + } +}; + +module.exports = image; diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js new file mode 100644 index 0000000..0783b05 --- /dev/null +++ b/core/server/helpers/index.js @@ -0,0 +1,134 @@ +var hbs = require('express-hbs'), + Promise = require('bluebird'), + errors = require('../errors'), + utils = require('./utils'), + i18n = require('../i18n'), + coreHelpers = {}, + registerHelpers; + +if (!utils.isProduction) { + hbs.handlebars.logger.level = 0; +} + +coreHelpers.asset = require('./asset'); +coreHelpers.author = require('./author'); +coreHelpers.body_class = require('./body_class'); +coreHelpers.content = require('./content'); +coreHelpers.date = require('./date'); +coreHelpers.encode = require('./encode'); +coreHelpers.excerpt = require('./excerpt'); +coreHelpers.facebook_url = require('./facebook_url'); +coreHelpers.foreach = require('./foreach'); +coreHelpers.get = require('./get'); +coreHelpers.ghost_foot = require('./ghost_foot'); +coreHelpers.ghost_head = require('./ghost_head'); +coreHelpers.image = require('./image'); +coreHelpers.is = require('./is'); +coreHelpers.has = require('./has'); +coreHelpers.meta_description = require('./meta_description'); +coreHelpers.meta_title = require('./meta_title'); +coreHelpers.navigation = require('./navigation'); +coreHelpers.pagination = require('./pagination'); +coreHelpers.plural = require('./plural'); +coreHelpers.post_class = require('./post_class'); +coreHelpers.prev_post = require('./prev_next'); +coreHelpers.next_post = require('./prev_next'); +coreHelpers.tags = require('./tags'); +coreHelpers.title = require('./title'); +coreHelpers.twitter_url = require('./twitter_url'); +coreHelpers.url = require('./url'); + +// Specialist helpers for certain templates +coreHelpers.input_password = require('./input_password'); +coreHelpers.input_email = require('./input_email'); +coreHelpers.page_url = require('./page_url'); +coreHelpers.pageUrl = require('./page_url').deprecated; + +coreHelpers.helperMissing = function (arg) { + if (arguments.length === 2) { + return undefined; + } + errors.logError(i18n.t('warnings.helpers.index.missingHelper', {arg: arg})); +}; + +// Register an async handlebars helper for a given handlebars instance +function registerAsyncHelper(hbs, name, fn) { + hbs.registerAsyncHelper(name, function (context, options, cb) { + // Handle the case where we only get context and cb + if (!cb) { + cb = options; + options = undefined; + } + + // Wrap the function passed in with a when.resolve so it can return either a promise or a value + Promise.resolve(fn.call(this, context, options)).then(function (result) { + cb(result); + }).catch(function (err) { + errors.logAndThrowError(err, 'registerAsyncThemeHelper: ' + name); + }); + }); +} + +// Register a handlebars helper for themes +function registerThemeHelper(name, fn) { + hbs.registerHelper(name, fn); +} + +// Register an async handlebars helper for themes +function registerAsyncThemeHelper(name, fn) { + registerAsyncHelper(hbs, name, fn); +} + +// Register a handlebars helper for admin +function registerAdminHelper(name, fn) { + coreHelpers.adminHbs.registerHelper(name, fn); +} + +registerHelpers = function (adminHbs) { + // Expose hbs instance for admin + coreHelpers.adminHbs = adminHbs; + + // Register theme helpers + registerThemeHelper('asset', coreHelpers.asset); + registerThemeHelper('author', coreHelpers.author); + registerThemeHelper('body_class', coreHelpers.body_class); + registerThemeHelper('content', coreHelpers.content); + registerThemeHelper('date', coreHelpers.date); + registerThemeHelper('encode', coreHelpers.encode); + registerThemeHelper('excerpt', coreHelpers.excerpt); + registerThemeHelper('foreach', coreHelpers.foreach); + registerThemeHelper('has', coreHelpers.has); + registerThemeHelper('is', coreHelpers.is); + registerThemeHelper('image', coreHelpers.image); + registerThemeHelper('input_email', coreHelpers.input_email); + registerThemeHelper('input_password', coreHelpers.input_password); + registerThemeHelper('meta_description', coreHelpers.meta_description); + registerThemeHelper('meta_title', coreHelpers.meta_title); + registerThemeHelper('navigation', coreHelpers.navigation); + registerThemeHelper('page_url', coreHelpers.page_url); + registerThemeHelper('pageUrl', coreHelpers.pageUrl); + registerThemeHelper('pagination', coreHelpers.pagination); + registerThemeHelper('plural', coreHelpers.plural); + registerThemeHelper('post_class', coreHelpers.post_class); + registerThemeHelper('tags', coreHelpers.tags); + registerThemeHelper('title', coreHelpers.title); + registerThemeHelper('twitter_url', coreHelpers.twitter_url); + registerThemeHelper('facebook_url', coreHelpers.facebook_url); + registerThemeHelper('url', coreHelpers.url); + + // Async theme helpers + registerAsyncThemeHelper('ghost_foot', coreHelpers.ghost_foot); + registerAsyncThemeHelper('ghost_head', coreHelpers.ghost_head); + registerAsyncThemeHelper('next_post', coreHelpers.next_post); + registerAsyncThemeHelper('prev_post', coreHelpers.prev_post); + registerAsyncThemeHelper('get', coreHelpers.get); + + // Register admin helpers + registerAdminHelper('asset', coreHelpers.asset); + registerAdminHelper('input_password', coreHelpers.input_password); +}; + +module.exports = coreHelpers; +module.exports.loadCoreHelpers = registerHelpers; +module.exports.registerThemeHelper = registerThemeHelper; +module.exports.registerAsyncThemeHelper = registerAsyncThemeHelper; diff --git a/core/server/helpers/input_email.js b/core/server/helpers/input_email.js new file mode 100644 index 0000000..a6298a1 --- /dev/null +++ b/core/server/helpers/input_email.js @@ -0,0 +1,43 @@ +// # Input Email Helper +// Usage: `{{input_email}}` +// +// Password input used on private.hbs for password-protected blogs +// +// We use the name meta_title to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var hbs = require('express-hbs'), + utils = require('./utils'), + input_email; + +input_email = function (options) { + options = options || {}; + options.hash = options.hash || {}; + + var className = (options.hash.class) ? options.hash.class : 'subscribe-email', + extras = '', + output; + + if (options.hash.autofocus) { + extras += 'autofocus="autofocus"'; + } + + if (options.hash.placeholder) { + extras += ' placeholder="' + options.hash.placeholder + '"'; + } + + if (options.hash.value) { + extras += ' value="' + options.hash.value + '"'; + } + + output = utils.inputTemplate({ + type: 'email', + name: 'email', + className: className, + extras: extras + }); + + return new hbs.handlebars.SafeString(output); +}; + +module.exports = input_email; diff --git a/core/server/helpers/input_password.js b/core/server/helpers/input_password.js new file mode 100644 index 0000000..13d89b7 --- /dev/null +++ b/core/server/helpers/input_password.js @@ -0,0 +1,35 @@ +// # Input Password Helper +// Usage: `{{input_password}}` +// +// Password input used on private.hbs for password-protected blogs +// +// We use the name meta_title to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var hbs = require('express-hbs'), + utils = require('./utils'), + input_password; + +input_password = function (options) { + options = options || {}; + options.hash = options.hash || {}; + + var className = (options.hash.class) ? options.hash.class : 'private-login-password', + extras = 'autofocus="autofocus"', + output; + + if (options.hash.placeholder) { + extras += ' placeholder="' + options.hash.placeholder + '"'; + } + + output = utils.inputTemplate({ + type: 'password', + name: 'password', + className: className, + extras: extras + }); + + return new hbs.handlebars.SafeString(output); +}; + +module.exports = input_password; diff --git a/core/server/helpers/is.js b/core/server/helpers/is.js new file mode 100644 index 0000000..04afd5b --- /dev/null +++ b/core/server/helpers/is.js @@ -0,0 +1,33 @@ +// # Is Helper +// Usage: `{{#is "paged"}}`, `{{#is "index, paged"}}` +// Checks whether we're in a given context. +var _ = require('lodash'), + errors = require('../errors'), + i18n = require('../i18n'), + is; + +is = function (context, options) { + options = options || {}; + + var currentContext = options.data.root.context; + + if (!_.isString(context)) { + errors.logWarn(i18n.t('warnings.helpers.is.invalidAttribute')); + return; + } + + function evaluateContext(expr) { + return expr.split(',').map(function (v) { + return v.trim(); + }).reduce(function (p, c) { + return p || _.includes(currentContext, c); + }, false); + } + + if (evaluateContext(context)) { + return options.fn(this); + } + return options.inverse(this); +}; + +module.exports = is; diff --git a/core/server/helpers/meta_description.js b/core/server/helpers/meta_description.js new file mode 100644 index 0000000..cbcfd26 --- /dev/null +++ b/core/server/helpers/meta_description.js @@ -0,0 +1,17 @@ +// # Meta Description Helper +// Usage: `{{meta_description}}` +// +// Page description used for sharing and SEO +// +// We use the name meta_description to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var getMetaDataDescription = require('../data/meta/description'); + +function meta_description(options) { + options = options || {}; + + return getMetaDataDescription(this, options.data.root) || ''; +} + +module.exports = meta_description; diff --git a/core/server/helpers/meta_title.js b/core/server/helpers/meta_title.js new file mode 100644 index 0000000..75ec228 --- /dev/null +++ b/core/server/helpers/meta_title.js @@ -0,0 +1,15 @@ +// # Meta Title Helper +// Usage: `{{meta_title}}` +// +// Page title used for sharing and SEO +// +// We use the name meta_title to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var getMetaDataTitle = require('../data/meta/title'); + +function meta_title(options) { + return getMetaDataTitle(this, options.data.root); +} + +module.exports = meta_title; diff --git a/core/server/helpers/navigation.js b/core/server/helpers/navigation.js new file mode 100644 index 0000000..04be028 --- /dev/null +++ b/core/server/helpers/navigation.js @@ -0,0 +1,74 @@ +// ### Navigation Helper +// `{{navigation}}` +// Outputs navigation menu of static urls + +var _ = require('lodash'), + hbs = require('express-hbs'), + i18n = require('../i18n'), + + errors = require('../errors'), + template = require('./template'), + navigation; + +navigation = function (options) { + /*jshint unused:false*/ + var navigationData = options.data.blog.navigation, + currentUrl = options.data.root.relativeUrl, + self = this, + output, + data; + + if (!_.isObject(navigationData) || _.isFunction(navigationData)) { + return errors.logAndThrowError(i18n.t('warnings.helpers.navigation.invalidData')); + } + + if (navigationData.filter(function (e) { + return (_.isUndefined(e.label) || _.isUndefined(e.url)); + }).length > 0) { + return errors.logAndThrowError(i18n.t('warnings.helpers.navigation.valuesMustBeDefined')); + } + + // check for non-null string values + if (navigationData.filter(function (e) { + return ((!_.isNull(e.label) && !_.isString(e.label)) || + (!_.isNull(e.url) && !_.isString(e.url))); + }).length > 0) { + return errors.logAndThrowError(i18n.t('warnings.helpers.navigation.valuesMustBeString')); + } + + function _slugify(label) { + return label.toLowerCase().replace(/[^\w ]+/g, '').replace(/ +/g, '-'); + } + + // strips trailing slashes and compares urls + function _isCurrentUrl(href, currentUrl) { + if (!currentUrl) { + return false; + } + + var strippedHref = href.replace(/\/+$/, ''), + strippedCurrentUrl = currentUrl.replace(/\/+$/, ''); + return strippedHref === strippedCurrentUrl; + } + + // {{navigation}} should no-op if no data passed in + if (navigationData.length === 0) { + return new hbs.SafeString(''); + } + + output = navigationData.map(function (e) { + var out = {}; + out.current = _isCurrentUrl(e.url, currentUrl); + out.label = e.label; + out.slug = _slugify(e.label); + out.url = e.url; + out.secure = self.secure; + return out; + }); + + data = _.merge({}, {navigation: output}); + + return template.execute('navigation', data, options); +}; + +module.exports = navigation; diff --git a/core/server/helpers/page_url.js b/core/server/helpers/page_url.js new file mode 100644 index 0000000..f54966a --- /dev/null +++ b/core/server/helpers/page_url.js @@ -0,0 +1,43 @@ +// ### Page URL Helper +// +// *Usage example:* +// `{{page_url 2}}` +// +// Returns the URL for the page specified in the current object +// context. +// +// We use the name page_url to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers +var errors = require('../errors'), + i18n = require('../i18n'), + getPaginatedUrl = require('../data/meta/paginated_url'), + page_url, + pageUrl; + +page_url = function (page, options) { + if (!options) { + options = page; + page = 1; + } + return getPaginatedUrl(page, options.data.root); +}; + +// ### Page URL Helper: DEPRECATED +// +// *Usage example:* +// `{{pageUrl 2}}` +// +// Returns the URL for the page specified in the current object +// context. This helper is deprecated and will be removed in future versions. +// +pageUrl = function (pageNum, options) { + errors.logWarn(i18n.t('warnings.helpers.page_url.isDeprecated')); + + /*jshint unused:false*/ + var self = this; + + return page_url.call(self, pageNum, options); +}; + +module.exports = page_url; +module.exports.deprecated = pageUrl; diff --git a/core/server/helpers/pagination.js b/core/server/helpers/pagination.js new file mode 100644 index 0000000..45e47e3 --- /dev/null +++ b/core/server/helpers/pagination.js @@ -0,0 +1,37 @@ +// ### Pagination Helper +// `{{pagination}}` +// Outputs previous and next buttons, along with info about the current page + +var _ = require('lodash'), + errors = require('../errors'), + template = require('./template'), + i18n = require('../i18n'), + pagination; + +pagination = function (options) { + /*jshint unused:false*/ + if (!_.isObject(this.pagination) || _.isFunction(this.pagination)) { + return errors.logAndThrowError(i18n.t('warnings.helpers.pagination.invalidData')); + } + + if (_.isUndefined(this.pagination.page) || _.isUndefined(this.pagination.pages) || + _.isUndefined(this.pagination.total) || _.isUndefined(this.pagination.limit)) { + return errors.logAndThrowError(i18n.t('warnings.helpers.pagination.valuesMustBeDefined')); + } + + if ((!_.isNull(this.pagination.next) && !_.isNumber(this.pagination.next)) || + (!_.isNull(this.pagination.prev) && !_.isNumber(this.pagination.prev))) { + return errors.logAndThrowError(i18n.t('warnings.helpers.pagination.nextPrevValuesMustBeNumeric')); + } + + if (!_.isNumber(this.pagination.page) || !_.isNumber(this.pagination.pages) || + !_.isNumber(this.pagination.total) || !_.isNumber(this.pagination.limit)) { + return errors.logAndThrowError(i18n.t('warnings.helpers.pagination.valuesMustBeNumeric')); + } + + var data = _.merge({}, this.pagination); + + return template.execute('pagination', data, options); +}; + +module.exports = pagination; diff --git a/core/server/helpers/plural.js b/core/server/helpers/plural.js new file mode 100644 index 0000000..b6e38f4 --- /dev/null +++ b/core/server/helpers/plural.js @@ -0,0 +1,32 @@ +// # Plural Helper +// Usage: `{{plural 0 empty='No posts' singular='% post' plural='% posts'}}` +// +// pluralises strings depending on item count +// +// The 1st argument is the numeric variable which the helper operates on +// The 2nd argument is the string that will be output if the variable's value is 0 +// The 3rd argument is the string that will be output if the variable's value is 1 +// The 4th argument is the string that will be output if the variable's value is 2+ + +var hbs = require('express-hbs'), + errors = require('../errors'), + _ = require('lodash'), + i18n = require('../i18n'), + plural; + +plural = function (number, options) { + if (_.isUndefined(options.hash) || _.isUndefined(options.hash.empty) || + _.isUndefined(options.hash.singular) || _.isUndefined(options.hash.plural)) { + return errors.logAndThrowError(i18n.t('warnings.helpers.plural.valuesMustBeDefined')); + } + + if (number === 0) { + return new hbs.handlebars.SafeString(options.hash.empty.replace('%', number)); + } else if (number === 1) { + return new hbs.handlebars.SafeString(options.hash.singular.replace('%', number)); + } else if (number >= 2) { + return new hbs.handlebars.SafeString(options.hash.plural.replace('%', number)); + } +}; + +module.exports = plural; diff --git a/core/server/helpers/post_class.js b/core/server/helpers/post_class.js new file mode 100644 index 0000000..3d0566c --- /dev/null +++ b/core/server/helpers/post_class.js @@ -0,0 +1,36 @@ +// # Post Class Helper +// Usage: `{{post_class}}` +// +// Output classes for the body element +// +// We use the name body_class to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var hbs = require('express-hbs'), + _ = require('lodash'), + post_class; + +post_class = function (options) { + /*jshint unused:false*/ + var classes = ['post'], + tags = this.post && this.post.tags ? this.post.tags : this.tags || [], + featured = this.post && this.post.featured ? this.post.featured : this.featured || false, + page = this.post && this.post.page ? this.post.page : this.page || false; + + if (tags) { + classes = classes.concat(tags.map(function (tag) { return 'tag-' + tag.slug; })); + } + + if (featured) { + classes.push('featured'); + } + + if (page) { + classes.push('page'); + } + + classes = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, ''); + return new hbs.handlebars.SafeString(classes.trim()); +}; + +module.exports = post_class; diff --git a/core/server/helpers/prev_next.js b/core/server/helpers/prev_next.js new file mode 100644 index 0000000..db17049 --- /dev/null +++ b/core/server/helpers/prev_next.js @@ -0,0 +1,43 @@ +// ### prevNext helper exposes methods for prev_post and next_post - separately defined in helpers index. +// Example usages +// `{{#prev_post}}
    next post{{/next_post}}' + +var api = require('../api'), + schema = require('../data/schema').checks, + Promise = require('bluebird'), + fetch, prevNext; + +fetch = function (apiOptions, options) { + return api.posts.read(apiOptions).then(function (result) { + var related = result.posts[0]; + + if (related.previous) { + return options.fn(related.previous); + } else if (related.next) { + return options.fn(related.next); + } else { + return options.inverse(this); + } + }); +}; + +// If prevNext method is called without valid post data then we must return a promise, if there is valid post data +// then the promise is handled in the api call. + +prevNext = function (options) { + options = options || {}; + + var apiOptions = { + include: options.name === 'prev_post' ? 'previous,previous.author,previous.tags' : 'next,next.author,next.tags' + }; + + if (schema.isPost(this) && this.status === 'published') { + apiOptions.slug = this.slug; + return fetch(apiOptions, options); + } else { + return Promise.resolve(options.inverse(this)); + } +}; + +module.exports = prevNext; diff --git a/core/server/helpers/tags.js b/core/server/helpers/tags.js new file mode 100644 index 0000000..eacecbf --- /dev/null +++ b/core/server/helpers/tags.js @@ -0,0 +1,70 @@ +// # Tags Helper +// Usage: `{{tags}}`, `{{tags separator=' - '}}` +// +// Returns a string of the tags on the post. +// By default, tags are separated by commas. +// +// Note that the standard {{#each tags}} implementation is unaffected by this helper + +var hbs = require('express-hbs'), + _ = require('lodash'), + config = require('../config'), + labs = require('../utils/labs'), + utils = require('./utils'), + tags; + +tags = function (options) { + options = options || {}; + options.hash = options.hash || {}; + + var autolink = !(_.isString(options.hash.autolink) && options.hash.autolink === 'false'), + separator = _.isString(options.hash.separator) ? options.hash.separator : ', ', + prefix = _.isString(options.hash.prefix) ? options.hash.prefix : '', + suffix = _.isString(options.hash.suffix) ? options.hash.suffix : '', + limit = options.hash.limit ? parseInt(options.hash.limit, 10) : undefined, + from = options.hash.from ? parseInt(options.hash.from, 10) : 1, + to = options.hash.to ? parseInt(options.hash.to, 10) : undefined, + visibility = utils.parseVisibility(options), + output = ''; + + function createTagList(tags) { + return _.reduce(tags, function (tagArray, tag) { + // If labs.internalTags is set && visibility is not set to all + // Then, if tag has a visibility property, and that visibility property is also not explicitly allowed, skip tag + // or if there is no visibility property, and options.hash.visibility was set, skip tag + if (labs.isSet('internalTags') && !_.includes(visibility, 'all')) { + if ( + (tag.visibility && !_.includes(visibility, tag.visibility) && !_.includes(visibility, 'all')) || + (!!options.hash.visibility && !_.includes(visibility, 'all') && !tag.visibility) + ) { + // Skip this tag + return tagArray; + } + } + + var tagOutput = autolink ? utils.linkTemplate({ + url: config.urlFor('tag', {tag: tag}), + text: _.escape(tag.name) + }) : _.escape(tag.name); + + tagArray.push(tagOutput); + + return tagArray; + }, []); + } + + if (this.tags && this.tags.length) { + output = createTagList(this.tags); + from -= 1; // From uses 1-indexed, but array uses 0-indexed. + to = to || limit + from || output.length; + output = output.slice(from, to).join(separator); + } + + if (output) { + output = prefix + output + suffix; + } + + return new hbs.handlebars.SafeString(output); +}; + +module.exports = tags; diff --git a/core/server/helpers/template.js b/core/server/helpers/template.js new file mode 100644 index 0000000..7f79d23 --- /dev/null +++ b/core/server/helpers/template.js @@ -0,0 +1,26 @@ +var templates = {}, + hbs = require('express-hbs'), + errors = require('../errors'), + i18n = require('../i18n'); + +// ## Template utils + +// Execute a template helper +// All template helpers are register as partial view. +templates.execute = function (name, context, options) { + var partial = hbs.handlebars.partials[name]; + + if (partial === undefined) { + errors.logAndThrowError(i18n.t('warnings.helpers.template.templateNotFound', {name: name})); + return; + } + + // If the partial view is not compiled, it compiles and saves in handlebars + if (typeof partial === 'string') { + hbs.registerPartial(partial); + } + + return new hbs.handlebars.SafeString(partial(context, options)); +}; + +module.exports = templates; diff --git a/core/server/helpers/title.js b/core/server/helpers/title.js new file mode 100644 index 0000000..adf42a3 --- /dev/null +++ b/core/server/helpers/title.js @@ -0,0 +1,13 @@ +// # Title Helper +// Usage: `{{title}}` +// +// Overrides the standard behaviour of `{[title}}` to ensure the content is correctly escaped + +var hbs = require('express-hbs'), + title; + +title = function () { + return new hbs.handlebars.SafeString(hbs.handlebars.Utils.escapeExpression(this.title || '')); +}; + +module.exports = title; diff --git a/core/server/helpers/tpl/navigation.hbs b/core/server/helpers/tpl/navigation.hbs new file mode 100644 index 0000000..1d30269 --- /dev/null +++ b/core/server/helpers/tpl/navigation.hbs @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/core/server/helpers/tpl/pagination.hbs b/core/server/helpers/tpl/pagination.hbs new file mode 100644 index 0000000..cd63031 --- /dev/null +++ b/core/server/helpers/tpl/pagination.hbs @@ -0,0 +1,9 @@ + diff --git a/core/server/helpers/tpl/subscribe_form.hbs b/core/server/helpers/tpl/subscribe_form.hbs new file mode 100644 index 0000000..ce32b66 --- /dev/null +++ b/core/server/helpers/tpl/subscribe_form.hbs @@ -0,0 +1,15 @@ +
    + {{! This is required for the form to work correctly }} + {{hidden}} + +
    + {{input_email class=input_class placeholder=placeholder value=email autofocus=autofocus}} +
    + + {{! This is used to get extra info about where this subscriber came from }} + {{script}} +
    + +{{#if error}} +

    {{{error.message}}}

    +{{/if}} diff --git a/core/server/helpers/twitter_url.js b/core/server/helpers/twitter_url.js new file mode 100644 index 0000000..38b381e --- /dev/null +++ b/core/server/helpers/twitter_url.js @@ -0,0 +1,26 @@ +// # Twitter URL Helper +// Usage: `{{twitter_url}}` or `{{twitter_url author.twitter}}` +// +// Output a url for a twitter username +// +// We use the name twitter_url to match the helper for consistency: +// jscs:disable requireCamelCaseOrUpperCaseIdentifiers + +var socialUrls = require('../utils/social-urls'), + findKey = require('./utils').findKey, + twitter_url; + +twitter_url = function twitter_url(username, options) { + if (!options) { + options = username; + username = findKey('twitter', this, options.data.blog); + } + + if (username) { + return socialUrls.twitterUrl(username); + } + + return null; +}; + +module.exports = twitter_url; diff --git a/core/server/helpers/url.js b/core/server/helpers/url.js new file mode 100644 index 0000000..ae9bc75 --- /dev/null +++ b/core/server/helpers/url.js @@ -0,0 +1,19 @@ +// # URL helper +// Usage: `{{url}}`, `{{url absolute="true"}}` +// +// Returns the URL for the current object scope i.e. If inside a post scope will return post permalink +// `absolute` flag outputs absolute URL, else URL is relative + +var hbs = require('express-hbs'), + getMetaDataUrl = require('../data/meta/url'); + +function url(options) { + var absolute = options && options.hash.absolute, + url = getMetaDataUrl(this, absolute); + + url = encodeURI(decodeURI(url)); + + return new hbs.SafeString(url); +} + +module.exports = url; diff --git a/core/server/helpers/utils.js b/core/server/helpers/utils.js new file mode 100644 index 0000000..6fbc42f --- /dev/null +++ b/core/server/helpers/utils.js @@ -0,0 +1,31 @@ +var _ = require('lodash'), + utils; + +utils = { + assetTemplate: _.template('<%= source %>?v=<%= version %>'), + linkTemplate: _.template('<%= text %>'), + scriptTemplate: _.template(''), + inputTemplate: _.template(' />'), + isProduction: process.env.NODE_ENV === 'production', + // @TODO this can probably be made more generic and used in more places + findKey: function findKey(key, object, data) { + if (object && _.has(object, key) && !_.isEmpty(object[key])) { + return object[key]; + } + + if (data && _.has(data, key) && !_.isEmpty(data[key])) { + return data[key]; + } + + return null; + }, + parseVisibility: function parseVisibility(options) { + if (!options.hash.visibility) { + return ['public']; + } + + return _.map(options.hash.visibility.split(','), _.trim); + } +}; + +module.exports = utils; diff --git a/core/server/i18n.js b/core/server/i18n.js new file mode 100644 index 0000000..13268ce --- /dev/null +++ b/core/server/i18n.js @@ -0,0 +1,120 @@ +/* global Intl */ + +var supportedLocales = ['en'], + _ = require('lodash'), + fs = require('fs'), + chalk = require('chalk'), + MessageFormat = require('intl-messageformat'), + + // TODO: fetch this dynamically based on overall blog settings (`key = "defaultLang"` in the `settings` table + currentLocale = 'en', + blos, + I18n; + +I18n = { + + /** + * Helper method to find and compile the given data context with a proper string resource. + * + * @param {string} path Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt") + * @param {object} [bindings] + * @returns {string} + */ + t: function t(path, bindings) { + var string = I18n.findString(path), + msg; + + // If the path returns an array (as in the case with anything that has multiple paragraphs such as emails), then + // loop through them and return an array of translated/formatted strings. Otherwise, just return the normal + // translated/formatted string. + if (_.isArray(string)) { + msg = []; + string.forEach(function (s) { + var m = new MessageFormat(s, currentLocale); + + msg.push(m.format(bindings)); + }); + } else { + msg = new MessageFormat(string, currentLocale); + msg = msg.format(bindings); + } + + return msg; + }, + + /** + * Parse JSON file for matching locale, returns string giving path. + * + * @param {string} msgPath Path with in the JSON language file to desired string (ie: "errors.init.jsNotBuilt") + * @returns {string} + */ + findString: function findString(msgPath) { + var matchingString, path; + // no path? no string + if (_.isEmpty(msgPath) || !_.isString(msgPath)) { + chalk.yellow('i18n:t() - received an empty path.'); + return ''; + } + + if (blos === undefined) { + I18n.init(); + } + + matchingString = blos; + + path = msgPath.split('.'); + path.forEach(function (key) { + // reassign matching object, or set to an empty string if there is no match + matchingString = matchingString[key] || null; + }); + + if (_.isNull(matchingString)) { + console.error('Unable to find matching path [' + msgPath + '] in locale file.\n'); + matchingString = 'i18n error: path "' + msgPath + '" was not found.'; + } + + return matchingString; + }, + + /** + * Setup i18n support: + * - Load proper language file in to memory + * - Polyfill node.js if it does not have Intl support or support for a particular locale + */ + init: function init() { + // read file for current locale and keep its content in memory + blos = fs.readFileSync(__dirname + '/translations/' + currentLocale + '.json'); + + // if translation file is not valid, you will see an error + try { + blos = JSON.parse(blos); + } catch (err) { + blos = undefined; + throw err; + } + + if (global.Intl) { + // Determine if the built-in `Intl` has the locale data we need. + var hasBuiltInLocaleData, + IntlPolyfill; + + hasBuiltInLocaleData = supportedLocales.every(function (locale) { + return Intl.NumberFormat.supportedLocalesOf(locale)[0] === locale && + Intl.DateTimeFormat.supportedLocalesOf(locale)[0] === locale; + }); + + if (!hasBuiltInLocaleData) { + // `Intl` exists, but it doesn't have the data we need, so load the + // polyfill and replace the constructors with need with the polyfill's. + IntlPolyfill = require('intl'); + Intl.NumberFormat = IntlPolyfill.NumberFormat; + Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat; + } + } else { + // No `Intl`, so use and load the polyfill. + global.Intl = require('intl'); + } + } +}; + +module.exports = I18n; diff --git a/core/server/index.js b/core/server/index.js new file mode 100644 index 0000000..913e00b --- /dev/null +++ b/core/server/index.js @@ -0,0 +1,204 @@ +// # Bootup +// This file needs serious love & refactoring + +/** + * make sure overrides get's called first! + * - keeping the overrides require here works for installing Ghost as npm! + * + * the call order is the following: + * - root index requires core module + * - core index requires server + * - overrides is the first package to load + */ +require('./overrides'); + +// Module dependencies +var express = require('express'), + _ = require('lodash'), + uuid = require('uuid'), + Promise = require('bluebird'), + i18n = require('./i18n'), + api = require('./api'), + config = require('./config'), + errors = require('./errors'), + middleware = require('./middleware'), + migrations = require('./data/migration'), + versioning = require('./data/schema/versioning'), + models = require('./models'), + permissions = require('./permissions'), + apps = require('./apps'), + xmlrpc = require('./data/xml/xmlrpc'), + slack = require('./data/slack'), + GhostServer = require('./ghost-server'), + scheduling = require('./scheduling'), + dbHash; + +function initDbHashAndFirstRun() { + return api.settings.read({key: 'dbHash', context: {internal: true}}).then(function (response) { + var hash = response.settings[0].value, + initHash; + + dbHash = hash; + + if (dbHash === null) { + initHash = uuid.v4(); + return api.settings.edit({settings: [{key: 'dbHash', value: initHash}]}, {context: {internal: true}}) + .then(function (response) { + dbHash = response.settings[0].value; + return dbHash; + // Use `then` here to do 'first run' actions + }); + } + + return dbHash; + }); +} + +// ## Initialise Ghost +// Sets up the express server instances, runs init on a bunch of stuff, configures views, helpers, routes and more +// Finally it returns an instance of GhostServer +function init(options) { + options = options || {}; + + var ghostServer = null, settingsMigrations, currentDatabaseVersion; + + // ### Initialisation + // The server and its dependencies require a populated config + // It returns a promise that is resolved when the application + // has finished starting up. + + // Initialize Internationalization + i18n.init(); + + // Load our config.js file from the local file system. + return config.load(options.config).then(function () { + return config.checkDeprecated(); + }).then(function () { + // Load models, no need to wait + models.init(); + + /** + * fresh install: + * - getDatabaseVersion will throw an error and we will create all tables (including populating settings) + * - this will run in one single transaction to avoid having problems with non existent settings + * - see https://github.com/TryGhost/Ghost/issues/7345 + */ + return versioning.getDatabaseVersion() + .then(function () { + /** + * No fresh install: + * - every time Ghost starts, we populate the default settings before we run migrations + * - important, because it can happen that a new added default property won't be existent + */ + return models.Settings.populateDefaults(); + }) + .catch(function (err) { + if (err instanceof errors.DatabaseNotPopulated) { + return migrations.populate(); + } + + return Promise.reject(err); + }); + }).then(function () { + /** + * a little bit of duplicated code, but: + * - ensure now we load the current database version and remember + */ + return versioning.getDatabaseVersion() + .then(function (_currentDatabaseVersion) { + currentDatabaseVersion = _currentDatabaseVersion; + }); + }).then(function () { + // ATTENTION: + // this piece of code was only invented for https://github.com/TryGhost/Ghost/issues/7351#issuecomment-250414759 + if (currentDatabaseVersion !== '008') { + return; + } + + if (config.database.client !== 'sqlite3') { + return; + } + + return models.Settings.findOne({key: 'migrations'}, options) + .then(function fetchedMigrationsSettings(result) { + try { + settingsMigrations = JSON.parse(result.attributes.value) || {}; + } catch (err) { + return; + } + + if (settingsMigrations.hasOwnProperty('006/01')) { + return; + } + + // force them to re-run 008, because we have fixed the date fixture migration + currentDatabaseVersion = '007'; + return versioning.setDatabaseVersion(null, '007'); + }); + }).then(function () { + var response = migrations.update.isDatabaseOutOfDate({ + fromVersion: currentDatabaseVersion, + toVersion: versioning.getNewestDatabaseVersion(), + forceMigration: process.env.FORCE_MIGRATION + }), maintenanceState; + + if (response.migrate === true) { + maintenanceState = config.maintenance.enabled || false; + config.maintenance.enabled = true; + + migrations.update.execute({ + fromVersion: currentDatabaseVersion, + toVersion: versioning.getNewestDatabaseVersion(), + forceMigration: process.env.FORCE_MIGRATION + }).then(function () { + config.maintenance.enabled = maintenanceState; + }).catch(function (err) { + if (!err) { + return; + } + + errors.logErrorAndExit(err, err.context, err.help); + }); + } else if (response.error) { + return Promise.reject(response.error); + } + }).then(function () { + // Initialize the permissions actions and objects + // NOTE: Must be done before initDbHashAndFirstRun calls + return permissions.init(); + }).then(function () { + // Initialize the settings cache now, + // This is an optimisation, so that further reads from settings are fast. + // We do also do this after boot + return api.init(); + }).then(function () { + return Promise.join( + // Check for or initialise a dbHash. + initDbHashAndFirstRun(), + // Initialize apps + apps.init(), + // Initialize xmrpc ping + xmlrpc.listen(), + // Initialize slack ping + slack.listen() + ); + }).then(function () { + // Get reference to an express app instance. + var parentApp = express(); + + // ## Middleware and Routing + middleware(parentApp); + + return new GhostServer(parentApp); + }).then(function (_ghostServer) { + ghostServer = _ghostServer; + + // scheduling can trigger api requests, that's why we initialize the module after the ghost server creation + // scheduling module can create x schedulers with different adapters + return scheduling.init(_.extend(config.scheduling, {apiUrl: config.apiUrl()})); + }).then(function () { + return ghostServer; + }); +} + +module.exports = init; diff --git a/core/server/mail/GhostMailer.js b/core/server/mail/GhostMailer.js new file mode 100644 index 0000000..55f8806 --- /dev/null +++ b/core/server/mail/GhostMailer.js @@ -0,0 +1,105 @@ +// # Mail +// Handles sending email for Ghost +var _ = require('lodash'), + Promise = require('bluebird'), + nodemailer = require('nodemailer'), + validator = require('validator'), + config = require('../config'), + i18n = require('../i18n'); + +function GhostMailer() { + var transport = config.mail && config.mail.transport || 'direct', + options = config.mail && _.clone(config.mail.options) || {}; + + this.state = {}; + + this.transport = nodemailer.createTransport(transport, options); + + this.state.usingDirect = transport === 'direct'; +} + +GhostMailer.prototype.from = function () { + var from = config.mail && (config.mail.from || config.mail.fromaddress), + defaultBlogTitle; + + // If we don't have a from address at all + if (!from) { + // Default to ghost@[blog.url] + from = 'ghost@' + this.getDomain(); + } + + // If we do have a from address, and it's just an email + if (validator.isEmail(from)) { + defaultBlogTitle = config.theme.title ? config.theme.title.replace(/"/g, '\\"') : i18n.t('common.mail.title', {domain: this.getDomain()}); + + from = '"' + defaultBlogTitle + '" <' + from + '>'; + } + + return from; +}; + +// Moved it to its own module +GhostMailer.prototype.getDomain = function () { + var domain = config.url.match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i')); + return domain && domain[1]; +}; + +// Sends an email message enforcing `to` (blog owner) and `from` fields +// This assumes that api.settings.read('email') was already done on the API level +GhostMailer.prototype.send = function (message) { + var self = this, + to; + + // important to clone message as we modify it + message = _.clone(message) || {}; + to = message.to || false; + + if (!(message && message.subject && message.html && message.to)) { + return Promise.reject(new Error(i18n.t('errors.mail.incompleteMessageData.error'))); + } + + message = _.extend(message, { + from: self.from(), + to: to, + generateTextFromHTML: true, + encoding: 'base64' + }); + + return new Promise(function (resolve, reject) { + self.transport.sendMail(message, function (error, response) { + if (error) { + return reject(new Error(error)); + } + + if (self.transport.transportType !== 'DIRECT') { + return resolve(response); + } + + response.statusHandler.once('failed', function (data) { + var reason = i18n.t('errors.mail.failedSendingEmail.error'); + + if (data.error && data.error.errno === 'ENOTFOUND') { + reason += i18n.t('errors.mail.noMailServerAtAddress.error', {domain: data.domain}); + } + reason += '.'; + return reject(new Error(reason)); + }); + + response.statusHandler.once('requeue', function (data) { + var errorMessage = i18n.t('errors.mail.messageNotSent.error'); + + if (data.error && data.error.message) { + errorMessage += i18n.t('errors.general.moreInfo', {info: data.error.message}); + } + + return reject(new Error(errorMessage)); + }); + + response.statusHandler.once('sent', function () { + return resolve(i18n.t('notices.mail.messageSent')); + }); + }); + }); +}; + +module.exports = GhostMailer; diff --git a/core/server/mail/README.md b/core/server/mail/README.md new file mode 100644 index 0000000..8ad9fae --- /dev/null +++ b/core/server/mail/README.md @@ -0,0 +1,41 @@ +# Modifying Email Templates + +## Things to keep in mind + +Before you start, here are some limitations you should be aware of: + +> When it comes to email HTML, note that all best practices from web development goes out the window. To make the look consistent, you should: +> - Use table based layouts +> - Use the old-school attribute styling for tables +> - Use ONLY inline-styles, and only very simple such. ``` + + + + + +
    +
    + + + + + + +
    + + + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + <% if (blog.logo) { %> + + + + + + +
    + + + + +
    +
    + + +
    +
    +
    + <% } %> + + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + +
    + + + + +
    + +

    + {{blog.title}} +

    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + +
    + + + + +
    + +

    + {{newsletter.interval}} digest — + {{newsletter.date}}

    + +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + <% if (blog.post[0].picture) { %> + + + + + + +
    + + + + +
    +
    + + Feature Image
    +
    +
    + <% } %> + + + + + + + + +
    + + + + +
    + + + + + <% if (blog.post[0].picture) { %> + + <% } else { %> +
    + <% } %> + + + + + + + + + +
    + + + + +
     
    +
    + + + + +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + +
    + + + + +
    + + +

    + + {{blog.post[0].title}} + +

    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + +
    + + + + +
    + + +

    + {{blog.post[0].text}} +

    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
    + + + + +
    +
    + + Read More + +
    +
    +
    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    +
    +
    + + + + +
     
    +
    + +
    +
    + + + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + <% if (blog.post.length > 1) { %> + + + + + + + + +
    + + + + +
     
    +
    + + + + +
    + +

    + {{blog.post[1].tag}} / {{blog.post[1].author}} +

    + + + + + + +
     
    + +

    + + {{blog.post[1].title}} + +

    + + + + + + +
     
    + +

    + {{blog.post[1].text}} +

    +
    +
    + + + + +
     
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + <% if (blog.post.length > 2) { %> + + + + + + + + +
    + + + + +
     
    +
    + + + + +
    + +

    + {{blog.post[2].tag}} / {{blog.post[2].author}} +

    + + + + + + +
     
    + +

    + + {{blog.post[2].title}} + +

    + + + + + + +
     
    + +

    + {{blog.post[2].text}} +

    +
    +
    + + + + +
     
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + <% if (blog.post.length > 3) { %> + + <% for (var i = 3; i < blog.post.length; i++) { %> + + + + + + + + +
    + + + + +
     
    +
    + + + + +
    + +

    + {{blog.post[i]['tag']}} / {{blog.post[i]['author']}} +

    + + + + + + +
     
    + +

    + + {{blog.post[i]['title']}} + +

    + + + + + + +
     
    + +

    + {{blog.post[i]['text']}} +

    +
    +
    + + + + +
     
    +
    + + + <% if (i < blog.post.length-1) { %> + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + <% } %> + + <% } %> + + + + + + + + + +
    + + + + +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + + + + + + + +
    + + + + +
     
    +
    + + + + +
    + + + + + + +
    + + + + +
    +
    + + Find more on {{blog.title}} +
    +
    +
    +
    +
    + + + + +
     
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    + + +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    +
    +
    + <% } %> + + <% } %> + + <% } %> + + + + + + + + +
    + + + + +
    + +

    + You’re receiving this email because you subscribed to {{newsletter.interval}} emails from {{blog.title}}
    If you’d prefer not to receive these, you can + + unsubscribe instantly + . +

    +
    +
    + + + + + + +
    + + + + +
    + + + + + + +
     
    +
    +
    + + +
    +
    +
    + +
                                                               
    + + diff --git a/core/server/mail/templates/raw/invite-user.html b/core/server/mail/templates/raw/invite-user.html new file mode 100644 index 0000000..45b599d --- /dev/null +++ b/core/server/mail/templates/raw/invite-user.html @@ -0,0 +1,127 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Welcome

    +

    {{blogName}} is using Ghost to publish things on the internet! {{invitedByName}} has invited you to join. Please click on the link below to activate your account.

    +

    Click here to activate your account

    +

    No idea what Ghost is? It's a simple, beautiful platform for running an online blog or publication. Writers, businesses and individuals from all over the world use Ghost to publish their stories and ideas. Find out more.

    +

    If you have trouble activating your {{blogName}} account, you can reach out to {{invitedByName}} on {{invitedByEmail}} for assistance.

    +

    Have fun, and good luck!

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + diff --git a/core/server/mail/templates/raw/reset-password.html b/core/server/mail/templates/raw/reset-password.html new file mode 100644 index 0000000..ae08510 --- /dev/null +++ b/core/server/mail/templates/raw/reset-password.html @@ -0,0 +1,125 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello!

    +

    A request has been made to reset your password on {{ siteUrl }}.

    +

    Please follow the link below to reset your password:

    Click here to reset your password

    +

    Ghost

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + diff --git a/core/server/mail/templates/raw/test.html b/core/server/mail/templates/raw/test.html new file mode 100644 index 0000000..49a8b71 --- /dev/null +++ b/core/server/mail/templates/raw/test.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello there!

    +

    Excellent! + You've successfully setup your email config for your Ghost blog over on {{ siteUrl }}

    +

    If you hadn't, you wouldn't be reading this email, but you are, so it looks like all is well :)

    +

    xoxo

    +

    Team Ghost
    + https://ghost.org

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + diff --git a/core/server/mail/templates/raw/welcome.html b/core/server/mail/templates/raw/welcome.html new file mode 100644 index 0000000..2e90687 --- /dev/null +++ b/core/server/mail/templates/raw/welcome.html @@ -0,0 +1,130 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello!

    +

    Good news! You've successfully created a brand new Ghost blog over on {{ siteUrl }}

    +

    You can log in to your admin account with the following details:

    +

    Email Address: {{ownerEmail}}
    + Password: The password you chose when you signed up

    +

    Keep this email somewhere safe for future reference, and have fun!

    +

    xoxo

    +

    Team Ghost
    + https://ghost.org

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + diff --git a/core/server/mail/templates/reset-password.html b/core/server/mail/templates/reset-password.html new file mode 100644 index 0000000..6b2171c --- /dev/null +++ b/core/server/mail/templates/reset-password.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello!

    +

    A request has been made to reset your password on {{ siteUrl }}.

    +

    Please follow the link below to reset your password:

    Click here to reset your password

    +

    Ghost

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + diff --git a/core/server/mail/templates/test.html b/core/server/mail/templates/test.html new file mode 100644 index 0000000..13ded8e --- /dev/null +++ b/core/server/mail/templates/test.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello there!

    +

    Excellent! + You've successfully setup your email config for your Ghost blog over on {{ siteUrl }}

    +

    If you hadn't, you wouldn't be reading this email, but you are, so it looks like all is well :)

    +

    xoxo

    +

    Team Ghost
    + https://ghost.org

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + + diff --git a/core/server/mail/templates/welcome.html b/core/server/mail/templates/welcome.html new file mode 100644 index 0000000..ca132cf --- /dev/null +++ b/core/server/mail/templates/welcome.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + +
    + + + + + +
    + +
    + + + + +
    + + +

    Hello!

    +

    Good news! You've successfully created a brand new Ghost blog over on {{ siteUrl }}

    +

    You can log in to your admin account with the following details:

    +

    Email Address: {{ownerEmail}}
    + Password: The password you chose when you signed up

    +

    Keep this email somewhere safe for future reference, and have fun!

    +

    xoxo

    +

    Team Ghost
    + https://ghost.org

    + + +
    +
    +
    + + + + + +
    + +
    + +
    + + + + diff --git a/core/server/mail/utils.js b/core/server/mail/utils.js new file mode 100644 index 0000000..e84d4d5 --- /dev/null +++ b/core/server/mail/utils.js @@ -0,0 +1,40 @@ +var _ = require('lodash').runInContext(), + fs = require('fs'), + Promise = require('bluebird'), + path = require('path'), + htmlToText = require('html-to-text'), + config = require('../config'), + templatesDir = path.resolve(__dirname, '..', 'mail', 'templates'); + +_.templateSettings.interpolate = /{{([\s\S]+?)}}/g; + +exports.generateContent = function generateContent(options) { + var defaults, + data; + + defaults = { + siteUrl: config.forceAdminSSL ? (config.urlSSL || config.url) : config.url + }; + + data = _.defaults(defaults, options.data); + + // read the proper email body template + return Promise.promisify(fs.readFile)(path.join(templatesDir, options.template + '.html'), 'utf8') + .then(function (content) { + var compiled, + htmlContent, + textContent; + + // insert user-specific data into the email + compiled = _.template(content); + htmlContent = compiled(data); + + // generate a plain-text version of the same email + textContent = htmlToText.fromString(htmlContent); + + return { + html: htmlContent, + text: textContent + }; + }); +}; diff --git a/core/server/middleware/api/version-match.js b/core/server/middleware/api/version-match.js new file mode 100644 index 0000000..ea668b0 --- /dev/null +++ b/core/server/middleware/api/version-match.js @@ -0,0 +1,20 @@ +var errors = require('../../errors'), + i18n = require('../../i18n'); + +function checkVersionMatch(req, res, next) { + var requestVersion = req.get('X-Ghost-Version'), + currentVersion = res.locals.safeVersion; + + if (requestVersion && requestVersion !== currentVersion) { + return next(new errors.VersionMismatchError( + i18n.t( + 'errors.middleware.api.versionMismatch', + {requestVersion: requestVersion, currentVersion: currentVersion} + ) + )); + } + + next(); +} + +module.exports = checkVersionMatch; diff --git a/core/server/middleware/auth-strategies.js b/core/server/middleware/auth-strategies.js new file mode 100644 index 0000000..5f221f1 --- /dev/null +++ b/core/server/middleware/auth-strategies.js @@ -0,0 +1,61 @@ +var models = require('../models'), + strategies; + +strategies = { + + /** + * ClientPasswordStrategy + * + * This strategy is used to authenticate registered OAuth clients. It is + * employed to protect the `token` endpoint, which consumers use to obtain + * access tokens. The OAuth 2.0 specification suggests that clients use the + * HTTP Basic scheme to authenticate (not implemented yet). + * Use of the client password strategy is implemented to support ember-simple-auth. + */ + clientPasswordStrategy: function clientPasswordStrategy(clientId, clientSecret, done) { + return models.Client.findOne({slug: clientId}, {withRelated: ['trustedDomains']}) + .then(function then(model) { + if (model) { + var client = model.toJSON({include: ['trustedDomains']}); + if (client.status === 'enabled' && client.secret === clientSecret) { + return done(null, client); + } + } + return done(null, false); + }); + }, + + /** + * BearerStrategy + * + * This strategy is used to authenticate users based on an access token (aka a + * bearer token). The user must have previously authorized a client + * application, which is issued an access token to make requests on behalf of + * the authorizing user. + */ + bearerStrategy: function bearerStrategy(accessToken, done) { + return models.Accesstoken.findOne({token: accessToken}) + .then(function then(model) { + if (model) { + var token = model.toJSON(); + if (token.expires > Date.now()) { + return models.User.findOne({id: token.user_id}) + .then(function then(model) { + if (model) { + var user = model.toJSON(), + info = {scope: '*'}; + return done(null, {id: user.id}, info); + } + return done(null, false); + }); + } else { + return done(null, false); + } + } else { + return done(null, false); + } + }); + } +}; + +module.exports = strategies; diff --git a/core/server/middleware/auth.js b/core/server/middleware/auth.js new file mode 100644 index 0000000..4e1a656 --- /dev/null +++ b/core/server/middleware/auth.js @@ -0,0 +1,135 @@ +var passport = require('passport'), + errors = require('../errors'), + events = require('../events'), + labs = require('../utils/labs'), + i18n = require('../i18n'), + + auth; + +function isBearerAutorizationHeader(req) { + var parts, + scheme, + credentials; + + if (req.headers && req.headers.authorization) { + parts = req.headers.authorization.split(' '); + } else if (req.query && req.query.access_token) { + return true; + } else { + return false; + } + + if (parts.length === 2) { + scheme = parts[0]; + credentials = parts[1]; + if (/^Bearer$/i.test(scheme)) { + return true; + } + } + return false; +} + +auth = { + + // ### Authenticate Client Middleware + authenticateClient: function authenticateClient(req, res, next) { + // skip client authentication if bearer token is present + if (isBearerAutorizationHeader(req)) { + return next(); + } + + if (req.query && req.query.client_id) { + req.body.client_id = req.query.client_id; + } + + if (req.query && req.query.client_secret) { + req.body.client_secret = req.query.client_secret; + } + + if (!req.body.client_id || !req.body.client_secret) { + errors.logError( + i18n.t('errors.middleware.auth.clientAuthenticationFailed'), + i18n.t('errors.middleware.auth.clientCredentialsNotProvided'), + i18n.t('errors.middleware.auth.forInformationRead', {url: 'http://api.ghost.org/docs/client-authentication'}) + ); + return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next); + } + + return passport.authenticate(['oauth2-client-password'], {session: false, failWithError: false}, + function authenticate(err, client) { + if (err) { + return next(err); // will generate a 500 error + } + + // req.body needs to be null for GET requests to build options correctly + delete req.body.client_id; + delete req.body.client_secret; + + if (!client) { + errors.logError( + i18n.t('errors.middleware.auth.clientAuthenticationFailed'), + i18n.t('errors.middleware.auth.clientCredentialsNotValid'), + i18n.t('errors.middleware.auth.forInformationRead', {url: 'http://api.ghost.org/docs/client-authentication'}) + ); + return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next); + } + + req.client = client; + + events.emit('client.authenticated', client); + return next(null, client); + } + )(req, res, next); + }, + + // ### Authenticate User Middleware + authenticateUser: function authenticateUser(req, res, next) { + return passport.authenticate('bearer', {session: false, failWithError: false}, + function authenticate(err, user, info) { + if (err) { + return next(err); // will generate a 500 error + } + + if (user) { + req.authInfo = info; + req.user = user; + + events.emit('user.authenticated', user); + return next(null, user, info); + } else if (isBearerAutorizationHeader(req)) { + return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next); + } else if (req.client) { + req.user = {id: 0}; + return next(); + } + + return errors.handleAPIError(new errors.UnauthorizedError(i18n.t('errors.middleware.auth.accessDenied')), req, res, next); + } + )(req, res, next); + }, + + // Workaround for missing permissions + // TODO: rework when https://github.com/TryGhost/Ghost/issues/3911 is done + requiresAuthorizedUser: function requiresAuthorizedUser(req, res, next) { + if (req.user && req.user.id) { + return next(); + } else { + return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next); + } + }, + + // ### Require user depending on public API being activated. + requiresAuthorizedUserPublicAPI: function requiresAuthorizedUserPublicAPI(req, res, next) { + if (labs.isSet('publicAPI') === true) { + return next(); + } else { + if (req.user && req.user.id) { + return next(); + } else { + return errors.handleAPIError(new errors.NoPermissionError(i18n.t('errors.middleware.auth.pleaseSignIn')), req, res, next); + } + } + } +}; + +module.exports = auth; diff --git a/core/server/middleware/cache-control.js b/core/server/middleware/cache-control.js new file mode 100644 index 0000000..1eeabe8 --- /dev/null +++ b/core/server/middleware/cache-control.js @@ -0,0 +1,36 @@ +// # CacheControl Middleware +// Usage: cacheControl(profile), where profile is one of 'public' or 'private' +// After: checkIsPrivate +// Before: routes +// App: Admin|Blog|API +// +// Allows each app to declare its own default caching rules + +var _ = require('lodash'), + cacheControl; + +cacheControl = function cacheControl(options) { + /*jslint unparam:true*/ + var profiles = { + public: 'public, max-age=0', + private: 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' + }, + output; + + if (_.isString(options) && profiles.hasOwnProperty(options)) { + output = profiles[options]; + } + + return function cacheControlHeaders(req, res, next) { + if (output) { + if (res.isPrivateBlog) { + res.set({'Cache-Control': profiles.private}); + } else { + res.set({'Cache-Control': output}); + } + } + next(); + }; +}; + +module.exports = cacheControl; diff --git a/core/server/middleware/check-ssl.js b/core/server/middleware/check-ssl.js new file mode 100644 index 0000000..a79b50f --- /dev/null +++ b/core/server/middleware/check-ssl.js @@ -0,0 +1,60 @@ +var config = require('../config'), + url = require('url'), + checkSSL; + +function isSSLrequired(isAdmin, configUrl, forceAdminSSL) { + var forceSSL = url.parse(configUrl).protocol === 'https:' ? true : false; + if (forceSSL || (isAdmin && forceAdminSSL)) { + return true; + } + return false; +} + +// The guts of checkSSL. Indicate forbidden or redirect according to configuration. +// Required args: forceAdminSSL, url and urlSSL should be passed from config. reqURL from req.url +function sslForbiddenOrRedirect(opt) { + var forceAdminSSL = opt.forceAdminSSL, + reqUrl = url.parse(opt.reqUrl), // expected to be relative-to-root + baseUrl = url.parse(opt.configUrlSSL || opt.configUrl), + response = { + // Check if forceAdminSSL: { redirect: false } is set, which means + // we should just deny non-SSL access rather than redirect + isForbidden: (forceAdminSSL && forceAdminSSL.redirect !== undefined && !forceAdminSSL.redirect), + + redirectUrl: function redirectUrl(query) { + return url.format({ + protocol: 'https:', + hostname: baseUrl.hostname, + port: baseUrl.port, + pathname: reqUrl.pathname, + query: query + }); + } + }; + + return response; +} + +// Check to see if we should use SSL +// and redirect if needed +checkSSL = function checkSSL(req, res, next) { + if (isSSLrequired(res.isAdmin, config.url, config.forceAdminSSL)) { + if (!req.secure) { + var response = sslForbiddenOrRedirect({ + forceAdminSSL: config.forceAdminSSL, + configUrlSSL: config.urlSSL, + configUrl: config.url, + reqUrl: req.originalUrl || req.url + }); + + if (response.isForbidden) { + return res.sendStatus(403); + } else { + return res.redirect(301, response.redirectUrl(req.query)); + } + } + } + next(); +}; + +module.exports = checkSSL; diff --git a/core/server/middleware/cors.js b/core/server/middleware/cors.js new file mode 100644 index 0000000..2ef620e --- /dev/null +++ b/core/server/middleware/cors.js @@ -0,0 +1,84 @@ +var cors = require('cors'), + _ = require('lodash'), + url = require('url'), + os = require('os'), + config = require('../config'), + whitelist = [], + ENABLE_CORS = {origin: true, maxAge: 86400}, + DISABLE_CORS = {origin: false}; + +/** + * Gather a list of local ipv4 addresses + * @return {Array} + */ +function getIPs() { + var ifaces = os.networkInterfaces(), + ips = [ + 'localhost' + ]; + + Object.keys(ifaces).forEach(function (ifname) { + ifaces[ifname].forEach(function (iface) { + // only support IPv4 + if (iface.family !== 'IPv4') { + return; + } + + ips.push(iface.address); + }); + }); + + return ips; +} + +function getUrls() { + var urls = [url.parse(config.url).hostname]; + + if (config.urlSSL) { + urls.push(url.parse(config.urlSSL).hostname); + } + + return urls; +} + +function getWhitelist() { + // This needs doing just one time after init + if (_.isEmpty(whitelist)) { + // origins that always match: localhost, local IPs, etc. + whitelist = whitelist.concat(getIPs()); + // Trusted urls from config.js + whitelist = whitelist.concat(getUrls()); + } + + return whitelist; +} + +/** + * Checks the origin and enables/disables CORS headers in the response. + * @param {Object} req express request object. + * @param {Function} cb callback that configures CORS. + * @return {null} + */ +function handleCORS(req, cb) { + var origin = req.get('origin'), + trustedDomains = req.client && req.client.trustedDomains; + + // Request must have an Origin header + if (!origin) { + return cb(null, DISABLE_CORS); + } + + // Origin matches a client_trusted_domain + if (_.some(trustedDomains, {trusted_domain: origin})) { + return cb(null, ENABLE_CORS); + } + + // Origin matches whitelist + if (getWhitelist().indexOf(url.parse(origin).hostname) > -1) { + return cb(null, ENABLE_CORS); + } + + return cb(null, DISABLE_CORS); +} + +module.exports = cors(handleCORS); diff --git a/core/server/middleware/decide-is-admin.js b/core/server/middleware/decide-is-admin.js new file mode 100644 index 0000000..1e3881f --- /dev/null +++ b/core/server/middleware/decide-is-admin.js @@ -0,0 +1,17 @@ +// # DecideIsAdmin Middleware +// Usage: decideIsAdmin(request, result, next) +// After: +// Before: +// App: Blog +// +// Helper function to determine if its an admin page. + +var decideIsAdmin; + +decideIsAdmin = function decideIsAdmin(req, res, next) { + /*jslint unparam:true*/ + res.isAdmin = req.url.lastIndexOf('/ghost/', 0) === 0; + next(); +}; + +module.exports = decideIsAdmin; diff --git a/core/server/middleware/index.js b/core/server/middleware/index.js new file mode 100644 index 0000000..e961d7a --- /dev/null +++ b/core/server/middleware/index.js @@ -0,0 +1,222 @@ +var bodyParser = require('body-parser'), + compress = require('compression'), + config = require('../config'), + errors = require('../errors'), + express = require('express'), + hbs = require('express-hbs'), + logger = require('morgan'), + path = require('path'), + routes = require('../routes'), + serveStatic = require('express').static, + storage = require('../storage'), + passport = require('passport'), + utils = require('../utils'), + sitemapHandler = require('../data/xml/sitemap/handler'), + multer = require('multer'), + tmpdir = require('os').tmpdir, + authStrategies = require('./auth-strategies'), + auth = require('./auth'), + cacheControl = require('./cache-control'), + checkSSL = require('./check-ssl'), + decideIsAdmin = require('./decide-is-admin'), + oauth = require('./oauth'), + redirectToSetup = require('./redirect-to-setup'), + serveSharedFile = require('./serve-shared-file'), + spamPrevention = require('./spam-prevention'), + prettyUrls = require('./pretty-urls'), + staticTheme = require('./static-theme'), + themeHandler = require('./theme-handler'), + maintenance = require('./maintenance'), + versionMatch = require('./api/version-match'), + cors = require('./cors'), + validation = require('./validation'), + redirects = require('./redirects'), + netjet = require('netjet'), + labs = require('./labs'), + helpers = require('../helpers'), + + ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy, + BearerStrategy = require('passport-http-bearer').Strategy, + + middleware, + setupMiddleware; + +middleware = { + upload: multer({dest: tmpdir()}), + validation: validation, + cacheControl: cacheControl, + spamPrevention: spamPrevention, + oauth: oauth, + api: { + authenticateClient: auth.authenticateClient, + authenticateUser: auth.authenticateUser, + requiresAuthorizedUser: auth.requiresAuthorizedUser, + requiresAuthorizedUserPublicAPI: auth.requiresAuthorizedUserPublicAPI, + errorHandler: errors.handleAPIError, + cors: cors, + prettyUrls: prettyUrls, + labs: labs, + versionMatch: versionMatch, + maintenance: maintenance + } +}; + +setupMiddleware = function setupMiddleware(blogApp) { + var logging = config.logging, + corePath = config.paths.corePath, + adminApp = express(), + adminHbs = hbs.create(); + + // ##Configuration + + // enabled gzip compression by default + if (config.server.compress !== false) { + blogApp.use(compress()); + } + + // ## View engine + // set the view engine + blogApp.set('view engine', 'hbs'); + + // Create a hbs instance for admin and init view engine + adminApp.set('view engine', 'hbs'); + adminApp.engine('hbs', adminHbs.express3({})); + + // Load helpers + helpers.loadCoreHelpers(adminHbs); + + // Initialize Auth Handlers & OAuth middleware + passport.use(new ClientPasswordStrategy(authStrategies.clientPasswordStrategy)); + passport.use(new BearerStrategy(authStrategies.bearerStrategy)); + oauth.init(); + + // Make sure 'req.secure' is valid for proxied requests + // (X-Forwarded-Proto header will be checked, if present) + blogApp.enable('trust proxy'); + + // Logging configuration + if (logging !== false) { + if (blogApp.get('env') !== 'development') { + blogApp.use(logger('combined', logging)); + } else { + blogApp.use(logger('dev', logging)); + } + } + + // Preload link headers + if (config.preloadHeaders) { + blogApp.use(netjet({ + cache: { + max: config.preloadHeaders + } + })); + } + + // you can extend Ghost with a custom redirects file + // see https://github.com/TryGhost/Ghost/issues/7707 + redirects(blogApp); + + // Favicon + blogApp.use(serveSharedFile('favicon.ico', 'image/x-icon', utils.ONE_DAY_S)); + + // Ghost-Url + blogApp.use(serveSharedFile('shared/ghost-url.js', 'application/javascript', utils.ONE_HOUR_S)); + blogApp.use(serveSharedFile('shared/ghost-url.min.js', 'application/javascript', utils.ONE_HOUR_S)); + + // Static assets + blogApp.use('/shared', serveStatic( + path.join(corePath, '/shared'), + {maxAge: utils.ONE_HOUR_MS, fallthrough: false} + )); + blogApp.use('/content/images', storage.getStorage().serve()); + blogApp.use('/public', serveStatic( + path.join(corePath, '/built/public'), + {maxAge: utils.ONE_YEAR_MS, fallthrough: false} + )); + + // First determine whether we're serving admin or theme content + blogApp.use(decideIsAdmin); + blogApp.use(themeHandler.updateActiveTheme); + blogApp.use(themeHandler.configHbsForContext); + + // Admin only config + blogApp.use('/ghost', serveStatic( + config.paths.clientAssets, + {maxAge: utils.ONE_YEAR_MS} + )); + + // Force SSL + // NOTE: Importantly this is _after_ the check above for admin-theme static resources, + // which do not need HTTPS. In fact, if HTTPS is forced on them, then 404 page might + // not display properly when HTTPS is not available! + blogApp.use(checkSSL); + adminApp.set('views', config.paths.adminViews); + + // Theme only config + blogApp.use(staticTheme()); + + // setup middleware for internal apps + // @TODO: refactor this to be a proper app middleware hook for internal & external apps + config.internalApps.forEach(function (appName) { + var app = require(path.join(config.paths.internalAppPath, appName)); + if (app.hasOwnProperty('setupMiddleware')) { + app.setupMiddleware(blogApp); + } + }); + + // Serve sitemap.xsl file + blogApp.use(serveSharedFile('sitemap.xsl', 'text/xsl', utils.ONE_DAY_S)); + + // Serve robots.txt if not found in theme + blogApp.use(serveSharedFile('robots.txt', 'text/plain', utils.ONE_HOUR_S)); + + // site map + sitemapHandler(blogApp); + + // Body parsing + blogApp.use(bodyParser.json({limit: '1mb'})); + blogApp.use(bodyParser.urlencoded({extended: true, limit: '1mb'})); + + blogApp.use(passport.initialize()); + + // ### Caching + // Blog frontend is cacheable + blogApp.use(cacheControl('public')); + // Admin shouldn't be cached + adminApp.use(cacheControl('private')); + // API shouldn't be cached + blogApp.use(routes.apiBaseUri, cacheControl('private')); + + // local data + blogApp.use(themeHandler.ghostLocals); + + // ### Routing + // Set up API routes + blogApp.use(routes.apiBaseUri, routes.api(middleware)); + + blogApp.use(prettyUrls); + + // Mount admin express app to /ghost and set up routes + adminApp.use(redirectToSetup); + adminApp.use(maintenance); + adminApp.use(routes.admin()); + + blogApp.use('/ghost', adminApp); + + // send 503 error page in case of maintenance + blogApp.use(maintenance); + + // Set up Frontend routes (including private blogging routes) + blogApp.use(routes.frontend()); + + // ### Error handling + // 404 Handler + blogApp.use(errors.error404); + + // 500 Handler + blogApp.use(errors.error500); +}; + +module.exports = setupMiddleware; +// Export middleware functions directly +module.exports.middleware = middleware; diff --git a/core/server/middleware/labs.js b/core/server/middleware/labs.js new file mode 100644 index 0000000..28bb708 --- /dev/null +++ b/core/server/middleware/labs.js @@ -0,0 +1,15 @@ +var errors = require('../errors'), + labsUtil = require('../utils/labs'), + labs; + +labs = { + subscribers: function subscribers(req, res, next) { + if (labsUtil.isSet('subscribers') === true) { + return next(); + } else { + return errors.handleAPIError(new errors.NotFoundError(), req, res, next); + } + } +}; + +module.exports = labs; diff --git a/core/server/middleware/maintenance.js b/core/server/middleware/maintenance.js new file mode 100644 index 0000000..4462fc6 --- /dev/null +++ b/core/server/middleware/maintenance.js @@ -0,0 +1,13 @@ +var config = require('../config'), + i18n = require('../i18n'), + errors = require('../errors'); + +module.exports = function (req, res, next) { + if (config.maintenance.enabled) { + return next(new errors.Maintenance( + i18n.t('errors.general.maintenance') + )); + } + + next(); +}; diff --git a/core/server/middleware/oauth.js b/core/server/middleware/oauth.js new file mode 100644 index 0000000..30e04ff --- /dev/null +++ b/core/server/middleware/oauth.js @@ -0,0 +1,147 @@ +var oauth2orize = require('oauth2orize'), + models = require('../models'), + utils = require('../utils'), + errors = require('../errors'), + spamPrevention = require('./spam-prevention'), + i18n = require('../i18n'), + + oauthServer, + oauth; + +function getBearerAutorizationHeader(req) { + var parts, + scheme, + token; + + if (req.headers && req.headers.authorization) { + parts = req.headers.authorization.split(' '); + scheme = parts[0]; + + if (/^Bearer$/i.test(scheme)) { + token = parts[1]; + } + } else if (req.query && req.query.access_token) { + token = req.query.access_token; + } + + return token; +} + +function exchangeRefreshToken(client, refreshToken, scope, body, authInfo, done) { + models.Refreshtoken.findOne({token: refreshToken}).then(function then(model) { + if (!model) { + return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidRefreshToken')), false); + } else { + var token = model.toJSON(), + accessToken = utils.uid(256), + accessExpires = Date.now() + utils.ONE_MONTH_MS, + refreshExpires = Date.now() + utils.SIX_MONTH_MS; + + if (token.expires > Date.now()) { + // Ember auto refreshes the tokens to avoid auto logout + // That's why we have to decrease the expiry of the old access token + // On restart, all expired tokens get deleted + // We can't just delete the old token, because client credentials can be used in multiple tabs/apps + // It's possible that no old access token is send by the client implementation (e.g. none ghost admin client) + models.Accesstoken.findOne({token: authInfo.accessToken || ''}) + .then(function (oldAccessToken) { + if (!oldAccessToken) { + return; + } + + return models.Accesstoken.edit({ + expires: Date.now() + utils.FIVE_MINUTES_MS + }, {id: oldAccessToken.id}); + }) + .then(function () { + return models.Accesstoken.add({ + token: accessToken, + user_id: token.user_id, + client_id: token.client_id, + expires: accessExpires + }); + }) + .then(function then() { + return models.Refreshtoken.edit({expires: refreshExpires}, {id: token.id}); + }) + .then(function then() { + return done(null, accessToken, {expires_in: utils.ONE_MONTH_S}); + }) + .catch(function handleError(error) { + return done(error, false); + }); + } else { + done(new errors.UnauthorizedError(i18n.t('errors.middleware.oauth.refreshTokenExpired')), false); + } + } + }); +} + +function exchangePassword(client, username, password, scope, done) { + // Validate the client + models.Client.findOne({slug: client.slug}).then(function then(client) { + if (!client) { + return done(new errors.NoPermissionError(i18n.t('errors.middleware.oauth.invalidClient')), false); + } + // Validate the user + return models.User.check({email: username, password: password}).then(function then(user) { + // Everything validated, return the access- and refreshtoken + var accessToken = utils.uid(256), + refreshToken = utils.uid(256), + accessExpires = Date.now() + utils.ONE_HOUR_MS, + refreshExpires = Date.now() + utils.ONE_WEEK_MS; + + return models.Accesstoken.add( + {token: accessToken, user_id: user.id, client_id: client.id, expires: accessExpires} + ).then(function then() { + return models.Refreshtoken.add( + {token: refreshToken, user_id: user.id, client_id: client.id, expires: refreshExpires} + ); + }).then(function then() { + spamPrevention.resetCounter(username); + return done(null, accessToken, refreshToken, {expires_in: utils.ONE_HOUR_S}); + }); + }).catch(function handleError(error) { + return done(error, false); + }); + }); +} + +oauth = { + + init: function init() { + oauthServer = oauth2orize.createServer(); + // remove all expired accesstokens on startup + models.Accesstoken.destroyAllExpired(); + + // remove all expired refreshtokens on startup + models.Refreshtoken.destroyAllExpired(); + + // Exchange user id and password for access tokens. The callback accepts the + // `client`, which is exchanging the user's name and password from the + // authorization request for verification. If these values are validated, the + // application issues an access token on behalf of the user who authorized the code. + oauthServer.exchange(oauth2orize.exchange.password({userProperty: 'client'}, + exchangePassword)); + + // Exchange the refresh token to obtain an access token. The callback accepts the + // `client`, which is exchanging a `refreshToken` previously issued by the server + // for verification. If these values are validated, the application issues an + // access token on behalf of the user who authorized the code. + oauthServer.exchange(oauth2orize.exchange.refreshToken({userProperty: 'client'}, + exchangeRefreshToken)); + }, + + // ### Generate access token Middleware + // register the oauth2orize middleware for password and refresh token grants + generateAccessToken: function generateAccessToken(req, res, next) { + // @TODO: see https://github.com/jaredhanson/oauth2orize/issues/182 + req.authInfo = { + accessToken: getBearerAutorizationHeader(req) + }; + + return oauthServer.token()(req, res, next); + } +}; + +module.exports = oauth; diff --git a/core/server/middleware/pretty-urls.js b/core/server/middleware/pretty-urls.js new file mode 100644 index 0000000..07675dd --- /dev/null +++ b/core/server/middleware/pretty-urls.js @@ -0,0 +1,11 @@ +var slashes = require('connect-slashes'), + utils = require('../utils'); + +module.exports = [ + slashes(true, { + headers: { + 'Cache-Control': 'public, max-age=' + utils.ONE_YEAR_S + } + }), + require('./uncapitalise') +]; diff --git a/core/server/middleware/redirect-to-setup.js b/core/server/middleware/redirect-to-setup.js new file mode 100644 index 0000000..43fbf84 --- /dev/null +++ b/core/server/middleware/redirect-to-setup.js @@ -0,0 +1,16 @@ +var api = require('../api'), + config = require('../config'); + +// Redirect to setup if no user exists +function redirectToSetup(req, res, next) { + api.authentication.isSetup().then(function then(exists) { + if (!exists.setup[0].status && !req.path.match(/\/setup\//)) { + return res.redirect(config.paths.subdir + '/ghost/setup/'); + } + next(); + }).catch(function handleError(err) { + return next(new Error(err)); + }); +} + +module.exports = redirectToSetup; diff --git a/core/server/middleware/redirects.js b/core/server/middleware/redirects.js new file mode 100644 index 0000000..25a1ec7 --- /dev/null +++ b/core/server/middleware/redirects.js @@ -0,0 +1,52 @@ +var fs = require('fs-extra'), + _ = require('lodash'), + config = require('../config'), + errors = require('../errors'), + utils = require('../utils'); + +/** + * you can extend Ghost with a custom redirects file + * see https://github.com/TryGhost/Ghost/issues/7707 + * file loads synchronously, because we need to register the routes before anything else + */ +module.exports = function redirects(blogApp) { + try { + var redirects = fs.readFileSync(config.paths.dataPath + '/redirects.json', 'utf-8'); + redirects = JSON.parse(redirects); + + _.each(redirects, function (redirect) { + if (!redirect.from || !redirect.to) { + errors.logError(null, 'Your redirects.json file is in a wrong format'); + return; + } + + /** + * always delete trailing slashes, doesn't matter if regex or not + * Example: + * - you define /my-blog-post-1/ as from property + * - /my-blog-post-1 or /my-blog-post-1/ should work + */ + if (redirect.from.match(/\/$/)) { + redirect.from = redirect.from.slice(0, -1); + } + + if (redirect.from[redirect.from.length - 1] !== '$') { + redirect.from += '\/?$'; + } + + blogApp.get(new RegExp(redirect.from), function (req, res) { + var maxAge = redirect.permanent ? utils.ONE_YEAR_S : 0; + + res.set({ + 'Cache-Control': 'public, max-age=' + maxAge + }); + + res.redirect(redirect.permanent ? 301 : 302, req.originalUrl.replace(new RegExp(redirect.from), redirect.to)); + }); + }); + } catch (err) { + if (err.code !== 'ENOENT') { + errors.logAndThrowError(err, 'Your redirects.json is broken.', 'Check if your JSON is valid.'); + } + } +}; diff --git a/core/server/middleware/serve-shared-file.js b/core/server/middleware/serve-shared-file.js new file mode 100644 index 0000000..3232bd3 --- /dev/null +++ b/core/server/middleware/serve-shared-file.js @@ -0,0 +1,51 @@ +var crypto = require('crypto'), + fs = require('fs'), + path = require('path'), + config = require('../config'); + +// ### ServeSharedFile Middleware +// Handles requests to robots.txt and favicon.ico (and caches them) +function serveSharedFile(file, type, maxAge) { + var content, + corePath = config.paths.corePath, + filePath, + blogRegex = /(\{\{blog-url\}\})/g, + apiRegex = /(\{\{api-url\}\})/g; + + filePath = file.match(/^shared/) ? path.join(corePath, file) : path.join(corePath, 'shared', file); + + return function serveSharedFile(req, res, next) { + if (req.path === '/' + file) { + if (content) { + res.writeHead(200, content.headers); + res.end(content.body); + } else { + fs.readFile(filePath, function readFile(err, buf) { + if (err) { + return next(err); + } + + if (type === 'text/xsl' || type === 'text/plain' || type === 'application/javascript') { + buf = buf.toString().replace(blogRegex, config.url.replace(/\/$/, '')); + buf = buf.toString().replace(apiRegex, config.apiUrl({cors: true})); + } + content = { + headers: { + 'Content-Type': type, + 'Content-Length': buf.length, + ETag: '"' + crypto.createHash('md5').update(buf, 'utf8').digest('hex') + '"', + 'Cache-Control': 'public, max-age=' + maxAge + }, + body: buf + }; + res.writeHead(200, content.headers); + res.end(content.body); + }); + } + } else { + next(); + } + }; +} + +module.exports = serveSharedFile; diff --git a/core/server/middleware/spam-prevention.js b/core/server/middleware/spam-prevention.js new file mode 100644 index 0000000..2b6e199 --- /dev/null +++ b/core/server/middleware/spam-prevention.js @@ -0,0 +1,125 @@ +// # SpamPrevention Middleware +// Usage: spamPrevention +// After: +// Before: +// App: Admin|Blog|API +// +// Helpers to handle spam detection on signin, forgot password, and protected pages. + +var _ = require('lodash'), + errors = require('../errors'), + config = require('../config'), + i18n = require('../i18n'), + loginSecurity = [], + forgottenSecurity = [], + spamPrevention; + +spamPrevention = { + /*jslint unparam:true*/ + // limit signin requests to ten failed requests per IP per hour + signin: function signin(req, res, next) { + var currentTime = process.hrtime()[0], + remoteAddress = req.connection.remoteAddress, + deniedRateLimit = '', + ipCount = '', + message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'), + rateSigninPeriod = config.rateSigninPeriod || 3600, + rateSigninAttempts = config.rateSigninAttempts || 10; + + if (req.body.username && req.body.grant_type === 'password') { + loginSecurity.push({ip: remoteAddress, time: currentTime, email: req.body.username}); + } else if (req.body.grant_type === 'refresh_token') { + return next(); + } else { + return next(new errors.BadRequestError(i18n.t('errors.middleware.spamprevention.noUsername'))); + } + + // filter entries that are older than rateSigninPeriod + loginSecurity = _.filter(loginSecurity, function filter(logTime) { + return (logTime.time + rateSigninPeriod > currentTime); + }); + + // check number of tries per IP address + ipCount = _.chain(loginSecurity).countBy('ip').value(); + deniedRateLimit = (ipCount[remoteAddress] > rateSigninAttempts); + + if (deniedRateLimit) { + errors.logError( + i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.error', {rateSigninAttempts: rateSigninAttempts, rateSigninPeriod: rateSigninPeriod}), + i18n.t('errors.middleware.spamprevention.tooManySigninAttempts.context') + ); + message += rateSigninPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'); + return next(new errors.TooManyRequestsError(message)); + } + next(); + }, + + // limit forgotten password requests to five requests per IP per hour for different email addresses + // limit forgotten password requests to five requests per email address + forgotten: function forgotten(req, res, next) { + var currentTime = process.hrtime()[0], + remoteAddress = req.connection.remoteAddress, + rateForgottenPeriod = config.rateForgottenPeriod || 3600, + rateForgottenAttempts = config.rateForgottenAttempts || 5, + email = req.body.passwordreset[0].email, + ipCount = '', + deniedRateLimit = '', + deniedEmailRateLimit = '', + message = i18n.t('errors.middleware.spamprevention.tooManyAttempts'), + index = _.findIndex(forgottenSecurity, function findIndex(logTime) { + return (logTime.ip === remoteAddress && logTime.email === email); + }); + + if (email) { + if (index !== -1) { + forgottenSecurity[index].count = forgottenSecurity[index].count + 1; + } else { + forgottenSecurity.push({ip: remoteAddress, time: currentTime, email: email, count: 0}); + } + } else { + return next(new errors.BadRequestError(i18n.t('errors.middleware.spamprevention.noEmail'))); + } + + // filter entries that are older than rateForgottenPeriod + forgottenSecurity = _.filter(forgottenSecurity, function filter(logTime) { + return (logTime.time + rateForgottenPeriod > currentTime); + }); + + // check number of tries with different email addresses per IP + ipCount = _.chain(forgottenSecurity).countBy('ip').value(); + deniedRateLimit = (ipCount[remoteAddress] > rateForgottenAttempts); + + if (index !== -1) { + deniedEmailRateLimit = (forgottenSecurity[index].count > rateForgottenAttempts); + } + + if (deniedEmailRateLimit) { + errors.logError( + i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.error', {rfa: rateForgottenAttempts, rfp: rateForgottenPeriod}), + i18n.t('errors.middleware.spamprevention.forgottenPasswordEmail.context') + ); + } + + if (deniedRateLimit) { + errors.logError( + i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.error', {rfa: rateForgottenAttempts, rfp: rateForgottenPeriod}), + i18n.t('errors.middleware.spamprevention.forgottenPasswordIp.context') + ); + } + + if (deniedEmailRateLimit || deniedRateLimit) { + message += rateForgottenPeriod === 3600 ? i18n.t('errors.middleware.spamprevention.waitOneHour') : i18n.t('errors.middleware.spamprevention.tryAgainLater'); + return next(new errors.TooManyRequestsError(message)); + } + + next(); + }, + + resetCounter: function resetCounter(email) { + loginSecurity = _.filter(loginSecurity, function filter(logTime) { + return (logTime.email !== email); + }); + } +}; + +module.exports = spamPrevention; diff --git a/core/server/middleware/static-theme.js b/core/server/middleware/static-theme.js new file mode 100644 index 0000000..7f4ebc7 --- /dev/null +++ b/core/server/middleware/static-theme.js @@ -0,0 +1,39 @@ +var _ = require('lodash'), + express = require('express'), + path = require('path'), + config = require('../config'), + utils = require('../utils'); + +function isBlackListedFileType(file) { + var blackListedFileTypes = ['.hbs', '.md', '.json'], + ext = path.extname(file); + return _.includes(blackListedFileTypes, ext); +} + +function isWhiteListedFile(file) { + var whiteListedFiles = ['manifest.json'], + base = path.basename(file); + return _.includes(whiteListedFiles, base); +} + +function forwardToExpressStatic(req, res, next) { + if (!req.app.get('activeTheme')) { + next(); + } else { + express.static( + path.join(config.paths.themePath, req.app.get('activeTheme')), + {maxAge: process.env.NODE_ENV === 'development' ? 0 : utils.ONE_YEAR_MS} + )(req, res, next); + } +} + +function staticTheme() { + return function blackListStatic(req, res, next) { + if (!isWhiteListedFile(req.path) && isBlackListedFileType(req.path)) { + return next(); + } + return forwardToExpressStatic(req, res, next); + }; +} + +module.exports = staticTheme; diff --git a/core/server/middleware/theme-handler.js b/core/server/middleware/theme-handler.js new file mode 100644 index 0000000..57e700e --- /dev/null +++ b/core/server/middleware/theme-handler.js @@ -0,0 +1,131 @@ +var _ = require('lodash'), + fs = require('fs'), + path = require('path'), + hbs = require('express-hbs'), + api = require('../api'), + config = require('../config'), + errors = require('../errors'), + i18n = require('../i18n'), + themeHandler; + +themeHandler = { + // ### GhostLocals Middleware + // Expose the standard locals that every external page should have available, + // separating between the theme and the admin + ghostLocals: function ghostLocals(req, res, next) { + // Make sure we have a locals value. + res.locals = res.locals || {}; + res.locals.version = config.ghostVersion; + res.locals.safeVersion = config.ghostVersion.match(/^(\d+\.)?(\d+)/)[0]; + // relative path from the URL + res.locals.relativeUrl = req.path; + + next(); + }, + + // ### configHbsForContext Middleware + // Setup handlebars for the current context (admin or theme) + configHbsForContext: function configHbsForContext(req, res, next) { + var themeData = _.cloneDeep(config.theme), + labsData = _.cloneDeep(config.labs), + blogApp = req.app; + + if (req.secure && config.urlSSL) { + // For secure requests override .url property with the SSL version + themeData.url = config.urlSSL.replace(/\/$/, ''); + } + + // Change camelCase to snake_case + themeData.posts_per_page = themeData.postsPerPage; + delete themeData.postsPerPage; + + hbs.updateTemplateOptions({data: {blog: themeData, labs: labsData}}); + + if (config.paths.themePath && blogApp.get('activeTheme')) { + blogApp.set('views', path.join(config.paths.themePath, blogApp.get('activeTheme'))); + } + + // Pass 'secure' flag to the view engine + // so that templates can choose 'url' vs 'urlSSL' + res.locals.secure = req.secure; + + next(); + }, + + // ### Activate Theme + // Helper for updateActiveTheme + activateTheme: function activateTheme(blogApp, activeTheme) { + var hbsOptions, + themePartials = path.join(config.paths.themePath, activeTheme, 'partials'); + + // clear the view cache + blogApp.cache = {}; + // reset the asset hash + config.assetHash = null; + + // set view engine + hbsOptions = { + partialsDir: [config.paths.helperTemplates], + onCompile: function onCompile(exhbs, source) { + return exhbs.handlebars.compile(source, {preventIndent: true}); + } + }; + + fs.stat(themePartials, function stat(err, stats) { + // Check that the theme has a partials directory before trying to use it + if (!err && stats && stats.isDirectory()) { + hbsOptions.partialsDir.push(themePartials); + } + }); + + blogApp.engine('hbs', hbs.express3(hbsOptions)); + + // Update user error template + errors.updateActiveTheme(activeTheme); + + // Set active theme variable on the express server + blogApp.set('activeTheme', activeTheme); + }, + + // ### updateActiveTheme + // Updates the blogApp's activeTheme variable and subsequently + // activates that theme's views with the hbs templating engine if it + // is not yet activated. + updateActiveTheme: function updateActiveTheme(req, res, next) { + var blogApp = req.app; + + api.settings.read({context: {internal: true}, key: 'activeTheme'}).then(function then(response) { + var activeTheme = response.settings[0]; + + // Check if the theme changed + if (activeTheme.value !== blogApp.get('activeTheme')) { + // Change theme + if (!config.paths.availableThemes.hasOwnProperty(activeTheme.value)) { + if (!res.isAdmin) { + // Throw an error if the theme is not available, but not on the admin UI + return errors.throwError(i18n.t('errors.middleware.themehandler.missingTheme', {theme: activeTheme.value})); + } else { + // At this point the activated theme is not present and the current + // request is for the admin client. In order to allow the user access + // to the admin client we set an hbs instance on the app so that middleware + // processing can continue. + blogApp.engine('hbs', hbs.express3()); + errors.logWarn(i18n.t('errors.middleware.themehandler.missingTheme', {theme: activeTheme.value})); + + return next(); + } + } else { + themeHandler.activateTheme(blogApp, activeTheme.value); + } + } + next(); + }).catch(function handleError(err) { + // Trying to start up without the active theme present, setup a simple hbs instance + // and render an error page straight away. + blogApp.engine('hbs', hbs.express3()); + next(err); + }); + } +}; + +module.exports = themeHandler; diff --git a/core/server/middleware/uncapitalise.js b/core/server/middleware/uncapitalise.js new file mode 100644 index 0000000..8976adc --- /dev/null +++ b/core/server/middleware/uncapitalise.js @@ -0,0 +1,46 @@ +// # uncapitalise Middleware +// Usage: uncapitalise(req, res, next) +// After: +// Before: +// App: Admin|Blog|API +// +// Detect upper case in req.path. + +var utils = require('../utils'), + uncapitalise; + +uncapitalise = function uncapitalise(req, res, next) { + /*jslint unparam:true*/ + var pathToTest = req.path, + isSignupOrReset = req.path.match(/(\/ghost\/(signup|reset)\/)/i), + isAPI = req.path.match(/(\/ghost\/api\/v[\d\.]+\/.*?\/)/i), + redirectPath; + + if (isSignupOrReset) { + pathToTest = isSignupOrReset[1]; + } + + // Do not lowercase anything after /api/v0.1/ to protect :key/:slug + if (isAPI) { + pathToTest = isAPI[1]; + } + + /** + * In node < 0.11.1 req.path is not encoded, afterwards, it is always encoded such that | becomes %7C etc. + * That encoding isn't useful here, as it triggers an extra uncapitalise redirect, so we decode the path first + */ + if (/[A-Z]/.test(decodeURIComponent(pathToTest))) { + // Adding baseUrl ensures subdirectories are kept + redirectPath = ( + (req.baseUrl ? req.baseUrl : '') + + utils.removeOpenRedirectFromUrl(req.url.replace(pathToTest, pathToTest.toLowerCase())) + ); + + res.set('Cache-Control', 'public, max-age=' + utils.ONE_YEAR_S); + res.redirect(301, redirectPath); + } else { + next(); + } +}; + +module.exports = uncapitalise; diff --git a/core/server/middleware/validation/index.js b/core/server/middleware/validation/index.js new file mode 100644 index 0000000..176d818 --- /dev/null +++ b/core/server/middleware/validation/index.js @@ -0,0 +1 @@ +exports.upload = require('./upload'); diff --git a/core/server/middleware/validation/upload.js b/core/server/middleware/validation/upload.js new file mode 100644 index 0000000..c1023d6 --- /dev/null +++ b/core/server/middleware/validation/upload.js @@ -0,0 +1,30 @@ +var apiUtils = require('../../api/utils'), + errors = require('../../errors'), + config = require('../../config'), + i18n = require('../../i18n'); + +module.exports = function upload(options) { + var type = options.type; + + // if we finish the data/importer logic, we forward the request to the specified importer + return function (req, res, next) { + var extensions = (config.uploads[type] && config.uploads[type].extensions) || [], + contentTypes = (config.uploads[type] && config.uploads[type].contentTypes) || []; + + req.file = req.file || {}; + req.file.name = req.file.originalname; + req.file.type = req.file.mimetype; + + // Check if a file was provided + if (!apiUtils.checkFileExists(req.file)) { + return next(new errors.NoPermissionError(i18n.t('errors.api.' + type + '.missingFile'))); + } + + // Check if the file is valid + if (!apiUtils.checkFileIsValid(req.file, contentTypes, extensions)) { + return next(new errors.UnsupportedMediaTypeError(i18n.t('errors.api.' + type + '.invalidFile', {extensions: extensions}))); + } + + next(); + }; +}; diff --git a/core/server/models/accesstoken.js b/core/server/models/accesstoken.js new file mode 100644 index 0000000..02532ee --- /dev/null +++ b/core/server/models/accesstoken.js @@ -0,0 +1,32 @@ +var ghostBookshelf = require('./base'), + Basetoken = require('./base/token'), + events = require('../events'), + + Accesstoken, + Accesstokens; + +Accesstoken = Basetoken.extend({ + tableName: 'accesstokens', + + emitChange: function emitChange(event) { + // Event named 'token' as access and refresh token will be merged in future, see #6626 + events.emit('token' + '.' + event, this); + }, + + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + + this.on('created', function onCreated(model) { + model.emitChange('added'); + }); + } +}); + +Accesstokens = ghostBookshelf.Collection.extend({ + model: Accesstoken +}); + +module.exports = { + Accesstoken: ghostBookshelf.model('Accesstoken', Accesstoken), + Accesstokens: ghostBookshelf.collection('Accesstokens', Accesstokens) +}; diff --git a/core/server/models/app-field.js b/core/server/models/app-field.js new file mode 100644 index 0000000..50c1ca3 --- /dev/null +++ b/core/server/models/app-field.js @@ -0,0 +1,20 @@ +var ghostBookshelf = require('./base'), + AppField, + AppFields; + +AppField = ghostBookshelf.Model.extend({ + tableName: 'app_fields', + + post: function post() { + return this.morphOne('Post', 'relatable'); + } +}); + +AppFields = ghostBookshelf.Collection.extend({ + model: AppField +}); + +module.exports = { + AppField: ghostBookshelf.model('AppField', AppField), + AppFields: ghostBookshelf.collection('AppFields', AppFields) +}; diff --git a/core/server/models/app-setting.js b/core/server/models/app-setting.js new file mode 100644 index 0000000..12df222 --- /dev/null +++ b/core/server/models/app-setting.js @@ -0,0 +1,20 @@ +var ghostBookshelf = require('./base'), + AppSetting, + AppSettings; + +AppSetting = ghostBookshelf.Model.extend({ + tableName: 'app_settings', + + app: function app() { + return this.belongsTo('App'); + } +}); + +AppSettings = ghostBookshelf.Collection.extend({ + model: AppSetting +}); + +module.exports = { + AppSetting: ghostBookshelf.model('AppSetting', AppSetting), + AppSettings: ghostBookshelf.collection('AppSettings', AppSettings) +}; diff --git a/core/server/models/app.js b/core/server/models/app.js new file mode 100644 index 0000000..402e35a --- /dev/null +++ b/core/server/models/app.js @@ -0,0 +1,61 @@ +var ghostBookshelf = require('./base'), + App, + Apps; + +App = ghostBookshelf.Model.extend({ + tableName: 'apps', + + saving: function saving(newPage, attr, options) { + /*jshint unused:false*/ + var self = this; + + ghostBookshelf.Model.prototype.saving.apply(this, arguments); + + if (this.hasChanged('slug') || !this.get('slug')) { + // Pass the new slug through the generator to strip illegal characters, detect duplicates + return ghostBookshelf.Model.generateSlug(App, this.get('slug') || this.get('name'), + {transacting: options.transacting}) + .then(function then(slug) { + self.set({slug: slug}); + }); + } + }, + + permissions: function permissions() { + return this.belongsToMany('Permission', 'permissions_apps'); + }, + + settings: function settings() { + return this.belongsToMany('AppSetting', 'app_settings'); + } +}, { + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['withRelated'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + } +}); + +Apps = ghostBookshelf.Collection.extend({ + model: App +}); + +module.exports = { + App: ghostBookshelf.model('App', App), + Apps: ghostBookshelf.collection('Apps', Apps) +}; diff --git a/core/server/models/base/index.js b/core/server/models/base/index.js new file mode 100644 index 0000000..d11b3d0 --- /dev/null +++ b/core/server/models/base/index.js @@ -0,0 +1,632 @@ +// # Base Model +// This is the model from which all other Ghost models extend. The model is based on Bookshelf.Model, and provides +// several basic behaviours such as UUIDs, as well as a set of Data methods for accessing information from the database. +// +// The models are internal to Ghost, only the API and some internal functions such as migration and import/export +// accesses the models directly. All other parts of Ghost, including the blog frontend, admin UI, and apps are only +// allowed to access data via the API. +var _ = require('lodash'), + bookshelf = require('bookshelf'), + moment = require('moment'), + Promise = require('bluebird'), + uuid = require('uuid'), + config = require('../../config'), + db = require('../../data/db'), + errors = require('../../errors'), + filters = require('../../filters'), + schema = require('../../data/schema'), + utils = require('../../utils'), + labs = require('../../utils/labs'), + validation = require('../../data/validation'), + plugins = require('../plugins'), + i18n = require('../../i18n'), + + ghostBookshelf, + proto; + +// ### ghostBookshelf +// Initializes a new Bookshelf instance called ghostBookshelf, for reference elsewhere in Ghost. +ghostBookshelf = bookshelf(db.knex); + +// Load the Bookshelf registry plugin, which helps us avoid circular dependencies +ghostBookshelf.plugin('registry'); + +// Load the Ghost access rules plugin, which handles passing permissions/context through the model layer +ghostBookshelf.plugin(plugins.accessRules); + +// Load the Ghost filter plugin, which handles applying a 'filter' to findPage requests +ghostBookshelf.plugin(plugins.filter); + +// Load the Ghost include count plugin, which allows for the inclusion of cross-table counts +ghostBookshelf.plugin(plugins.includeCount); + +// Load the Ghost pagination plugin, which gives us the `fetchPage` method on Models +ghostBookshelf.plugin(plugins.pagination); + +// Update collision plugin +ghostBookshelf.plugin(plugins.collision); + +// Cache an instance of the base model prototype +proto = ghostBookshelf.Model.prototype; + +// ## ghostBookshelf.Model +// The Base Model which other Ghost objects will inherit from, +// including some convenience functions as static properties on the model. +ghostBookshelf.Model = ghostBookshelf.Model.extend({ + // Bookshelf `hasTimestamps` - handles created_at and updated_at properties + hasTimestamps: true, + + // Ghost option handling - get permitted attributes from server/data/schema.js, where the DB schema is defined + permittedAttributes: function permittedAttributes() { + return _.keys(schema.tables[this.tableName]); + }, + + // Bookshelf `defaults` - default values setup on every model creation + defaults: function defaults() { + return { + uuid: uuid.v4() + }; + }, + + // When loading an instance, subclasses can specify default to fetch + defaultColumnsToFetch: function defaultColumnsToFetch() { + return []; + }, + + // Bookshelf `initialize` - declare a constructor-like method for model creation + initialize: function initialize() { + var self = this, + options = arguments[1] || {}; + + // make options include available for toJSON() + if (options.include) { + this.include = _.clone(options.include); + } + + this.on('creating', this.creating, this); + this.on('saving', function onSaving(model, attributes, options) { + return Promise.resolve(self.saving(model, attributes, options)).then(function then() { + return self.validate(model, attributes, options); + }); + }); + }, + + validate: function validate() { + return validation.validateSchema(this.tableName, this.toJSON()); + }, + + creating: function creating(newObj, attr, options) { + if (!this.get('created_by')) { + this.set('created_by', this.contextUser(options)); + } + }, + + saving: function saving(newObj, attr, options) { + // Remove any properties which don't belong on the model + this.attributes = this.pick(this.permittedAttributes()); + // Store the previous attributes so we can tell what was updated later + this._updatedAttributes = newObj.previousAttributes(); + + this.set('updated_by', this.contextUser(options)); + }, + + /** + * before we insert dates into the database, we have to normalize + * date format is now in each db the same + */ + fixDatesWhenSave: function fixDates(attrs) { + var self = this; + + _.each(attrs, function each(value, key) { + if (value !== null + && schema.tables[self.tableName].hasOwnProperty(key) + && schema.tables[self.tableName][key].type === 'dateTime') { + attrs[key] = moment(value).format('YYYY-MM-DD HH:mm:ss'); + } + }); + + return attrs; + }, + + /** + * all supported databases (pg, sqlite, mysql) return different values + * + * sqlite: + * - knex returns a UTC String + * pg: + * - has an active UTC session through knex and returns UTC Date + * mysql: + * - knex wraps the UTC value into a local JS Date + */ + fixDatesWhenFetch: function fixDates(attrs) { + var self = this; + + _.each(attrs, function each(value, key) { + if (value !== null + && schema.tables[self.tableName].hasOwnProperty(key) + && schema.tables[self.tableName][key].type === 'dateTime') { + attrs[key] = moment(value).toDate(); + } + }); + + return attrs; + }, + + // Convert integers to real booleans + fixBools: function fixBools(attrs) { + var self = this; + _.each(attrs, function each(value, key) { + if (schema.tables[self.tableName].hasOwnProperty(key) + && schema.tables[self.tableName][key].type === 'bool') { + attrs[key] = value ? true : false; + } + }); + + return attrs; + }, + + // Get the user from the options object + contextUser: function contextUser(options) { + // Default to context user + if ((options.context && options.context.user) || (options.context && options.context.user === 0)) { + return options.context.user; + // Other wise use the internal override + } else if (options.context && options.context.internal) { + return 1; + } else if (options.context && options.context.external) { + return 0; + } else { + errors.logAndThrowError(new Error(i18n.t('errors.models.base.index.missingContext'))); + } + }, + + // format date before writing to DB, bools work + format: function format(attrs) { + return this.fixDatesWhenSave(attrs); + }, + + // format data and bool when fetching from DB + parse: function parse(attrs) { + return this.fixBools(this.fixDatesWhenFetch(attrs)); + }, + + toJSON: function toJSON(options) { + var attrs = _.extend({}, this.attributes), + self = this; + options = options || {}; + options = _.pick(options, ['shallow', 'baseKey', 'include', 'context']); + + if (options && options.shallow) { + return attrs; + } + + if (options && options.include) { + this.include = _.union(this.include, options.include); + } + + _.each(this.relations, function each(relation, key) { + if (key.substring(0, 7) !== '_pivot_') { + // if include is set, expand to full object + var fullKey = _.isEmpty(options.baseKey) ? key : options.baseKey + '.' + key; + if (_.includes(self.include, fullKey)) { + attrs[key] = relation.toJSON(_.extend({}, options, {baseKey: fullKey, include: self.include})); + } + } + }); + + // @TODO upgrade bookshelf & knex and use serialize & toJSON to do this in a neater way (see #6103) + return proto.finalize.call(this, attrs); + }, + + // Get attributes that have been updated (values before a .save() call) + updatedAttributes: function updatedAttributes() { + return this._updatedAttributes || {}; + }, + + // Get a specific updated attribute value + updated: function updated(attr) { + return this.updatedAttributes()[attr]; + }, + + /** + * There is difference between `updated` and `previous`: + * Depending on the hook (before or after writing into the db), both fields have a different meaning. + * e.g. onSaving -> before db write (has to use previous) + * onUpdated -> after db write (has to use updated) + * + * hasDateChanged('attr', {beforeWrite: true}) + */ + hasDateChanged: function (attr, options) { + options = options || {}; + return moment(this.get(attr)).diff(moment(options.beforeWrite ? this.previous(attr) : this.updated(attr))) !== 0; + } +}, { + // ## Data Utility Functions + + /** + * Returns an array of keys permitted in every method's `options` hash. + * Can be overridden and added to by a model's `permittedOptions` method. + * + * importing: is used when import a JSON file or when migrating the database + * + * @return {Object} Keys allowed in the `options` hash of every model's method. + */ + permittedOptions: function permittedOptions() { + // terms to whitelist for all methods. + return ['context', 'include', 'transacting', 'importing']; + }, + + /** + * Filters potentially unsafe model attributes, so you can pass them to Bookshelf / Knex. + * This filter should be called before each insert/update operation. + * + * @param {Object} data Has keys representing the model's attributes/fields in the database. + * @return {Object} The filtered results of the passed in data, containing only what's allowed in the schema. + */ + filterData: function filterData(data) { + var permittedAttributes = this.prototype.permittedAttributes(), + filteredData = _.pick(data, permittedAttributes), + sanitizedData = this.sanitizeData(filteredData); + + return sanitizedData; + }, + + /** + * `sanitizeData` ensures that client data is in the correct format for further operations. + * + * Dates: + * - client dates are sent as ISO format (moment(..).format()) + * - server dates are in JS Date format + * >> when bookshelf fetches data from the database, all dates are in JS Dates + * >> see `parse` + * - Bookshelf updates the model with the new client data via the `set` function + * - Bookshelf uses a simple `isEqual` function from lodash to detect real changes + * - .previous(attr) and .get(attr) returns false obviously + * - internally we use our `hasDateChanged` if we have to compare previous/updated dates + * - but Bookshelf is not in our control for this case + * + * @IMPORTANT + * Before the new client data get's inserted again, the dates get's retransformed into + * proper strings, see `format`. + */ + sanitizeData: function sanitizeData(data) { + var tableName = _.result(this.prototype, 'tableName'); + + _.each(data, function (value, key) { + if (value !== null + && schema.tables[tableName].hasOwnProperty(key) + && schema.tables[tableName][key].type === 'dateTime' + && typeof value === 'string' + ) { + data[key] = moment(value).toDate(); + } + }); + + return data; + }, + + /** + * Filters potentially unsafe `options` in a model method's arguments, so you can pass them to Bookshelf / Knex. + * @param {Object} options Represents options to filter in order to be passed to the Bookshelf query. + * @param {String} methodName The name of the method to check valid options for. + * @return {Object} The filtered results of `options`. + */ + filterOptions: function filterOptions(options, methodName) { + var permittedOptions = this.permittedOptions(methodName), + filteredOptions = _.pick(options, permittedOptions); + + return filteredOptions; + }, + + // ## Model Data Functions + + /** + * ### Find All + * Fetches all the data for a particular model + * @param {Object} options (optional) + * @return {Promise(ghostBookshelf.Collection)} Collection of all Models + */ + findAll: function findAll(options) { + options = this.filterOptions(options, 'findAll'); + options.withRelated = _.union(options.withRelated, options.include); + + var itemCollection = this.forge(null, {context: options.context}); + + // transforms fictive keywords like 'all' (status:all) into correct allowed values + if (this.processOptions) { + this.processOptions(options); + } + + itemCollection.applyDefaultAndCustomFilters(options); + + return itemCollection.fetchAll(options).then(function then(result) { + if (options.include) { + _.each(result.models, function each(item) { + item.include = options.include; + }); + } + return result; + }); + }, + + /** + * ### Find Page + * Find results by page - returns an object containing the + * information about the request (page, limit), along with the + * info needed for pagination (pages, total). + * + * **response:** + * + * { + * posts: [ + * {...}, ... + * ], + * page: __, + * limit: __, + * pages: __, + * total: __ + * } + * + * @param {Object} options + */ + findPage: function findPage(options) { + options = options || {}; + + var self = this, + itemCollection = this.forge(null, {context: options.context}), + tableName = _.result(this.prototype, 'tableName'), + requestedColumns = options.columns; + + // Set this to true or pass ?debug=true as an API option to get output + itemCollection.debug = options.debug && process.env.NODE_ENV !== 'production'; + + // Filter options so that only permitted ones remain + options = this.filterOptions(options, 'findPage'); + + // This applies default properties like 'staticPages' and 'status' + // And then converts them to 'where' options... this behaviour is effectively deprecated in favour + // of using filter - it's only be being kept here so that we can transition cleanly. + this.processOptions(options); + + // Add Filter behaviour + itemCollection.applyDefaultAndCustomFilters(options); + + // Handle related objects + // TODO: this should just be done for all methods @ the API level + options.withRelated = _.union(options.withRelated, options.include); + + // Ensure only valid fields/columns are added to query + // and append default columns to fetch + if (options.columns) { + options.columns = _.intersection(options.columns, this.prototype.permittedAttributes()); + options.columns = _.union(options.columns, this.prototype.defaultColumnsToFetch()); + } + + if (options.order) { + options.order = self.parseOrderOption(options.order, options.include); + } else if (self.orderDefaultRaw) { + options.orderRaw = self.orderDefaultRaw(); + } else { + options.order = self.orderDefaultOptions(); + } + + return itemCollection.fetchPage(options).then(function formatResponse(response) { + var data = {}, + models = []; + + options.columns = requestedColumns; + models = response.collection.toJSON(options); + + // re-add any computed properties that were stripped out before the call to fetchPage + // pick only requested before returning JSON + data[tableName] = _.map(models, function transform(model) { + return options.columns ? _.pick(model, options.columns) : model; + }); + data.meta = {pagination: response.pagination}; + return data; + }); + }, + + /** + * ### Find One + * Naive find one where data determines what to match on + * @param {Object} data + * @param {Object} options (optional) + * @return {Promise(ghostBookshelf.Model)} Single Model + */ + findOne: function findOne(data, options) { + data = this.filterData(data); + options = this.filterOptions(options, 'findOne'); + // We pass include to forge so that toJSON has access + return this.forge(data, {include: options.include}).fetch(options); + }, + + /** + * ### Edit + * Naive edit + * + * We always forward the `method` option to Bookshelf, see http://bookshelfjs.org/#Model-instance-save. + * Based on the `method` option Bookshelf and Ghost can determine if a query is an insert or an update. + * + * @param {Object} data + * @param {Object} options (optional) + * @return {Promise(ghostBookshelf.Model)} Edited Model + */ + edit: function edit(data, options) { + var id = options.id, + model = this.forge({id: id}); + + data = this.filterData(data); + options = this.filterOptions(options, 'edit'); + + // We allow you to disable timestamps when run migration, so that the posts `updated_at` value is the same + if (options.importing) { + model.hasTimestamps = false; + } + + return model.fetch(options).then(function then(object) { + if (object) { + return object.save(data, _.merge({method: 'update'}, options)); + } + }); + }, + + /** + * ### Add + * Naive add + * @param {Object} data + * @param {Object} options (optional) + * @return {Promise(ghostBookshelf.Model)} Newly Added Model + */ + add: function add(data, options) { + data = this.filterData(data); + options = this.filterOptions(options, 'add'); + var model = this.forge(data); + + // We allow you to disable timestamps when importing posts so that the new posts `updated_at` value is the same + // as the import json blob. More details refer to https://github.com/TryGhost/Ghost/issues/1696 + if (options.importing) { + model.hasTimestamps = false; + } + return model.save(null, options); + }, + + /** + * ### Destroy + * Naive destroy + * @param {Object} options (optional) + * @return {Promise(ghostBookshelf.Model)} Empty Model + */ + destroy: function destroy(options) { + var id = options.id; + options = this.filterOptions(options, 'destroy'); + + // Fetch the object before destroying it, so that the changed data is available to events + return this.forge({id: id}).fetch(options).then(function then(obj) { + return obj.destroy(options); + }); + }, + + /** + * ### Generate Slug + * Create a string to act as the permalink for an object. + * @param {ghostBookshelf.Model} Model Model type to generate a slug for + * @param {String} base The string for which to generate a slug, usually a title or name + * @param {Object} options Options to pass to findOne + * @return {Promise(String)} Resolves to a unique slug string + */ + generateSlug: function generateSlug(Model, base, options) { + var slug, + slugTryCount = 1, + baseName = Model.prototype.tableName.replace(/s$/, ''), + // Look for a matching slug, append an incrementing number if so + checkIfSlugExists, longSlug; + + checkIfSlugExists = function checkIfSlugExists(slugToFind) { + var args = {slug: slugToFind}; + // status is needed for posts + if (options && options.status) { + args.status = options.status; + } + return Model.findOne(args, options).then(function then(found) { + var trimSpace; + + if (!found) { + return slugToFind; + } + + slugTryCount += 1; + + // If we shortened, go back to the full version and try again + if (slugTryCount === 2 && longSlug) { + slugToFind = longSlug; + longSlug = null; + slugTryCount = 1; + return checkIfSlugExists(slugToFind); + } + + // If this is the first time through, add the hyphen + if (slugTryCount === 2) { + slugToFind += '-'; + } else { + // Otherwise, trim the number off the end + trimSpace = -(String(slugTryCount - 1).length); + slugToFind = slugToFind.slice(0, trimSpace); + } + + slugToFind += slugTryCount; + + return checkIfSlugExists(slugToFind); + }); + }; + + slug = utils.safeString(base, options); + + // If it's a user, let's try to cut it down (unless this is a human request) + if (baseName === 'user' && options && options.shortSlug && slugTryCount === 1 && slug !== 'ghost-owner') { + longSlug = slug; + slug = (slug.indexOf('-') > -1) ? slug.substr(0, slug.indexOf('-')) : slug; + } + + if (!_.has(options, 'importing') || !options.importing) { + // TODO: remove the labs requirement when internal tags is out of beta + // This checks if the first character of a tag name is a #. If it is, this + // is an internal tag, and as such we should add 'hash' to the beginning of the slug + if (labs.isSet('internalTags') && baseName === 'tag' && /^#/.test(base)) { + slug = 'hash-' + slug; + } + } + + // Check the filtered slug doesn't match any of the reserved keywords + return filters.doFilter('slug.reservedSlugs', config.slugs.reserved).then(function then(slugList) { + // Some keywords cannot be changed + slugList = _.union(slugList, config.slugs.protected); + + return _.includes(slugList, slug) ? slug + '-' + baseName : slug; + }).then(function then(slug) { + // if slug is empty after trimming use the model name + if (!slug) { + slug = baseName; + } + // Test for duplicate slugs. + return checkIfSlugExists(slug); + }); + }, + + parseOrderOption: function (order, include) { + var permittedAttributes, result, rules; + + permittedAttributes = this.prototype.permittedAttributes(); + if (include && include.indexOf('count.posts') > -1) { + permittedAttributes.push('count.posts'); + } + result = {}; + rules = order.split(','); + + _.each(rules, function (rule) { + var match, field, direction; + + match = /^([a-z0-9_\.]+)\s+(asc|desc)$/i.exec(rule.trim()); + + // invalid order syntax + if (!match) { + return; + } + + field = match[1].toLowerCase(); + direction = match[2].toUpperCase(); + + if (permittedAttributes.indexOf(field) === -1) { + return; + } + + result[field] = direction; + }); + + return result; + } + +}); + +// Export ghostBookshelf for use elsewhere +module.exports = ghostBookshelf; diff --git a/core/server/models/base/listeners.js b/core/server/models/base/listeners.js new file mode 100644 index 0000000..a4082c1 --- /dev/null +++ b/core/server/models/base/listeners.js @@ -0,0 +1,84 @@ +var config = require('../../config'), + events = require(config.paths.corePath + '/server/events'), + models = require(config.paths.corePath + '/server/models'), + errors = require(config.paths.corePath + '/server/errors'), + sequence = require(config.paths.corePath + '/server/utils/sequence'), + moment = require('moment-timezone'), + _ = require('lodash'); + +/** + * WHEN access token is created we will update last_login for user. + */ +events.on('token.added', function (tokenModel) { + models.User.edit({last_login: moment().toDate()}, {id: tokenModel.get('user_id')}) + .catch(function (err) { + errors.logError(err); + }); +}); + +/** + * WHEN timezone changes, we will: + * - reschedule all scheduled posts + * - draft scheduled posts, when the published_at would be in the past + */ +events.on('settings.activeTimezone.edited', function (settingModel) { + var newTimezone = settingModel.attributes.value, + previousTimezone = settingModel._updatedAttributes.value, + timezoneOffsetDiff = moment.tz(previousTimezone).utcOffset() - moment.tz(newTimezone).utcOffset(), + options = {context: {internal: true}}; + + // CASE: TZ was updated, but did not change + if (previousTimezone === newTimezone) { + return; + } + + /** + * CASE: + * `Post.findAll` and the Post.edit` must run in one single transaction. + * We lock the target row on fetch by using the `forUpdate` option. + * Read more in models/post.js - `onFetching` + */ + return models.Base.transaction(function (transacting) { + options.transacting = transacting; + options.forUpdate = true; + + return models.Post.findAll(_.merge({filter: 'status:scheduled'}, options)) + .then(function (results) { + if (!results.length) { + return; + } + + return sequence(results.map(function (post) { + return function reschedulePostIfPossible() { + var newPublishedAtMoment = moment(post.get('published_at')).add(timezoneOffsetDiff, 'minutes'); + + /** + * CASE: + * - your configured TZ is GMT+01:00 + * - now is 10AM +01:00 (9AM UTC) + * - your post should be published 8PM +01:00 (7PM UTC) + * - you reconfigure your blog TZ to GMT+08:00 + * - now is 5PM +08:00 (9AM UTC) + * - if we don't change the published_at, 7PM + 8 hours === next day 5AM + * - so we update published_at to 7PM - 480minutes === 11AM UTC + * - 11AM UTC === 7PM +08:00 + */ + if (newPublishedAtMoment.isBefore(moment().add(5, 'minutes'))) { + post.set('status', 'draft'); + } else { + post.set('published_at', newPublishedAtMoment.toDate()); + } + + return models.Post.edit(post.toJSON(), _.merge({id: post.id}, options)).reflect(); + }; + })).each(function (result) { + if (!result.isFulfilled()) { + errors.logError(result.reason()); + } + }); + }) + .catch(function (err) { + errors.logError(err); + }); + }); +}); diff --git a/core/server/models/base/token.js b/core/server/models/base/token.js new file mode 100644 index 0000000..04cdcdf --- /dev/null +++ b/core/server/models/base/token.js @@ -0,0 +1,82 @@ +var Promise = require('bluebird'), + ghostBookshelf = require('./index'), + errors = require('../../errors'), + i18n = require('../../i18n'), + + Basetoken; + +Basetoken = ghostBookshelf.Model.extend({ + + user: function user() { + return this.belongsTo('User'); + }, + + client: function client() { + return this.belongsTo('Client'); + }, + + // override for base function since we don't have + // a created_by field for sessions + creating: function creating(newObj, attr, options) { + /*jshint unused:false*/ + }, + + // override for base function since we don't have + // a updated_by field for sessions + saving: function saving(newObj, attr, options) { + /*jshint unused:false*/ + // Remove any properties which don't belong on the model + this.attributes = this.pick(this.permittedAttributes()); + } + +}, { + destroyAllExpired: function destroyAllExpired(options) { + options = this.filterOptions(options, 'destroyAll'); + return ghostBookshelf.Collection.forge([], {model: this}) + .query('where', 'expires', '<', Date.now()) + .fetch(options) + .then(function then(collection) { + return collection.invokeThen('destroy', options); + }); + }, + /** + * ### destroyByUser + * @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy + */ + destroyByUser: function destroyByUser(options) { + var userId = options.id; + + options = this.filterOptions(options, 'destroyByUser'); + + if (userId) { + return ghostBookshelf.Collection.forge([], {model: this}) + .query('where', 'user_id', '=', userId) + .fetch(options) + .then(function then(collection) { + return collection.invokeThen('destroy', options); + }); + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.base.token.noUserFound'))); + }, + + /** + * ### destroyByToken + * @param {[type]} options has token where token is the token to destroy + */ + destroyByToken: function destroyByToken(options) { + var token = options.token; + + options = this.filterOptions(options, 'destroyByUser'); + options.require = true; + + return this.forge() + .query('where', 'token', '=', token) + .fetch(options) + .then(function then(model) { + return model.destroy(options); + }); + } +}); + +module.exports = Basetoken; diff --git a/core/server/models/base/utils.js b/core/server/models/base/utils.js new file mode 100644 index 0000000..38118ac --- /dev/null +++ b/core/server/models/base/utils.js @@ -0,0 +1,71 @@ +/** + * # Utils + * Parts of the model code which can be split out and unit tested + */ +var _ = require('lodash'), + tagUpdate; + +tagUpdate = { + fetchCurrentPost: function fetchCurrentPost(PostModel, id, options) { + return PostModel.forge({id: id}).fetch(_.extend({}, options, {withRelated: ['tags']})); + }, + + fetchMatchingTags: function fetchMatchingTags(TagModel, tagsToMatch, options) { + if (_.isEmpty(tagsToMatch)) { + return false; + } + return TagModel.forge() + .query('whereIn', 'name', _.map(tagsToMatch, 'name')).fetchAll(options); + }, + + detachTagFromPost: function detachTagFromPost(post, tag, options) { + return function () { + // See tgriesser/bookshelf#294 for an explanation of _.omit(options, 'query') + return post.tags().detach(tag.id, _.omit(options, 'query')); + }; + }, + + attachTagToPost: function attachTagToPost(post, tag, index, options) { + return function () { + // See tgriesser/bookshelf#294 for an explanation of _.omit(options, 'query') + return post.tags().attach({tag_id: tag.id, sort_order: index}, _.omit(options, 'query')); + }; + }, + + createTagThenAttachTagToPost: function createTagThenAttachTagToPost(TagModel, post, tag, index, options) { + var fields = ['name', 'slug', 'description', 'image', 'visibility', 'parent_id', 'meta_title', 'meta_description']; + return function () { + return TagModel.add(_.pick(tag, fields), options).then(function then(createdTag) { + return tagUpdate.attachTagToPost(post, createdTag, index, options)(); + }); + }; + }, + + updateTagOrderForPost: function updateTagOrderForPost(post, tag, index, options) { + return function () { + return post.tags().updatePivot( + {sort_order: index}, _.extend({}, options, {query: {where: {tag_id: tag.id}}}) + ); + }; + }, + + // Test if two tags are the same, checking ID first, and falling back to name + tagsAreEqual: function tagsAreEqual(tag1, tag2) { + if (tag1.hasOwnProperty('id') && tag2.hasOwnProperty('id')) { + return parseInt(tag1.id, 10) === parseInt(tag2.id, 10); + } + return tag1.name.toString() === tag2.name.toString(); + }, + tagSetsAreEqual: function tagSetsAreEqual(tags1, tags2) { + // If the lengths are different, they cannot be the same + if (tags1.length !== tags2.length) { + return false; + } + // Return if no item is not the same (double negative is horrible) + return !_.some(tags1, function (tag1, index) { + return !tagUpdate.tagsAreEqual(tag1, tags2[index]); + }); + } +}; + +module.exports.tagUpdate = tagUpdate; diff --git a/core/server/models/client-trusted-domain.js b/core/server/models/client-trusted-domain.js new file mode 100644 index 0000000..f851b25 --- /dev/null +++ b/core/server/models/client-trusted-domain.js @@ -0,0 +1,17 @@ +var ghostBookshelf = require('./base'), + + ClientTrustedDomain, + ClientTrustedDomains; + +ClientTrustedDomain = ghostBookshelf.Model.extend({ + tableName: 'client_trusted_domains' +}); + +ClientTrustedDomains = ghostBookshelf.Collection.extend({ + model: ClientTrustedDomain +}); + +module.exports = { + ClientTrustedDomain: ghostBookshelf.model('ClientTrustedDomain', ClientTrustedDomain), + ClientTrustedDomains: ghostBookshelf.collection('ClientTrustedDomains', ClientTrustedDomains) +}; diff --git a/core/server/models/client.js b/core/server/models/client.js new file mode 100644 index 0000000..4d5c547 --- /dev/null +++ b/core/server/models/client.js @@ -0,0 +1,57 @@ +var ghostBookshelf = require('./base'), + crypto = require('crypto'), + uuid = require('uuid'), + + Client, + Clients; + +Client = ghostBookshelf.Model.extend({ + + tableName: 'clients', + + defaults: function defaults() { + var env = process.env.NODE_ENV, + secret = env.indexOf('testing') !== 0 ? crypto.randomBytes(6).toString('hex') : 'not_available'; + + return { + uuid: uuid.v4(), + secret: secret, + status: 'development', + type: 'ua' + }; + }, + + trustedDomains: function trustedDomains() { + return this.hasMany('ClientTrustedDomain', 'client_id'); + } +}, { + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['columns', 'withRelated'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + } +}); + +Clients = ghostBookshelf.Collection.extend({ + model: Client +}); + +module.exports = { + Client: ghostBookshelf.model('Client', Client), + Clients: ghostBookshelf.collection('Clients', Clients) +}; diff --git a/core/server/models/index.js b/core/server/models/index.js new file mode 100644 index 0000000..95d17bf --- /dev/null +++ b/core/server/models/index.js @@ -0,0 +1,46 @@ +/** + * Dependencies + */ + +var _ = require('lodash'), + exports, + models; + +// enable event listeners +require('./base/listeners'); + +/** + * Expose all models + */ +exports = module.exports; + +models = [ + 'accesstoken', + 'app-field', + 'app-setting', + 'app', + 'client-trusted-domain', + 'client', + 'permission', + 'post', + 'refreshtoken', + 'role', + 'settings', + 'subscriber', + 'tag', + 'user' +]; + +function init() { + exports.Base = require('./base'); + + models.forEach(function (name) { + _.extend(exports, require('./' + name)); + }); +} + +/** + * Expose `init` + */ + +exports.init = init; diff --git a/core/server/models/permission.js b/core/server/models/permission.js new file mode 100644 index 0000000..2e9454c --- /dev/null +++ b/core/server/models/permission.js @@ -0,0 +1,30 @@ +var ghostBookshelf = require('./base'), + + Permission, + Permissions; + +Permission = ghostBookshelf.Model.extend({ + + tableName: 'permissions', + + roles: function roles() { + return this.belongsToMany('Role'); + }, + + users: function users() { + return this.belongsToMany('User'); + }, + + apps: function apps() { + return this.belongsToMany('App'); + } +}); + +Permissions = ghostBookshelf.Collection.extend({ + model: Permission +}); + +module.exports = { + Permission: ghostBookshelf.model('Permission', Permission), + Permissions: ghostBookshelf.collection('Permissions', Permissions) +}; diff --git a/core/server/models/plugins/access-rules.js b/core/server/models/plugins/access-rules.js new file mode 100644 index 0000000..c33eae4 --- /dev/null +++ b/core/server/models/plugins/access-rules.js @@ -0,0 +1,50 @@ +// # Access Rules +// +// Extends Bookshelf.Model.force to take a 'context' option which provides information on how this query should +// be treated in terms of data access rules - currently just detecting public requests +module.exports = function (Bookshelf) { + var model = Bookshelf.Model, + Model; + + Model = Bookshelf.Model.extend({ + /** + * Cached copy of the context setup for this model instance + */ + _context: null, + + /** + * ## Is Public Context? + * A helper to determine if this is a public request or not + * @returns {boolean} + */ + isPublicContext: function isPublicContext() { + return !!(this._context && this._context.public); + }, + + isInternalContext: function isInternalContext() { + return !!(this._context && this._context.internal); + } + }, + { + /** + * ## Forge + * Ensure that context gets set as part of the forge + * + * @param {object} attributes + * @param {object} options + * @returns {Bookshelf.Model} model + */ + forge: function forge(attributes, options) { + var self = model.forge.apply(this, arguments); + + if (options && options.context) { + self._context = options.context; + delete options.context; + } + + return self; + } + }); + + Bookshelf.Model = Model; +}; diff --git a/core/server/models/plugins/collision.js b/core/server/models/plugins/collision.js new file mode 100644 index 0000000..9a0926b --- /dev/null +++ b/core/server/models/plugins/collision.js @@ -0,0 +1,85 @@ +var moment = require('moment-timezone'), + Promise = require('bluebird'), + _ = require('lodash'), + errors = require('../../errors'); + +module.exports = function (Bookshelf) { + var ParentModel = Bookshelf.Model, + Model; + + Model = Bookshelf.Model.extend({ + /** + * Update collision protection. + * + * IMPORTANT NOTES: + * The `sync` method is called for any query e.g. update, add, delete, fetch + * + * We had the option to override Bookshelf's `save` method, but hooking into the `sync` method gives us + * the ability to access the `changed` object. Bookshelf already knows which attributes has changed. + * + * Bookshelf's timestamp function can't be overridden, as it's synchronous, there is no way to return an Error. + * + * If we want to enable the collision plugin for other tables, the queries might need to run in a transaction. + * This depends on if we fetch the model before editing. Imagine two concurrent requests come in, both would fetch + * the same current database values and both would succeed to update and override each other. + */ + sync: function timestamp(options) { + var parentSync = ParentModel.prototype.sync.apply(this, arguments), + originalUpdateSync = parentSync.update, + self = this, err; + + // CASE: only enabled for posts table + if (this.tableName !== 'posts' || + !self.serverData || + ((options.method !== 'update' && options.method !== 'patch') || !options.method) + ) { + return parentSync; + } + + /** + * Only hook into the update sync + * + * IMPORTANT NOTES: + * Even if the client sends a different `id` property, it get's ignored by bookshelf. + * Because you can't change the `id` of an existing post. + * + * HTML is always auto generated, ignore. + */ + parentSync.update = function update() { + var changed = _.omit(self.changed, [ + 'created_at', 'updated_at', 'author_id', 'id', + 'published_by', 'updated_by', 'html' + ]), + clientUpdatedAt = moment(self.clientData.updated_at || self.serverData.updated_at || new Date()), + serverUpdatedAt = moment(self.serverData.updated_at || clientUpdatedAt); + + if (Object.keys(changed).length) { + if (clientUpdatedAt.diff(serverUpdatedAt) !== 0) { + err = new errors.InternalServerError('Saving failed! Someone else is editing this post.'); + err.code = 'UPDATE_COLLISION'; + return Promise.reject(err); + } + } + + return originalUpdateSync.apply(this, arguments); + }; + + return parentSync; + }, + + /** + * We have to remember current server data and client data. + * The `sync` method has no access to it. + * `updated_at` is already set to "Date.now" when the overridden `sync.update` is called. + * See https://github.com/tgriesser/bookshelf/blob/79c526870e618748caf94e7476a0bc796ee090a6/src/model.js#L955 + */ + save: function save(data) { + this.clientData = _.cloneDeep(data) || {}; + this.serverData = _.cloneDeep(this.attributes); + + return ParentModel.prototype.save.apply(this, arguments); + } + }); + + Bookshelf.Model = Model; +}; diff --git a/core/server/models/plugins/filter.js b/core/server/models/plugins/filter.js new file mode 100644 index 0000000..d251f38 --- /dev/null +++ b/core/server/models/plugins/filter.js @@ -0,0 +1,181 @@ +var _ = require('lodash'), + errors = require('../../errors'), + gql = require('ghost-gql'), + i18n = require('../../i18n'), + filter, + filterUtils; + +filterUtils = { + /** + * ## Combine Filters + * Util to combine the enforced, default and custom filters such that they behave accordingly + * @param {String|Object} enforced - filters which must ALWAYS be applied + * @param {String|Object} defaults - filters which must be applied if a matching filter isn't provided + * @param {...String|Object} [custom] - custom filters which are additional + * @returns {*} + */ + combineFilters: function combineFilters(enforced, defaults, custom /* ...custom */) { + custom = Array.prototype.slice.call(arguments, 2); + + // Ensure everything has been run through the gql parser + try { + enforced = enforced ? (_.isString(enforced) ? gql.parse(enforced) : enforced) : null; + defaults = defaults ? (_.isString(defaults) ? gql.parse(defaults) : defaults) : null; + custom = _.map(custom, function (arg) { + return _.isString(arg) ? gql.parse(arg) : arg; + }); + } catch (error) { + errors.logAndThrowError( + new errors.ValidationError(error.message, 'filter'), + i18n.t('errors.models.plugins.filter.errorParsing'), + i18n.t('errors.models.plugins.filter.forInformationRead', {url: 'http://api.ghost.org/docs/filter'}) + ); + } + + // Merge custom filter options into a single set of statements + custom = gql.json.mergeStatements.apply(this, custom); + + // if there is no enforced or default statements, return just the custom statements; + if (!enforced && !defaults) { + return custom; + } + + // Reduce custom filters based on enforced filters + if (custom && !_.isEmpty(custom.statements) && enforced && !_.isEmpty(enforced.statements)) { + custom.statements = gql.json.rejectStatements(custom.statements, function (customStatement) { + return gql.json.findStatement(enforced.statements, customStatement, 'prop'); + }); + } + + // Reduce default filters based on custom filters + if (defaults && !_.isEmpty(defaults.statements) && custom && !_.isEmpty(custom.statements)) { + defaults.statements = gql.json.rejectStatements(defaults.statements, function (defaultStatement) { + return gql.json.findStatement(custom.statements, defaultStatement, 'prop'); + }); + } + + // Merge enforced and defaults + enforced = gql.json.mergeStatements(enforced, defaults); + + if (_.isEmpty(custom.statements)) { + return enforced; + } + + if (_.isEmpty(enforced.statements)) { + return custom; + } + + return { + statements: [ + {group: enforced.statements}, + {group: custom.statements, func: 'and'} + ] + }; + } +}; + +filter = function filter(Bookshelf) { + var Model = Bookshelf.Model.extend({ + // Cached copy of the filters setup for this model instance + _filters: null, + // Override these on the various models + enforcedFilters: function enforcedFilters() {}, + defaultFilters: function defaultFilters() {}, + + /** + * ## Post process Filters + * Post Process filters looking for joins etc + * @TODO refactor this + * @param {object} options + */ + postProcessFilters: function postProcessFilters(options) { + var joinTables = this._filters.joins; + + if (joinTables && joinTables.indexOf('tags') > -1) { + // We need to use leftOuterJoin to insure we still include posts which don't have tags in the result + // The where clause should restrict which items are returned + this + .query('leftOuterJoin', 'posts_tags', 'posts_tags.post_id', '=', 'posts.id') + .query('leftOuterJoin', 'tags', 'posts_tags.tag_id', '=', 'tags.id'); + + // The order override should ONLY happen if we are doing an "IN" query + // TODO move the order handling to the query building that is currently inside pagination + // TODO make the order handling in pagination handle orderByRaw + // TODO extend this handling to all joins + if (gql.json.findStatement(this._filters.statements, {prop: /^tags/, op: 'IN'})) { + // TODO make this count the number of MATCHING tags, not just the number of tags + this.query('orderByRaw', 'count(tags.id) DESC'); + } + + // We need to add a group by to counter the double left outer join + // TODO improve on the group by handling + options.groups = options.groups || []; + options.groups.push('posts.id'); + } + + if (joinTables && joinTables.indexOf('author') > -1) { + this + .query('join', 'users as author', 'author.id', '=', 'posts.author_id'); + } + }, + + /** + * ## fetchAndCombineFilters + * Helper method, uses the combineFilters util to apply filters to the current model instance + * based on options and the set enforced/default filters for this resource + * @param {Object} options + * @returns {Bookshelf.Model} + */ + fetchAndCombineFilters: function fetchAndCombineFilters(options) { + options = options || {}; + + this._filters = filterUtils.combineFilters( + this.enforcedFilters(), + this.defaultFilters(), + options.filter, + options.where + ); + + return this; + }, + + /** + * ## Apply Filters + * Method which makes the necessary query builder calls (through knex) for the filters set + * on this model instance + * @param {Object} options + * @returns {Bookshelf.Model} + */ + applyDefaultAndCustomFilters: function applyDefaultAndCustomFilters(options) { + var self = this; + + // @TODO figure out a better place/way to trigger loading filters + if (!this._filters) { + this.fetchAndCombineFilters(options); + } + + if (this._filters) { + if (this.debug) { + gql.json.printStatements(this._filters.statements); + } + + this.query(function (qb) { + gql.knexify(qb, self._filters); + }); + + // Replaces processGQLResult + this.postProcessFilters(options); + } + + return this; + } + }); + + Bookshelf.Model = Model; +}; + +/** + * ## Export Filter plugin + * @api public + */ +module.exports = filter; diff --git a/core/server/models/plugins/include-count.js b/core/server/models/plugins/include-count.js new file mode 100644 index 0000000..105286c --- /dev/null +++ b/core/server/models/plugins/include-count.js @@ -0,0 +1,97 @@ +var _ = require('lodash'); + +module.exports = function (Bookshelf) { + var modelProto = Bookshelf.Model.prototype, + Model, + countQueryBuilder; + + countQueryBuilder = { + tags: { + posts: function addPostCountToTags(model) { + model.query('columns', 'tags.*', function (qb) { + qb.count('posts.id') + .from('posts') + .leftOuterJoin('posts_tags', 'posts.id', 'posts_tags.post_id') + .whereRaw('posts_tags.tag_id = tags.id') + .as('count__posts'); + + if (model.isPublicContext()) { + // @TODO use the filter behavior for posts + qb.andWhere('posts.page', '=', false); + qb.andWhere('posts.status', '=', 'published'); + } + }); + } + }, + users: { + posts: function addPostCountToTags(model) { + model.query('columns', 'users.*', function (qb) { + qb.count('posts.id') + .from('posts') + .whereRaw('posts.author_id = users.id') + .as('count__posts'); + + if (model.isPublicContext()) { + // @TODO use the filter behavior for posts + qb.andWhere('posts.page', '=', false); + qb.andWhere('posts.status', '=', 'published'); + } + }); + } + } + }; + + Model = Bookshelf.Model.extend({ + addCounts: function (options) { + if (!options) { + return; + } + + var tableName = _.result(this, 'tableName'); + + if (options.include && options.include.indexOf('count.posts') > -1) { + // remove post_count from withRelated and include + options.withRelated = _.pull([].concat(options.withRelated), 'count.posts'); + + // Call the query builder + countQueryBuilder[tableName].posts(this); + } + }, + fetch: function () { + this.addCounts.apply(this, arguments); + + if (this.debug) { + console.log('QUERY', this.query().toQuery()); + } + + // Call parent fetch + return modelProto.fetch.apply(this, arguments); + }, + fetchAll: function () { + this.addCounts.apply(this, arguments); + + if (this.debug) { + console.log('QUERY', this.query().toQuery()); + } + + // Call parent fetchAll + return modelProto.fetchAll.apply(this, arguments); + }, + + finalize: function (attrs) { + var countRegex = /^(count)(__)(.*)$/; + _.forOwn(attrs, function (value, key) { + var match = key.match(countRegex); + if (match) { + attrs[match[1]] = attrs[match[1]] || {}; + attrs[match[1]][match[3]] = value; + delete attrs[key]; + } + }); + + return attrs; + } + }); + + Bookshelf.Model = Model; +}; diff --git a/core/server/models/plugins/index.js b/core/server/models/plugins/index.js new file mode 100644 index 0000000..c168a44 --- /dev/null +++ b/core/server/models/plugins/index.js @@ -0,0 +1,7 @@ +module.exports = { + accessRules: require('./access-rules'), + filter: require('./filter'), + includeCount: require('./include-count'), + pagination: require('./pagination'), + collision: require('./collision') +}; diff --git a/core/server/models/plugins/pagination.js b/core/server/models/plugins/pagination.js new file mode 100644 index 0000000..a9523a2 --- /dev/null +++ b/core/server/models/plugins/pagination.js @@ -0,0 +1,207 @@ +// # Pagination +// +// Extends Bookshelf.Model with a `fetchPage` method. Handles everything to do with paginated requests. +var _ = require('lodash'), + defaults, + paginationUtils, + pagination; + +/** + * ### Default pagination values + * These are overridden via `options` passed to each function + * @typedef {Object} defaults + * @default + * @property {Number} `page` \- page in set to display (default: 1) + * @property {Number|String} `limit` \- no. results per page (default: 15) + */ +defaults = { + page: 1, + limit: 15 +}; + +/** + * ## Pagination Utils + * @api private + * @type {{parseOptions: Function, query: Function, formatResponse: Function}} + */ +paginationUtils = { + /** + * ### Parse Options + * Take the given options and ensure they are valid pagination options, else use the defaults + * @param {options} options + * @returns {options} options sanitised for pagination + */ + parseOptions: function parseOptions(options) { + options = _.defaults(options || {}, defaults); + + if (options.limit !== 'all') { + options.limit = parseInt(options.limit, 10) || defaults.limit; + } + + options.page = parseInt(options.page, 10) || defaults.page; + + return options; + }, + /** + * ### Query + * Apply the necessary parameters to paginate the query + * @param {bookshelf.Model} model + * @param {options} options + */ + addLimitAndOffset: function addLimitAndOffset(model, options) { + if (_.isNumber(options.limit)) { + model + .query('limit', options.limit) + .query('offset', options.limit * (options.page - 1)); + } + }, + + /** + * ### Format Response + * Takes the no. items returned and original options and calculates all of the pagination meta data + * @param {Number} totalItems + * @param {options} options + * @returns {pagination} pagination metadata + */ + formatResponse: function formatResponse(totalItems, options) { + var calcPages = Math.ceil(totalItems / options.limit) || 0, + pagination = { + page: options.page || defaults.page, + limit: options.limit, + pages: calcPages === 0 ? 1 : calcPages, + total: totalItems, + next: null, + prev: null + }; + + if (pagination.pages > 1) { + if (pagination.page === 1) { + pagination.next = pagination.page + 1; + } else if (pagination.page === pagination.pages) { + pagination.prev = pagination.page - 1; + } else { + pagination.next = pagination.page + 1; + pagination.prev = pagination.page - 1; + } + } + + return pagination; + } +}; + +// ## Object Definitions + +/** + * ### Pagination Object + * @typedef {Object} pagination + * @property {Number} page \- page in set to display + * @property {Number|String} limit \- no. results per page, or 'all' + * @property {Number} pages \- total no. pages in the full set + * @property {Number} total \- total no. items in the full set + * @property {Number|null} next \- next page + * @property {Number|null} prev \- previous page + */ + +/** + * ### Fetch Page Options + * @typedef {Object} options + * @property {Number} page \- page in set to display + * @property {Number|String} limit \- no. results per page, or 'all' + * @property {Object} order \- set of order by params and directions + */ + +/** + * ### Fetch Page Response + * @typedef {Object} paginatedResult + * @property {Array} collection \- set of results + * @property {pagination} pagination \- pagination metadata + */ + +/** + * ## Pagination + * Extends `bookshelf.Model` with `fetchPage` + * @param {Bookshelf} bookshelf \- the instance to plug into + */ +pagination = function pagination(bookshelf) { + // Extend updates the first object passed to it, no need for an assignment + _.extend(bookshelf.Model.prototype, { + /** + * ### Fetch page + * A `fetch` extension to get a paginated set of items from a collection + * + * We trigger two queries: + * 1. count query to know how many pages left (important: we don't attach any group/order statements!) + * 2. the actualy fetch query with limit and page property + * + * @param {options} options + * @returns {paginatedResult} set of results + pagination metadata + */ + fetchPage: function fetchPage(options) { + // Setup pagination options + options = paginationUtils.parseOptions(options); + + // Get the table name and idAttribute for this model + var tableName = _.result(this.constructor.prototype, 'tableName'), + idAttribute = _.result(this.constructor.prototype, 'idAttribute'), + self = this, + countPromise = this.query().clone().select( + bookshelf.knex.raw('count(distinct ' + tableName + '.' + idAttribute + ') as aggregate') + ); + + // the debug flag doesn't work for the raw knex count query! + if (this.debug) { + console.log('COUNT', countPromise.toQuery()); + } + + // #### Pre count clauses + // Add any where or join clauses which need to be included with the aggregate query + + // Clone the base query & set up a promise to get the count of total items in the full set + // Due to lack of support for count distinct, this is pretty complex. + return countPromise.then(function (countResult) { + // #### Post count clauses + // Add any where or join clauses which need to NOT be included with the aggregate query + + // Setup the pagination parameters so that we return the correct items from the set + paginationUtils.addLimitAndOffset(self, options); + + // Apply ordering options if they are present + if (options.order && !_.isEmpty(options.order)) { + _.forOwn(options.order, function (direction, property) { + if (property === 'count.posts') { + self.query('orderBy', 'count__posts', direction); + } else { + self.query('orderBy', tableName + '.' + property, direction); + } + }); + } else if (options.orderRaw) { + self.query(function (qb) { + qb.orderByRaw(options.orderRaw); + }); + } + + if (options.groups && !_.isEmpty(options.groups)) { + _.each(options.groups, function (group) { + self.query('groupBy', group); + }); + } + + // Setup the promise to do a fetch on our collection, running the specified query + // @TODO: ensure option handling is done using an explicit pick elsewhere + return self.fetchAll(_.omit(options, ['page', 'limit'])) + .then(function (fetchResult) { + return { + collection: fetchResult, + pagination: paginationUtils.formatResponse(countResult[0] ? countResult[0].aggregate : 0, options) + }; + }); + }); + } + }); +}; + +/** + * ## Export pagination plugin + * @api public + */ +module.exports = pagination; diff --git a/core/server/models/post.js b/core/server/models/post.js new file mode 100644 index 0000000..942c497 --- /dev/null +++ b/core/server/models/post.js @@ -0,0 +1,796 @@ +// # Post Model +var _ = require('lodash'), + uuid = require('uuid'), + moment = require('moment'), + Promise = require('bluebird'), + sequence = require('../utils/sequence'), + errors = require('../errors'), + Showdown = require('showdown-ghost'), + converter = new Showdown.converter({extensions: ['ghostgfm', 'footnotes', 'highlight']}), + ghostBookshelf = require('./base'), + events = require('../events'), + config = require('../config'), + baseUtils = require('./base/utils'), + i18n = require('../i18n'), + Post, + Posts; + +Post = ghostBookshelf.Model.extend({ + + tableName: 'posts', + + emitChange: function emitChange(event, usePreviousResourceType) { + var resourceType = this.get('page') ? 'page' : 'post'; + if (usePreviousResourceType) { + resourceType = this.updated('page') ? 'page' : 'post'; + } + events.emit(resourceType + '.' + event, this); + }, + + defaults: function defaults() { + return { + uuid: uuid.v4(), + status: 'draft' + }; + }, + + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + + /** + * http://knexjs.org/#Builder-forUpdate + * https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html + * + * Lock target collection/model for further update operations. + * This avoids collisions and possible content override cases. + * + * `forUpdate` is only supported for posts right now + */ + this.on('fetching:collection', function (model, columns, options) { + if (options.forUpdate && options.transacting) { + options.query.forUpdate(); + } + }); + + this.on('fetching', function (model, columns, options) { + if (options.forUpdate && options.transacting) { + options.query.forUpdate(); + } + }); + + /** + * We update the tags after the Post was inserted. + * We update the tags before the Post was updated, see `onSaving` event. + * `onCreated` is called before `onSaved`. + */ + this.on('created', function onCreated(model, response, options) { + var status = model.get('status'); + + model.emitChange('added'); + + if (['published', 'scheduled'].indexOf(status) !== -1) { + model.emitChange(status); + } + + return this.updateTags(model, response, options); + }); + + this.on('updated', function onUpdated(model) { + model.statusChanging = model.get('status') !== model.updated('status'); + model.isPublished = model.get('status') === 'published'; + model.isScheduled = model.get('status') === 'scheduled'; + model.wasPublished = model.updated('status') === 'published'; + model.wasScheduled = model.updated('status') === 'scheduled'; + model.resourceTypeChanging = model.get('page') !== model.updated('page'); + model.publishedAtHasChanged = model.hasDateChanged('published_at'); + model.needsReschedule = model.publishedAtHasChanged && model.isScheduled; + + // Handle added and deleted for post -> page or page -> post + if (model.resourceTypeChanging) { + if (model.wasPublished) { + model.emitChange('unpublished', true); + } + + if (model.wasScheduled) { + model.emitChange('unscheduled', true); + } + + model.emitChange('deleted', true); + model.emitChange('added'); + + if (model.isPublished) { + model.emitChange('published'); + } + + if (model.isScheduled) { + model.emitChange('scheduled'); + } + } else { + if (model.statusChanging) { + // CASE: was published before and is now e.q. draft or scheduled + if (model.wasPublished) { + model.emitChange('unpublished'); + } + + // CASE: was draft or scheduled before and is now e.q. published + if (model.isPublished) { + model.emitChange('published'); + } + + // CASE: was draft or published before and is now e.q. scheduled + if (model.isScheduled) { + model.emitChange('scheduled'); + } + + // CASE: from scheduled to something + if (model.wasScheduled && !model.isScheduled && !model.isPublished) { + model.emitChange('unscheduled'); + } + } else { + if (model.isPublished) { + model.emitChange('published.edited'); + } + + if (model.needsReschedule) { + model.emitChange('rescheduled'); + } + } + + // Fire edited if this wasn't a change between resourceType + model.emitChange('edited'); + } + }); + + this.on('destroying', function (model, options) { + return model.load('tags', options) + .then(function (response) { + if (!response.related || !response.related('tags') || !response.related('tags').length) { + return; + } + + return Promise.mapSeries(response.related('tags').models, function (tag) { + return baseUtils.tagUpdate.detachTagFromPost(model, tag, options)(); + }); + }) + .then(function () { + // @TODO: filtering is not possible for subscribers, see https://github.com/TryGhost/GQL/issues/22 + return ghostBookshelf.model('Subscriber') + .where({ + post_id: model.id + }) + .fetchAll(options); + }) + .then(function (response) { + return Promise.each(response.models, function (subscriber) { + subscriber.set('post_id', null); + return subscriber.save(null, options); + }); + }) + .then(function () { + if (model.previous('status') === 'published') { + model.emitChange('unpublished'); + } + + model.emitChange('deleted'); + }); + }); + }, + + saving: function saving(model, attr, options) { + options = options || {}; + + var self = this, + title, + i, + // Variables to make the slug checking more readable + newTitle = this.get('title'), + newStatus = this.get('status'), + olderStatus = this.previous('status'), + prevTitle = this._previousAttributes.title, + prevSlug = this._previousAttributes.slug, + tagsToCheck = this.get('tags'), + publishedAt = this.get('published_at'), + publishedAtHasChanged = this.hasDateChanged('published_at', {beforeWrite: true}), + tags = [], ops = []; + + // CASE: disallow published -> scheduled + // @TODO: remove when we have versioning based on updated_at + if (newStatus !== olderStatus && newStatus === 'scheduled' && olderStatus === 'published') { + return Promise.reject(new errors.ValidationError( + i18n.t('errors.models.post.isAlreadyPublished', {key: 'status'}) + )); + } + + // CASE: both page and post can get scheduled + if (newStatus === 'scheduled') { + if (!publishedAt) { + return Promise.reject(new errors.ValidationError( + i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'}) + )); + } else if (!moment(publishedAt).isValid()) { + return Promise.reject(new errors.ValidationError( + i18n.t('errors.models.post.valueCannotBeBlank', {key: 'published_at'}) + )); + // CASE: to schedule/reschedule a post, a minimum diff of x minutes is needed (default configured is 2minutes) + } else if ( + publishedAtHasChanged && + moment(publishedAt).isBefore(moment().add(config.times.cannotScheduleAPostBeforeInMinutes, 'minutes')) && + !options.importing + ) { + return Promise.reject(new errors.ValidationError( + i18n.t('errors.models.post.expectedPublishedAtInFuture', { + cannotScheduleAPostBeforeInMinutes: config.times.cannotScheduleAPostBeforeInMinutes + }) + )); + } + } + + // If we have a tags property passed in + if (!_.isUndefined(tagsToCheck) && !_.isNull(tagsToCheck)) { + // and deduplicate upper/lowercase tags + _.each(tagsToCheck, function each(item) { + for (i = 0; i < tags.length; i = i + 1) { + if (tags[i].name.toLocaleLowerCase() === item.name.toLocaleLowerCase()) { + return; + } + } + + tags.push(item); + }); + + // keep tags for 'saved' event + // get('tags') will be removed after saving, because it's not a direct attribute of posts (it's a relation) + this.tagsToSave = tags; + } + + ghostBookshelf.Model.prototype.saving.call(this, model, attr, options); + + this.set('html', converter.makeHtml(_.toString(this.get('markdown')))); + + // disabling sanitization until we can implement a better version + title = this.get('title') || i18n.t('errors.models.post.untitled'); + this.set('title', _.toString(title).trim()); + + // ### Business logic for published_at and published_by + // If the current status is 'published' and published_at is not set, set it to now + if (newStatus === 'published' && !publishedAt) { + this.set('published_at', new Date()); + } + + // If the current status is 'published' and the status has just changed ensure published_by is set correctly + if (newStatus === 'published' && this.hasChanged('status')) { + // unless published_by is set and we're importing, set published_by to contextUser + if (!(this.get('published_by') && options.importing)) { + this.set('published_by', this.contextUser(options)); + } + } else { + // In any other case (except import), `published_by` should not be changed + if (this.hasChanged('published_by') && !options.importing) { + this.set('published_by', this.previous('published_by')); + } + } + + /** + * - `updateTags` happens before the post is saved to the database + * - when editing a post, it's running in a transaction, see `Post.edit` + * - we are using a update collision detection, we have to know if tags were updated in the client + * + * NOTE: For adding a post, updateTags happens after the post insert, see `onCreated` event + */ + if (options.method === 'update' || options.method === 'patch') { + ops.push(function updateTags() { + return self.updateTags(model, attr, options); + }); + } + + // If a title is set, not the same as the old title, a draft post, and has never been published + if (prevTitle !== undefined && newTitle !== prevTitle && newStatus === 'draft' && !publishedAt) { + ops.push(function updateSlug() { + // Pass the new slug through the generator to strip illegal characters, detect duplicates + return ghostBookshelf.Model.generateSlug(Post, self.get('title'), + {status: 'all', transacting: options.transacting, importing: options.importing}) + .then(function then(slug) { + // After the new slug is found, do another generate for the old title to compare it to the old slug + return ghostBookshelf.Model.generateSlug(Post, prevTitle, + {status: 'all', transacting: options.transacting, importing: options.importing} + ).then(function then(prevTitleSlug) { + // If the old slug is the same as the slug that was generated from the old title + // then set a new slug. If it is not the same, means was set by the user + if (prevTitleSlug === prevSlug) { + self.set({slug: slug}); + } + }); + }); + }); + } else { + ops.push(function updateSlug() { + // If any of the attributes above were false, set initial slug and check to see if slug was changed by the user + if (self.hasChanged('slug') || !self.get('slug')) { + // Pass the new slug through the generator to strip illegal characters, detect duplicates + return ghostBookshelf.Model.generateSlug(Post, self.get('slug') || self.get('title'), + {status: 'all', transacting: options.transacting, importing: options.importing}) + .then(function then(slug) { + self.set({slug: slug}); + }); + } + + return Promise.resolve(); + }); + } + + return sequence(ops); + }, + + creating: function creating(model, attr, options) { + options = options || {}; + + // set any dynamic default properties + if (!this.get('author_id')) { + this.set('author_id', this.contextUser(options)); + } + + ghostBookshelf.Model.prototype.creating.call(this, model, attr, options); + }, + + /** + * ### updateTags + * Update tags that are attached to a post. Create any tags that don't already exist. + * @param {Object} savedModel + * @param {Object} response + * @param {Object} options + * @return {Promise(ghostBookshelf.Models.Post)} Updated Post model + */ + updateTags: function updateTags(savedModel, response, options) { + if (_.isUndefined(this.tagsToSave)) { + // The tag property was not set, so we shouldn't be doing any playing with tags on this request + return Promise.resolve(); + } + + var newTags = this.tagsToSave, + TagModel = ghostBookshelf.model('Tag'); + + options = options || {}; + + function doTagUpdates(options) { + return Promise.props({ + currentPost: baseUtils.tagUpdate.fetchCurrentPost(Post, savedModel.id, options), + existingTags: baseUtils.tagUpdate.fetchMatchingTags(TagModel, newTags, options) + }).then(function fetchedData(results) { + var currentTags = results.currentPost.related('tags').toJSON(options), + existingTags = results.existingTags ? results.existingTags.toJSON(options) : [], + tagOps = [], + tagsToRemove, + tagsToCreate; + + // CASE: if nothing has changed, unset `tags`. + if (baseUtils.tagUpdate.tagSetsAreEqual(newTags, currentTags)) { + savedModel.unset('tags'); + return; + } + + // Tags from the current tag array which don't exist in the new tag array should be removed + tagsToRemove = _.reject(currentTags, function (currentTag) { + if (newTags.length === 0) { + return false; + } + return _.some(newTags, function (newTag) { + return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag); + }); + }); + + // Tags from the new tag array which don't exist in the DB should be created + tagsToCreate = _.map(_.reject(newTags, function (newTag) { + return _.some(existingTags, function (existingTag) { + return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag); + }); + }), 'name'); + + // Remove any tags which don't exist anymore + _.each(tagsToRemove, function (tag) { + tagOps.push(baseUtils.tagUpdate.detachTagFromPost(savedModel, tag, options)); + }); + + // Loop through the new tags and either add them, attach them, or update them + _.each(newTags, function (newTag, index) { + var tag; + + if (tagsToCreate.indexOf(newTag.name) > -1) { + tagOps.push(baseUtils.tagUpdate.createTagThenAttachTagToPost(TagModel, savedModel, newTag, index, options)); + } else { + // try to find a tag on the current post which matches + tag = _.find(currentTags, function (currentTag) { + return baseUtils.tagUpdate.tagsAreEqual(currentTag, newTag); + }); + + if (tag) { + tagOps.push(baseUtils.tagUpdate.updateTagOrderForPost(savedModel, tag, index, options)); + return; + } + + // else finally, find the existing tag which matches + tag = _.find(existingTags, function (existingTag) { + return baseUtils.tagUpdate.tagsAreEqual(existingTag, newTag); + }); + + if (tag) { + tagOps.push(baseUtils.tagUpdate.attachTagToPost(savedModel, tag, index, options)); + } + } + }); + + return sequence(tagOps); + }); + } + + // Handle updating tags in a transaction, unless we're already in one + if (options.transacting) { + return doTagUpdates(options); + } else { + return ghostBookshelf.transaction(function (t) { + options.transacting = t; + + return doTagUpdates(options); + }).then(function () { + // Don't do anything, the transaction processed ok + }).catch(function failure(error) { + errors.logError( + error, + i18n.t('errors.models.post.tagUpdates.error'), + i18n.t('errors.models.post.tagUpdates.help') + ); + return Promise.reject(new errors.InternalServerError( + i18n.t('errors.models.post.tagUpdates.error') + ' ' + i18n.t('errors.models.post.tagUpdates.help') + error + )); + }); + } + }, + + // Relations + author: function author() { + return this.belongsTo('User', 'author_id'); + }, + + created_by: function createdBy() { + return this.belongsTo('User', 'created_by'); + }, + + updated_by: function updatedBy() { + return this.belongsTo('User', 'updated_by'); + }, + + published_by: function publishedBy() { + return this.belongsTo('User', 'published_by'); + }, + + tags: function tags() { + return this.belongsToMany('Tag').withPivot('sort_order').query('orderBy', 'sort_order', 'ASC'); + }, + + fields: function fields() { + return this.morphMany('AppField', 'relatable'); + }, + + defaultColumnsToFetch: function defaultColumnsToFetch() { + return ['id', 'published_at', 'slug', 'author_id']; + }, + + toJSON: function toJSON(options) { + options = options || {}; + + var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); + + if (!options.columns || (options.columns && options.columns.indexOf('author') > -1)) { + attrs.author = attrs.author || attrs.author_id; + delete attrs.author_id; + } + + if (!options.columns || (options.columns && options.columns.indexOf('url') > -1)) { + attrs.url = config.urlPathForPost(attrs); + } + + return attrs; + }, + enforcedFilters: function enforcedFilters() { + return this.isPublicContext() ? 'status:published' : null; + }, + defaultFilters: function defaultFilters() { + if (this.isInternalContext()) { + return null; + } + + return this.isPublicContext() ? 'page:false' : 'page:false+status:published'; + } +}, { + orderDefaultOptions: function orderDefaultOptions() { + return { + status: 'ASC', + published_at: 'DESC', + updated_at: 'DESC', + id: 'DESC' + }; + }, + + orderDefaultRaw: function () { + return '' + + 'CASE WHEN posts.status = \'scheduled\' THEN 1 ' + + 'WHEN posts.status = \'draft\' THEN 2 ' + + 'ELSE 3 END ASC,' + + 'posts.published_at DESC,' + + 'posts.updated_at DESC,' + + 'posts.id DESC'; + }, + + /** + * @deprecated in favour of filter + */ + processOptions: function processOptions(options) { + if (!options.staticPages && !options.status) { + return options; + } + + // This is the only place that 'options.where' is set now + options.where = {statements: []}; + + // Step 4: Setup filters (where clauses) + if (options.staticPages && options.staticPages !== 'all') { + // convert string true/false to boolean + if (!_.isBoolean(options.staticPages)) { + options.staticPages = _.includes(['true', '1'], options.staticPages); + } + options.where.statements.push({prop: 'page', op: '=', value: options.staticPages}); + delete options.staticPages; + } else if (options.staticPages === 'all') { + options.where.statements.push({prop: 'page', op: 'IN', value: [true, false]}); + delete options.staticPages; + } + + // Unless `all` is passed as an option, filter on + // the status provided. + if (options.status && options.status !== 'all') { + // make sure that status is valid + options.status = _.includes(['published', 'draft', 'scheduled'], options.status) ? options.status : 'published'; + options.where.statements.push({prop: 'status', op: '=', value: options.status}); + delete options.status; + } else if (options.status === 'all') { + options.where.statements.push({prop: 'status', op: 'IN', value: ['published', 'draft', 'scheduled']}); + delete options.status; + } + + return options; + }, + + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['columns', 'importing', 'withRelated', 'require', 'forUpdate'], + findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status', 'staticPages'], + findAll: ['columns', 'filter', 'forUpdate'], + edit: ['forUpdate'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + /** + * Manually add 'tags' attribute since it's not in the schema and call parent. + * + * @param {Object} data Has keys representing the model's attributes/fields in the database. + * @return {Object} The filtered results of the passed in data, containing only what's allowed in the schema. + */ + filterData: function filterData(data) { + var filteredData = ghostBookshelf.Model.filterData.apply(this, arguments), + extraData = _.pick(data, ['tags']); + + _.merge(filteredData, extraData); + return filteredData; + }, + + // ## Model Data Functions + + /** + * ### Find One + * @extends ghostBookshelf.Model.findOne to handle post status + * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One) + */ + findOne: function findOne(data, options) { + options = options || {}; + + var withNext = _.includes(options.include, 'next'), + withPrev = _.includes(options.include, 'previous'), + nextRelations = _.transform(options.include, function (relations, include) { + if (include === 'next.tags') { + relations.push('tags'); + } else if (include === 'next.author') { + relations.push('author'); + } + }, []), + prevRelations = _.transform(options.include, function (relations, include) { + if (include === 'previous.tags') { + relations.push('tags'); + } else if (include === 'previous.author') { + relations.push('author'); + } + }, []); + + data = _.defaults(data || {}, { + status: 'published' + }); + + if (data.status === 'all') { + delete data.status; + } + + // Add related objects, excluding next and previous as they are not real db objects + options.withRelated = _.union(options.withRelated, _.pull( + [].concat(options.include), + 'next', 'next.author', 'next.tags', 'previous', 'previous.author', 'previous.tags') + ); + + return ghostBookshelf.Model.findOne.call(this, data, options).then(function then(post) { + if ((withNext || withPrev) && post && !post.page) { + var publishedAt = moment(post.get('published_at')).format('YYYY-MM-DD HH:mm:ss'), + prev, + next; + + if (withNext) { + next = Post.forge().query(function queryBuilder(qb) { + qb.where('status', '=', 'published') + .andWhere('page', '=', 0) + .andWhere('published_at', '>', publishedAt) + .orderBy('published_at', 'asc') + .limit(1); + }).fetch({withRelated: nextRelations}); + } + + if (withPrev) { + prev = Post.forge().query(function queryBuilder(qb) { + qb.where('status', '=', 'published') + .andWhere('page', '=', 0) + .andWhere('published_at', '<', publishedAt) + .orderBy('published_at', 'desc') + .limit(1); + }).fetch({withRelated: prevRelations}); + } + + return Promise.join(next, prev) + .then(function then(nextAndPrev) { + if (nextAndPrev[0]) { + post.relations.next = nextAndPrev[0]; + } + if (nextAndPrev[1]) { + post.relations.previous = nextAndPrev[1]; + } + return post; + }); + } + + return post; + }); + }, + + /** + * ### Edit + * Fetches and saves to Post. See model.Base.edit + * + * @extends ghostBookshelf.Model.edit to handle returning the full object and manage _updatedAttributes + * **See:** [ghostBookshelf.Model.edit](base.js.html#edit) + */ + edit: function edit(data, options) { + var self = this, + editPost = function editPost(data, options) { + options.forUpdate = true; + + return ghostBookshelf.Model.edit.call(self, data, options).then(function then(post) { + return self.findOne({status: 'all', id: options.id}, options) + .then(function then(found) { + if (found) { + // Pass along the updated attributes for checking status changes + found._updatedAttributes = post._updatedAttributes; + return found; + } + }); + }); + }; + + options = options || {}; + + if (options.transacting) { + return editPost(data, options); + } + + return ghostBookshelf.transaction(function (transacting) { + options.transacting = transacting; + return editPost(data, options); + }); + }, + + /** + * ### Add + * @extends ghostBookshelf.Model.add to handle returning the full object + * **See:** [ghostBookshelf.Model.add](base.js.html#add) + */ + add: function add(data, options) { + var self = this; + options = options || {}; + + return ghostBookshelf.Model.add.call(this, data, options).then(function then(post) { + return self.findOne({status: 'all', id: post.id}, options); + }); + }, + + /** + * ### destroyByAuthor + * @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy + */ + destroyByAuthor: Promise.method(function destroyByAuthor(options) { + var postCollection = Posts.forge(), + authorId = options.id; + + options = this.filterOptions(options, 'destroyByAuthor'); + + if (!authorId) { + throw new errors.NotFoundError(i18n.t('errors.models.post.noUserFound')); + } + + return postCollection + .query('where', 'author_id', '=', authorId) + .fetch(options) + .call('invokeThen', 'destroy', options) + .catch(function (error) { + throw new errors.InternalServerError(error.message || error); + }); + }), + + permissible: function permissible(postModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) { + var self = this, + postModel = postModelOrId, + origArgs; + + // If we passed in an id instead of a model, get the model + // then check the permissions + if (_.isNumber(postModelOrId) || _.isString(postModelOrId)) { + // Grab the original args without the first one + origArgs = _.toArray(arguments).slice(1); + + // Get the actual post model + return this.findOne({id: postModelOrId, status: 'all'}).then(function then(foundPostModel) { + // Build up the original args but substitute with actual model + var newArgs = [foundPostModel].concat(origArgs); + + return self.permissible.apply(self, newArgs); + }, errors.logAndThrowError); + } + + if (postModel) { + // If this is the author of the post, allow it. + hasUserPermission = hasUserPermission || context.user === postModel.get('author_id'); + } + + if (hasUserPermission && hasAppPermission) { + return Promise.resolve(); + } + + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.post.notEnoughPermission'))); + } +}); + +Posts = ghostBookshelf.Collection.extend({ + model: Post +}); + +module.exports = { + Post: ghostBookshelf.model('Post', Post), + Posts: ghostBookshelf.collection('Posts', Posts) +}; diff --git a/core/server/models/refreshtoken.js b/core/server/models/refreshtoken.js new file mode 100644 index 0000000..569f1ff --- /dev/null +++ b/core/server/models/refreshtoken.js @@ -0,0 +1,18 @@ +var ghostBookshelf = require('./base'), + Basetoken = require('./base/token'), + + Refreshtoken, + Refreshtokens; + +Refreshtoken = Basetoken.extend({ + tableName: 'refreshtokens' +}); + +Refreshtokens = ghostBookshelf.Collection.extend({ + model: Refreshtoken +}); + +module.exports = { + Refreshtoken: ghostBookshelf.model('Refreshtoken', Refreshtoken), + Refreshtokens: ghostBookshelf.collection('Refreshtokens', Refreshtokens) +}; diff --git a/core/server/models/role.js b/core/server/models/role.js new file mode 100644 index 0000000..56e8c48 --- /dev/null +++ b/core/server/models/role.js @@ -0,0 +1,91 @@ +var _ = require('lodash'), + errors = require('../errors'), + ghostBookshelf = require('./base'), + Promise = require('bluebird'), + i18n = require('../i18n'), + + Role, + Roles; + +Role = ghostBookshelf.Model.extend({ + + tableName: 'roles', + + users: function users() { + return this.belongsToMany('User'); + }, + + permissions: function permissions() { + return this.belongsToMany('Permission'); + } +}, { + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['withRelated'], + findAll: ['withRelated'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + permissible: function permissible(roleModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) { + var self = this, + checkAgainst = [], + origArgs; + + // If we passed in an id instead of a model, get the model + // then check the permissions + if (_.isNumber(roleModelOrId) || _.isString(roleModelOrId)) { + // Grab the original args without the first one + origArgs = _.toArray(arguments).slice(1); + // Get the actual role model + return this.findOne({id: roleModelOrId, status: 'all'}).then(function then(foundRoleModel) { + // Build up the original args but substitute with actual model + var newArgs = [foundRoleModel].concat(origArgs); + + return self.permissible.apply(self, newArgs); + }, errors.logAndThrowError); + } + + if (action === 'assign' && loadedPermissions.user) { + if (_.some(loadedPermissions.user.roles, {name: 'Owner'})) { + checkAgainst = ['Owner', 'Administrator', 'Editor', 'Author']; + } else if (_.some(loadedPermissions.user.roles, {name: 'Administrator'})) { + checkAgainst = ['Administrator', 'Editor', 'Author']; + } else if (_.some(loadedPermissions.user.roles, {name: 'Editor'})) { + checkAgainst = ['Author']; + } + + // Role in the list of permissible roles + hasUserPermission = roleModelOrId && _.includes(checkAgainst, roleModelOrId.get('name')); + } + + if (hasUserPermission && hasAppPermission) { + return Promise.resolve(); + } + + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.role.notEnoughPermission'))); + } +}); + +Roles = ghostBookshelf.Collection.extend({ + model: Role +}); + +module.exports = { + Role: ghostBookshelf.model('Role', Role), + Roles: ghostBookshelf.collection('Roles', Roles) +}; diff --git a/core/server/models/settings.js b/core/server/models/settings.js new file mode 100644 index 0000000..4f7e6da --- /dev/null +++ b/core/server/models/settings.js @@ -0,0 +1,192 @@ +var Settings, + ghostBookshelf = require('./base'), + uuid = require('uuid'), + _ = require('lodash'), + errors = require('../errors'), + Promise = require('bluebird'), + validation = require('../data/validation'), + events = require('../events'), + internalContext = {context: {internal: true}}, + i18n = require('../i18n'), + + defaultSettings; + +// For neatness, the defaults file is split into categories. +// It's much easier for us to work with it as a single level +// instead of iterating those categories every time +function parseDefaultSettings() { + var defaultSettingsInCategories = require('../data/schema/').defaultSettings, + defaultSettingsFlattened = {}; + + _.each(defaultSettingsInCategories, function each(settings, categoryName) { + _.each(settings, function each(setting, settingName) { + setting.type = categoryName; + setting.key = settingName; + + defaultSettingsFlattened[settingName] = setting; + }); + }); + + return defaultSettingsFlattened; +} + +function getDefaultSettings() { + if (!defaultSettings) { + defaultSettings = parseDefaultSettings(); + } + + return defaultSettings; +} + +// Each setting is saved as a separate row in the database, +// but the overlying API treats them as a single key:value mapping +Settings = ghostBookshelf.Model.extend({ + + tableName: 'settings', + + defaults: function defaults() { + return { + uuid: uuid.v4(), + type: 'core' + }; + }, + + emitChange: function emitChange(event) { + events.emit('settings' + '.' + event, this); + }, + + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + + this.on('created', function (model) { + model.emitChange('added'); + model.emitChange(model.attributes.key + '.' + 'added'); + }); + this.on('updated', function (model) { + model.emitChange('edited'); + model.emitChange(model.attributes.key + '.' + 'edited'); + }); + this.on('destroyed', function (model) { + model.emitChange('deleted'); + model.emitChange(model.attributes.key + '.' + 'deleted'); + }); + }, + + validate: function validate(model, attributes, options) { + var self = this, + setting = this.toJSON(); + + return validation.validateSchema(self.tableName, setting).then(function then() { + return validation.validateSettings(getDefaultSettings(), self); + }).then(function () { + var themeName = setting.value || ''; + + if (setting.key !== 'activeTheme' || options.importing) { + return; + } + + return validation.validateActiveTheme(themeName, {showWarning: model.isNew()}); + }); + } +}, { + findOne: function (data, options) { + if (_.isEmpty(data)) { + options = data; + } + + // Allow for just passing the key instead of attributes + if (!_.isObject(data)) { + data = {key: data}; + } + + return Promise.resolve(ghostBookshelf.Model.findOne.call(this, data, options)); + }, + + edit: function (data, options) { + var self = this; + options = this.filterOptions(options, 'edit'); + + if (!Array.isArray(data)) { + data = [data]; + } + + return Promise.map(data, function (item) { + // Accept an array of models as input + if (item.toJSON) { item = item.toJSON(); } + if (!(_.isString(item.key) && item.key.length > 0)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.settings.valueCannotBeBlank'))); + } + + item = self.filterData(item); + + return Settings.forge({key: item.key}).fetch(options).then(function then(setting) { + var saveData = {}; + + if (setting) { + if (item.hasOwnProperty('value')) { + saveData.value = item.value; + } + // Internal context can overwrite type (for fixture migrations) + if (options.context && options.context.internal && item.hasOwnProperty('type')) { + saveData.type = item.type; + } + // it's allowed to edit all attributes in case of importing/migrating + if (options.importing) { + saveData = item; + } + + return setting.save(saveData, options); + } + + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.settings.unableToFindSetting', {key: item.key}))); + }, errors.logAndThrowError); + }); + }, + + populateDefault: function (key) { + if (!getDefaultSettings()[key]) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.settings.unableToFindDefaultSetting', {key: key}))); + } + + return this.findOne({key: key}).then(function then(foundSetting) { + if (foundSetting) { + return foundSetting; + } + + var defaultSetting = _.clone(getDefaultSettings()[key]); + defaultSetting.value = defaultSetting.defaultValue; + + return Settings.forge(defaultSetting).save(null, internalContext); + }); + }, + + populateDefaults: function populateDefaults(options) { + options = options || {}; + + options = _.merge({}, options, internalContext); + + return this.findAll(options).then(function then(allSettings) { + var usedKeys = allSettings.models.map(function mapper(setting) { return setting.get('key'); }), + insertOperations = []; + + _.each(getDefaultSettings(), function each(defaultSetting, defaultSettingKey) { + var isMissingFromDB = usedKeys.indexOf(defaultSettingKey) === -1; + // Temporary code to deal with old databases with currentVersion settings + if (defaultSettingKey === 'databaseVersion' && usedKeys.indexOf('currentVersion') !== -1) { + isMissingFromDB = false; + } + if (isMissingFromDB) { + defaultSetting.value = defaultSetting.defaultValue; + insertOperations.push(Settings.forge(defaultSetting).save(null, options)); + } + }); + + return Promise.all(insertOperations); + }); + } + +}); + +module.exports = { + Settings: ghostBookshelf.model('Settings', Settings) +}; diff --git a/core/server/models/subscriber.js b/core/server/models/subscriber.js new file mode 100644 index 0000000..592d99b --- /dev/null +++ b/core/server/models/subscriber.js @@ -0,0 +1,108 @@ +var ghostBookshelf = require('./base'), + errors = require('../errors'), + events = require('../events'), + i18n = require('../i18n'), + Promise = require('bluebird'), + uuid = require('uuid'), + Subscriber, + Subscribers; + +Subscriber = ghostBookshelf.Model.extend({ + tableName: 'subscribers', + + emitChange: function emitChange(event) { + events.emit('subscriber' + '.' + event, this); + }, + defaults: function defaults() { + return { + uuid: uuid.v4(), + status: 'subscribed' + }; + }, + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + this.on('created', function onCreated(model) { + model.emitChange('added'); + }); + this.on('updated', function onUpdated(model) { + model.emitChange('edited'); + }); + this.on('destroyed', function onDestroyed(model) { + model.emitChange('deleted'); + }); + } +}, { + + orderDefaultOptions: function orderDefaultOptions() { + return {}; + }, + /** + * @deprecated in favour of filter + */ + processOptions: function processOptions(options) { + return options; + }, + + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findPage: ['page', 'limit', 'columns', 'filter', 'order'], + findAll: ['columns'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + permissible: function permissible(postModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) { + // CASE: external is only allowed to add and edit subscribers + if (context.external) { + if (['add', 'edit'].indexOf(action) !== -1) { + return Promise.resolve(); + } + } + + if (hasUserPermission && hasAppPermission) { + return Promise.resolve(); + } + + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.subscriber.notEnoughPermission'))); + }, + + // TODO: This is a copy paste of models/user.js! + getByEmail: function getByEmail(email, options) { + options = options || {}; + options.require = true; + + return Subscribers.forge(options).fetch(options).then(function then(subscribers) { + var subscriberWithEmail = subscribers.find(function findSubscriber(subscriber) { + return subscriber.get('email').toLowerCase() === email.toLowerCase(); + }); + + if (subscriberWithEmail) { + return subscriberWithEmail; + } + }).catch(function (error) { + if (error.message === 'NotFound' || error.message === 'EmptyResponse') { + return Promise.resolve(); + } + + return Promise.reject(error); + }); + } +}); + +Subscribers = ghostBookshelf.Collection.extend({ + model: Subscriber +}); + +module.exports = { + Subscriber: ghostBookshelf.model('Subscriber', Subscriber), + Subscribers: ghostBookshelf.collection('Subscriber', Subscribers) +}; diff --git a/core/server/models/tag.js b/core/server/models/tag.js new file mode 100644 index 0000000..1b845cf --- /dev/null +++ b/core/server/models/tag.js @@ -0,0 +1,127 @@ +var _ = require('lodash'), + ghostBookshelf = require('./base'), + events = require('../events'), + Tag, + Tags; + +Tag = ghostBookshelf.Model.extend({ + + tableName: 'tags', + + emitChange: function emitChange(event) { + events.emit('tag' + '.' + event, this); + }, + + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + + this.on('created', function onCreated(model) { + model.emitChange('added'); + }); + this.on('updated', function onUpdated(model) { + model.emitChange('edited'); + }); + this.on('destroyed', function onDestroyed(model) { + model.emitChange('deleted'); + }); + }, + + saving: function saving(newPage, attr, options) { + /*jshint unused:false*/ + + var self = this; + + ghostBookshelf.Model.prototype.saving.apply(this, arguments); + + if (this.hasChanged('slug') || !this.get('slug')) { + // Pass the new slug through the generator to strip illegal characters, detect duplicates + return ghostBookshelf.Model.generateSlug(Tag, this.get('slug') || this.get('name'), + {transacting: options.transacting}) + .then(function then(slug) { + self.set({slug: slug}); + }); + } + }, + + posts: function posts() { + return this.belongsToMany('Post'); + }, + + toJSON: function toJSON(options) { + options = options || {}; + + var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); + + attrs.parent = attrs.parent || attrs.parent_id; + delete attrs.parent_id; + + return attrs; + } +}, { + orderDefaultOptions: function orderDefaultOptions() { + return {}; + }, + + /** + * @deprecated in favour of filter + */ + processOptions: function processOptions(options) { + return options; + }, + + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findPage: ['page', 'limit', 'columns', 'filter', 'order'], + findAll: ['columns'], + findOne: ['visibility'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + /** + * ### Find One + * @overrides ghostBookshelf.Model.findOne + */ + findOne: function findOne(data, options) { + options = options || {}; + + options = this.filterOptions(options, 'findOne'); + data = this.filterData(data, 'findOne'); + + var tag = this.forge(data); + + // Add related objects + options.withRelated = _.union(options.withRelated, options.include); + + return tag.fetch(options); + }, + + destroy: function destroy(options) { + var id = options.id; + options = this.filterOptions(options, 'destroy'); + + return this.forge({id: id}).fetch({withRelated: ['posts']}).then(function destroyTagsAndPost(tag) { + return tag.related('posts').detach().then(function destroyTags() { + return tag.destroy(options); + }); + }); + } +}); + +Tags = ghostBookshelf.Collection.extend({ + model: Tag +}); + +module.exports = { + Tag: ghostBookshelf.model('Tag', Tag), + Tags: ghostBookshelf.collection('Tags', Tags) +}; diff --git a/core/server/models/user.js b/core/server/models/user.js new file mode 100644 index 0000000..feb3cc4 --- /dev/null +++ b/core/server/models/user.js @@ -0,0 +1,855 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + errors = require('../errors'), + utils = require('../utils'), + gravatar = require('../utils/gravatar'), + bcrypt = require('bcryptjs'), + ghostBookshelf = require('./base'), + crypto = require('crypto'), + validator = require('validator'), + validation = require('../data/validation'), + events = require('../events'), + i18n = require('../i18n'), + pipeline = require('../utils/pipeline'), + + bcryptGenSalt = Promise.promisify(bcrypt.genSalt), + bcryptHash = Promise.promisify(bcrypt.hash), + bcryptCompare = Promise.promisify(bcrypt.compare), + + tokenSecurity = {}, + activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4', 'locked'], + invitedStates = ['invited', 'invited-pending'], + allStates = activeStates.concat(invitedStates), + User, + Users; + +function validatePasswordLength(password) { + return validator.isLength(password, 8); +} + +function generatePasswordHash(password) { + // Generate a new salt + return bcryptGenSalt().then(function (salt) { + // Hash the provided password with bcrypt + return bcryptHash(password, salt); + }); +} + +User = ghostBookshelf.Model.extend({ + + tableName: 'users', + + emitChange: function emitChange(event) { + events.emit('user' + '.' + event, this); + }, + + initialize: function initialize() { + ghostBookshelf.Model.prototype.initialize.apply(this, arguments); + + this.on('created', function onCreated(model) { + model.emitChange('added'); + + // active is the default state, so if status isn't provided, this will be an active user + if (!model.get('status') || _.includes(activeStates, model.get('status'))) { + model.emitChange('activated'); + } + }); + this.on('updated', function onUpdated(model) { + model.statusChanging = model.get('status') !== model.updated('status'); + model.isActive = _.includes(activeStates, model.get('status')); + + if (model.statusChanging) { + model.emitChange(model.isActive ? 'activated' : 'deactivated'); + } else { + if (model.isActive) { + model.emitChange('activated.edited'); + } + } + + model.emitChange('edited'); + }); + this.on('destroyed', function onDestroyed(model) { + if (_.includes(activeStates, model.previous('status'))) { + model.emitChange('deactivated'); + } + + model.emitChange('deleted'); + }); + }, + + saving: function saving(newPage, attr, options) { + /*jshint unused:false*/ + + var self = this; + + ghostBookshelf.Model.prototype.saving.apply(this, arguments); + + if (this.hasChanged('slug') || !this.get('slug')) { + // Generating a slug requires a db call to look for conflicting slugs + return ghostBookshelf.Model.generateSlug(User, this.get('slug') || this.get('name'), + {status: 'all', transacting: options.transacting, shortSlug: !this.get('slug')}) + .then(function then(slug) { + self.set({slug: slug}); + }); + } + }, + + // For the user model ONLY it is possible to disable validations. + // This is used to bypass validation during the credential check, and must never be done with user-provided data + // Should be removed when #3691 is done + validate: function validate() { + var opts = arguments[1], + userData; + + if (opts && _.has(opts, 'validate') && opts.validate === false) { + return; + } + + // use the base toJSON since this model's overridden toJSON + // removes fields and we want everything to run through the validator. + userData = ghostBookshelf.Model.prototype.toJSON.call(this); + + return validation.validateSchema(this.tableName, userData); + }, + + // Get the user from the options object + contextUser: function contextUser(options) { + // Default to context user + if (options.context && options.context.user) { + return options.context.user; + // Other wise use the internal override + } else if (options.context && options.context.internal) { + return 1; + // This is the user object, so try using this user's id + } else if (this.get('id')) { + return this.get('id'); + } else { + errors.logAndThrowError(new errors.NotFoundError(i18n.t('errors.models.user.missingContext'))); + } + }, + + toJSON: function toJSON(options) { + options = options || {}; + + var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); + // remove password hash for security reasons + delete attrs.password; + + if (!options || !options.context || (!options.context.user && !options.context.internal)) { + delete attrs.email; + } + + return attrs; + }, + + format: function format(options) { + if (!_.isEmpty(options.website) && + !validator.isURL(options.website, { + require_protocol: true, + protocols: ['http', 'https']})) { + options.website = 'http://' + options.website; + } + return ghostBookshelf.Model.prototype.format.call(this, options); + }, + + posts: function posts() { + return this.hasMany('Posts', 'created_by'); + }, + + roles: function roles() { + return this.belongsToMany('Role'); + }, + + permissions: function permissions() { + return this.belongsToMany('Permission'); + }, + + hasRole: function hasRole(roleName) { + var roles = this.related('roles'); + + return roles.some(function getRole(role) { + return role.get('name') === roleName; + }); + }, + + enforcedFilters: function enforcedFilters() { + if (this.isInternalContext()) { + return null; + } + + return this.isPublicContext() ? 'status:[' + activeStates.join(',') + ']' : null; + }, + + defaultFilters: function defaultFilters() { + return this.isPublicContext() ? null : 'status:[' + activeStates.join(',') + ']'; + } +}, { + orderDefaultOptions: function orderDefaultOptions() { + return { + last_login: 'DESC', + name: 'ASC', + created_at: 'DESC' + }; + }, + + /** + * @deprecated in favour of filter + */ + processOptions: function processOptions(options) { + if (!options.status) { + return options; + } + + // This is the only place that 'options.where' is set now + options.where = {statements: []}; + + var value; + + // Filter on the status. A status of 'all' translates to no filter since we want all statuses + if (options.status !== 'all') { + // make sure that status is valid + options.status = allStates.indexOf(options.status) > -1 ? options.status : 'active'; + } + + if (options.status === 'active') { + value = activeStates; + } else if (options.status === 'invited') { + value = invitedStates; + } else if (options.status === 'all') { + value = allStates; + } else { + value = options.status; + } + + options.where.statements.push({prop: 'status', op: 'IN', value: value}); + delete options.status; + + return options; + }, + + /** + * Returns an array of keys permitted in a method's `options` hash, depending on the current method. + * @param {String} methodName The name of the method to check valid options for. + * @return {Array} Keys allowed in the `options` hash of the model's method. + */ + permittedOptions: function permittedOptions(methodName) { + var options = ghostBookshelf.Model.permittedOptions(), + + // whitelists for the `options` hash argument on methods, by method name. + // these are the only options that can be passed to Bookshelf / Knex. + validOptions = { + findOne: ['withRelated', 'status'], + setup: ['id'], + edit: ['withRelated', 'id'], + findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'], + findAll: ['filter'] + }; + + if (validOptions[methodName]) { + options = options.concat(validOptions[methodName]); + } + + return options; + }, + + /** + * ### Find One + * @extends ghostBookshelf.Model.findOne to include roles + * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One) + */ + findOne: function findOne(data, options) { + var query, + status, + optInc, + lookupRole = data.role; + + delete data.role; + + data = _.defaults(data || {}, { + status: 'active' + }); + + status = data.status; + delete data.status; + + options = options || {}; + optInc = options.include; + options.withRelated = _.union(options.withRelated, options.include); + data = this.filterData(data); + + // Support finding by role + if (lookupRole) { + options.withRelated = _.union(options.withRelated, ['roles']); + options.include = _.union(options.include, ['roles']); + + query = this.forge(data, {include: options.include}); + + query.query('join', 'roles_users', 'users.id', '=', 'roles_users.user_id'); + query.query('join', 'roles', 'roles_users.role_id', '=', 'roles.id'); + query.query('where', 'roles.name', '=', lookupRole); + } else { + // We pass include to forge so that toJSON has access + query = this.forge(data, {include: options.include}); + } + + if (status === 'active') { + query.query('whereIn', 'status', activeStates); + } else if (status === 'invited') { + query.query('whereIn', 'status', invitedStates); + } else if (status !== 'all') { + status = allStates.indexOf(status) !== -1 ? status : 'active'; + query.query('where', {status: status}); + } + + options = this.filterOptions(options, 'findOne'); + delete options.include; + options.include = optInc; + + return query.fetch(options); + }, + + /** + * ### Edit + * + * Note: In case of login the last_login attribute gets updated. + * + * @extends ghostBookshelf.Model.edit to handle returning the full object + * **See:** [ghostBookshelf.Model.edit](base.js.html#edit) + */ + edit: function edit(data, options) { + var self = this, + roleId, + ops = []; + + if (data.roles && data.roles.length > 1) { + return Promise.reject( + new errors.ValidationError(i18n.t('errors.models.user.onlyOneRolePerUserSupported')) + ); + } + + options = options || {}; + options.withRelated = _.union(options.withRelated, options.include); + + if (data.email) { + ops.push(function checkForDuplicateEmail() { + return self.getByEmail(data.email).then(function then(user) { + if (user && Number(user.id) !== Number(options.id)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.userUpdateError.emailIsAlreadyInUse'))); + } + }); + }); + } + + ops.push(function update() { + return ghostBookshelf.Model.edit.call(self, data, options).then(function then(user) { + if (!data.roles) { + return user; + } + + roleId = parseInt(data.roles[0].id || data.roles[0], 10); + + return user.roles().fetch().then(function then(roles) { + // return if the role is already assigned + if (roles.models[0].id === roleId) { + return; + } + return ghostBookshelf.model('Role').findOne({id: roleId}); + }).then(function then(roleToAssign) { + if (roleToAssign && roleToAssign.get('name') === 'Owner') { + return Promise.reject( + new errors.ValidationError(i18n.t('errors.models.user.methodDoesNotSupportOwnerRole')) + ); + } else { + // assign all other roles + return user.roles().updatePivot({role_id: roleId}); + } + }).then(function then() { + options.status = 'all'; + return self.findOne({id: user.id}, options); + }); + }); + }); + + return pipeline(ops); + }, + + /** + * ## Add + * Naive user add + * Hashes the password provided before saving to the database. + * + * @param {object} data + * @param {object} options + * @extends ghostBookshelf.Model.add to manage all aspects of user signup + * **See:** [ghostBookshelf.Model.add](base.js.html#Add) + */ + add: function add(data, options) { + var self = this, + userData = this.filterData(data), + roles; + + userData.password = _.toString(userData.password); + + options = this.filterOptions(options, 'add'); + options.withRelated = _.union(options.withRelated, options.include); + + // check for too many roles + if (data.roles && data.roles.length > 1) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.onlyOneRolePerUserSupported'))); + } + + if (!validatePasswordLength(userData.password)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength'))); + } + + function getAuthorRole() { + return ghostBookshelf.model('Role').findOne({name: 'Author'}, _.pick(options, 'transacting')).then(function then(authorRole) { + return [authorRole.get('id')]; + }); + } + + roles = data.roles || getAuthorRole(); + delete data.roles; + + return generatePasswordHash(userData.password).then(function then(hash) { + // Assign the hashed password + userData.password = hash; + // LookupGravatar + return gravatar.lookup(userData); + }).then(function then(userData) { + // Save the user with the hashed password + return ghostBookshelf.Model.add.call(self, userData, options); + }).then(function then(addedUser) { + // Assign the userData to our created user so we can pass it back + userData = addedUser; + // if we are given a "role" object, only pass in the role ID in place of the full object + return Promise.resolve(roles).then(function then(roles) { + roles = _.map(roles, function mapper(role) { + if (_.isString(role)) { + return parseInt(role, 10); + } else if (_.isNumber(role)) { + return role; + } else { + return parseInt(role.id, 10); + } + }); + + return addedUser.roles().attach(roles, options); + }); + }).then(function then() { + // find and return the added user + return self.findOne({id: userData.id, status: 'all'}, options); + }); + }, + + setup: function setup(data, options) { + var self = this, + userData = this.filterData(data); + + if (!validatePasswordLength(userData.password)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength'))); + } + + options = this.filterOptions(options, 'setup'); + options.withRelated = _.union(options.withRelated, options.include); + options.shortSlug = true; + + return generatePasswordHash(data.password).then(function then(hash) { + // Assign the hashed password + userData.password = hash; + + return Promise.join( + gravatar.lookup(userData), + ghostBookshelf.Model.generateSlug.call(this, User, userData.name, options) + ); + }).then(function then(results) { + userData = results[0]; + userData.slug = results[1]; + + return self.edit.call(self, userData, options); + }); + }, + + permissible: function permissible(userModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) { + var self = this, + userModel = userModelOrId, + origArgs; + + // If we passed in a model without its related roles, we need to fetch it again + if (_.isObject(userModelOrId) && !_.isObject(userModelOrId.related('roles'))) { + userModelOrId = userModelOrId.id; + } + // If we passed in an id instead of a model get the model first + if (_.isNumber(userModelOrId) || _.isString(userModelOrId)) { + // Grab the original args without the first one + origArgs = _.toArray(arguments).slice(1); + // Get the actual user model + return this.findOne({id: userModelOrId, status: 'all'}, {include: ['roles']}).then(function then(foundUserModel) { + // Build up the original args but substitute with actual model + var newArgs = [foundUserModel].concat(origArgs); + + return self.permissible.apply(self, newArgs); + }, errors.logAndThrowError); + } + + if (action === 'edit') { + // Owner can only be editted by owner + if (loadedPermissions.user && userModel.hasRole('Owner')) { + hasUserPermission = _.some(loadedPermissions.user.roles, {name: 'Owner'}); + } + // Users with the role 'Editor' and 'Author' have complex permissions when the action === 'edit' + // We now have all the info we need to construct the permissions + if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Author'})) { + // If this is the same user that requests the operation allow it. + hasUserPermission = hasUserPermission || context.user === userModel.get('id'); + } + + if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) { + // If this is the same user that requests the operation allow it. + hasUserPermission = context.user === userModel.get('id'); + + // Alternatively, if the user we are trying to edit is an Author, allow it + hasUserPermission = hasUserPermission || userModel.hasRole('Author'); + } + } + + if (action === 'destroy') { + // Owner cannot be deleted EVER + if (loadedPermissions.user && userModel.hasRole('Owner')) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.notEnoughPermission'))); + } + + // Users with the role 'Editor' have complex permissions when the action === 'destroy' + if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) { + // If this is the same user that requests the operation allow it. + hasUserPermission = context.user === userModel.get('id'); + + // Alternatively, if the user we are trying to edit is an Author, allow it + hasUserPermission = hasUserPermission || userModel.hasRole('Author'); + } + } + + if (hasUserPermission && hasAppPermission) { + return Promise.resolve(); + } + + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.notEnoughPermission'))); + }, + + setWarning: function setWarning(user, options) { + var status = user.get('status'), + regexp = /warn-(\d+)/i, + level; + + if (status === 'active') { + user.set('status', 'warn-1'); + level = 1; + } else { + level = parseInt(status.match(regexp)[1], 10) + 1; + if (level > 4) { + user.set('status', 'locked'); + } else { + user.set('status', 'warn-' + level); + } + } + return Promise.resolve(user.save(options)).then(function then() { + return 5 - level; + }); + }, + + // Finds the user by email, and checks the password + check: function check(object) { + var self = this, + s; + return this.getByEmail(object.email).then(function then(user) { + if (!user) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr'))); + } + if (user.get('status') === 'invited' || user.get('status') === 'invited-pending' || + user.get('status') === 'inactive' + ) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.userIsInactive'))); + } + if (user.get('status') !== 'locked') { + return bcryptCompare(object.password, user.get('password')).then(function then(matched) { + if (!matched) { + return Promise.resolve(self.setWarning(user, {validate: false})).then(function then(remaining) { + if (remaining === 0) { + // If remaining attempts = 0, the account has been locked, so show a locked account message + return Promise.reject(new errors.NoPermissionError( + i18n.t('errors.models.user.accountLocked'))); + } + + s = (remaining > 1) ? 's' : ''; + return Promise.reject(new errors.UnauthorizedError(i18n.t('errors.models.user.incorrectPasswordAttempts', {remaining: remaining, s: s}))); + + // Use comma structure, not .catch, because we don't want to catch incorrect passwords + }, function handleError(error) { + // If we get a validation or other error during this save, catch it and log it, but don't + // cause a login error because of it. The user validation is not important here. + errors.logError( + error, + i18n.t('errors.models.user.userUpdateError.context'), + i18n.t('errors.models.user.userUpdateError.help') + ); + return Promise.reject(new errors.UnauthorizedError(i18n.t('errors.models.user.incorrectPassword'))); + }); + } + + return Promise.resolve(user.set({status: 'active', last_login: new Date()}).save({validate: false})) + .catch(function handleError(error) { + // If we get a validation or other error during this save, catch it and log it, but don't + // cause a login error because of it. The user validation is not important here. + errors.logError( + error, + i18n.t('errors.models.user.userUpdateError.context'), + i18n.t('errors.models.user.userUpdateError.help') + ); + return user; + }); + }, errors.logAndThrowError); + } + return Promise.reject(new errors.NoPermissionError( + i18n.t('errors.models.user.accountLocked'))); + }, function handleError(error) { + if (error.message === 'NotFound' || error.message === 'EmptyResponse') { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr'))); + } + + return Promise.reject(error); + }); + }, + + /** + * Naive change password method + * @param {Object} object + * @param {Object} options + */ + changePassword: function changePassword(object, options) { + var self = this, + newPassword = object.newPassword, + ne2Password = object.ne2Password, + userId = parseInt(object.user_id), + oldPassword = object.oldPassword, + user; + + // If the two passwords do not match + if (newPassword !== ne2Password) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.newPasswordsDoNotMatch'))); + } + + // If the old password is empty when changing current user's password + if (userId === options.context.user && _.isEmpty(oldPassword)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordRequiredForOperation'))); + } + + // If password is not complex enough + if (!validatePasswordLength(newPassword)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength'))); + } + + return self.forge({id: userId}).fetch({require: true}).then(function then(_user) { + user = _user; + // If the user is the current user, check old password + if (userId === options.context.user) { + return bcryptCompare(oldPassword, user.get('password')); + } + // If user is admin and changing another user's password, old password isn't compared to the old one + return true; + }).then(function then(matched) { + if (!matched) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.incorrectPassword'))); + } + + return generatePasswordHash(newPassword); + }).then(function then(hash) { + return user.save({password: hash}); + }); + }, + + generateResetToken: function generateResetToken(email, expires, dbHash) { + return this.getByEmail(email).then(function then(foundUser) { + if (!foundUser) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.noUserWithEnteredEmailAddr'))); + } + + var hash = crypto.createHash('sha256'), + text = ''; + + // Token: + // BASE64(TIMESTAMP + email + HASH(TIMESTAMP + email + oldPasswordHash + dbHash )) + hash.update(String(expires)); + hash.update(email.toLocaleLowerCase()); + hash.update(foundUser.get('password')); + hash.update(String(dbHash)); + + text += [expires, email, hash.digest('base64')].join('|'); + return new Buffer(text).toString('base64'); + }); + }, + + validateToken: function validateToken(token, dbHash) { + /*jslint bitwise:true*/ + // TODO: Is there a chance the use of ascii here will cause problems if oldPassword has weird characters? + var tokenText = new Buffer(token, 'base64').toString('ascii'), + parts, + expires, + email; + + parts = tokenText.split('|'); + + // Check if invalid structure + if (!parts || parts.length !== 3) { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidTokenStructure'))); + } + + expires = parseInt(parts[0], 10); + email = parts[1]; + + if (isNaN(expires)) { + return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidTokenExpiration'))); + } + + // Check if token is expired to prevent replay attacks + if (expires < Date.now()) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.expiredToken'))); + } + + // to prevent brute force attempts to reset the password the combination of email+expires is only allowed for + // 10 attempts + if (tokenSecurity[email + '+' + expires] && tokenSecurity[email + '+' + expires].count >= 10) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.tokenLocked'))); + } + + return this.generateResetToken(email, expires, dbHash).then(function then(generatedToken) { + // Check for matching tokens with timing independent comparison + var diff = 0, + i; + + // check if the token length is correct + if (token.length !== generatedToken.length) { + diff = 1; + } + + for (i = token.length - 1; i >= 0; i = i - 1) { + diff |= token.charCodeAt(i) ^ generatedToken.charCodeAt(i); + } + + if (diff === 0) { + return email; + } + + // increase the count for email+expires for each failed attempt + tokenSecurity[email + '+' + expires] = { + count: tokenSecurity[email + '+' + expires] ? tokenSecurity[email + '+' + expires].count + 1 : 1 + }; + return Promise.reject(new errors.BadRequestError(i18n.t('errors.models.user.invalidToken'))); + }); + }, + + resetPassword: function resetPassword(options) { + var self = this, + token = options.token, + newPassword = options.newPassword, + ne2Password = options.ne2Password, + dbHash = options.dbHash; + + if (newPassword !== ne2Password) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.newPasswordsDoNotMatch'))); + } + + if (!validatePasswordLength(newPassword)) { + return Promise.reject(new errors.ValidationError(i18n.t('errors.models.user.passwordDoesNotComplyLength'))); + } + + // Validate the token; returns the email address from token + return self.validateToken(utils.decodeBase64URLsafe(token), dbHash).then(function then(email) { + // Fetch the user by email, and hash the password at the same time. + return Promise.join( + self.getByEmail(email), + generatePasswordHash(newPassword) + ); + }).then(function then(results) { + if (!results[0]) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.userNotFound'))); + } + + // Update the user with the new password hash + var foundUser = results[0], + passwordHash = results[1]; + + return foundUser.save({password: passwordHash, status: 'active'}); + }); + }, + + transferOwnership: function transferOwnership(object, options) { + var ownerRole, + contextUser; + + return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Owner'}), + User.findOne({id: options.context.user}, {include: ['roles']})) + .then(function then(results) { + ownerRole = results[0]; + contextUser = results[1]; + + // check if user has the owner role + var currentRoles = contextUser.toJSON(options).roles; + if (!_.some(currentRoles, {id: ownerRole.id})) { + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.models.user.onlyOwnerCanTransferOwnerRole'))); + } + + return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Administrator'}), + User.findOne({id: object.id}, {include: ['roles']})); + }).then(function then(results) { + var adminRole = results[0], + user = results[1], + currentRoles = user.toJSON(options).roles; + + if (!_.some(currentRoles, {id: adminRole.id})) { + return Promise.reject(new errors.ValidationError('errors.models.user.onlyAdmCanBeAssignedOwnerRole')); + } + + // convert owner to admin + return Promise.join(contextUser.roles().updatePivot({role_id: adminRole.id}), + user.roles().updatePivot({role_id: ownerRole.id}), + user.id); + }).then(function then(results) { + return Users.forge() + .query('whereIn', 'id', [contextUser.id, results[2]]) + .fetch({withRelated: ['roles']}); + }).then(function then(users) { + options.include = ['roles']; + return users.toJSON(options); + }); + }, + + // Get the user by email address, enforces case insensitivity rejects if the user is not found + // When multi-user support is added, email addresses must be deduplicated with case insensitivity, so that + // joe@bloggs.com and JOE@BLOGGS.COM cannot be created as two separate users. + getByEmail: function getByEmail(email, options) { + options = options || {}; + // We fetch all users and process them in JS as there is no easy way to make this query across all DBs + // Although they all support `lower()`, sqlite can't case transform unicode characters + // This is somewhat mute, as validator.isEmail() also doesn't support unicode, but this is much easier / more + // likely to be fixed in the near future. + options.require = true; + + return Users.forge(options).fetch(options).then(function then(users) { + var userWithEmail = users.find(function findUser(user) { + return user.get('email').toLowerCase() === email.toLowerCase(); + }); + if (userWithEmail) { + return userWithEmail; + } + }); + } +}); + +Users = ghostBookshelf.Collection.extend({ + model: User +}); + +module.exports = { + User: ghostBookshelf.model('User', User), + Users: ghostBookshelf.collection('Users', Users) +}; diff --git a/core/server/overrides.js b/core/server/overrides.js new file mode 100644 index 0000000..23449b1 --- /dev/null +++ b/core/server/overrides.js @@ -0,0 +1,10 @@ +var moment = require('moment-timezone'); + +/** + * force UTC + * - you can require moment or moment-timezone, both is configured to UTC + * - you are allowed to use new Date() to instantiate datetime values for models, because they are transformed into UTC in the model layer + * - be careful when not working with models, every value from the native JS Date is local TZ + * - be careful when you work with date operations, therefor always wrap a date into moment + */ +moment.tz.setDefault('UTC'); diff --git a/core/server/permissions/effective.js b/core/server/permissions/effective.js new file mode 100644 index 0000000..06faab4 --- /dev/null +++ b/core/server/permissions/effective.js @@ -0,0 +1,56 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + Models = require('../models'), + errors = require('../errors'), + i18n = require('../i18n'), + effective; + +effective = { + user: function (id) { + return Models.User.findOne({id: id, status: 'all'}, {include: ['permissions', 'roles', 'roles.permissions']}) + .then(function (foundUser) { + // CASE: {context: {user: id}} where the id is not in our database + if (!foundUser) { + return Promise.reject(new errors.NotFoundError(i18n.t('errors.models.user.userNotFound'))); + } + + var seenPerms = {}, + rolePerms = _.map(foundUser.related('roles').models, function (role) { + return role.related('permissions').models; + }), + allPerms = [], + user = foundUser.toJSON(); + + rolePerms.push(foundUser.related('permissions').models); + + _.each(rolePerms, function (rolePermGroup) { + _.each(rolePermGroup, function (perm) { + var key = perm.get('action_type') + '-' + perm.get('object_type') + '-' + perm.get('object_id'); + + // Only add perms once + if (seenPerms[key]) { + return; + } + + allPerms.push(perm); + seenPerms[key] = true; + }); + }); + + return {permissions: allPerms, roles: user.roles}; + }, errors.logAndThrowError); + }, + + app: function (appName) { + return Models.App.findOne({name: appName}, {withRelated: ['permissions']}) + .then(function (foundApp) { + if (!foundApp) { + return []; + } + + return {permissions: foundApp.related('permissions').models}; + }, errors.logAndThrowError); + } +}; + +module.exports = effective; diff --git a/core/server/permissions/index.js b/core/server/permissions/index.js new file mode 100644 index 0000000..19e6982 --- /dev/null +++ b/core/server/permissions/index.js @@ -0,0 +1,320 @@ +// canThis(someUser).edit.posts([id]|[[ids]]) +// canThis(someUser).edit.post(somePost|somePostId) + +var _ = require('lodash'), + Promise = require('bluebird'), + errors = require('../errors'), + Models = require('../models'), + effectivePerms = require('./effective'), + i18n = require('../i18n'), + init, + refresh, + canThis, + CanThisResult, + exported; + +function hasActionsMap() { + // Just need to find one key in the actionsMap + + return _.some(exported.actionsMap, function (val, key) { + /*jslint unparam:true*/ + return Object.hasOwnProperty.call(exported.actionsMap, key); + }); +} + +function parseContext(context) { + // Parse what's passed to canThis.beginCheck for standard user and app scopes + var parsed = { + internal: false, + external: false, + user: null, + app: null, + public: true + }; + + if (context && (context === 'external' || context.external)) { + parsed.external = true; + parsed.public = false; + } + + if (context && (context === 'internal' || context.internal)) { + parsed.internal = true; + parsed.public = false; + } + + if (context && context.user) { + parsed.user = context.user; + parsed.public = false; + } + + if (context && context.app) { + parsed.app = context.app; + parsed.public = false; + } + + return parsed; +} + +function applyStatusRules(docName, method, opts) { + var errorMsg = i18n.t('errors.permissions.applyStatusRules.error', {docName: docName}); + + // Enforce status 'active' for users + if (docName === 'users') { + if (!opts.status) { + return 'active'; + } else if (opts.status !== 'active') { + throw errorMsg; + } + } + + // Enforce status 'published' for posts + if (docName === 'posts') { + if (!opts.status) { + return 'published'; + } else if ( + method === 'read' + && (opts.status === 'draft' || opts.status === 'all') + && _.isString(opts.uuid) && _.isUndefined(opts.id) && _.isUndefined(opts.slug) + ) { + // public read requests can retrieve a draft, but only by UUID + return opts.status; + } else if (opts.status !== 'published') { + // any other parameter would make this a permissions error + throw errorMsg; + } + } + + return opts.status; +} + +/** + * API Public Permission Rules + * This method enforces the rules for public requests + * @param {String} docName + * @param {String} method (read || browse) + * @param {Object} options + * @returns {Object} options + */ +function applyPublicRules(docName, method, options) { + try { + // If this is a public context + if (parseContext(options.context).public === true) { + if (method === 'browse') { + options.status = applyStatusRules(docName, method, options); + } else if (method === 'read') { + options.data.status = applyStatusRules(docName, method, options.data); + } + } + + return Promise.resolve(options); + } catch (err) { + return Promise.reject(err); + } +} + +// Base class for canThis call results +CanThisResult = function () { + return; +}; + +CanThisResult.prototype.buildObjectTypeHandlers = function (objTypes, actType, context, permissionLoad) { + var objectTypeModelMap = { + post: Models.Post, + role: Models.Role, + user: Models.User, + permission: Models.Permission, + setting: Models.Settings, + subscriber: Models.Subscriber + }; + + // Iterate through the object types, i.e. ['post', 'tag', 'user'] + return _.reduce(objTypes, function (objTypeHandlers, objType) { + // Grab the TargetModel through the objectTypeModelMap + var TargetModel = objectTypeModelMap[objType]; + + // Create the 'handler' for the object type; + // the '.post()' in canThis(user).edit.post() + objTypeHandlers[objType] = function (modelOrId) { + var modelId; + + // If it's an internal request, resolve immediately + if (context.internal) { + return Promise.resolve(); + } + + if (_.isNumber(modelOrId) || _.isString(modelOrId)) { + // It's an id already, do nothing + modelId = modelOrId; + } else if (modelOrId) { + // It's a model, get the id + modelId = modelOrId.id; + } + // Wait for the user loading to finish + return permissionLoad.then(function (loadedPermissions) { + // Iterate through the user permissions looking for an affirmation + var userPermissions = loadedPermissions.user ? loadedPermissions.user.permissions : null, + appPermissions = loadedPermissions.app ? loadedPermissions.app.permissions : null, + hasUserPermission, + hasAppPermission, + checkPermission = function (perm) { + var permObjId; + + // Look for a matching action type and object type first + if (perm.get('action_type') !== actType || perm.get('object_type') !== objType) { + return false; + } + + // Grab the object id (if specified, could be null) + permObjId = perm.get('object_id'); + + // If we didn't specify a model (any thing) + // or the permission didn't have an id scope set + // then the "thing" has permission + if (!modelId || !permObjId) { + return true; + } + + // Otherwise, check if the id's match + // TODO: String vs Int comparison possibility here? + return modelId === permObjId; + }; + + if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'})) { + hasUserPermission = true; + } else if (!_.isEmpty(userPermissions)) { + hasUserPermission = _.some(userPermissions, checkPermission); + } + + // Check app permissions if they were passed + hasAppPermission = true; + if (!_.isNull(appPermissions)) { + hasAppPermission = _.some(appPermissions, checkPermission); + } + + // Offer a chance for the TargetModel to override the results + if (TargetModel && _.isFunction(TargetModel.permissible)) { + return TargetModel.permissible( + modelId, actType, context, loadedPermissions, hasUserPermission, hasAppPermission + ); + } + + if (hasUserPermission && hasAppPermission) { + return; + } + + return Promise.reject(new errors.NoPermissionError(i18n.t('errors.permissions.noPermissionToAction'))); + }); + }; + + return objTypeHandlers; + }, {}); +}; + +CanThisResult.prototype.beginCheck = function (context) { + var self = this, + userPermissionLoad, + appPermissionLoad, + permissionsLoad; + + // Get context.user and context.app + context = parseContext(context); + + if (!hasActionsMap()) { + throw new Error(i18n.t('errors.permissions.noActionsMapFound.error')); + } + + // Kick off loading of effective user permissions if necessary + if (context.user) { + userPermissionLoad = effectivePerms.user(context.user); + } else { + // Resolve null if no context.user to prevent db call + userPermissionLoad = Promise.resolve(null); + } + + // Kick off loading of effective app permissions if necessary + if (context.app) { + appPermissionLoad = effectivePerms.app(context.app); + } else { + // Resolve null if no context.app + appPermissionLoad = Promise.resolve(null); + } + + // Wait for both user and app permissions to load + permissionsLoad = Promise.all([userPermissionLoad, appPermissionLoad]).then(function (result) { + return { + user: result[0], + app: result[1] + }; + }); + + // Iterate through the actions and their related object types + _.each(exported.actionsMap, function (objTypes, actType) { + // Build up the object type handlers; + // the '.post()' parts in canThis(user).edit.post() + var objTypeHandlers = self.buildObjectTypeHandlers(objTypes, actType, context, permissionsLoad); + + // Define a property for the action on the result; + // the '.edit' in canThis(user).edit.post() + Object.defineProperty(self, actType, { + writable: false, + enumerable: false, + configurable: false, + value: objTypeHandlers + }); + }); + + // Return this for chaining + return this; +}; + +canThis = function (context) { + var result = new CanThisResult(); + + return result.beginCheck(context); +}; + +init = refresh = function (options) { + options = options || {}; + + // Load all the permissions + return Models.Permission.findAll(options).then(function (perms) { + var seenActions = {}; + + exported.actionsMap = {}; + + // Build a hash map of the actions on objects, i.e + /* + { + 'edit': ['post', 'tag', 'user', 'page'], + 'delete': ['post', 'user'], + 'create': ['post', 'user', 'page'] + } + */ + _.each(perms.models, function (perm) { + var actionType = perm.get('action_type'), + objectType = perm.get('object_type'); + + exported.actionsMap[actionType] = exported.actionsMap[actionType] || []; + seenActions[actionType] = seenActions[actionType] || {}; + + // Check if we've already seen this action -> object combo + if (seenActions[actionType][objectType]) { + return; + } + + exported.actionsMap[actionType].push(objectType); + seenActions[actionType][objectType] = true; + }); + + return exported.actionsMap; + }); +}; + +module.exports = exported = { + init: init, + refresh: refresh, + canThis: canThis, + parseContext: parseContext, + applyPublicRules: applyPublicRules, + actionsMap: {} +}; diff --git a/core/server/routes/admin.js b/core/server/routes/admin.js new file mode 100644 index 0000000..85eed12 --- /dev/null +++ b/core/server/routes/admin.js @@ -0,0 +1,14 @@ +var admin = require('../controllers/admin'), + express = require('express'), + + adminRoutes; + +adminRoutes = function () { + var router = express.Router(); + + router.get('*', admin.index); + + return router; +}; + +module.exports = adminRoutes; diff --git a/core/server/routes/api.js b/core/server/routes/api.js new file mode 100644 index 0000000..1cea0f5 --- /dev/null +++ b/core/server/routes/api.js @@ -0,0 +1,186 @@ +// # API routes +var express = require('express'), + api = require('../api'), + apiRoutes; + +/** + * IMPORTANT + * - cors middleware MUST happen before pretty urls, because otherwise cors header can get lost + * - cors middleware MUST happen after authenticateClient, because authenticateClient reads the trusted domains + */ +apiRoutes = function apiRoutes(middleware) { + var router = express.Router(), + // Authentication for public endpoints + authenticatePublic = [ + middleware.api.authenticateClient, + middleware.api.authenticateUser, + middleware.api.requiresAuthorizedUserPublicAPI, + middleware.api.cors, + middleware.api.prettyUrls + ], + // Require user for private endpoints + authenticatePrivate = [ + middleware.api.authenticateClient, + middleware.api.authenticateUser, + middleware.api.requiresAuthorizedUser, + middleware.api.cors, + middleware.api.prettyUrls + ]; + + // alias delete with del + router.del = router.delete; + + // send 503 json response in case of maintenance + router.use(middleware.api.maintenance); + + // Check version matches for API requests, depends on res.locals.safeVersion being set + // Therefore must come after themeHandler.ghostLocals, for now + router.use(middleware.api.versionMatch); + + // ## CORS pre-flight check + router.options('*', middleware.api.cors); + + // ## Configuration + router.get('/configuration', authenticatePrivate, api.http(api.configuration.read)); + router.get('/configuration/:key', authenticatePrivate, api.http(api.configuration.read)); + router.get('/configuration/timezones', authenticatePrivate, api.http(api.configuration.read)); + + // ## Posts + router.get('/posts', authenticatePublic, api.http(api.posts.browse)); + + router.post('/posts', authenticatePrivate, api.http(api.posts.add)); + router.get('/posts/:id', authenticatePublic, api.http(api.posts.read)); + router.get('/posts/slug/:slug', authenticatePublic, api.http(api.posts.read)); + router.put('/posts/:id', authenticatePrivate, api.http(api.posts.edit)); + router.del('/posts/:id', authenticatePrivate, api.http(api.posts.destroy)); + + // ## Schedules + router.put('/schedules/posts/:id', [middleware.api.authenticateClient, middleware.api.authenticateUser], api.http(api.schedules.publishPost)); + + // ## Settings + router.get('/settings', authenticatePrivate, api.http(api.settings.browse)); + router.get('/settings/:key', authenticatePrivate, api.http(api.settings.read)); + router.put('/settings', authenticatePrivate, api.http(api.settings.edit)); + + // ## Users + router.get('/users', authenticatePublic, api.http(api.users.browse)); + + router.get('/users/:id', authenticatePublic, api.http(api.users.read)); + router.get('/users/slug/:slug', authenticatePublic, api.http(api.users.read)); + router.get('/users/email/:email', authenticatePublic, api.http(api.users.read)); + router.put('/users/password', authenticatePrivate, api.http(api.users.changePassword)); + router.put('/users/owner', authenticatePrivate, api.http(api.users.transferOwnership)); + router.put('/users/:id', authenticatePrivate, api.http(api.users.edit)); + router.post('/users', authenticatePrivate, api.http(api.users.add)); + router.del('/users/:id', authenticatePrivate, api.http(api.users.destroy)); + + // ## Tags + router.get('/tags', authenticatePublic, api.http(api.tags.browse)); + router.get('/tags/:id', authenticatePublic, api.http(api.tags.read)); + router.get('/tags/slug/:slug', authenticatePublic, api.http(api.tags.read)); + router.post('/tags', authenticatePrivate, api.http(api.tags.add)); + router.put('/tags/:id', authenticatePrivate, api.http(api.tags.edit)); + router.del('/tags/:id', authenticatePrivate, api.http(api.tags.destroy)); + + // ## Subscribers + router.get('/subscribers', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.browse)); + router.get('/subscribers/csv', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.exportCSV)); + router.post('/subscribers/csv', + middleware.api.labs.subscribers, + authenticatePrivate, + middleware.upload.single('subscribersfile'), + middleware.validation.upload({type: 'subscribers'}), + api.http(api.subscribers.importCSV) + ); + router.get('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.read)); + router.post('/subscribers', middleware.api.labs.subscribers, authenticatePublic, api.http(api.subscribers.add)); + router.put('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.edit)); + router.del('/subscribers/:id', middleware.api.labs.subscribers, authenticatePrivate, api.http(api.subscribers.destroy)); + + // ## Roles + router.get('/roles/', authenticatePrivate, api.http(api.roles.browse)); + + // ## Clients + router.get('/clients/slug/:slug', api.http(api.clients.read)); + + // ## Slugs + router.get('/slugs/:type/:name', authenticatePrivate, api.http(api.slugs.generate)); + + // ## Themes + router.get('/themes/:name/download', + authenticatePrivate, + api.http(api.themes.download) + ); + + router.post('/themes/upload', + authenticatePrivate, + middleware.upload.single('theme'), + middleware.validation.upload({type: 'themes'}), + api.http(api.themes.upload) + ); + + router.del('/themes/:name', + authenticatePrivate, + api.http(api.themes.destroy) + ); + + // ## Notifications + router.get('/notifications', authenticatePrivate, api.http(api.notifications.browse)); + router.post('/notifications', authenticatePrivate, api.http(api.notifications.add)); + router.del('/notifications/:id', authenticatePrivate, api.http(api.notifications.destroy)); + + // ## DB + router.get('/db', authenticatePrivate, api.http(api.db.exportContent)); + router.post('/db', + authenticatePrivate, + middleware.upload.single('importfile'), + middleware.validation.upload({type: 'db'}), + api.http(api.db.importContent) + ); + router.del('/db', authenticatePrivate, api.http(api.db.deleteAllContent)); + + // ## Mail + router.post('/mail', authenticatePrivate, api.http(api.mail.send)); + router.post('/mail/test', authenticatePrivate, api.http(api.mail.sendTest)); + + // ## Slack + router.post('/slack/test', authenticatePrivate, api.http(api.slack.sendTest)); + + // ## Authentication + router.post('/authentication/passwordreset', + middleware.spamPrevention.forgotten, + api.http(api.authentication.generateResetToken) + ); + + // ## Endpoint to exchange a one time access-token with a pair of AT/RT + router.post('/authentication/setup/three', middleware.api.authenticateClient, api.http(api.authentication.onSetupStep3)); + + router.put('/authentication/passwordreset', api.http(api.authentication.resetPassword)); + router.post('/authentication/invitation', api.http(api.authentication.acceptInvitation)); + router.get('/authentication/invitation', api.http(api.authentication.isInvitation)); + router.post('/authentication/setup', api.http(api.authentication.setup)); + router.put('/authentication/setup', authenticatePrivate, api.http(api.authentication.updateSetup)); + router.get('/authentication/setup', api.http(api.authentication.isSetup)); + router.post('/authentication/token', + middleware.spamPrevention.signin, + middleware.api.authenticateClient, + middleware.oauth.generateAccessToken + ); + router.post('/authentication/revoke', authenticatePrivate, api.http(api.authentication.revoke)); + + // ## Uploads + // @TODO: rename endpoint to /images/upload (or similar) + router.post('/uploads', + authenticatePrivate, + middleware.upload.single('uploadimage'), + middleware.validation.upload({type: 'images'}), + api.http(api.uploads.add) + ); + + // API Router middleware + router.use(middleware.api.errorHandler); + + return router; +}; + +module.exports = apiRoutes; diff --git a/core/server/routes/frontend.js b/core/server/routes/frontend.js new file mode 100644 index 0000000..87bf9ec --- /dev/null +++ b/core/server/routes/frontend.js @@ -0,0 +1,49 @@ +var express = require('express'), + path = require('path'), + config = require('../config'), + frontend = require('../controllers/frontend'), + channels = require('../controllers/frontend/channels'), + utils = require('../utils'), + + frontendRoutes; + +frontendRoutes = function frontendRoutes() { + var router = express.Router(), + subdir = config.paths.subdir, + routeKeywords = config.routeKeywords; + + // ### Admin routes + router.get(/^\/(logout|signout)\/$/, function redirectToSignout(req, res) { + utils.redirect301(res, subdir + '/ghost/signout/'); + }); + router.get(/^\/signup\/$/, function redirectToSignup(req, res) { + utils.redirect301(res, subdir + '/ghost/signup/'); + }); + + // redirect to /ghost and let that do the authentication to prevent redirects to /ghost//admin etc. + router.get(/^\/((ghost-admin|admin|wp-admin|dashboard|signin|login)\/?)$/, function redirectToAdmin(req, res) { + utils.redirect301(res, subdir + '/ghost/'); + }); + + // Post Live Preview + router.get('/' + routeKeywords.preview + '/:uuid', frontend.preview); + + // Channels + router.use(channels.router()); + + // setup routes for internal apps + // @TODO: refactor this to be a proper app route hook for internal & external apps + config.internalApps.forEach(function (appName) { + var app = require(path.join(config.paths.internalAppPath, appName)); + if (app.hasOwnProperty('setupRoutes')) { + app.setupRoutes(router); + } + }); + + // Default + router.get('*', frontend.single); + + return router; +}; + +module.exports = frontendRoutes; diff --git a/core/server/routes/index.js b/core/server/routes/index.js new file mode 100644 index 0000000..0c2d434 --- /dev/null +++ b/core/server/routes/index.js @@ -0,0 +1,10 @@ +var api = require('./api'), + admin = require('./admin'), + frontend = require('./frontend'); + +module.exports = { + apiBaseUri: '/ghost/api/v0.1/', + api: api, + admin: admin, + frontend: frontend +}; diff --git a/core/server/scheduling/SchedulingBase.js b/core/server/scheduling/SchedulingBase.js new file mode 100644 index 0000000..8b9fa98 --- /dev/null +++ b/core/server/scheduling/SchedulingBase.js @@ -0,0 +1,8 @@ +function SchedulingBase() { + Object.defineProperty(this, 'requiredFns', { + value: ['schedule', 'unschedule', 'reschedule', 'run'], + writable: false + }); +} + +module.exports = SchedulingBase; diff --git a/core/server/scheduling/SchedulingDefault.js b/core/server/scheduling/SchedulingDefault.js new file mode 100644 index 0000000..93ac797 --- /dev/null +++ b/core/server/scheduling/SchedulingDefault.js @@ -0,0 +1,220 @@ +var util = require('util'), + moment = require('moment'), + request = require('superagent'), + SchedulingBase = require(__dirname + '/SchedulingBase'), + errors = require(__dirname + '/../errors'); + +/** + * allJobs is a sorted list by time attribute + */ +function SchedulingDefault(options) { + SchedulingBase.call(this, options); + + this.runTimeoutInMs = 1000 * 60 * 5; + this.offsetInMinutes = 10; + this.beforePingInMs = -50; + this.retryTimeoutInMs = 1000 * 5; + + this.allJobs = {}; + this.deletedJobs = {}; +} + +util.inherits(SchedulingDefault, SchedulingBase); + +/** + * add to list + */ +SchedulingDefault.prototype.schedule = function (object) { + this._addJob(object); +}; + +/** + * remove from list + * add to list + */ +SchedulingDefault.prototype.reschedule = function (object) { + this._deleteJob({time: object.extra.oldTime, url: object.url}); + this._addJob(object); +}; + +/** + * remove from list + * deletion happens right before execution + */ +SchedulingDefault.prototype.unschedule = function (object) { + this._deleteJob(object); +}; + +/** + * check if there are new jobs which needs to be published in the next x minutes + * because allJobs is a sorted list, we don't have to iterate over all jobs, just until the offset is too big + */ +SchedulingDefault.prototype.run = function () { + var self = this, + timeout = null; + + timeout = setTimeout(function () { + var times = Object.keys(self.allJobs), + nextJobs = {}; + + times.every(function (time) { + if (moment(Number(time)).diff(moment(), 'minutes') <= self.offsetInMinutes) { + nextJobs[time] = self.allJobs[time]; + delete self.allJobs[time]; + return true; + } + + // break! + return false; + }); + + clearTimeout(timeout); + self._execute(nextJobs); + + // recursive! + self.run(); + }, self.runTimeoutInMs); +}; + +/** + * each timestamp key entry can have multiple jobs + */ +SchedulingDefault.prototype._addJob = function (object) { + var timestamp = moment(object.time).valueOf(), + keys = [], + sortedJobs = {}, + instantJob = {}, + i = 0; + + // CASE: should have been already pinged or should be pinged soon + if (moment(timestamp).diff(moment(), 'minutes') < this.offsetInMinutes) { + instantJob[timestamp] = [object]; + this._execute(instantJob); + return; + } + + // CASE: are there jobs already scheduled for the same time? + if (!this.allJobs[timestamp]) { + this.allJobs[timestamp] = []; + } + + this.allJobs[timestamp].push(object); + + keys = Object.keys(this.allJobs); + keys.sort(); + + for (i = 0; i < keys.length; i = i + 1) { + sortedJobs[keys[i]] = this.allJobs[keys[i]]; + } + + this.allJobs = sortedJobs; +}; + +SchedulingDefault.prototype._deleteJob = function (object) { + if (!object.time) { + return; + } + + var deleteKey = object.url + '_' + moment(object.time).valueOf(); + + if (!this.deletedJobs[deleteKey]) { + this.deletedJobs[deleteKey] = []; + } + + this.deletedJobs[deleteKey].push(object); +}; + +/** + * ping jobs + * setTimeout is not accurate, but we can live with that fact and use setImmediate feature to qualify + * we don't want to use process.nextTick, this would block any I/O operation + */ +SchedulingDefault.prototype._execute = function (jobs) { + var keys = Object.keys(jobs), + self = this; + + keys.forEach(function (timestamp) { + var timeout = null, + diff = moment(Number(timestamp)).diff(moment()); + + // awake a little before + timeout = setTimeout(function () { + clearTimeout(timeout); + + (function retry() { + var immediate = setImmediate(function () { + clearImmediate(immediate); + + if (moment().diff(moment(Number(timestamp))) <= self.beforePingInMs) { + return retry(); + } + + var toExecute = jobs[timestamp]; + delete jobs[timestamp]; + + toExecute.forEach(function (job) { + var deleteKey = job.url + '_' + moment(job.time).valueOf(); + + if (self.deletedJobs[deleteKey]) { + if (self.deletedJobs[deleteKey].length === 1) { + delete self.deletedJobs[deleteKey]; + } else { + self.deletedJobs[deleteKey].pop(); + } + + return; + } + + self._pingUrl(job); + }); + }); + })(); + }, diff - 200); + }); +}; + +/** + * - if we detect to publish a post in the past (case blog is down), we add a force flag + */ +SchedulingDefault.prototype._pingUrl = function (object) { + var url = object.url, + time = object.time, + httpMethod = object.extra ? object.extra.httpMethod : 'PUT', + tries = object.tries || 0, + maxTries = 30, + req = request[httpMethod.toLowerCase()](url), + self = this, timeout; + + if (moment(time).isBefore(moment())) { + if (httpMethod === 'GET') { + req.query('force=true'); + } else { + req.send({ + force: true + }); + } + } + + req.end(function (err, response) { + if (err) { + // CASE: post/page was deleted already + if (response && response.status === 404) { + return; + } + + // CASE: blog is in maintenance mode, retry + if (response && response.status === 503 && tries < maxTries) { + timeout = setTimeout(function pingAgain() { + clearTimeout(timeout); + + object.tries = tries + 1; + self._pingUrl(object); + }, self.retryTimeoutInMs); + } + + errors.logError(err); + } + }); +}; + +module.exports = SchedulingDefault; diff --git a/core/server/scheduling/index.js b/core/server/scheduling/index.js new file mode 100644 index 0000000..15152b9 --- /dev/null +++ b/core/server/scheduling/index.js @@ -0,0 +1,11 @@ +var postScheduling = require(__dirname + '/post-scheduling'); + +/** + * scheduling modules: + * - post scheduling: publish posts/pages when scheduled + */ +exports.init = function init(options) { + options = options || {}; + + return postScheduling.init(options); +}; diff --git a/core/server/scheduling/post-scheduling/index.js b/core/server/scheduling/post-scheduling/index.js new file mode 100644 index 0000000..15dd41d --- /dev/null +++ b/core/server/scheduling/post-scheduling/index.js @@ -0,0 +1,96 @@ +var Promise = require('bluebird'), + moment = require('moment'), + utils = require(__dirname + '/../utils'), + events = require(__dirname + '/../../events'), + errors = require(__dirname + '/../../errors'), + models = require(__dirname + '/../../models'), + config = require(__dirname + '/../../config'), + schedules = require(__dirname + '/../../api/schedules'), + _private = {}; + +_private.normalize = function normalize(options) { + var object = options.object, + apiUrl = options.apiUrl, + client = options.client; + + return { + time: moment(object.get('published_at')).valueOf(), + url: config.urlJoin(apiUrl, 'schedules', 'posts', object.get('id')) + '?client_id=' + client.get('slug') + '&client_secret=' + client.get('secret'), + extra: { + httpMethod: 'PUT', + oldTime: object.updated('published_at') ? moment(object.updated('published_at')).valueOf() : null + } + }; +}; + +_private.loadClient = function loadClient() { + return models.Client.findOne({slug: 'ghost-scheduler'}, {columns: ['slug', 'secret']}); +}; + +_private.loadScheduledPosts = function () { + return schedules.getScheduledPosts() + .then(function (result) { + return result.posts || []; + }); +}; + +exports.init = function init(options) { + var config = options || {}, + apiUrl = config.apiUrl, + adapter = null, + client = null; + + if (!config) { + return Promise.reject(new errors.IncorrectUsage('post-scheduling: no config was provided')); + } + + if (!apiUrl) { + return Promise.reject(new errors.IncorrectUsage('post-scheduling: no apiUrl was provided')); + } + + return _private.loadClient() + .then(function (_client) { + client = _client; + + return utils.createAdapter(config); + }) + .then(function (_adapter) { + adapter = _adapter; + + return _private.loadScheduledPosts(); + }) + .then(function (scheduledPosts) { + if (!scheduledPosts.length) { + return; + } + + scheduledPosts.forEach(function (object) { + adapter.reschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client})); + }); + }) + .then(function () { + adapter.run(); + }) + .then(function () { + events.onMany([ + 'post.scheduled', + 'page.scheduled' + ], function (object) { + adapter.schedule(_private.normalize({object: object, apiUrl: apiUrl, client: client})); + }); + + events.onMany([ + 'post.rescheduled', + 'page.rescheduled' + ], function (object) { + adapter.reschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client})); + }); + + events.onMany([ + 'post.unscheduled', + 'page.unscheduled' + ], function (object) { + adapter.unschedule(_private.normalize({object: object, apiUrl: apiUrl, client: client})); + }); + }); +}; diff --git a/core/server/scheduling/utils.js b/core/server/scheduling/utils.js new file mode 100644 index 0000000..91b08d3 --- /dev/null +++ b/core/server/scheduling/utils.js @@ -0,0 +1,54 @@ +var _ = require('lodash'), + Promise = require('bluebird'), + SchedulingBase = require(__dirname + '/SchedulingBase'), + errors = require(__dirname + '/../errors'); + +exports.createAdapter = function (options) { + options = options || {}; + + var adapter = null, + activeAdapter = options.active, + path = options.path; + + if (!activeAdapter) { + return Promise.reject(new errors.IncorrectUsage('Please provide an active adapter.')); + } + + /** + * CASE: active adapter is a npm module + */ + try { + adapter = new (require(activeAdapter))(options); + } catch (err) { + if (err.code !== 'MODULE_NOT_FOUND') { + return Promise.reject(new errors.IncorrectUsage(err.message)); + } + } + + /** + * CASE: active adapter is located in specific ghost path + */ + try { + adapter = adapter || new (require(path + activeAdapter))(options); + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + return Promise.reject(new errors.IncorrectUsage('MODULE_NOT_FOUND', activeAdapter)); + } + + return Promise.reject(new errors.IncorrectUsage(err.message)); + } + + if (!(adapter instanceof SchedulingBase)) { + return Promise.reject(new errors.IncorrectUsage('Your adapter does not inherit from the SchedulingBase.')); + } + + if (!adapter.requiredFns) { + return Promise.reject(new errors.IncorrectUsage('Your adapter does not provide the minimum required functions.')); + } + + if (_.xor(adapter.requiredFns, Object.keys(_.pick(Object.getPrototypeOf(adapter), adapter.requiredFns))).length) { + return Promise.reject(new errors.IncorrectUsage('Your adapter does not provide the minimum required functions.')); + } + + return Promise.resolve(adapter); +}; diff --git a/core/server/storage/base.js b/core/server/storage/base.js new file mode 100644 index 0000000..04aba11 --- /dev/null +++ b/core/server/storage/base.js @@ -0,0 +1,68 @@ +var moment = require('moment'), + path = require('path'); + +function StorageBase() { + Object.defineProperty(this, 'requiredFns', { + value: ['exists', 'save', 'serve', 'delete'], + writable: false + }); +} + +StorageBase.prototype.getTargetDir = function (baseDir) { + var m = moment(), + month = m.format('MM'), + year = m.format('YYYY'); + + if (baseDir) { + return path.join(baseDir, year, month); + } + + return path.join(year, month); +}; + +StorageBase.prototype.generateUnique = function (store, dir, name, ext, i) { + var self = this, + filename, + append = ''; + + if (i) { + append = '-' + i; + } + + if (ext) { + filename = path.join(dir, name + append + ext); + } else { + filename = path.join(dir, name + append); + } + + return store.exists(filename).then(function (exists) { + if (exists) { + i = i + 1; + return self.generateUnique(store, dir, name, ext, i); + } else { + return filename; + } + }); +}; + +StorageBase.prototype.getUniqueFileName = function (store, image, targetDir) { + var ext = path.extname(image.name), name; + + // poor extension validation + // .1 is not a valid extension + if (!ext.match(/.\d/)) { + name = this.getSanitizedFileName(path.basename(image.name, ext)); + return this.generateUnique(store, targetDir, name, ext, 0); + } else { + name = this.getSanitizedFileName(path.basename(image.name)); + return this.generateUnique(store, targetDir, name, null, 0); + } +}; + +StorageBase.prototype.getSanitizedFileName = function getSanitizedFileName(fileName) { + // below only matches ascii characters, @, and . + // unicode filenames like город.zip would therefore resolve to ----.zip + return fileName.replace(/[^\w@.]/gi, '-'); +}; + +module.exports = StorageBase; diff --git a/core/server/storage/index.js b/core/server/storage/index.js new file mode 100644 index 0000000..86f3a36 --- /dev/null +++ b/core/server/storage/index.js @@ -0,0 +1,68 @@ +var errors = require('../errors'), + config = require('../config'), + Base = require('./base'), + _ = require('lodash'), + storage = {}; + +/** + * type: images|themes + */ +function getStorage(type) { + type = type || 'images'; + + var storageChoice = config.storage.active[type], + storageConfig = config.storage[storageChoice]; + + // CASE: type does not exist + if (!storageChoice) { + throw new errors.IncorrectUsage('No adapter found for type: ' + type); + } + + // cache? + if (storage[storageChoice]) { + return storage[storageChoice]; + } + + // CASE: load adapter from custom path (.../content/storage) + try { + storage[storageChoice] = require(config.paths.storagePath.custom + storageChoice); + } catch (err) { + // CASE: only throw error if module does exist + if (err.code !== 'MODULE_NOT_FOUND') { + throw new errors.IncorrectUsage(err.message); + } + // CASE: if module not found it can be an error within the adapter (cannot find bluebird for example) + else if (err.code === 'MODULE_NOT_FOUND' && err.message.indexOf(config.paths.storagePath.custom + storageChoice) === -1) { + throw new errors.IncorrectUsage(err.message); + } + } + + // CASE: either storage[storageChoice] is already set or why check for in the default storage path + try { + storage[storageChoice] = storage[storageChoice] || require(config.paths.storagePath.default + storageChoice); + } catch (err) { + if (err.code === 'MODULE_NOT_FOUND') { + throw new errors.IncorrectUsage('We cannot find your adpter in: ' + config.paths.storagePath.custom + ' or: ' + config.paths.storagePath.default); + } else { + throw new errors.IncorrectUsage(err.message); + } + } + + storage[storageChoice] = new storage[storageChoice](storageConfig); + + if (!(storage[storageChoice] instanceof Base)) { + throw new errors.IncorrectUsage('Your storage adapter does not inherit from the Storage Base.'); + } + + if (!storage[storageChoice].requiredFns) { + throw new errors.IncorrectUsage('Your storage adapter does not provide the minimum required functions.'); + } + + if (_.xor(storage[storageChoice].requiredFns, Object.keys(_.pick(Object.getPrototypeOf(storage[storageChoice]), storage[storageChoice].requiredFns))).length) { + throw new errors.IncorrectUsage('Your storage adapter does not provide the minimum required functions.'); + } + + return storage[storageChoice]; +} + +module.exports.getStorage = getStorage; diff --git a/core/server/storage/local-file-store.js b/core/server/storage/local-file-store.js new file mode 100644 index 0000000..f7f88a6 --- /dev/null +++ b/core/server/storage/local-file-store.js @@ -0,0 +1,109 @@ +// # Local File System Image Storage module +// The (default) module for storing images, using the local file system + +var serveStatic = require('express').static, + fs = require('fs-extra'), + os = require('os'), + path = require('path'), + util = require('util'), + Promise = require('bluebird'), + errors = require('../errors'), + config = require('../config'), + utils = require('../utils'), + BaseStore = require('./base'), + remove = Promise.promisify(fs.remove); + +function LocalFileStore() { + BaseStore.call(this); +} + +util.inherits(LocalFileStore, BaseStore); + +// ### Save +// Saves the image to storage (the file system) +// - image is the express image object +// - returns a promise which ultimately returns the full url to the uploaded image +LocalFileStore.prototype.save = function (image, targetDir) { + targetDir = targetDir || this.getTargetDir(config.paths.imagesPath); + var targetFilename; + + return this.getUniqueFileName(this, image, targetDir).then(function (filename) { + targetFilename = filename; + return Promise.promisify(fs.mkdirs)(targetDir); + }).then(function () { + return Promise.promisify(fs.copy)(image.path, targetFilename); + }).then(function () { + // The src for the image must be in URI format, not a file system path, which in Windows uses \ + // For local file system storage can use relative path so add a slash + var fullUrl = (config.paths.subdir + '/' + config.paths.imagesRelPath + '/' + + path.relative(config.paths.imagesPath, targetFilename)).replace(new RegExp('\\' + path.sep, 'g'), '/'); + return fullUrl; + }).catch(function (e) { + errors.logError(e); + return Promise.reject(e); + }); +}; + +LocalFileStore.prototype.exists = function (filename) { + return new Promise(function (resolve) { + fs.stat(filename, function (err) { + var exists = !err; + resolve(exists); + }); + }); +}; + +// middleware for serving the files +LocalFileStore.prototype.serve = function (options) { + options = options || {}; + + // CASE: serve themes + // serveStatic can't be used to serve themes, because + // download files depending on the route (see `send` npm module) + if (options.isTheme) { + return function downloadTheme(req, res, next) { + var themeName = options.name, + themePath = path.join(config.paths.themePath, themeName), + zipName = themeName + '.zip', + // store this in a unique temporary folder + zipBasePath = path.join(os.tmpdir(), utils.uid(10)), + zipPath = path.join(zipBasePath, zipName), + stream; + + Promise.promisify(fs.ensureDir)(zipBasePath) + .then(function () { + return Promise.promisify(utils.zipFolder)(themePath, zipPath); + }) + .then(function (length) { + res.set({ + 'Content-disposition': 'attachment; filename={themeName}.zip'.replace('{themeName}', themeName), + 'Content-Type': 'application/zip', + 'Content-Length': length + }); + + stream = fs.createReadStream(zipPath); + stream.pipe(res); + }) + .catch(function (err) { + next(err); + }) + .finally(function () { + remove(zipBasePath); + }); + }; + } else { + // CASE: serve images + // For some reason send divides the max age number by 1000 + // Fallthrough: false ensures that if an image isn't found, it automatically 404s + return serveStatic(config.paths.imagesPath, {maxAge: utils.ONE_YEAR_MS, fallthrough: false}); + } +}; + +LocalFileStore.prototype.delete = function (fileName, targetDir) { + targetDir = targetDir || this.getTargetDir(config.paths.imagesPath); + + var pathToDelete = path.join(targetDir, fileName); + return remove(pathToDelete); +}; + +module.exports = LocalFileStore; diff --git a/core/server/translations/en.json b/core/server/translations/en.json new file mode 100644 index 0000000..21422bb --- /dev/null +++ b/core/server/translations/en.json @@ -0,0 +1,574 @@ +{ + "common": { + "mail": { + "title": "Ghost at {domain}" + }, + "seeLinkForInstructions": "See {link} for instructions.", + "time": { + "seconds": "seconds" + }, + "api": { + "authentication": { + "sampleBlogDescription": "Thoughts, stories and ideas.", + "mail": { + "resetPassword": "Reset Password", + "checkEmailForInstructions": "Check your email for further instructions.", + "passwordChanged": "Password changed successfully.", + "invitationAccepted": "Invitation accepted.", + "yourNewGhostBlog": "Your New Ghost Blog" + } + }, + "mail": { + "testGhostEmail": "Test Ghost Email" + }, + "users": { + "mail": { + "invitedByName": "{invitedByName} has invited you to join {blogName}" + } + }, + "clients": { + "clientNotFound": "Client not found" + } + } + }, + "errors": { + "apps": { + "failedToParseActiveAppsSettings": { + "error": "Failed to parse activeApps setting value: {message}", + "context": "Your apps will not be loaded.", + "help": "Check your settings table for typos in the activeApps value. It should look like: [\"app-1\", \"app2\"] (double quotes required)." + }, + "appWillNotBeLoaded": { + "error": "The app will not be loaded", + "help": "Check with the app creator, or read the app documentation for more details on app requirements" + }, + "permissionsErrorLoadingApp": { + "error": "Error loading app named {name}; problem reading permissions: {message}" + }, + "noInstallMethodLoadingApp": { + "error": "Error loading app named {name}; no install() method defined." + }, + "noActivateMethodLoadingApp": { + "error": "Error loading app named {name}; no activate() method defined." + }, + "accessResourceWithoutPermission": { + "error": "The App \"{name}\" attempted to perform an action or access a resource ({perm}.{method}) without permission." + }, + "mustProvideAppName": { + "error": "Must provide an app name for api context" + }, + "mustProvideAppPermissions": { + "error": "Must provide app permissions" + }, + "unsafeAppRequire": { + "error": "Unsafe App require: {msg}" + } + }, + "middleware": { + "api": { + "versionMismatch": "Request for version {requestVersion} does not match current version {currentVersion}." + }, + "auth": { + "clientAuthenticationFailed": "Client Authentication Failed", + "clientCredentialsNotProvided": "Client credentials were not provided", + "clientCredentialsNotValid": "Client credentials were not valid", + "forInformationRead": "For information on how to fix this, please read {url}.", + "accessDenied": "Access denied.", + "pleaseSignIn": "Please Sign In" + }, + "oauth": { + "invalidClient": "Invalid client.", + "invalidRefreshToken": "Invalid refresh token.", + "refreshTokenExpired": "Refresh token expired.", + "tokenExpired": "Token expired." + }, + "privateblogging": { + "wrongPassword": "Wrong password" + }, + "spamprevention": { + "tooManyAttempts": "Too many attempts.", + "noUsername": "No username.", + "noPassword": "No password entered", + "tooManySigninAttempts": { + "error": "Only {rateSigninAttempts} tries per IP address every {rateSigninPeriod} seconds.", + "context": "Too many login attempts." + }, + "tryAgainLater": " Please try again later", + "waitOneHour": " Please wait 1 hour.", + "noEmail": "No email.", + "forgottenPasswordEmail": { + "error": "Only {rfa} forgotten password attempts per email every {rfp} seconds.", + "context": "Forgotten password reset attempt failed" + }, + "forgottenPasswordIp": { + "error": "Only {rfa} tries per IP address every {rfp} seconds.", + "context": "Forgotten password reset attempt failed" + } + }, + "themehandler": { + "missingTheme": "The currently active theme \"{theme}\" is missing." + } + }, + "utils": { + "parsepackagejson": { + "couldNotReadPackage": "Could not read package.json file", + "nameOrVersionMissing": "\"name\" or \"version\" is missing from theme package.json file.", + "willBeRequired": "This will be required in future. Please see {url}", + "themeFileIsMalformed": "Theme package.json file is malformed" + }, + "validatethemes": { + "themeWithNoPackage": { + "message": "Found a theme with no package.json file", + "context": "Theme name: {name}", + "help": "This will be required in future. Please see {url}" + }, + "malformedPackage": { + "message": "Found a malformed package.json", + "context": "Theme name: {name}", + "help": "Valid package.json will be required in future. Please see {url}" + } + } + }, + "config": { + "couldNotLocateConfigFile": { + "error": "Could not locate a configuration file.", + "help": "Please check your deployment for config.js or config.example.js." + }, + "couldNotOpenForReading": { + "error": "Could not open {file} for read.", + "help": "Please check your deployment for config.js or config.example.js." + }, + "couldNotOpenForWriting": { + "error": "Could not open {file} for write.", + "help": "Please check your deployment for config.js or config.example.js." + }, + "invalidUrlInConfig": { + "error": "invalid site url", + "description": "Your site url in config.js is invalid.", + "help": "Please make sure this is a valid url before restarting" + }, + "urlCannotContainGhostSubdir": { + "error": "ghost subdirectory not allowed", + "description": "Your site url in config.js cannot contain a subdirectory called ghost.", + "help": "Please rename the subdirectory before restarting" + }, + "urlCannotContainPrivateSubdir": { + "error": "private subdirectory not allowed", + "description": "Your site url in config.js cannot contain a subdirectory called private.", + "help": "Please rename the subdirectory before restarting" + }, + "dbConfigInvalid": { + "error": "invalid database configuration", + "description": "Your database configuration in config.js is invalid.", + "help": "Please make sure this is a valid Bookshelf database configuration" + }, + "deprecatedProperty": { + "error": "The configuration property [{property}] has been deprecated.", + "explanation": "This will be removed in a future version, please update your config.js file.", + "help": "Please check {url} for the most up-to-date example." + }, + "invalidServerValues": { + "error": "invalid server configuration", + "description": "Your server values (socket, or host and port) in config.js are invalid.", + "help": "Please provide them before restarting." + } + }, + "general": { + "maintenance": "Ghost is currently undergoing maintenance, please wait a moment then retry.", + "moreInfo": "\nMore info: {info}", + "requiredOnFuture": "This will be required in future. Please see {link}" + }, + "httpServer": { + "addressInUse": { + "error": "(EADDRINUSE) Cannot start Ghost.", + "context": "Port {port} is already in use by another program.", + "help": "Is another Ghost instance already running?" + }, + "otherError": { + "error": "(Code: {errorNumber})", + "context": "There was an error starting your server.", + "help": "Please use the error code above to search for a solution." + } + }, + "mail": { + "incompleteMessageData": { + "error": "Error: Incomplete message data." + }, + "failedSendingEmail": { + "error": "Error: Failed to send email" + }, + "noMailServerAtAddress": { + "error": " - no mail server found at {domain}" + }, + "messageNotSent": { + "error": "Error: Message could not be sent" + } + }, + "models": { + "subscriber": { + "notEnoughPermission": "You do not have permission to perform this action" + }, + "post": { + "untitled": "(Untitled)", + "valueCannotBeBlank": "Value in {key} cannot be blank.", + "isAlreadyPublished": "Your post is already published, please reload your page.", + "expectedPublishedAtInFuture": "Date must be at least {cannotScheduleAPostBeforeInMinutes} minutes in the future.", + "noUserFound": "No user found", + "notEnoughPermission": "You do not have permission to perform this action", + "tagUpdates": { + "error": "Unable to save tags.", + "help": "Your post was saved, but your tags were not updated." + } + }, + "role": { + "notEnoughPermission": "You do not have permission to perform this action" + }, + "settings": { + "valueCannotBeBlank": "Value in [settings.key] cannot be blank.", + "unableToFindSetting": "Unable to find setting to update: {key}", + "unableToFindDefaultSetting": "Unable to find default setting: {key}" + }, + "user": { + "missingContext": "missing context", + "onlyOneRolePerUserSupported": "Only one role per user is supported at the moment.", + "methodDoesNotSupportOwnerRole": "This method does not support assigning the owner role", + "passwordDoesNotComplyLength": "Your password must be at least 8 characters long.", + "notEnoughPermission": "You do not have permission to perform this action", + "noUserWithEnteredEmailAddr": "There is no user with that email address.", + "userIsInactive": "The user with that email address is inactive.", + "incorrectPasswordAttempts": "Your password is incorrect.
    {remaining} attempt{s} remaining!", + "userUpdateError": { + "emailIsAlreadyInUse": "Email is already in use", + "context": "Error thrown from user update during login", + "help": "Visit and save your profile after logging in to check for problems." + }, + "incorrectPassword": "Your password is incorrect.", + "accountLocked": "Your account is locked. Please reset your password to log in again by clicking the \"Forgotten password?\" link!", + "newPasswordsDoNotMatch": "Your new passwords do not match", + "passwordRequiredForOperation": "Password is required for this operation", + "invalidTokenStructure": "Invalid token structure", + "invalidTokenExpiration": "Invalid token expiration", + "expiredToken": "Expired token", + "tokenLocked": "Token locked", + "invalidToken": "Invalid token", + "userNotFound": "User not found", + "onlyOwnerCanTransferOwnerRole": "Only owners are able to transfer the owner role.", + "onlyAdmCanBeAssignedOwnerRole": "Only administrators can be assigned the owner role." + }, + "base": { + "index": { + "missingContext": "missing context" + }, + "token": { + "noUserFound": "No user found", + "tokenNotFound": "Token not found" + } + }, + "plugins": { + "filter": { + "errorParsing": "Error parsing filter", + "forInformationRead": "For more information on how to use filter, see {url}" + } + } + }, + "permissions": { + "noActionsMapFound": { + "error": "No actions map found, ensure you have loaded permissions into database and then call permissions.init() before use." + }, + "applyStatusRules": { + "error": "You do not have permission to retrieve {docName} with that status" + }, + "noPermissionToAction": "You do not have permission to perform this action" + }, + "update-check": { + "checkingForUpdatesFailed": { + "error": "Checking for updates failed, your blog will continue to function.", + "help": "If you get this error repeatedly, please seek help from {url}." + }, + "unableToDecodeUpdateResponse": { + "error": "Unable to decode update response" + } + }, + "api": { + "authentication": { + "setupUnableToRun": "Database missing fixture data. Please reset database and try again.", + "setupMustBeCompleted": "Setup must be completed before making this request.", + "noEmailProvided": "No email provided.", + "invalidEmailReceived": "The server did not receive a valid email", + "setupAlreadyCompleted": "Setup has already been completed.", + "unableToSendWelcomeEmail": "Unable to send welcome email, your blog will continue to function.", + "checkEmailConfigInstructions": "Please see {url} for instructions on configuring email.", + "notLoggedIn": "You are not logged in.", + "notTheBlogOwner": "You are not the blog owner.", + "invalidTokenTypeHint": "Invalid token_type_hint given.", + "invalidTokenProvided": "Invalid token provided", + "tokenRevocationFailed": "Token revocation failed" + }, + "clients": { + "clientNotFound": "Client not found." + }, + "configuration": { + "invalidKey": "Invalid key" + }, + "db": { + "missingFile": "Please select a database file to import.", + "invalidFile": "Unsupported file. Please try any of the following formats: {extensions}", + "noPermissionToExportData": "You do not have permission to export data (no rights).", + "noPermissionToImportData": "You do not have permission to import data (no rights)." + }, + "mail": { + "noPermissionToSendEmail": "You do not have permission to send mail.", + "cannotFindCurrentUser": "Could not find the current user" + }, + "notifications": { + "noPermissionToBrowseNotif": "You do not have permission to browse notifications.", + "noPermissionToAddNotif": "You do not have permission to add notifications.", + "noPermissionToDestroyNotif": "You do not have permission to destroy notifications.", + "noPermissionToDismissNotif": "You do not have permission to dismiss this notification.", + "notificationDoesNotExist": "Notification does not exist." + }, + "posts": { + "postNotFound": "Post not found." + }, + "job": { + "notFound": "Job not found.", + "publishInThePast": "Use the force flag to publish a post in the past." + }, + "settings": { + "problemFindingSetting": "Problem finding setting: {key}", + "accessCoreSettingFromExtReq": "Attempted to access core setting from external request", + "invalidJsonInLabs": "Error: Invalid JSON in settings.labs", + "labsColumnCouldNotBeParsed": "The column with key \"labs\" could not be parsed as JSON", + "tryUpdatingLabs": "Please try updating a setting on the labs page, or manually editing your DB", + "noPermissionToEditSettings": "You do not have permission to edit settings.", + "noPermissionToReadSettings": "You do not have permission to read settings." + }, + "slugs": { + "couldNotGenerateSlug": "Could not generate slug.", + "unknownSlugType": "Unknown slug type '{type}'." + }, + "subscribers": { + "missingFile": "Please select a csv.", + "invalidFile": "Please select a valid CSV file to import.", + "subscriberNotFound": "Subscriber not found.", + "subscriberAlreadyExists": "Email address is already subscribed." + }, + "tags": { + "tagNotFound": "Tag not found." + }, + "themes": { + "noPermissionToBrowseThemes": "You do not have permission to browse themes.", + "noPermissionToEditThemes": "You do not have permission to edit themes.", + "themeDoesNotExist": "Theme does not exist.", + "invalidTheme": "Theme is not compatible or contains errors.", + "missingFile": "Please select a theme.", + "invalidFile": "Please select a valid zip file.", + "overrideCasper": "Please rename your zip, it's not allowed to override the default casper theme.", + "destroyCasper": "Deleting the default casper theme is not allowed." + }, + "images": { + "missingFile": "Please select an image.", + "invalidFile": "Please select a valid image." + }, + "users": { + "userNotFound": "User not found.", + "cannotChangeOwnRole": "You cannot change your own role.", + "cannotChangeOwnersRole": "Cannot change Owner's role", + "noPermissionToEditUser": "You do not have permission to edit this user", + "notAllowedToCreateOwner": "Not allowed to create an owner user.", + "noPermissionToAddUser": "You do not have permission to add this user", + "noEmailProvided": "No email provided.", + "userAlreadyRegistered": "User is already registered.", + "errorSendingEmail": { + "error": "Error sending email: {message}", + "help": "Please check your email settings and resend the invitation." + }, + "noPermissionToDestroyUser": "You do not have permission to destroy this user.", + "noPermissionToChangeUsersPwd": "You do not have permission to change the password for this user" + }, + "utils": { + "noPermissionToCall": "You do not have permission to {method} {docName}", + "noRootKeyProvided": "No root key ('{docName}') provided.", + "invalidIdProvided": "Invalid id provided." + } + }, + "data": { + "export": { + "errorExportingData": "Error exporting data" + }, + "import": { + "dataImporter": { + "unableToFindOwner": "Unable to find an owner" + }, + "index": { + "duplicateEntryFound": "Duplicate entry found. Multiple values of '{value}' found for {offendingProperty}." + }, + "utils": { + "dataLinkedToUnknownUser": "Attempting to import data linked to unknown user id {userToMap}" + } + }, + "importer": { + "index": { + "couldNotCleanUpFile": { + "error": "Import could not clean up file ", + "context": "Your blog will continue to work as expected" + }, + "unsupportedRoonExport": "Your zip file looks like an old format Roon export, please re-export your Roon blog and try again.", + "noContentToImport": "Zip did not include any content to import.", + "invalidZipStructure": "Invalid zip file structure.", + "invalidZipFileBaseDirectory": "Invalid zip file: base directory read failed", + "zipContainsMultipleDataFormats": "Zip file contains multiple data formats. Please split up and import separately." + }, + "handlers": { + "json": { + "invalidJsonFormat": "Invalid JSON format, expected `{ db: [exportedData] }`", + "apiDbImportContent": "API DB import content", + "checkImportJsonIsValid": "check that the import file is valid JSON.", + "failedToParseImportJson": "Failed to parse the import JSON file." + } + } + }, + "versioning": { + "index": { + "dbVersionNotRecognized": "Database version is not recognized", + "databaseNotPopulated": "Database is not populated.", + "cannotMigrate": { + "error": "Unable to upgrade from version 0.4.2 or earlier.", + "context": "Please upgrade to 0.7.1 first." + } + } + }, + "xml": { + "xmlrpc": { + "pingUpdateFailed": { + "error": "Pinging services for updates on your blog failed, your blog will continue to function.", + "help": "If you get this error repeatedly, please seek help on {url}." + } + } + } + }, + "errors": { + "noMessageSupplied": "no message supplied", + "error": "\nERROR:", + "warning": "\nWarning:", + "anErrorOccurred": "An error occurred", + "unknownErrorOccurred": "An unknown error occurred.", + "unknownError": "Unknown Error", + "unknownApiError": "Unknown API Error", + "databaseIsReadOnly": "Your database is in read only mode. Visitors can read your blog, but you can't log in or add posts.", + "checkDatabase": "Check your database file and make sure that file owner and permissions are correct.", + "notEnoughPermission": "You do not have permission to perform this action", + "errorWhilstRenderingError": "Error whilst rendering error page", + "errorTemplateHasError": "Error template has an error", + "oopsErrorTemplateHasError": "Oops, seems there is an error in the error template.", + "encounteredError": "Encountered the error: ", + "whilstTryingToRender": "whilst trying to render an error page for the error: ", + "renderingErrorPage": "Rendering Error Page", + "caughtProcessingError": "Ghost caught a processing error in the middleware layer.", + "pageNotFound": "Page not found" + } + }, + "warnings": { + "index": { + "usingDirectMethodToSendEmail": "Ghost is attempting to use a direct method to send email. \nIt is recommended that you explicitly configure an email service.", + "unableToSendEmail": "Ghost is currently unable to send email." + }, + "helpers": { + "helperNotAvailable": "The \\{\\{{helperName}\\}\\} helper is not available.", + "apiMustBeEnabled": "The {flagName} labs flag must be enabled if you wish to use the \\{\\{{helperName}\\}\\} helper.", + "seeLink": "See {url}", + "foreach": { + "iteratorNeeded": "Need to pass an iterator to #foreach" + }, + "get": { + "mustBeCalledAsBlock": "Get helper must be called as a block", + "invalidResource": "Invalid resource given to get helper", + "helperNotAvailable": "The \\{\\{get\\}\\} helper is not available.", + "apiMustBeEnabled": "Public API access must be enabled if you wish to use the \\{\\{get\\}\\} helper.", + "seeLink": "See {url}" + }, + "has": { + "invalidAttribute": "Invalid or no attribute given to has helper" + }, + "index": { + "missingHelper": "Missing helper: '{arg}'" + }, + "is": { + "invalidAttribute": "Invalid or no attribute given to is helper" + }, + "navigation": { + "invalidData": "navigation data is not an object or is a function", + "valuesMustBeDefined": "All values must be defined for label, url and current", + "valuesMustBeString": "Invalid value, Url and Label must be strings" + }, + "page_url": { + "isDeprecated": "Warning: pageUrl is deprecated, please use page_url instead\nThe helper pageUrl has been replaced with page_url in Ghost 0.4.2, and will be removed entirely in Ghost 0.6\nIn your theme's pagination.hbs file, pageUrl should be renamed to page_url" + }, + "pagination": { + "invalidData": "pagination data is not an object or is a function", + "valuesMustBeDefined": "All values must be defined for page, pages, limit and total", + "nextPrevValuesMustBeNumeric": "Invalid value, Next/Prev must be a number", + "valuesMustBeNumeric": "Invalid value, check page, pages, limit and total are numbers" + }, + "plural": { + "valuesMustBeDefined": "All values must be defined for empty, singular and plural" + }, + "template": { + "templateNotFound": "Template {name} not found." + } + } + }, + "notices": { + "controllers": { + "newVersionAvailable": "Ghost {version} is available! Hot Damn. {link} to upgrade." + }, + "index": { + "welcomeToGhost": "Welcome to Ghost.", + "youAreRunningUnderEnvironment": "You're running under the {environment} environment.", + "yourURLisSetTo": "Your URL is set to {url} ." + }, + "httpServer": { + "cantTouchThis": "Can't touch this", + "ghostIsRunning": "Ghost is running...", + "yourBlogIsAvailableOn": "\nYour blog is now available on {url}", + "ctrlCToShutDown": "\nCtrl+C to shut down", + "ghostIsRunningIn": "Ghost is running in {env}...", + "listeningOn": "\nListening on", + "urlConfiguredAs": "\nUrl configured as: {url}", + "ghostHasShutdown": "\nGhost has shut down", + "yourBlogIsNowOffline": "\nYour blog is now offline", + "ghostWasRunningFor": "\nGhost was running for", + "ghostIsClosingConnections": "Ghost is closing connections" + }, + "mail": { + "messageSent": "Message sent. Double check inbox and spam folder!" + }, + "api": { + "users": { + "pwdChangedSuccessfully": "Password changed successfully." + } + }, + "data": { + "fixtures": { + "canSafelyDelete": "\n", + "jQueryRemoved": "jQuery has been removed from Ghost core and is now being loaded from the jQuery Foundation's CDN.", + "canBeChanged": "This can be changed or removed in your Code Injection settings area." + }, + "utils": { + "index": { + "noSupportForDatabase": "No support for database client {client}" + } + }, + "validation": { + "index": { + "valueCannotBeBlank": "Value in [{tableName}.{columnKey}] cannot be blank.", + "valueMustBeBoolean": "Value in [settings.key] must be one of true, false, 0 or 1.", + "valueExceedsMaxLength": "Value in [{tableName}.{columnKey}] exceeds maximum length of {maxlength} characters.", + "valueIsNotInteger": "Value in [{tableName}.{columnKey}] is not an integer.", + "themeCannotBeActivated": "The theme \"{themeName}\" cannot be activated because it is not currently installed.", + "validationFailed": "Validation ({validationName}) failed for {key}" + } + } + } + } +} diff --git a/core/server/update-check.js b/core/server/update-check.js new file mode 100644 index 0000000..8579a01 --- /dev/null +++ b/core/server/update-check.js @@ -0,0 +1,255 @@ +// # Update Checking Service +// +// Makes a request to Ghost.org to check if there is a new version of Ghost available. +// The service is provided in return for users opting in to anonymous usage data collection. +// +// Blog owners can opt-out of update checks by setting `privacy: { useUpdateCheck: false }` in their config.js +// +// The data collected is as follows: +// +// - blog id - a hash of the blog hostname, pathname and dbHash. No identifiable info is stored. +// - ghost version +// - node version +// - npm version +// - env - production or development +// - database type - SQLite, MySQL, PostgreSQL +// - email transport - mail.options.service, or otherwise mail.transport +// - created date - database creation date +// - post count - total number of posts +// - user count - total number of users +// - theme - name of the currently active theme +// - apps - names of any active apps + +var crypto = require('crypto'), + exec = require('child_process').exec, + https = require('https'), + moment = require('moment'), + semver = require('semver'), + Promise = require('bluebird'), + _ = require('lodash'), + url = require('url'), + + api = require('./api'), + config = require('./config'), + errors = require('./errors'), + i18n = require('./i18n'), + internal = {context: {internal: true}}, + allowedCheckEnvironments = ['development', 'production'], + checkEndpoint = 'updates.ghost.org', + currentVersion = config.ghostVersion; + +function updateCheckError(error) { + api.settings.edit( + {settings: [{key: 'nextUpdateCheck', value: Math.round(Date.now() / 1000 + 24 * 3600)}]}, + internal + ); + + errors.logError( + error, + i18n.t('errors.update-check.checkingForUpdatesFailed.error'), + i18n.t('errors.update-check.checkingForUpdatesFailed.help', {url: 'http://docs.ghost.org/v0.11.9'}) + ); +} + +/** + * If the custom message is intended for current version, create and store a custom notification. + * @param {Object} message {id: uuid, version: '0.9.x', content: '' } + * @return {*|Promise} + */ +function createCustomNotification(message) { + if (!semver.satisfies(currentVersion, message.version)) { + return Promise.resolve(); + } + + var notification = { + status: 'alert', + type: 'info', + custom: true, + uuid: message.id, + dismissible: true, + message: message.content + }, + getAllNotifications = api.notifications.browse({context: {internal: true}}), + getSeenNotifications = api.settings.read(_.extend({key: 'seenNotifications'}, internal)); + + return Promise.join(getAllNotifications, getSeenNotifications, function joined(all, seen) { + var isSeen = _.includes(JSON.parse(seen.settings[0].value || []), notification.uuid), + isDuplicate = _.some(all.notifications, {message: notification.message}); + + if (!isSeen && !isDuplicate) { + return api.notifications.add({notifications: [notification]}, {context: {internal: true}}); + } + }); +} + +function updateCheckData() { + var data = {}, + mailConfig = config.mail; + + data.ghost_version = currentVersion; + data.node_version = process.versions.node; + data.env = process.env.NODE_ENV; + data.database_type = config.database.client; + data.email_transport = mailConfig && + (mailConfig.options && mailConfig.options.service ? + mailConfig.options.service : + mailConfig.transport); + + return Promise.props({ + hash: api.settings.read(_.extend({key: 'dbHash'}, internal)).reflect(), + theme: api.settings.read(_.extend({key: 'activeTheme'}, internal)).reflect(), + apps: api.settings.read(_.extend({key: 'activeApps'}, internal)) + .then(function (response) { + var apps = response.settings[0]; + + apps = JSON.parse(apps.value); + + return _.reduce(apps, function (memo, item) { return memo === '' ? memo + item : memo + ', ' + item; }, ''); + }).reflect(), + posts: api.posts.browse().reflect(), + users: api.users.browse(internal).reflect(), + npm: Promise.promisify(exec)('npm -v').reflect() + }).then(function (descriptors) { + var hash = descriptors.hash.value().settings[0], + theme = descriptors.theme.value().settings[0], + apps = descriptors.apps.value(), + posts = descriptors.posts.value(), + users = descriptors.users.value(), + npm = descriptors.npm.value(), + blogUrl = url.parse(config.url), + blogId = blogUrl.hostname + blogUrl.pathname.replace(/\//, '') + hash.value; + + data.blog_id = crypto.createHash('md5').update(blogId).digest('hex'); + data.theme = theme ? theme.value : ''; + data.apps = apps || ''; + data.post_count = posts && posts.meta && posts.meta.pagination ? posts.meta.pagination.total : 0; + data.user_count = users && users.users && users.users.length ? users.users.length : 0; + data.blog_created_at = users && users.users && users.users[0] && users.users[0].created_at ? moment(users.users[0].created_at).unix() : ''; + data.npm_version = npm.trim(); + + return data; + }).catch(updateCheckError); +} + +function updateCheckRequest() { + return updateCheckData().then(function then(reqData) { + var resData = '', + headers, + req; + + reqData = JSON.stringify(reqData); + + headers = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(reqData) + }; + + return new Promise(function p(resolve, reject) { + req = https.request({ + hostname: checkEndpoint, + method: 'POST', + headers: headers + }, function handler(res) { + res.on('error', function onError(error) { reject(error); }); + res.on('data', function onData(chunk) { resData += chunk; }); + res.on('end', function onEnd() { + try { + resData = JSON.parse(resData); + resolve(resData); + } catch (e) { + reject(i18n.t('errors.update-check.unableToDecodeUpdateResponse.error')); + } + }); + }); + + req.on('socket', function onSocket(socket) { + // Wait a maximum of 10seconds + socket.setTimeout(10000); + socket.on('timeout', function onTimeout() { + req.abort(); + }); + }); + + req.on('error', function onError(error) { + reject(error); + }); + + req.write(reqData); + req.end(); + }); + }); +} + +/** + * Handles the response from the update check + * Does three things with the information received: + * 1. Updates the time we can next make a check + * 2. Checks if the version in the response is new, and updates the notification setting + * 3. Create custom notifications is response from UpdateCheck as "messages" array which has the following structure: + * + * "messages": [{ + * "id": ed9dc38c-73e5-4d72-a741-22b11f6e151a, + * "version": "0.5.x", + * "content": "

    Hey there! 0.6 is available, visit Ghost.org to grab your copy now" + * ]} + * + * @param {Object} response + * @return {Promise} + */ +function updateCheckResponse(response) { + return Promise.all([ + api.settings.edit({settings: [{key: 'nextUpdateCheck', value: response.next_check}]}, internal), + api.settings.edit({settings: [{key: 'displayUpdateNotification', value: response.version}]}, internal) + ]).then(function () { + var messages = response.messages || []; + return Promise.map(messages, createCustomNotification); + }); +} + +function updateCheck() { + // The check will not happen if: + // 1. updateCheck is defined as false in config.js + // 2. we've already done a check this session + // 3. we're not in production or development mode + // TODO: need to remove config.updateCheck in favor of config.privacy.updateCheck in future version (it is now deprecated) + if (config.updateCheck === false || config.isPrivacyDisabled('useUpdateCheck') || _.indexOf(allowedCheckEnvironments, process.env.NODE_ENV) === -1) { + // No update check + return Promise.resolve(); + } else { + return api.settings.read(_.extend({key: 'nextUpdateCheck'}, internal)).then(function then(result) { + var nextUpdateCheck = result.settings[0]; + + if (nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) { + // It's not time to check yet + return; + } else { + // We need to do a check + return updateCheckRequest() + .then(updateCheckResponse) + .catch(updateCheckError); + } + }).catch(updateCheckError); + } +} + +function showUpdateNotification() { + return api.settings.read(_.extend({key: 'displayUpdateNotification'}, internal)).then(function then(response) { + var display = response.settings[0]; + + // Version 0.4 used boolean to indicate the need for an update. This special case is + // translated to the version string. + // TODO: remove in future version. + if (display.value === 'false' || display.value === 'true' || display.value === '1' || display.value === '0') { + display.value = '0.4.0'; + } + + if (display && display.value && currentVersion && semver.gt(display.value, currentVersion)) { + return display.value; + } + + return false; + }); +} + +module.exports = updateCheck; +module.exports.showUpdateNotification = showUpdateNotification; diff --git a/core/server/utils/asset-hash.js b/core/server/utils/asset-hash.js new file mode 100644 index 0000000..f5f4155 --- /dev/null +++ b/core/server/utils/asset-hash.js @@ -0,0 +1,6 @@ +var crypto = require('crypto'), + packageInfo = require('../../../package.json'); + +module.exports = function generateAssetHash() { + return (crypto.createHash('md5').update(packageInfo.version + Date.now()).digest('hex')).substring(0, 10); +}; diff --git a/core/server/utils/cached-image-size-from-url.js b/core/server/utils/cached-image-size-from-url.js new file mode 100644 index 0000000..e941ba6 --- /dev/null +++ b/core/server/utils/cached-image-size-from-url.js @@ -0,0 +1,37 @@ +var imageSizeCache = {}, + size = require('./image-size-from-url'), + Promise = require('bluebird'), + errors = require('../errors'), + getImageSizeFromUrl = size.getImageSizeFromUrl; + +/** + * Get cached image size from URL + * Always returns {object} imageSizeCache + * @param {string} url + * @returns {Promise} imageSizeCache + * @description Takes a url and returns image width and height from cache if available. + * If not in cache, `getImageSizeFromUrl` is called and returns the dimensions in a Promise. + */ +function getCachedImageSizeFromUrl(url) { + if (!url || url === undefined || url === null) { + return; + } + + // image size is not in cache + if (!imageSizeCache[url]) { + return getImageSizeFromUrl(url).then(function (res) { + imageSizeCache[url] = res; + + return Promise.resolve(imageSizeCache[url]); + }).catch(function (err) { + errors.logError(err, err.context); + + // in case of error we just attach the url + return Promise.resolve(imageSizeCache[url] = url); + }); + } + // returns image size from cache + return Promise.resolve(imageSizeCache[url]); +} + +module.exports = getCachedImageSizeFromUrl; diff --git a/core/server/utils/downzero.js b/core/server/utils/downzero.js new file mode 100644 index 0000000..95e01a0 --- /dev/null +++ b/core/server/utils/downzero.js @@ -0,0 +1,109 @@ +// Functions to imitate the behavior of Downsize@0.0.5 with 'words: "0"' (heavily based on Downsize) + +var stack, tagName, tagBuffer, truncatedText, parseState, pointer, + states = {unitialized: 0, tag_commenced: 1, tag_string: -1, tag_string_single: -2, comment: -3}, + voidElements = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', + 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; + +function getTagName(tag) { + var tagName = (tag || '').match(/<\/*([a-z0-9\:\-\_]+)/i); + return tagName ? tagName[1] : null; +} + +function closeTag(openingTag) { + var tagName = (getTagName(openingTag)) ? '' : ''; + return tagName; +} + +function downzero(text) { + stack = []; + tagName = ''; + tagBuffer = ''; + truncatedText = ''; + parseState = 0; + pointer = 0; + + for (; pointer < text.length; pointer += 1) { + if (parseState !== states.unitialized) { + tagBuffer += text[pointer]; + } + + switch (text[pointer]) { + case '<': + if (parseState === states.unitialized && text[pointer + 1].match(/[a-z0-9\-\_\/\!]/)) { + parseState = states.tag_commenced; + tagBuffer += text[pointer]; + } + + break; + case '!': + if (parseState === states.tag_commenced && text[pointer - 1] === '<') { + parseState = states.comment; + } + + break; + case '\"': + if (parseState === states.tag_string) { + parseState = states.tag_commenced; + } else if (parseState === states.tag_string_single) { + // if double quote is found in a single quote string, ignore it and let the string finish + break; + } else if (parseState !== states.unitialized) { + parseState = states.tag_string; + } + + break; + case '\'': + if (parseState === states.tag_string_single) { + parseState = states.tag_commenced; + } else if (parseState === states.tag_string) { + break; + } else if (parseState !== states.unitialized) { + parseState = states.tag_string_single; + } + + break; + case '>': + if (parseState === states.tag_commenced) { + parseState = states.unitialized; + truncatedText += tagBuffer; + tagName = getTagName(tagBuffer); + + if (tagBuffer.match(/<\s*\//) && getTagName(stack[stack.length - 1]) === tagName) { + stack.pop(); + } else if (voidElements.indexOf(tagName) < 0 && !tagBuffer.match(/\/\s*>$/)) { + stack.push(tagBuffer); + } + tagBuffer = ''; + + continue; + } + + if (parseState === states.comment && text.substring(pointer - 2, pointer) === '--') { + parseState = states.unitialized; + truncatedText += tagBuffer; + tagBuffer = ''; + + continue; + } + + break; + case '-': + break; + } + + if (!parseState) { + break; + } + } + + truncatedText += tagBuffer; + + while (stack.length) { + truncatedText += closeTag(stack.pop()); + } + + return truncatedText; +} + +module.exports = downzero; diff --git a/core/server/utils/gravatar.js b/core/server/utils/gravatar.js new file mode 100644 index 0000000..9d65163 --- /dev/null +++ b/core/server/utils/gravatar.js @@ -0,0 +1,42 @@ +var Promise = require('bluebird'), + config = require('../config'), + crypto = require('crypto'), + https = require('https'); + +module.exports.lookup = function lookup(userData, timeout) { + var gravatarUrl = '//www.gravatar.com/avatar/' + + crypto.createHash('md5').update(userData.email.toLowerCase().trim()).digest('hex') + + '?s=250'; + + return new Promise(function gravatarRequest(resolve) { + if (config.isPrivacyDisabled('useGravatar') || process.env.NODE_ENV.indexOf('testing') > -1) { + return resolve(userData); + } + + var request, timer, timerEnded = false; + + request = https.get('https:' + gravatarUrl + '&d=404&r=x', function (response) { + clearTimeout(timer); + if (response.statusCode !== 404 && !timerEnded) { + gravatarUrl += '&d=mm&r=x'; + userData.image = gravatarUrl; + } + + resolve(userData); + }); + + request.on('error', function () { + clearTimeout(timer); + // just resolve with no image url + if (!timerEnded) { + return resolve(userData); + } + }); + + timer = setTimeout(function () { + timerEnded = true; + request.abort(); + return resolve(userData); + }, timeout || 2000); + }); +}; diff --git a/core/server/utils/image-size-from-url.js b/core/server/utils/image-size-from-url.js new file mode 100644 index 0000000..f94cef8 --- /dev/null +++ b/core/server/utils/image-size-from-url.js @@ -0,0 +1,114 @@ +// Supported formats of https://github.com/image-size/image-size: +// BMP, GIF, JPEG, PNG, PSD, TIFF, WebP, SVG +// *** +// Takes the url of the image and an optional timeout +// getImageSizeFromUrl returns an Object like this +// { +// height: 50, +// url: 'http://myblog.com/images/cat.jpg', +// width: 50 +// }; +// if the dimensions can be fetched and rejects with error, if not. +// *** +// In case we get a locally stored image or a not complete url (like //www.gravatar.com/andsoon), +// we add the protocol to the incomplete one and use urlFor() to get the absolute URL. +// If the request fails or image-size is not able to read the file, we reject with error. + +var sizeOf = require('image-size'), + url = require('url'), + Promise = require('bluebird'), + http = require('http'), + https = require('https'), + config = require('../config'), + dimensions, + request, + requestHandler; + +/** + * @description read image dimensions from URL + * @param {String} imagePath + * @returns {Promise} imageObject or error + */ +module.exports.getImageSizeFromUrl = function getImageSizeFromUrl(imagePath) { + return new Promise(function imageSizeRequest(resolve, reject) { + var imageObject = {}, + options, + timeout = config.times.getImageSizeTimeoutInMS || 10000; + + imageObject.url = imagePath; + + // check if we got an url without any protocol + if (imagePath.indexOf('http') === -1) { + // our gravatar urls start with '//' in that case add 'http:' + if (imagePath.indexOf('//') === 0) { + // it's a gravatar url + imagePath = 'http:' + imagePath; + } else { + // get absolute url for image + imagePath = config.urlFor('image', {image: imagePath}, true); + } + } + + options = url.parse(imagePath); + + requestHandler = imagePath.indexOf('https') === 0 ? https : http; + options.headers = {'User-Agent': 'Mozilla/5.0'}; + + request = requestHandler.get(options, function (res) { + var chunks = []; + + res.on('data', function (chunk) { + chunks.push(chunk); + }); + + res.on('end', function () { + if (res.statusCode === 200) { + try { + dimensions = sizeOf(Buffer.concat(chunks)); + + imageObject.width = dimensions.width; + imageObject.height = dimensions.height; + + return resolve(imageObject); + } catch (err) { + err.context = imagePath; + + return reject(err); + } + } else { + var err = new Error(); + + if (res.statusCode === 404) { + err.message = 'Image not found.'; + } else { + err.message = 'Unknown Request error.'; + } + + err.context = imagePath; + err.statusCode = res.statusCode; + + return reject(err); + } + }); + }).on('socket', function (socket) { + if (timeout) { + socket.setTimeout(timeout); + + /** + * https://nodejs.org/api/http.html + * "...if a callback is assigned to the Server's 'timeout' event, timeouts must be handled explicitly" + * + * socket.destroy will jump to the error listener + */ + socket.on('timeout', function () { + request.abort(); + socket.destroy(new Error('Request timed out.')); + }); + } + }).on('error', function (err) { + err.context = imagePath; + + return reject(err); + }); + }); +}; diff --git a/core/server/utils/index.js b/core/server/utils/index.js new file mode 100644 index 0000000..1e03b7b --- /dev/null +++ b/core/server/utils/index.js @@ -0,0 +1,112 @@ +var unidecode = require('unidecode'), + _ = require('lodash'), + utils, + getRandomInt; + +/** + * Return a random int, used by `utils.uid()` + * + * @param {Number} min + * @param {Number} max + * @return {Number} + * @api private + */ +getRandomInt = function (min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +utils = { + /** + * Timespans in seconds and milliseconds for better readability + */ + ONE_HOUR_S: 3600, + ONE_DAY_S: 86400, + ONE_MONTH_S: 2628000, + SIX_MONTH_S: 15768000, + ONE_YEAR_S: 31536000, + FIVE_MINUTES_MS: 300000, + ONE_HOUR_MS: 3600000, + ONE_DAY_MS: 86400000, + ONE_WEEK_MS: 604800000, + ONE_MONTH_MS: 2628000000, + SIX_MONTH_MS: 15768000000, + ONE_YEAR_MS: 31536000000, + + /** + * Return a unique identifier with the given `len`. + * + * utils.uid(10); + * // => "FDaS435D2z" + * + * @param {Number} len + * @return {String} + * @api private + */ + uid: function (len) { + var buf = [], + chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', + charlen = chars.length, + i; + + for (i = 1; i < len; i = i + 1) { + buf.push(chars[getRandomInt(0, charlen - 1)]); + } + + return buf.join(''); + }, + safeString: function (string, options) { + options = options || {}; + + // Handle the £ symbol separately, since it needs to be removed before the unicode conversion. + string = string.replace(/£/g, '-'); + + // Remove non ascii characters + string = unidecode(string); + + // Replace URL reserved chars: `@:/?#[]!$&()*+,;=` as well as `\%<>|^~£"{}` and \` + string = string.replace(/(\s|\.|@|:|\/|\?|#|\[|\]|!|\$|&|\(|\)|\*|\+|,|;|=|\\|%|<|>|\||\^|~|"|\{|\}|`|–|—)/g, '-') + // Remove apostrophes + .replace(/'/g, '') + // Make the whole thing lowercase + .toLowerCase(); + + // We do not need to make the following changes when importing data + if (!_.has(options, 'importing') || !options.importing) { + // Convert 2 or more dashes into a single dash + string = string.replace(/-+/g, '-') + // Remove trailing dash + .replace(/-$/, '') + // Remove any dashes at the beginning + .replace(/^-/, ''); + } + + // Handle whitespace at the beginning or end. + string = string.trim(); + + return string; + }, + // The token is encoded URL safe by replacing '+' with '-', '\' with '_' and removing '=' + // NOTE: the token is not encoded using valid base64 anymore + encodeBase64URLsafe: function (base64String) { + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + }, + // Decode url safe base64 encoding and add padding ('=') + decodeBase64URLsafe: function (base64String) { + base64String = base64String.replace(/-/g, '+').replace(/_/g, '/'); + while (base64String.length % 4) { + base64String += '='; + } + return base64String; + }, + redirect301: function redirect301(res, path) { + /*jslint unparam:true*/ + res.set({'Cache-Control': 'public, max-age=' + utils.ONE_YEAR_S}); + res.redirect(301, path); + }, + + readCSV: require('./read-csv'), + removeOpenRedirectFromUrl: require('./remove-open-redirect-from-url'), + zipFolder: require('./zip-folder') +}; + +module.exports = utils; diff --git a/core/server/utils/labs.js b/core/server/utils/labs.js new file mode 100644 index 0000000..ea799a8 --- /dev/null +++ b/core/server/utils/labs.js @@ -0,0 +1,10 @@ +var config = require('../config'), + flagIsSet; + +flagIsSet = function flagIsSet(flag) { + var labsConfig = config.labs; + + return labsConfig && labsConfig[flag] && labsConfig[flag] === true; +}; + +module.exports.isSet = flagIsSet; diff --git a/core/server/utils/make-absolute-urls.js b/core/server/utils/make-absolute-urls.js new file mode 100644 index 0000000..2519ffc --- /dev/null +++ b/core/server/utils/make-absolute-urls.js @@ -0,0 +1,57 @@ +var cheerio = require('cheerio'), + url = require('url'), + config = require('../config'); + +/** + * Make absolute URLs + * @param {string} html + * @param {string} siteUrl (blog URL) + * @param {string} itemUrl (URL of current context) + * @returns {object} htmlContent + * @description Takes html, blog url and item url and converts relative url into + * absolute urls. Returns an object. The html string can be accessed by calling `html()` on + * the variable that takes the result of this function + */ +function makeAbsoluteUrls(html, siteUrl, itemUrl) { + var htmlContent = cheerio.load(html, {decodeEntities: false}); + // convert relative resource urls to absolute + ['href', 'src'].forEach(function forEach(attributeName) { + htmlContent('[' + attributeName + ']').each(function each(ix, el) { + var baseUrl, + attributeValue, + parsed; + + el = htmlContent(el); + + attributeValue = el.attr(attributeName); + + // if URL is absolute move on to the next element + try { + parsed = url.parse(attributeValue); + + if (parsed.protocol) { + return; + } + + // Do not convert protocol relative URLs + if (attributeValue.lastIndexOf('//', 0) === 0) { + return; + } + } catch (e) { + return; + } + + // compose an absolute URL + + // if the relative URL begins with a '/' use the blog URL (including sub-directory) + // as the base URL, otherwise use the post's URL. + baseUrl = attributeValue[0] === '/' ? siteUrl : itemUrl; + attributeValue = config.urlJoin(baseUrl, attributeValue); + el.attr(attributeName, attributeValue); + }); + }); + + return htmlContent; +} + +module.exports = makeAbsoluteUrls; diff --git a/core/server/utils/npm/preinstall.js b/core/server/utils/npm/preinstall.js new file mode 100644 index 0000000..5157f83 --- /dev/null +++ b/core/server/utils/npm/preinstall.js @@ -0,0 +1,47 @@ +var validVersions = process.env.npm_package_engines_node.split(' || '), + currentVersion = process.versions.node, + foundMatch = false, + majMinRegex = /(\d+\.\d+)/, + majorRegex = /^\d+/, + minorRegex = /\d+$/, + exitCodes = { + NODE_VERSION_UNSUPPORTED: 231 + }; + +function doError() { + console.error('\x1B[31mERROR: Unsupported version of Node'); + console.error('\x1B[37mGhost supports LTS Node versions: ' + process.env.npm_package_engines_node); + console.error('You are currently using version: ' + process.versions.node + '\033[0m'); + console.error('\x1B[32mThis check can be overridden, see https://docs.ghost.org/v0.11.9/docs/supported-node-versions for more info\033[0m'); + + process.exit(exitCodes.NODE_VERSION_UNSUPPORTED); +} + +if (process.env.GHOST_NODE_VERSION_CHECK === 'false') { + console.log('\x1B[33mSkipping Node version check\033[0m'); +} else { + try { + currentVersion = currentVersion.match(majMinRegex)[0]; + + validVersions.forEach(function (version) { + var matchChar = version.charAt(0), + versionString = version.match(majMinRegex)[0]; + + if ( + (matchChar === '~' && currentVersion === versionString) + || (matchChar === '^' + && currentVersion.match(majorRegex)[0] === versionString.match(majorRegex)[0] + && parseInt(currentVersion.match(minorRegex)[0]) >= parseInt(versionString.match(minorRegex)[0]) + ) + ) { + foundMatch = true; + } + }); + + if (foundMatch !== true) { + doError(); + } + } catch (e) { + doError(); + } +} diff --git a/core/server/utils/packages/filter-packages.js b/core/server/utils/packages/filter-packages.js new file mode 100644 index 0000000..eb0d1f7 --- /dev/null +++ b/core/server/utils/packages/filter-packages.js @@ -0,0 +1,43 @@ +var _ = require('lodash'), + notAPackageRegex = /^\.|_messages|README.md/i, + filterPackages; + +/** + * ### Filter Packages + * Normalizes paths read by read-packages so that the apps and themes modules can use them. + * Iterates over each package and return an array of objects which are simplified representations of the package + * with 3 properties: + * package name as `name`, the package.json as `package` and an active field set to true if this package is active + * + * @param {object} packages as returned by read-packages + * @param {array/string} active as read from the settings object + * @returns {Array} of objects with useful info about apps / themes + */ +filterPackages = function filterPackages(packages, active) { + // turn active into an array (so themes and apps can be checked the same) + if (!Array.isArray(active)) { + active = [active]; + } + + return _.reduce(packages, function (result, pkg, key) { + var item = {}; + if (!key.match(notAPackageRegex)) { + item = { + name: key, + package: pkg['package.json'] || false + }; + + // At the moment we only support themes. This property is used in Ghost-Admin LTS + // It is not used in Ghost-Admin 1.0, and therefore this can be removed. + if (_.indexOf(active, key) !== -1) { + item.active = true; + } + + result.push(item); + } + + return result; + }, []); +}; + +module.exports = filterPackages; diff --git a/core/server/utils/packages/index.js b/core/server/utils/packages/index.js new file mode 100644 index 0000000..e29194d --- /dev/null +++ b/core/server/utils/packages/index.js @@ -0,0 +1,17 @@ +/** + * # Package Utils + * + * Ghost has / is in the process of gaining support for several different types of sub-packages: + * - Themes: have always been packages, but we're going to lean more heavily on npm & package.json in future + * - Adapters: an early version of apps, replace fundamental pieces like storage, will become npm modules + * - Apps: plugins that can be installed whilst Ghost is running & modify behaviour + * - More? + * + * These utils facilitate loading, reading, managing etc, packages from the file system. + */ + +module.exports = { + read: require('./read-packages'), + parsePackageJSON: require('./parse-package-json'), + filterPackages: require('./filter-packages') +}; diff --git a/core/server/utils/packages/parse-package-json.js b/core/server/utils/packages/parse-package-json.js new file mode 100644 index 0000000..5b44e8d --- /dev/null +++ b/core/server/utils/packages/parse-package-json.js @@ -0,0 +1,55 @@ +/** + * Dependencies + */ + +var Promise = require('bluebird'), + fs = require('fs'), + i18n = require('../../i18n'), + + readFile = Promise.promisify(fs.readFile); + +/** + * Parse package.json and validate it has + * all the required fields + */ + +function parsePackageJson(path) { + return readFile(path) + .catch(function () { + var err = new Error(i18n.t('errors.utils.parsepackagejson.couldNotReadPackage')); + err.context = path; + + return Promise.reject(err); + }) + .then(function (source) { + var hasRequiredKeys, json, err; + + try { + json = JSON.parse(source); + + hasRequiredKeys = json.name && json.version; + + if (!hasRequiredKeys) { + err = new Error(i18n.t('errors.utils.parsepackagejson.nameOrVersionMissing')); + err.context = path; + err.help = i18n.t('errors.utils.parsepackagejson.willBeRequired', {url: 'http://themes.ghost.org/docs/packagejson'}); + + return Promise.reject(err); + } + + return json; + } catch (parseError) { + err = new Error(i18n.t('errors.utils.parsepackagejson.themeFileIsMalformed')); + err.context = path; + err.help = i18n.t('errors.utils.parsepackagejson.willBeRequired', {url: 'http://themes.ghost.org/docs/packagejson'}); + + return Promise.reject(err); + } + }); +} + +/** + * Expose `parsePackageJson` + */ + +module.exports = parsePackageJson; diff --git a/core/server/utils/packages/read-packages.js b/core/server/utils/packages/read-packages.js new file mode 100644 index 0000000..de2e2ee --- /dev/null +++ b/core/server/utils/packages/read-packages.js @@ -0,0 +1,89 @@ +/** + * Dependencies + */ + +var parsePackageJson = require('./parse-package-json'), + Promise = require('bluebird'), + _ = require('lodash'), + join = require('path').join, + fs = require('fs'), + + notAPackageRegex = /^\.|_messages|README.md|node_modules|bower_components/i, + packageJSONPath = 'package.json', + + statFile = Promise.promisify(fs.stat), + readDir = Promise.promisify(fs.readdir), + + readPackage, + readPackages, + processPackage; + +/** + * Recursively read directory and find the packages in it + */ +processPackage = function processPackage(absolutePath, packageName) { + var pkg = { + name: packageName, + path: absolutePath + }; + return parsePackageJson(join(absolutePath, packageJSONPath)) + .then(function gotPackageJSON(packageJSON) { + pkg['package.json'] = packageJSON; + return pkg; + }) + .catch(function noPackageJSON() { + // ignore invalid package.json for now, + // because Ghost does not rely/use them at the moment + // in the future, this .catch() will need to be removed, + // so that error is thrown on invalid json syntax + pkg['package.json'] = null; + return pkg; + }); +}; + +readPackage = function readPackage(packagePath, packageName) { + var absolutePath = join(packagePath, packageName); + return statFile(absolutePath) + .then(function (stat) { + if (!stat.isDirectory()) { + return {}; + } + + return processPackage(absolutePath, packageName) + .then(function gotPackage(pkg) { + var res = {}; + res[packageName] = pkg; + return res; + }); + }) + .catch(function () { + return Promise.reject(new Error('Package not found')); + }); +}; + +readPackages = function readPackages(packagePath) { + return readDir(packagePath) + .filter(function (packageName) { + // Filter out things which are not packages by regex + if (packageName.match(notAPackageRegex)) { + return; + } + // Check the remaining items to ensure they are a directory + return statFile(join(packagePath, packageName)).then(function (stat) { + return stat.isDirectory(); + }); + }) + .map(function readPackageJson(packageName) { + var absolutePath = join(packagePath, packageName); + return processPackage(absolutePath, packageName); + }) + .then(function (packages) { + return _.keyBy(packages, 'name'); + }); +}; + +/** + * Expose Public API + */ +module.exports.all = readPackages; +module.exports.one = readPackage; diff --git a/core/server/utils/pipeline.js b/core/server/utils/pipeline.js new file mode 100644 index 0000000..18d99b5 --- /dev/null +++ b/core/server/utils/pipeline.js @@ -0,0 +1,31 @@ +/** + * # Pipeline Utility + * + * Based on pipeline.js from when.js: + * https://github.com/cujojs/when/blob/3.7.4/pipeline.js + */ +var Promise = require('bluebird'); + +function pipeline(tasks /* initial arguments */) { + var args = Array.prototype.slice.call(arguments, 1), + + runTask = function (task, args) { + // Self-optimizing function to run first task with multiple + // args using apply, but subsequent tasks via direct invocation + runTask = function (task, arg) { + return task(arg); + }; + + return task.apply(null, args); + }; + + // Resolve any promises for the arguments passed in first + return Promise.all(args).then(function (args) { + // Iterate through the tasks passing args from one into the next + return Promise.reduce(tasks, function (arg, task) { + return runTask(task, arg); + }, args); + }); +} + +module.exports = pipeline; diff --git a/core/server/utils/read-csv.js b/core/server/utils/read-csv.js new file mode 100644 index 0000000..bd74b31 --- /dev/null +++ b/core/server/utils/read-csv.js @@ -0,0 +1,59 @@ +var Promise = require('bluebird'), + csvParser = require('csv-parser'), + _ = require('lodash'), + fs = require('fs'); + +function readCSV(options) { + var columnsToExtract = options.columnsToExtract || [], + results = [], rows = []; + + return new Promise(function (resolve, reject) { + var readFile = fs.createReadStream(options.path); + + readFile.on('err', function (err) { + reject(err); + }) + .pipe(csvParser()) + .on('data', function (row) { + rows.push(row); + }) + .on('end', function () { + // If CSV is single column - return all values including header + var headers = _.keys(rows[0]), result = {}, columnMap = {}; + if (columnsToExtract.length === 1 && headers.length === 1) { + results = _.map(rows, function (value) { + result = {}; + result[columnsToExtract[0].name] = value[headers[0]]; + return result; + }); + + // Add first row + result = {}; + result[columnsToExtract[0].name] = headers[0]; + results = [result].concat(results); + } else { + // If there are multiple columns in csv file + // try to match headers using lookup value + + _.map(columnsToExtract, function findMatches(column) { + _.each(headers, function checkheader(header) { + if (column.lookup.test(header)) { + columnMap[column.name] = header; + } + }); + }); + + results = _.map(rows, function evaluateRow(row) { + var result = {}; + _.each(columnMap, function returnMatches(value, key) { + result[key] = row[value]; + }); + return result; + }); + } + resolve(results); + }); + }); +} + +module.exports = readCSV; diff --git a/core/server/utils/read-themes.js b/core/server/utils/read-themes.js new file mode 100644 index 0000000..b40f65e --- /dev/null +++ b/core/server/utils/read-themes.js @@ -0,0 +1,60 @@ +/** + * # Read Themes + * + * Util that wraps packages.read + */ +var _ = require('lodash'), + packages = require('../utils/packages'), + Promise = require('bluebird'), + join = require('path').join, + errors = require('../errors'), + i18n = require('../i18n'), + + glob = Promise.promisify(require('glob')); + +function populateTemplates(themes) { + return Promise + // Load templates for each theme in the object + .each(Object.keys(themes), function loadTemplates(themeName) { + // Load all the files which match x.hbs = top level templates + return glob('*.hbs', {cwd: themes[themeName].path}) + .then(function gotTemplates(templates) { + // Update the original themes object + _.each(templates, function (template) { + themes[themeName][template] = join(themes[themeName].path, template); + }); + }); + }) + // Return the original (now updated) object, not the result of Promise.each + .return(themes); +} + +/** + * Read active theme + */ +function readActiveTheme(dir, name) { + return packages + .read.one(dir, name) + .then(populateTemplates) + .catch(function () { + // For now we return an empty object as this is not fatal unless the frontend of the blog is requested + errors.logWarn(i18n.t('errors.middleware.themehandler.missingTheme', {theme: name})); + return {}; + }); +} + +/** + * Read themes + */ +function readThemes(dir) { + return packages + .read.all(dir) + .then(populateTemplates); +} + +/** + * Expose `read-themes` + */ + +module.exports = readThemes; +module.exports.active = readActiveTheme; diff --git a/core/server/utils/remove-open-redirect-from-url.js b/core/server/utils/remove-open-redirect-from-url.js new file mode 100644 index 0000000..a92f735 --- /dev/null +++ b/core/server/utils/remove-open-redirect-from-url.js @@ -0,0 +1,30 @@ +var url = require('url'); + +function removeDoubleCharacters(character, string) { + var stringArray = string.split(''); + + return stringArray.reduce(function (newString, currentCharacter, index) { + if ( + currentCharacter === character && + stringArray[index + 1] === character + ) { + return newString; + } + + return newString + currentCharacter; + }, ''); +} + +function removeOpenRedirectFromUrl(urlString) { + var parsedUrl = url.parse(urlString); + + return ( + (parsedUrl.protocol ? parsedUrl.protocol + '//' : '') + // http:// + (parsedUrl.auth || '') + + (parsedUrl.host || '') + + removeDoubleCharacters('/', parsedUrl.path) + + (parsedUrl.hash || '') + ); +} + +module.exports = removeOpenRedirectFromUrl; diff --git a/core/server/utils/sequence.js b/core/server/utils/sequence.js new file mode 100644 index 0000000..34d6d42 --- /dev/null +++ b/core/server/utils/sequence.js @@ -0,0 +1,17 @@ +var Promise = require('bluebird'); + +/** + * expects an array of functions returning a promise + */ +function sequence(tasks /* Any Arguments */) { + var args = Array.prototype.slice.call(arguments, 1); + + return Promise.reduce(tasks, function (results, task) { + return task.apply(this, args).then(function (result) { + results.push(result); + return results; + }); + }, []); +} + +module.exports = sequence; diff --git a/core/server/utils/social-urls.js b/core/server/utils/social-urls.js new file mode 100644 index 0000000..fffe1fb --- /dev/null +++ b/core/server/utils/social-urls.js @@ -0,0 +1,9 @@ +module.exports.twitterUrl = function twitterUrl(username) { + // Creates the canonical twitter URL without the '@' + return 'https://twitter.com/' + username.replace(/^@/, ''); +}; + +module.exports.facebookUrl = function facebookUrl(username) { + // Handles a starting slash, this shouldn't happen, but just in case + return 'https://www.facebook.com/' + username.replace(/^\//, ''); +}; diff --git a/core/server/utils/startup-check.js b/core/server/utils/startup-check.js new file mode 100644 index 0000000..924a30e --- /dev/null +++ b/core/server/utils/startup-check.js @@ -0,0 +1,276 @@ +var packages = require('../../../package.json'), + path = require('path'), + crypto = require('crypto'), + fs = require('fs'), + mode = process.env.NODE_ENV === undefined ? 'development' : process.env.NODE_ENV, + appRoot = path.resolve(__dirname, '../../../'), + configFilePath = process.env.GHOST_CONFIG || path.join(appRoot, 'config.js'), + checks, + exitCodes = { + NODE_VERSION_UNSUPPORTED: 231, + NODE_ENV_CONFIG_MISSING: 232, + DEPENDENCIES_MISSING: 233, + CONTENT_PATH_NOT_ACCESSIBLE: 234, + CONTENT_PATH_NOT_WRITABLE: 235, + SQLITE_DB_NOT_WRITABLE: 236, + BUILT_FILES_DO_NOT_EXIST: 237 + }; + +checks = { + check: function check() { + this.nodeVersion(); + this.nodeEnv(); + this.packages(); + this.contentPath(); + this.mail(); + this.sqlite(); + this.builtFilesExist(); + }, + + // Make sure the node version is supported + nodeVersion: function checkNodeVersion() { + // Tell users if their node version is not supported, and exit + var semver = require('semver'); + + if (process.env.GHOST_NODE_VERSION_CHECK !== 'false' && + !semver.satisfies(process.versions.node, packages.engines.node)) { + console.error('\x1B[31mERROR: Unsupported version of Node'); + console.error('\x1B[31mGhost needs Node version ' + packages.engines.node + + ' you are using version ' + process.versions.node + '\033[0m\n'); + console.error('\x1B[32mPlease see https://docs.ghost.org/v0.11.9/docs/supported-node-versions for more information\033[0m'); + + process.exit(exitCodes.NODE_VERSION_UNSUPPORTED); + } + }, + + nodeEnv: function checkNodeEnvState() { + // Check if config path resolves, if not check for NODE_ENV in config.example.js prior to copy + var fd, + configFile, + config; + + try { + fd = fs.openSync(configFilePath, 'r'); + fs.closeSync(fd); + } catch (e) { + configFilePath = path.join(appRoot, 'config.example.js'); + } + + configFile = require(configFilePath); + config = configFile[mode]; + + if (!config) { + console.error('\x1B[31mERROR: Cannot find the configuration for the current NODE_ENV: ' + + process.env.NODE_ENV + '\033[0m\n'); + console.error('\x1B[32mEnsure your config.js has a section for the current NODE_ENV value' + + ' and is formatted properly.\033[0m'); + + process.exit(exitCodes.NODE_ENV_CONFIG_MISSING); + } + }, + + // Make sure package.json dependencies have been installed. + packages: function checkPackages() { + if (mode !== 'production' && mode !== 'development') { + return; + } + + var errors = []; + + Object.keys(packages.dependencies).forEach(function (p) { + try { + require.resolve(p); + } catch (e) { + errors.push(e.message); + } + }); + + if (!errors.length) { + return; + } + + errors = errors.join('\n '); + + console.error('\x1B[31mERROR: Ghost is unable to start due to missing dependencies:\033[0m\n ' + errors); + console.error('\x1B[32m\nPlease run `npm install --production` and try starting Ghost again.'); + console.error('\x1B[32mHelp and documentation can be found at https://docs.ghost.org/v0.11.9.\033[0m\n'); + + process.exit(exitCodes.DEPENDENCIES_MISSING); + }, + + // Check content path permissions + contentPath: function checkContentPaths() { + if (mode !== 'production' && mode !== 'development') { + return; + } + + var configFile, + config, + contentPath, + contentSubPaths = ['apps', 'data', 'images', 'themes'], + fd, + errorHeader = '\x1B[31mERROR: Unable to access Ghost\'s content path:\033[0m', + errorHelp = '\x1B[32mCheck that the content path exists and file system permissions are correct.' + + '\nHelp and documentation can be found at https://docs.ghost.org/v0.11.9.\033[0m'; + + // Get the content path to test. If it's defined in config.js use that, if not use the default + try { + configFile = require(configFilePath); + config = configFile[mode]; + + if (config && config.paths && config.paths.contentPath) { + contentPath = config.paths.contentPath; + } else { + contentPath = path.join(appRoot, 'content'); + } + } catch (e) { + // If config.js doesn't exist yet, check the default content path location + contentPath = path.join(appRoot, 'content'); + } + + // Use all sync io calls so that we stay in this function until all checks are complete + + // Check the root content path + try { + fd = fs.openSync(contentPath, 'r'); + fs.closeSync(fd); + } catch (e) { + console.error(errorHeader); + console.error(' ' + e.message); + console.error('\n' + errorHelp); + + process.exit(exitCodes.CONTENT_PATH_NOT_ACCESSIBLE); + } + + // Check each of the content path subdirectories + try { + contentSubPaths.forEach(function (sub) { + var dir = path.join(contentPath, sub), + randomFile = path.join(dir, crypto.randomBytes(8).toString('hex')); + + fd = fs.openSync(dir, 'r'); + fs.closeSync(fd); + + // Check write access to directory by attempting to create a random file + fd = fs.openSync(randomFile, 'wx+'); + fs.closeSync(fd); + fs.unlinkSync(randomFile); + }); + } catch (e) { + console.error(errorHeader); + console.error(' ' + e.message); + console.error('\n' + errorHelp); + + process.exit(exitCodes.CONTENT_PATH_NOT_WRITABLE); + } + }, + + // Make sure sqlite3 database is available for read/write + sqlite: function checkSqlite() { + if (mode !== 'production' && mode !== 'development') { + return; + } + + var configFile, + config, + appRoot = path.resolve(__dirname, '../../../'), + dbPath, + fd; + + try { + configFile = require(configFilePath); + config = configFile[mode]; + + // Abort check if database type is not sqlite3 + if (config && config.database && config.database.client !== 'sqlite3') { + return; + } + + if (config && config.database && config.database.connection) { + dbPath = config.database.connection.filename; + } + } catch (e) { + // If config.js doesn't exist, use the default path + dbPath = path.join(appRoot, 'content', 'data', mode === 'production' ? 'ghost.db' : 'ghost-dev.db'); + } + + // Check for read/write access on sqlite db file + try { + fd = fs.openSync(dbPath, 'r+'); + fs.closeSync(fd); + } catch (e) { + // Database file not existing is not an error as sqlite will create it. + if (e.code === 'ENOENT') { + return; + } + + console.error('\x1B[31mERROR: Unable to open sqlite3 database file for read/write\033[0m'); + console.error(' ' + e.message); + console.error('\n\x1B[32mCheck that the sqlite3 database file permissions allow read and write access.'); + console.error('Help and documentation can be found at https://docs.ghost.org/v0.11.9.\033[0m'); + + process.exit(exitCodes.SQLITE_DB_NOT_WRITABLE); + } + }, + + mail: function checkMail() { + var configFile, + config; + + try { + configFile = require(configFilePath); + config = configFile[mode]; + } catch (e) { + configFilePath = path.join(appRoot, 'config.example.js'); + } + + if (!config.mail || !config.mail.transport) { + console.error('\x1B[31mWARNING: Ghost is attempting to use a direct method to send email. \nIt is recommended that you explicitly configure an email service.\033[0m'); + console.error('\x1B[32mHelp and documentation can be found at https://docs.ghost.org/v0.11.9/docs/mail-config.\033[0m\n'); + } + }, + + builtFilesExist: function builtFilesExist() { + var configFile, + config, + location, + fileNames = ['ghost.js', 'vendor.js', 'ghost.css', 'vendor.css']; + + try { + configFile = require(configFilePath); + config = configFile[mode]; + + if (config.paths && config.paths.clientAssets) { + location = config.paths.clientAssets; + } else { + location = path.join(appRoot, '/core/built/assets/'); + } + } catch (e) { + location = path.join(appRoot, '/core/built/assets/'); + } + + if (process.env.NODE_ENV === 'production') { + // Production uses `.min` files + fileNames = fileNames.map(function (file) { + return file.replace('.', '.min.'); + }); + } + + function checkExist(fileName) { + try { + fs.statSync(fileName); + } catch (e) { + console.error('\x1B[31mERROR: Javascript files have not been built.\033[0m'); + console.error('\n\x1B[32mPlease read the getting started instructions at:'); + console.error('https://docs.ghost.org/v0.11.9/docs/getting-started-guide\033[0m'); + process.exit(exitCodes.BUILT_FILES_DO_NOT_EXIST); + } + } + + fileNames.forEach(function (fileName) { + checkExist(location + fileName); + }); + } +}; + +module.exports = checks; diff --git a/core/server/utils/zip-folder.js b/core/server/utils/zip-folder.js new file mode 100644 index 0000000..84d2325 --- /dev/null +++ b/core/server/utils/zip-folder.js @@ -0,0 +1,27 @@ +var archiver = require('archiver'), + fs = require('fs'); + +module.exports = function zipFolder(folderToZip, destination, callback) { + var output = fs.createWriteStream(destination), + archive = archiver.create('zip', {}); + + // CASE: always ask for the real path, because the target folder could be a symlink + fs.realpath(folderToZip, function (err, realpath) { + if (err) { + return callback(err); + } + + output.on('close', function () { + callback(null, archive.pointer()); + }); + + archive.on('error', function (err) { + callback(err, null); + }); + + archive.directory(realpath, '/'); + archive.pipe(output); + archive.finalize(); + }); +}; + diff --git a/core/server/views/default.hbs b/core/server/views/default.hbs new file mode 100644 index 0000000..3746661 --- /dev/null +++ b/core/server/views/default.hbs @@ -0,0 +1,53 @@ + + + + + + + + Ghost Admin + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#each configuration as |config key|}} + + {{/each}} + + + + + + + + + +
    + +{{! Dem scripts }} + + + + diff --git a/core/server/views/user-error.hbs b/core/server/views/user-error.hbs new file mode 100644 index 0000000..56cfd3c --- /dev/null +++ b/core/server/views/user-error.hbs @@ -0,0 +1,58 @@ + + + + + + + + {{code}} — {{message}} + + + + + + + + + + + + +
    +
    +
    +
    +
    +
    + +
    +

    {{code}}

    +

    {{message}}

    + Go to the front page → +
    +
    +
    + {{#if stack}} +
    +

    Stack Trace

    +

    {{message}}

    +
      + {{#each stack}} +
    • + at + {{#if function}}{{function}}{{/if}} + ({{at}}) +
    • + {{/each}} +
    +
    + {{/if}} +
    +
    +
    +
    + + diff --git a/core/shared/favicon.ico b/core/shared/favicon.ico new file mode 100644 index 0000000..25c0c08 Binary files /dev/null and b/core/shared/favicon.ico differ diff --git a/core/shared/ghost-url.js b/core/shared/ghost-url.js new file mode 100644 index 0000000..0868405 --- /dev/null +++ b/core/shared/ghost-url.js @@ -0,0 +1,76 @@ +(function () { + 'use strict'; + + var apiUrl = '{{api-url}}', + clientId, + clientSecret, + url, + init; + + function generateQueryString(object) { + var queries = [], + i; + + if (!object) { + return ''; + } + + for (i in object) { + if (object.hasOwnProperty(i) && (!!object[i] || object[i] === false)) { + queries.push(i + '=' + encodeURIComponent(object[i])); + } + } + + if (queries.length) { + return '?' + queries.join('&'); + } + return ''; + } + + url = { + api: function () { + var args = Array.prototype.slice.call(arguments), + queryOptions, + requestUrl = apiUrl; + + queryOptions = args.pop(); + + if (queryOptions && typeof queryOptions !== 'object') { + args.push(queryOptions); + queryOptions = {}; + } + + queryOptions = queryOptions || {}; + + queryOptions.client_id = clientId; + queryOptions.client_secret = clientSecret; + + if (args.length) { + args.forEach(function (el) { + requestUrl += el.replace(/^\/|\/$/g, '') + '/'; + }); + } + + return requestUrl + generateQueryString(queryOptions); + } + }; + + init = function (options) { + clientId = options.clientId ? options.clientId : ''; + clientSecret = options.clientSecret ? options.clientSecret : ''; + apiUrl = options.url ? options.url : (apiUrl.match(/{\{api-url}}/) ? '' : apiUrl); + }; + + if (typeof window !== 'undefined') { + window.ghost = window.ghost || {}; + window.ghost.url = url; + window.ghost.init = init; + } + + if (typeof module !== 'undefined') { + module.exports = { + url: url, + init: init + }; + } +})(); diff --git a/core/shared/ghost-url.min.js b/core/shared/ghost-url.min.js new file mode 100644 index 0000000..6993f93 --- /dev/null +++ b/core/shared/ghost-url.min.js @@ -0,0 +1 @@ +!function(){"use strict";function a(a){var b,c=[];if(!a)return"";for(b in a)a.hasOwnProperty(b)&&(a[b]||a[b]===!1)&&c.push(b+"="+encodeURIComponent(a[b]));return c.length?"?"+c.join("&"):""}var b,c,d,e,f="{{api-url}}";d={api:function(){var d,e=Array.prototype.slice.call(arguments),g=f;return d=e.pop(),d&&"object"!=typeof d&&(e.push(d),d={}),d=d||{},d.client_id=b,d.client_secret=c,e.length&&e.forEach(function(a){g+=a.replace(/^\/|\/$/g,"")+"/"}),g+a(d)}},e=function(a){b=a.clientId?a.clientId:"",c=a.clientSecret?a.clientSecret:"",f=a.url?a.url:f.match(/{\{api-url}}/)?"":f},"undefined"!=typeof window&&(window.ghost=window.ghost||{},window.ghost.url=d,window.ghost.init=e),"undefined"!=typeof module&&(module.exports={url:d,init:e})}(); \ No newline at end of file diff --git a/core/shared/robots.txt b/core/shared/robots.txt new file mode 100644 index 0000000..b9f1b17 --- /dev/null +++ b/core/shared/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Sitemap: {{blog-url}}/sitemap.xml +Disallow: /ghost/ diff --git a/core/shared/sitemap.xsl b/core/shared/sitemap.xsl new file mode 100644 index 0000000..778ba5e --- /dev/null +++ b/core/shared/sitemap.xsl @@ -0,0 +1,145 @@ + + + + + + + XML Sitemap + + + + +
    +

    XML Sitemap

    +

    + This is a sitemap generated by Ghost to allow search engines to discover this blog's content. +

    + + + + + + + + + + + + + + + + + + + +
    SitemapLast Modified
    + + + +
    +
    + +

    ← Back to index

    + + + + + + + + + + + + + + + + + + + + + + + +
    URL ( total)PrioImagesCh. Freq.Last Modified
    + + + + + + + + + + + + + + +
    +

    ← Back to index

    +
    +
    + + + +
    +
    \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..dd2d88c --- /dev/null +++ b/index.js @@ -0,0 +1,20 @@ +// # Ghost Startup +// Orchestrates the startup of Ghost when run from command line. + +var ghost = require('./core'), + express = require('express'), + errors = require('./core/server/errors'), + parentApp = express(); + +// Make sure dependencies are installed and file system permissions are correct. +require('./core/server/utils/startup-check').check(); + +ghost().then(function (ghostServer) { + // Mount our Ghost instance on our desired subdirectory path if it exists. + parentApp.use(ghostServer.config.paths.subdir, ghostServer.rootApp); + + // Let Ghost handle starting our server instance. + ghostServer.start(parentApp); +}).catch(function (err) { + errors.logErrorAndExit(err, err.context, err.help); +}); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json new file mode 100644 index 0000000..1df7bdb --- /dev/null +++ b/npm-shrinkwrap.json @@ -0,0 +1,3488 @@ +{ + "name": "ghost", + "version": "0.11.10", + "dependencies": { + "abbrev": { + "version": "1.1.0", + "from": "abbrev@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz" + }, + "accepts": { + "version": "1.3.3", + "from": "accepts@>=1.3.3 <1.4.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz" + }, + "addressparser": { + "version": "0.3.2", + "from": "addressparser@>=0.3.2 <0.4.0", + "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-0.3.2.tgz" + }, + "align-text": { + "version": "0.1.4", + "from": "align-text@>=0.1.3 <0.2.0", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz" + }, + "amdefine": { + "version": "1.0.1", + "from": "amdefine@>=0.0.4", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz" + }, + "amperize": { + "version": "0.3.4", + "from": "amperize@0.3.4", + "resolved": "https://registry.npmjs.org/amperize/-/amperize-0.3.4.tgz" + }, + "ansi-regex": { + "version": "2.1.1", + "from": "ansi-regex@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz" + }, + "ansi-styles": { + "version": "2.2.1", + "from": "ansi-styles@>=2.2.1 <3.0.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" + }, + "ap": { + "version": "0.2.0", + "from": "ap@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/ap/-/ap-0.2.0.tgz", + "optional": true + }, + "append-field": { + "version": "0.1.0", + "from": "append-field@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-0.1.0.tgz" + }, + "aproba": { + "version": "1.0.4", + "from": "aproba@^1.0.3", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.0.4.tgz" + }, + "archiver": { + "version": "1.3.0", + "from": "archiver@1.3.0", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-1.3.0.tgz", + "dependencies": { + "glob": { + "version": "7.1.2", + "from": "glob@>=7.0.0 <8.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz" + } + } + }, + "archiver-utils": { + "version": "1.3.0", + "from": "archiver-utils@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", + "dependencies": { + "glob": { + "version": "7.1.2", + "from": "glob@>=7.0.0 <8.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz" + } + } + }, + "are-we-there-yet": { + "version": "1.1.2", + "from": "are-we-there-yet@~1.1.2", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.2.tgz", + "dependencies": { + "readable-stream": { + "version": "2.1.5", + "from": "readable-stream@^2.0.0 || ^1.1.13", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "dependencies": { + "buffer-shims": { + "version": "1.0.0", + "from": "buffer-shims@^1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz" + }, + "core-util-is": { + "version": "1.0.2", + "from": "core-util-is@~1.0.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" + }, + "inherits": { + "version": "2.0.3", + "from": "inherits@~2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" + }, + "isarray": { + "version": "1.0.0", + "from": "isarray@~1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + }, + "process-nextick-args": { + "version": "1.0.7", + "from": "process-nextick-args@~1.0.6", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@~0.10.x", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "util-deprecate": { + "version": "1.0.2", + "from": "util-deprecate@~1.0.1", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + } + } + } + } + }, + "arr-diff": { + "version": "2.0.0", + "from": "arr-diff@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz" + }, + "arr-flatten": { + "version": "1.0.3", + "from": "arr-flatten@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.3.tgz" + }, + "array-flatten": { + "version": "1.1.1", + "from": "array-flatten@1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz" + }, + "array-unique": { + "version": "0.2.1", + "from": "array-unique@>=0.2.1 <0.3.0", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz" + }, + "asn1": { + "version": "0.2.3", + "from": "asn1@>=0.2.3 <0.3.0", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz" + }, + "assert-plus": { + "version": "0.2.0", + "from": "assert-plus@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" + }, + "assertion-error": { + "version": "1.0.2", + "from": "assertion-error@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.2.tgz" + }, + "async": { + "version": "2.1.4", + "from": "async@2.1.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.1.4.tgz" + }, + "asynckit": { + "version": "0.4.0", + "from": "asynckit@>=0.4.0 <0.5.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" + }, + "aws-sdk": { + "version": "2.0.5", + "from": "aws-sdk@2.0.5", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.0.5.tgz" + }, + "aws-sdk-apis": { + "version": "3.1.10", + "from": "aws-sdk-apis@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/aws-sdk-apis/-/aws-sdk-apis-3.1.10.tgz" + }, + "aws-sign2": { + "version": "0.6.0", + "from": "aws-sign2@>=0.6.0 <0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz" + }, + "aws4": { + "version": "1.6.0", + "from": "aws4@>=1.2.1 <2.0.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz" + }, + "babel-runtime": { + "version": "6.23.0", + "from": "babel-runtime@>=6.6.1 <7.0.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.23.0.tgz" + }, + "balanced-match": { + "version": "1.0.0", + "from": "balanced-match@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz" + }, + "basic-auth": { + "version": "1.0.4", + "from": "basic-auth@>=1.0.3 <1.1.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.0.4.tgz" + }, + "bcrypt-pbkdf": { + "version": "1.0.1", + "from": "bcrypt-pbkdf@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz", + "optional": true + }, + "bcryptjs": { + "version": "2.4.3", + "from": "bcryptjs@2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz" + }, + "bignumber.js": { + "version": "1.0.1", + "from": "bignumber.js@1.0.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-1.0.1.tgz", + "optional": true + }, + "bl": { + "version": "1.2.1", + "from": "bl@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.1.tgz" + }, + "block-stream": { + "version": "0.0.9", + "from": "block-stream@*", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz" + }, + "bluebird": { + "version": "3.5.0", + "from": "bluebird@3.5.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz" + }, + "body-parser": { + "version": "1.17.0", + "from": "body-parser@1.17.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.17.0.tgz", + "dependencies": { + "debug": { + "version": "2.6.1", + "from": "debug@2.6.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz" + }, + "ms": { + "version": "0.7.2", + "from": "ms@0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" + }, + "qs": { + "version": "6.3.1", + "from": "qs@6.3.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.1.tgz" + } + } + }, + "bookshelf": { + "version": "0.10.2", + "from": "bookshelf@0.10.2", + "resolved": "https://registry.npmjs.org/bookshelf/-/bookshelf-0.10.2.tgz" + }, + "boolbase": { + "version": "1.0.0", + "from": "boolbase@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" + }, + "boom": { + "version": "2.10.1", + "from": "boom@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz" + }, + "brace-expansion": { + "version": "1.1.8", + "from": "brace-expansion@>=1.1.7 <2.0.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz" + }, + "braces": { + "version": "1.8.5", + "from": "braces@>=1.8.2 <2.0.0", + "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz" + }, + "buffer-crc32": { + "version": "0.2.13", + "from": "buffer-crc32@>=0.2.1 <0.3.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz" + }, + "buffer-writer": { + "version": "1.0.1", + "from": "buffer-writer@1.0.1", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz", + "optional": true + }, + "bunyan": { + "version": "1.8.5", + "from": "bunyan@1.8.5", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.5.tgz" + }, + "bunyan-loggly": { + "version": "1.1.0", + "from": "bunyan-loggly@1.1.0", + "resolved": "https://registry.npmjs.org/bunyan-loggly/-/bunyan-loggly-1.1.0.tgz" + }, + "busboy": { + "version": "0.2.14", + "from": "busboy@>=0.2.11 <0.3.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", + "dependencies": { + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "readable-stream": { + "version": "1.1.14", + "from": "readable-stream@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + } + } + }, + "bytes": { + "version": "2.4.0", + "from": "bytes@2.4.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz" + }, + "caller": { + "version": "1.0.1", + "from": "caller@1.0.1", + "resolved": "https://registry.npmjs.org/caller/-/caller-1.0.1.tgz" + }, + "camelcase": { + "version": "1.2.1", + "from": "camelcase@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz" + }, + "caseless": { + "version": "0.11.0", + "from": "caseless@>=0.11.0 <0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz" + }, + "center-align": { + "version": "0.1.3", + "from": "center-align@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz" + }, + "chai": { + "version": "3.5.0", + "from": "chai@>=1.9.2 <4.0.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz" + }, + "chalk": { + "version": "1.1.3", + "from": "chalk@1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz" + }, + "cheerio": { + "version": "0.22.0", + "from": "cheerio@0.22.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.22.0.tgz" + }, + "cjson": { + "version": "0.2.1", + "from": "cjson@>=0.2.1 <0.3.0", + "resolved": "https://registry.npmjs.org/cjson/-/cjson-0.2.1.tgz" + }, + "cliui": { + "version": "2.1.0", + "from": "cliui@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "from": "wordwrap@0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" + } + } + }, + "clone": { + "version": "1.0.2", + "from": "clone@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz" + }, + "code-point-at": { + "version": "1.1.0", + "from": "code-point-at@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz" + }, + "colors": { + "version": "1.1.2", + "from": "colors@>=1.1.2 <2.0.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz" + }, + "combined-stream": { + "version": "1.0.5", + "from": "combined-stream@>=1.0.5 <1.1.0", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz" + }, + "commander": { + "version": "2.9.0", + "from": "commander@>=2.9.0 <3.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" + }, + "component-emitter": { + "version": "1.2.1", + "from": "component-emitter@>=1.2.0 <2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz" + }, + "compress-commons": { + "version": "1.2.0", + "from": "compress-commons@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-1.2.0.tgz" + }, + "compressible": { + "version": "2.0.10", + "from": "compressible@>=2.0.8 <2.1.0", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.10.tgz" + }, + "compression": { + "version": "1.6.2", + "from": "compression@1.6.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.6.2.tgz", + "dependencies": { + "bytes": { + "version": "2.3.0", + "from": "bytes@2.3.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz" + }, + "debug": { + "version": "2.2.0", + "from": "debug@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "ms@0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "concat-map": { + "version": "0.0.1", + "from": "concat-map@0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + }, + "concat-stream": { + "version": "1.5.0", + "from": "concat-stream@1.5.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.0.tgz", + "dependencies": { + "readable-stream": { + "version": "2.0.6", + "from": "readable-stream@>=2.0.0 <2.1.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + } + } + }, + "config-chain": { + "version": "1.1.11", + "from": "config-chain@>=1.1.5 <1.2.0", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.11.tgz" + }, + "connect-slashes": { + "version": "1.3.1", + "from": "connect-slashes@1.3.1", + "resolved": "https://registry.npmjs.org/connect-slashes/-/connect-slashes-1.3.1.tgz" + }, + "console-control-strings": { + "version": "1.1.0", + "from": "console-control-strings@~1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz" + }, + "content-disposition": { + "version": "0.5.2", + "from": "content-disposition@0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz" + }, + "content-type": { + "version": "1.0.2", + "from": "content-type@>=1.0.2 <1.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz" + }, + "cookie": { + "version": "0.3.1", + "from": "cookie@0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz" + }, + "cookie-session": { + "version": "1.2.0", + "from": "cookie-session@1.2.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-1.2.0.tgz", + "dependencies": { + "debug": { + "version": "2.2.0", + "from": "debug@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "ms@0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "cookie-signature": { + "version": "1.0.6", + "from": "cookie-signature@1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" + }, + "cookiejar": { + "version": "2.1.1", + "from": "cookiejar@>=2.0.6 <3.0.0", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.1.tgz" + }, + "cookies": { + "version": "0.5.0", + "from": "cookies@0.5.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.5.0.tgz" + }, + "core-js": { + "version": "2.4.1", + "from": "core-js@>=2.4.0 <3.0.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.1.tgz" + }, + "core-util-is": { + "version": "1.0.2", + "from": "core-util-is@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" + }, + "cors": { + "version": "2.8.3", + "from": "cors@2.8.3", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.3.tgz" + }, + "crc": { + "version": "3.4.4", + "from": "crc@>=3.4.4 <4.0.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.4.4.tgz" + }, + "crc32-stream": { + "version": "2.0.0", + "from": "crc32-stream@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz" + }, + "create-error": { + "version": "0.3.1", + "from": "create-error@>=0.3.1 <0.4.0", + "resolved": "https://registry.npmjs.org/create-error/-/create-error-0.3.1.tgz" + }, + "cryptiles": { + "version": "2.0.5", + "from": "cryptiles@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz" + }, + "css-select": { + "version": "1.2.0", + "from": "css-select@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "dependencies": { + "domutils": { + "version": "1.5.1", + "from": "domutils@1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz" + } + } + }, + "css-what": { + "version": "2.1.0", + "from": "css-what@>=2.1.0 <2.2.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz" + }, + "csv-parser": { + "version": "1.11.0", + "from": "csv-parser@1.11.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-1.11.0.tgz", + "dependencies": { + "minimist": { + "version": "1.2.0", + "from": "minimist@>=1.2.0 <2.0.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" + } + } + }, + "dashdash": { + "version": "1.14.1", + "from": "dashdash@>=1.12.0 <2.0.0", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "from": "assert-plus@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" + } + } + }, + "debug": { + "version": "2.6.8", + "from": "debug@>=2.2.0 <3.0.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.8.tgz" + }, + "decamelize": { + "version": "1.2.0", + "from": "decamelize@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" + }, + "deep-eql": { + "version": "0.1.3", + "from": "deep-eql@>=0.1.3 <0.2.0", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "dependencies": { + "type-detect": { + "version": "0.1.1", + "from": "type-detect@0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz" + } + } + }, + "deep-equal": { + "version": "1.0.1", + "from": "deep-equal@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz" + }, + "deep-extend": { + "version": "0.4.1", + "from": "deep-extend@~0.4.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.1.tgz" + }, + "delayed-stream": { + "version": "1.0.0", + "from": "delayed-stream@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" + }, + "delegates": { + "version": "1.0.0", + "from": "delegates@^1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz" + }, + "depd": { + "version": "1.1.0", + "from": "depd@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz" + }, + "destroy": { + "version": "1.0.4", + "from": "destroy@>=1.0.4 <1.1.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz" + }, + "detect-file": { + "version": "0.1.0", + "from": "detect-file@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-0.1.0.tgz" + }, + "dicer": { + "version": "0.2.5", + "from": "dicer@0.2.5", + "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz", + "dependencies": { + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "readable-stream": { + "version": "1.1.14", + "from": "readable-stream@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + } + } + }, + "directmail": { + "version": "0.1.8", + "from": "directmail@>=0.1.7 <0.2.0", + "resolved": "https://registry.npmjs.org/directmail/-/directmail-0.1.8.tgz" + }, + "dkim-signer": { + "version": "0.1.2", + "from": "dkim-signer@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/dkim-signer/-/dkim-signer-0.1.2.tgz", + "dependencies": { + "punycode": { + "version": "1.2.4", + "from": "punycode@>=1.2.4 <1.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.2.4.tgz" + } + } + }, + "dom-serializer": { + "version": "0.1.0", + "from": "dom-serializer@>=0.0.0 <1.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz", + "dependencies": { + "domelementtype": { + "version": "1.1.3", + "from": "domelementtype@>=1.1.1 <1.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz" + } + } + }, + "domelementtype": { + "version": "1.3.0", + "from": "domelementtype@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz" + }, + "domhandler": { + "version": "2.4.1", + "from": "domhandler@>=2.3.0 <3.0.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.1.tgz" + }, + "domutils": { + "version": "1.6.2", + "from": "domutils@>=1.5.1 <2.0.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.6.2.tgz" + }, + "downsize": { + "version": "0.0.8", + "from": "downsize@0.0.8", + "resolved": "https://registry.npmjs.org/downsize/-/downsize-0.0.8.tgz" + }, + "dtrace-provider": { + "version": "0.8.3", + "from": "dtrace-provider@>=0.8.0 <0.9.0", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.3.tgz", + "optional": true + }, + "ebnf-parser": { + "version": "0.1.10", + "from": "ebnf-parser@>=0.1.9 <0.2.0", + "resolved": "https://registry.npmjs.org/ebnf-parser/-/ebnf-parser-0.1.10.tgz" + }, + "ecc-jsbn": { + "version": "0.1.1", + "from": "ecc-jsbn@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "optional": true + }, + "editorconfig": { + "version": "0.13.2", + "from": "editorconfig@>=0.13.2 <0.14.0", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.13.2.tgz" + }, + "ee-first": { + "version": "1.1.1", + "from": "ee-first@1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" + }, + "emits": { + "version": "3.0.0", + "from": "emits@3.0.0", + "resolved": "https://registry.npmjs.org/emits/-/emits-3.0.0.tgz" + }, + "encodeurl": { + "version": "1.0.1", + "from": "encodeurl@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz" + }, + "encoding": { + "version": "0.1.12", + "from": "encoding@>=0.1.7 <0.2.0", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz" + }, + "end-of-stream": { + "version": "1.4.0", + "from": "end-of-stream@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.0.tgz" + }, + "entities": { + "version": "1.1.1", + "from": "entities@>=1.1.1 <2.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz" + }, + "escape-html": { + "version": "1.0.3", + "from": "escape-html@>=1.0.3 <1.1.0", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" + }, + "escape-string-regexp": { + "version": "1.0.5", + "from": "escape-string-regexp@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" + }, + "escodegen": { + "version": "0.0.21", + "from": "escodegen@0.0.21", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-0.0.21.tgz", + "dependencies": { + "esprima": { + "version": "1.0.4", + "from": "esprima@>=1.0.2 <1.1.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" + } + } + }, + "esprima": { + "version": "1.2.2", + "from": "esprima@1.2.2", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.2.tgz" + }, + "estraverse": { + "version": "0.0.4", + "from": "estraverse@>=0.0.4 <0.1.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-0.0.4.tgz" + }, + "etag": { + "version": "1.8.0", + "from": "etag@>=1.8.0 <1.9.0", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.0.tgz" + }, + "expand-brackets": { + "version": "0.1.5", + "from": "expand-brackets@>=0.1.4 <0.2.0", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz" + }, + "expand-range": { + "version": "1.8.2", + "from": "expand-range@>=1.8.1 <2.0.0", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz" + }, + "expand-tilde": { + "version": "1.2.2", + "from": "expand-tilde@>=1.2.2 <2.0.0", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-1.2.2.tgz" + }, + "express": { + "version": "4.15.0", + "from": "express@4.15.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.15.0.tgz", + "dependencies": { + "debug": { + "version": "2.6.1", + "from": "debug@2.6.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz" + }, + "ms": { + "version": "0.7.2", + "from": "ms@0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" + }, + "qs": { + "version": "6.3.1", + "from": "qs@6.3.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.1.tgz" + } + } + }, + "express-hbs": { + "version": "1.0.4", + "from": "express-hbs@1.0.4", + "resolved": "https://registry.npmjs.org/express-hbs/-/express-hbs-1.0.4.tgz" + }, + "extend": { + "version": "3.0.1", + "from": "extend@>=3.0.0 <3.1.0", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz" + }, + "extglob": { + "version": "0.3.2", + "from": "extglob@>=0.3.1 <0.4.0", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz" + }, + "extract-zip-fork": { + "version": "1.5.1", + "from": "extract-zip-fork@1.5.1", + "resolved": "https://registry.npmjs.org/extract-zip-fork/-/extract-zip-fork-1.5.1.tgz", + "dependencies": { + "debug": { + "version": "0.7.4", + "from": "debug@0.7.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" + }, + "mkdirp": { + "version": "0.5.0", + "from": "mkdirp@0.5.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.0.tgz" + } + } + }, + "extsprintf": { + "version": "1.0.2", + "from": "extsprintf@1.0.2", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz" + }, + "fd-slicer": { + "version": "1.0.1", + "from": "fd-slicer@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz" + }, + "filename-regex": { + "version": "2.0.1", + "from": "filename-regex@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz" + }, + "fill-range": { + "version": "2.2.3", + "from": "fill-range@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz" + }, + "finalhandler": { + "version": "1.0.3", + "from": "finalhandler@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.0.3.tgz", + "dependencies": { + "debug": { + "version": "2.6.7", + "from": "debug@2.6.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.7.tgz" + } + } + }, + "find-root": { + "version": "1.0.0", + "from": "find-root@1.0.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.0.0.tgz" + }, + "findup-sync": { + "version": "0.4.3", + "from": "findup-sync@>=0.4.2 <0.5.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.3.tgz" + }, + "flagged-respawn": { + "version": "0.3.2", + "from": "flagged-respawn@>=0.3.2 <0.4.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz" + }, + "follow-redirects": { + "version": "0.0.3", + "from": "follow-redirects@0.0.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-0.0.3.tgz", + "dependencies": { + "underscore": { + "version": "1.8.3", + "from": "underscore@", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz" + } + } + }, + "for-in": { + "version": "1.0.2", + "from": "for-in@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz" + }, + "for-own": { + "version": "0.1.5", + "from": "for-own@>=0.1.4 <0.2.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz" + }, + "forever-agent": { + "version": "0.6.1", + "from": "forever-agent@>=0.6.1 <0.7.0", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" + }, + "form-data": { + "version": "2.0.0", + "from": "form-data@>=2.0.0 <2.1.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.0.0.tgz" + }, + "formidable": { + "version": "1.1.1", + "from": "formidable@>=1.1.1 <2.0.0", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.1.1.tgz" + }, + "forwarded": { + "version": "0.1.0", + "from": "forwarded@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz" + }, + "fresh": { + "version": "0.5.0", + "from": "fresh@0.5.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.0.tgz" + }, + "fs-exists-sync": { + "version": "0.1.0", + "from": "fs-exists-sync@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz" + }, + "fs-extra": { + "version": "2.1.2", + "from": "fs-extra@2.1.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-2.1.2.tgz" + }, + "fs.realpath": { + "version": "1.0.0", + "from": "fs.realpath@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + }, + "fstream": { + "version": "1.0.10", + "from": "fstream@^1.0.2", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.10.tgz", + "dependencies": { + "graceful-fs": { + "version": "4.1.9", + "from": "graceful-fs@^4.1.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.9.tgz" + } + } + }, + "fstream-ignore": { + "version": "1.0.5", + "from": "fstream-ignore@~1.0.5", + "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz", + "dependencies": { + "minimatch": { + "version": "3.0.3", + "from": "minimatch@^3.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", + "dependencies": { + "brace-expansion": { + "version": "1.1.6", + "from": "brace-expansion@^1.0.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.6.tgz", + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "from": "balanced-match@^0.4.1", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz" + }, + "concat-map": { + "version": "0.0.1", + "from": "concat-map@0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + } + } + } + } + } + } + }, + "gauge": { + "version": "2.6.0", + "from": "gauge@~2.6.0", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.6.0.tgz", + "dependencies": { + "object-assign": { + "version": "4.1.0", + "from": "object-assign@^4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz" + }, + "signal-exit": { + "version": "3.0.1", + "from": "signal-exit@^3.0.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.1.tgz" + } + } + }, + "generate-function": { + "version": "1.1.0", + "from": "generate-function@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-1.1.0.tgz" + }, + "generate-object-property": { + "version": "1.2.0", + "from": "generate-object-property@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz" + }, + "generic-pool": { + "version": "2.5.4", + "from": "generic-pool@>=2.4.2 <3.0.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.5.4.tgz" + }, + "getpass": { + "version": "0.1.7", + "from": "getpass@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "from": "assert-plus@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" + } + } + }, + "ghost-gql": { + "version": "0.0.6", + "from": "ghost-gql@0.0.6", + "resolved": "https://registry.npmjs.org/ghost-gql/-/ghost-gql-0.0.6.tgz" + }, + "ghost-ignition": { + "version": "2.8.11", + "from": "ghost-ignition@>=2.8.7 <3.0.0", + "resolved": "https://registry.npmjs.org/ghost-ignition/-/ghost-ignition-2.8.11.tgz" + }, + "glob": { + "version": "5.0.15", + "from": "glob@5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + }, + "glob-base": { + "version": "0.3.0", + "from": "glob-base@>=0.3.0 <0.4.0", + "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz" + }, + "glob-parent": { + "version": "2.0.0", + "from": "glob-parent@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz" + }, + "global-modules": { + "version": "0.2.3", + "from": "global-modules@>=0.2.3 <0.3.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz" + }, + "global-prefix": { + "version": "0.1.5", + "from": "global-prefix@>=0.1.4 <0.2.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-0.1.5.tgz" + }, + "graceful-fs": { + "version": "4.1.11", + "from": "graceful-fs@>=4.1.0 <5.0.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz" + }, + "graceful-readlink": { + "version": "1.0.1", + "from": "graceful-readlink@>=1.0.0", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" + }, + "gscan": { + "version": "0.2.4", + "from": "gscan@0.2.4", + "resolved": "https://registry.npmjs.org/gscan/-/gscan-0.2.4.tgz", + "dependencies": { + "bluebird": { + "version": "3.4.6", + "from": "bluebird@3.4.6", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz" + }, + "chalk": { + "version": "1.1.1", + "from": "chalk@1.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.1.tgz" + }, + "content-disposition": { + "version": "0.5.1", + "from": "content-disposition@0.5.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.1.tgz" + }, + "debug": { + "version": "2.2.0", + "from": "debug@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" + }, + "etag": { + "version": "1.7.0", + "from": "etag@>=1.7.0 <1.8.0", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.7.0.tgz" + }, + "express": { + "version": "4.14.0", + "from": "express@4.14.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.14.0.tgz" + }, + "express-hbs": { + "version": "1.0.3", + "from": "express-hbs@1.0.3", + "resolved": "https://registry.npmjs.org/express-hbs/-/express-hbs-1.0.3.tgz" + }, + "finalhandler": { + "version": "0.5.0", + "from": "finalhandler@0.5.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-0.5.0.tgz" + }, + "fresh": { + "version": "0.3.0", + "from": "fresh@0.3.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz" + }, + "fs-extra": { + "version": "0.26.2", + "from": "fs-extra@0.26.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.26.2.tgz" + }, + "glob": { + "version": "7.0.5", + "from": "glob@7.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.5.tgz" + }, + "http-errors": { + "version": "1.5.1", + "from": "http-errors@>=1.5.0 <1.6.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.5.1.tgz" + }, + "js-beautify": { + "version": "1.6.4", + "from": "js-beautify@1.6.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.6.4.tgz" + }, + "lodash": { + "version": "3.10.1", + "from": "lodash@3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "ms@0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + }, + "multer": { + "version": "1.1.0", + "from": "multer@1.1.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.1.0.tgz" + }, + "object-assign": { + "version": "3.0.0", + "from": "object-assign@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" + }, + "qs": { + "version": "6.2.0", + "from": "qs@6.2.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.0.tgz" + }, + "send": { + "version": "0.14.1", + "from": "send@0.14.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.14.1.tgz" + }, + "serve-static": { + "version": "1.11.2", + "from": "serve-static@>=1.11.1 <1.12.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.11.2.tgz", + "dependencies": { + "ms": { + "version": "0.7.2", + "from": "ms@0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" + }, + "send": { + "version": "0.14.2", + "from": "send@0.14.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.14.2.tgz" + } + } + }, + "setprototypeof": { + "version": "1.0.2", + "from": "setprototypeof@1.0.2", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.2.tgz" + } + } + }, + "handlebars": { + "version": "4.0.6", + "from": "handlebars@4.0.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.6.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + } + } + }, + "har-validator": { + "version": "2.0.6", + "from": "har-validator@>=2.0.6 <2.1.0", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz" + }, + "has-ansi": { + "version": "2.0.0", + "from": "has-ansi@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" + }, + "has-color": { + "version": "0.1.7", + "from": "has-color@^0.1.7", + "resolved": "https://registry.npmjs.org/has-color/-/has-color-0.1.7.tgz" + }, + "has-unicode": { + "version": "2.0.1", + "from": "has-unicode@^2.0.0", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz" + }, + "hawk": { + "version": "3.1.3", + "from": "hawk@>=3.1.3 <3.2.0", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz" + }, + "he": { + "version": "1.1.1", + "from": "he@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz" + }, + "hijackresponse": { + "version": "2.0.1", + "from": "hijackresponse@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/hijackresponse/-/hijackresponse-2.0.1.tgz" + }, + "hoek": { + "version": "2.16.3", + "from": "hoek@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz" + }, + "homedir-polyfill": { + "version": "1.0.1", + "from": "homedir-polyfill@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz" + }, + "html-to-text": { + "version": "3.2.0", + "from": "html-to-text@3.2.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-3.2.0.tgz" + }, + "htmlparser2": { + "version": "3.9.2", + "from": "htmlparser2@3.9.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz" + }, + "http-errors": { + "version": "1.6.1", + "from": "http-errors@>=1.6.1 <1.7.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.1.tgz" + }, + "http-signature": { + "version": "1.1.1", + "from": "http-signature@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz" + }, + "iconv-lite": { + "version": "0.4.15", + "from": "iconv-lite@0.4.15", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz" + }, + "image-size": { + "version": "0.5.1", + "from": "image-size@0.5.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.1.tgz" + }, + "inflection": { + "version": "1.12.0", + "from": "inflection@>=1.5.1 <2.0.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz" + }, + "inflight": { + "version": "1.0.6", + "from": "inflight@>=1.0.4 <2.0.0", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" + }, + "inherits": { + "version": "2.0.3", + "from": "inherits@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" + }, + "ini": { + "version": "1.3.4", + "from": "ini@>=1.3.4 <2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz" + }, + "interpret": { + "version": "0.6.6", + "from": "interpret@>=0.6.5 <0.7.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz" + }, + "intl": { + "version": "1.2.5", + "from": "intl@1.2.5", + "resolved": "https://registry.npmjs.org/intl/-/intl-1.2.5.tgz" + }, + "intl-messageformat": { + "version": "1.3.0", + "from": "intl-messageformat@1.3.0", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-1.3.0.tgz" + }, + "intl-messageformat-parser": { + "version": "1.2.0", + "from": "intl-messageformat-parser@1.2.0", + "resolved": "https://registry.npmjs.org/intl-messageformat-parser/-/intl-messageformat-parser-1.2.0.tgz" + }, + "invert-kv": { + "version": "1.0.0", + "from": "invert-kv@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz" + }, + "ipaddr.js": { + "version": "1.3.0", + "from": "ipaddr.js@1.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.3.0.tgz" + }, + "is-buffer": { + "version": "1.1.5", + "from": "is-buffer@>=1.1.5 <2.0.0", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz" + }, + "is-dotfile": { + "version": "1.0.3", + "from": "is-dotfile@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz" + }, + "is-equal-shallow": { + "version": "0.1.3", + "from": "is-equal-shallow@>=0.1.3 <0.2.0", + "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz" + }, + "is-extendable": { + "version": "0.1.1", + "from": "is-extendable@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz" + }, + "is-extglob": { + "version": "1.0.0", + "from": "is-extglob@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "from": "is-fullwidth-code-point@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz" + }, + "is-glob": { + "version": "2.0.1", + "from": "is-glob@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" + }, + "is-my-json-valid": { + "version": "2.16.0", + "from": "is-my-json-valid@>=2.12.4 <3.0.0", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.0.tgz", + "dependencies": { + "generate-function": { + "version": "2.0.0", + "from": "generate-function@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" + } + } + }, + "is-number": { + "version": "2.1.0", + "from": "is-number@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz" + }, + "is-posix-bracket": { + "version": "0.1.1", + "from": "is-posix-bracket@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz" + }, + "is-primitive": { + "version": "2.0.0", + "from": "is-primitive@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz" + }, + "is-property": { + "version": "1.0.2", + "from": "is-property@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" + }, + "is-typedarray": { + "version": "1.0.0", + "from": "is-typedarray@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" + }, + "is-windows": { + "version": "0.2.0", + "from": "is-windows@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz" + }, + "isarray": { + "version": "1.0.0", + "from": "isarray@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + }, + "isexe": { + "version": "2.0.0", + "from": "isexe@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" + }, + "isobject": { + "version": "2.1.0", + "from": "isobject@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz" + }, + "isstream": { + "version": "0.1.2", + "from": "isstream@>=0.1.2 <0.2.0", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" + }, + "jison": { + "version": "0.4.13", + "from": "jison@0.4.13", + "resolved": "https://registry.npmjs.org/jison/-/jison-0.4.13.tgz", + "dependencies": { + "esprima": { + "version": "1.0.4", + "from": "esprima@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" + } + } + }, + "jison-lex": { + "version": "0.2.1", + "from": "jison-lex@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/jison-lex/-/jison-lex-0.2.1.tgz" + }, + "js-beautify": { + "version": "1.6.8", + "from": "js-beautify@1.6.8", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.6.8.tgz" + }, + "jsbn": { + "version": "0.1.1", + "from": "jsbn@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "optional": true + }, + "json-schema": { + "version": "0.2.3", + "from": "json-schema@0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz" + }, + "json-stringify-safe": { + "version": "5.0.1", + "from": "json-stringify-safe@>=5.0.1 <6.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" + }, + "jsonfile": { + "version": "2.4.0", + "from": "jsonfile@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz" + }, + "jsonpath": { + "version": "0.2.11", + "from": "jsonpath@0.2.11", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-0.2.11.tgz", + "dependencies": { + "underscore": { + "version": "1.7.0", + "from": "underscore@1.7.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz" + } + } + }, + "jsonpointer": { + "version": "4.0.1", + "from": "jsonpointer@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz" + }, + "JSONSelect": { + "version": "0.4.0", + "from": "JSONSelect@0.4.0", + "resolved": "https://registry.npmjs.org/JSONSelect/-/JSONSelect-0.4.0.tgz" + }, + "jsprim": { + "version": "1.4.0", + "from": "jsprim@>=1.2.2 <2.0.0", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz", + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "from": "assert-plus@1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" + } + } + }, + "keygrip": { + "version": "1.0.1", + "from": "keygrip@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.1.tgz" + }, + "kind-of": { + "version": "3.2.2", + "from": "kind-of@>=3.0.2 <4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz" + }, + "klaw": { + "version": "1.3.1", + "from": "klaw@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz" + }, + "knex": { + "version": "0.12.9", + "from": "knex@0.12.9", + "resolved": "https://registry.npmjs.org/knex/-/knex-0.12.9.tgz", + "dependencies": { + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "minimist": { + "version": "1.1.3", + "from": "minimist@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz" + }, + "readable-stream": { + "version": "1.1.14", + "from": "readable-stream@>=1.1.12 <2.0.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + } + } + }, + "lazy-cache": { + "version": "1.0.4", + "from": "lazy-cache@>=1.0.3 <2.0.0", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz" + }, + "lazystream": { + "version": "1.0.0", + "from": "lazystream@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz" + }, + "lcid": { + "version": "1.0.0", + "from": "lcid@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz" + }, + "lex-parser": { + "version": "0.1.4", + "from": "lex-parser@>=0.1.3 <0.2.0", + "resolved": "https://registry.npmjs.org/lex-parser/-/lex-parser-0.1.4.tgz" + }, + "liftoff": { + "version": "2.2.5", + "from": "liftoff@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.2.5.tgz" + }, + "lodash": { + "version": "4.17.4", + "from": "lodash@4.17.4", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz" + }, + "lodash.assignin": { + "version": "4.2.0", + "from": "lodash.assignin@>=4.0.9 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz" + }, + "lodash.bind": { + "version": "4.2.1", + "from": "lodash.bind@>=4.1.4 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz" + }, + "lodash.defaults": { + "version": "4.2.0", + "from": "lodash.defaults@>=4.0.1 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz" + }, + "lodash.filter": { + "version": "4.6.0", + "from": "lodash.filter@>=4.4.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz" + }, + "lodash.flatten": { + "version": "4.4.0", + "from": "lodash.flatten@>=4.2.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz" + }, + "lodash.foreach": { + "version": "4.5.0", + "from": "lodash.foreach@>=4.3.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz" + }, + "lodash.map": { + "version": "4.6.0", + "from": "lodash.map@>=4.4.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz" + }, + "lodash.merge": { + "version": "4.6.0", + "from": "lodash.merge@4.6.0", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.0.tgz" + }, + "lodash.pick": { + "version": "4.4.0", + "from": "lodash.pick@>=4.2.1 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz" + }, + "lodash.reduce": { + "version": "4.6.0", + "from": "lodash.reduce@>=4.4.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz" + }, + "lodash.reject": { + "version": "4.6.0", + "from": "lodash.reject@>=4.4.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz" + }, + "lodash.some": { + "version": "4.6.0", + "from": "lodash.some@>=4.4.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz" + }, + "lodash.unescape": { + "version": "4.0.1", + "from": "lodash.unescape@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz" + }, + "loggly": { + "version": "1.1.1", + "from": "loggly@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/loggly/-/loggly-1.1.1.tgz" + }, + "longest": { + "version": "1.0.1", + "from": "longest@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz" + }, + "lru-cache": { + "version": "3.2.0", + "from": "lru-cache@>=3.2.0 <4.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz" + }, + "mailcomposer": { + "version": "0.2.12", + "from": "mailcomposer@>=0.2.10 <0.3.0", + "resolved": "https://registry.npmjs.org/mailcomposer/-/mailcomposer-0.2.12.tgz", + "dependencies": { + "he": { + "version": "0.3.6", + "from": "he@>=0.3.6 <0.4.0", + "resolved": "https://registry.npmjs.org/he/-/he-0.3.6.tgz" + }, + "mime": { + "version": "1.2.11", + "from": "mime@>=1.2.11 <1.3.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz" + } + } + }, + "media-typer": { + "version": "0.3.0", + "from": "media-typer@0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" + }, + "merge-descriptors": { + "version": "1.0.1", + "from": "merge-descriptors@1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" + }, + "methods": { + "version": "1.1.2", + "from": "methods@>=1.1.2 <1.2.0", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" + }, + "micromatch": { + "version": "2.3.11", + "from": "micromatch@>=2.3.7 <3.0.0", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz" + }, + "mime": { + "version": "1.3.4", + "from": "mime@1.3.4", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" + }, + "mime-db": { + "version": "1.27.0", + "from": "mime-db@>=1.27.0 <1.28.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz" + }, + "mime-types": { + "version": "2.1.15", + "from": "mime-types@>=2.1.15 <2.2.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz" + }, + "mimelib": { + "version": "0.2.19", + "from": "mimelib@>=0.2.15 <0.3.0", + "resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.2.19.tgz" + }, + "minimatch": { + "version": "3.0.4", + "from": "minimatch@>=3.0.4 <4.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + }, + "moment": { + "version": "2.18.1", + "from": "moment@2.18.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz" + }, + "moment-timezone": { + "version": "0.5.13", + "from": "moment-timezone@0.5.13", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.13.tgz" + }, + "morgan": { + "version": "1.7.0", + "from": "morgan@1.7.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.7.0.tgz", + "dependencies": { + "debug": { + "version": "2.2.0", + "from": "debug@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" + }, + "ms": { + "version": "0.7.1", + "from": "ms@0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "ms": { + "version": "2.0.0", + "from": "ms@2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" + }, + "multer": { + "version": "1.3.0", + "from": "multer@1.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.3.0.tgz", + "dependencies": { + "object-assign": { + "version": "3.0.0", + "from": "object-assign@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" + } + } + }, + "mv": { + "version": "2.1.1", + "from": "mv@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "optional": true, + "dependencies": { + "glob": { + "version": "6.0.4", + "from": "glob@>=6.0.1 <7.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "optional": true + }, + "rimraf": { + "version": "2.4.5", + "from": "rimraf@>=2.4.0 <2.5.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "optional": true + } + } + }, + "mysql": { + "version": "2.1.1", + "from": "mysql@2.1.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.1.1.tgz", + "optional": true, + "dependencies": { + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "optional": true + }, + "readable-stream": { + "version": "1.1.14", + "from": "readable-stream@>=1.1.9 <1.2.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "optional": true + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "optional": true + } + } + }, + "nan": { + "version": "2.6.2", + "from": "nan@>=2.3.3 <3.0.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.6.2.tgz", + "optional": true + }, + "nconf": { + "version": "0.8.4", + "from": "nconf@0.8.4", + "resolved": "https://registry.npmjs.org/nconf/-/nconf-0.8.4.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, + "camelcase": { + "version": "2.1.1", + "from": "camelcase@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz" + }, + "cliui": { + "version": "3.2.0", + "from": "cliui@>=3.0.3 <4.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz" + }, + "window-size": { + "version": "0.1.4", + "from": "window-size@>=0.1.4 <0.2.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz" + }, + "yargs": { + "version": "3.32.0", + "from": "yargs@>=3.19.0 <4.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz" + } + } + }, + "ncp": { + "version": "2.0.0", + "from": "ncp@>=2.0.0 <2.1.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "optional": true + }, + "ndjson": { + "version": "1.5.0", + "from": "ndjson@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/ndjson/-/ndjson-1.5.0.tgz", + "dependencies": { + "minimist": { + "version": "1.2.0", + "from": "minimist@>=1.2.0 <2.0.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" + } + } + }, + "negotiator": { + "version": "0.6.1", + "from": "negotiator@0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz" + }, + "netjet": { + "version": "1.1.3", + "from": "netjet@1.1.3", + "resolved": "https://registry.npmjs.org/netjet/-/netjet-1.1.3.tgz", + "dependencies": { + "lru-cache": { + "version": "4.1.1", + "from": "lru-cache@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz" + } + } + }, + "nock": { + "version": "9.0.13", + "from": "nock@>=9.0.2 <10.0.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-9.0.13.tgz" + }, + "node-pre-gyp": { + "version": "0.6.31", + "from": "node-pre-gyp@~0.6.31", + "dependencies": { + "request": { + "version": "2.76.0", + "from": "request@^2.75.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.76.0.tgz", + "dependencies": { + "aws-sign2": { + "version": "0.6.0", + "from": "aws-sign2@~0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz" + }, + "aws4": { + "version": "1.5.0", + "from": "aws4@^1.2.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.5.0.tgz" + }, + "caseless": { + "version": "0.11.0", + "from": "caseless@~0.11.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.11.0.tgz" + }, + "combined-stream": { + "version": "1.0.5", + "from": "combined-stream@~1.0.5", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", + "dependencies": { + "delayed-stream": { + "version": "1.0.0", + "from": "delayed-stream@~1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" + } + } + }, + "extend": { + "version": "3.0.0", + "from": "extend@~3.0.0", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.0.tgz" + }, + "forever-agent": { + "version": "0.6.1", + "from": "forever-agent@~0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" + }, + "form-data": { + "version": "2.1.1", + "from": "form-data@~2.1.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.1.tgz", + "dependencies": { + "asynckit": { + "version": "0.4.0", + "from": "asynckit@^0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" + } + } + }, + "har-validator": { + "version": "2.0.6", + "from": "har-validator@~2.0.6", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz", + "dependencies": { + "chalk": { + "version": "1.1.3", + "from": "chalk@^1.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "from": "ansi-styles@^2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" + }, + "escape-string-regexp": { + "version": "1.0.5", + "from": "escape-string-regexp@^1.0.2", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" + }, + "has-ansi": { + "version": "2.0.0", + "from": "has-ansi@^2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "dependencies": { + "ansi-regex": { + "version": "2.0.0", + "from": "ansi-regex@^2.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" + } + } + }, + "strip-ansi": { + "version": "3.0.1", + "from": "strip-ansi@^3.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "dependencies": { + "ansi-regex": { + "version": "2.0.0", + "from": "ansi-regex@^2.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" + } + } + }, + "supports-color": { + "version": "2.0.0", + "from": "supports-color@^2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" + } + } + }, + "commander": { + "version": "2.9.0", + "from": "commander@^2.9.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", + "dependencies": { + "graceful-readlink": { + "version": "1.0.1", + "from": "graceful-readlink@>= 1.0.0", + "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" + } + } + }, + "is-my-json-valid": { + "version": "2.15.0", + "from": "is-my-json-valid@^2.12.4", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz", + "dependencies": { + "generate-function": { + "version": "2.0.0", + "from": "generate-function@^2.0.0", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" + }, + "generate-object-property": { + "version": "1.2.0", + "from": "generate-object-property@^1.1.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "dependencies": { + "is-property": { + "version": "1.0.2", + "from": "is-property@^1.0.0", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" + } + } + }, + "jsonpointer": { + "version": "4.0.0", + "from": "jsonpointer@^4.0.0", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.0.tgz" + }, + "xtend": { + "version": "4.0.1", + "from": "xtend@^4.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" + } + } + }, + "pinkie-promise": { + "version": "2.0.1", + "from": "pinkie-promise@^2.0.0", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "dependencies": { + "pinkie": { + "version": "2.0.4", + "from": "pinkie@^2.0.0", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" + } + } + } + } + }, + "hawk": { + "version": "3.1.3", + "from": "hawk@~3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "dependencies": { + "boom": { + "version": "2.10.1", + "from": "boom@2.x.x", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz" + }, + "cryptiles": { + "version": "2.0.5", + "from": "cryptiles@2.x.x", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz" + }, + "hoek": { + "version": "2.16.3", + "from": "hoek@2.x.x", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz" + }, + "sntp": { + "version": "1.0.9", + "from": "sntp@1.x.x", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz" + } + } + }, + "http-signature": { + "version": "1.1.1", + "from": "http-signature@~1.1.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "dependencies": { + "assert-plus": { + "version": "0.2.0", + "from": "assert-plus@^0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" + }, + "jsprim": { + "version": "1.3.1", + "from": "jsprim@^1.2.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.3.1.tgz", + "dependencies": { + "extsprintf": { + "version": "1.0.2", + "from": "extsprintf@1.0.2", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz" + }, + "json-schema": { + "version": "0.2.3", + "from": "json-schema@0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz" + }, + "verror": { + "version": "1.3.6", + "from": "verror@1.3.6", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz" + } + } + }, + "sshpk": { + "version": "1.10.1", + "from": "sshpk@^1.7.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.10.1.tgz", + "dependencies": { + "asn1": { + "version": "0.2.3", + "from": "asn1@~0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz" + }, + "assert-plus": { + "version": "1.0.0", + "from": "assert-plus@^1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" + }, + "bcrypt-pbkdf": { + "version": "1.0.0", + "from": "bcrypt-pbkdf@^1.0.0", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz", + "optional": true + }, + "dashdash": { + "version": "1.14.0", + "from": "dashdash@^1.12.0", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.0.tgz" + }, + "ecc-jsbn": { + "version": "0.1.1", + "from": "ecc-jsbn@~0.1.1", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", + "optional": true + }, + "getpass": { + "version": "0.1.6", + "from": "getpass@^0.1.1", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.6.tgz" + }, + "jodid25519": { + "version": "1.0.2", + "from": "jodid25519@^1.0.0", + "resolved": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz", + "optional": true + }, + "jsbn": { + "version": "0.1.0", + "from": "jsbn@~0.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.0.tgz", + "optional": true + }, + "tweetnacl": { + "version": "0.14.3", + "from": "tweetnacl@~0.14.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.3.tgz", + "optional": true + } + } + } + } + }, + "is-typedarray": { + "version": "1.0.0", + "from": "is-typedarray@~1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" + }, + "isstream": { + "version": "0.1.2", + "from": "isstream@~0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" + }, + "json-stringify-safe": { + "version": "5.0.1", + "from": "json-stringify-safe@~5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" + }, + "mime-types": { + "version": "2.1.12", + "from": "mime-types@~2.1.7", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.12.tgz", + "dependencies": { + "mime-db": { + "version": "1.24.0", + "from": "mime-db@~1.24.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.24.0.tgz" + } + } + }, + "node-uuid": { + "version": "1.4.7", + "from": "node-uuid@~1.4.7", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.7.tgz" + }, + "oauth-sign": { + "version": "0.8.2", + "from": "oauth-sign@~0.8.1", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz" + }, + "qs": { + "version": "6.3.0", + "from": "qs@~6.3.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.0.tgz" + }, + "stringstream": { + "version": "0.0.5", + "from": "stringstream@~0.0.4", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz" + }, + "tough-cookie": { + "version": "2.3.2", + "from": "tough-cookie@~2.3.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz", + "dependencies": { + "punycode": { + "version": "1.4.1", + "from": "punycode@^1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" + } + } + }, + "tunnel-agent": { + "version": "0.4.3", + "from": "tunnel-agent@~0.4.1", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz" + } + } + }, + "rimraf": { + "version": "2.5.4", + "from": "rimraf@~2.5.4", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz", + "dependencies": { + "glob": { + "version": "7.1.1", + "from": "glob@^7.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz", + "dependencies": { + "fs.realpath": { + "version": "1.0.0", + "from": "fs.realpath@^1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" + }, + "inflight": { + "version": "1.0.6", + "from": "inflight@^1.0.4", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.2", + "from": "wrappy@1", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + } + } + }, + "inherits": { + "version": "2.0.3", + "from": "inherits@2", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" + }, + "minimatch": { + "version": "3.0.3", + "from": "minimatch@^3.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", + "dependencies": { + "brace-expansion": { + "version": "1.1.6", + "from": "brace-expansion@^1.0.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.6.tgz", + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "from": "balanced-match@^0.4.1", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz" + }, + "concat-map": { + "version": "0.0.1", + "from": "concat-map@0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + } + } + } + } + }, + "once": { + "version": "1.4.0", + "from": "once@^1.3.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.2", + "from": "wrappy@1", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + } + } + }, + "path-is-absolute": { + "version": "1.0.1", + "from": "path-is-absolute@^1.0.0", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + } + } + } + } + } + } + }, + "nodemailer": { + "version": "0.7.1", + "from": "nodemailer@0.7.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-0.7.1.tgz", + "dependencies": { + "he": { + "version": "0.3.6", + "from": "he@>=0.3.6 <0.4.0", + "resolved": "https://registry.npmjs.org/he/-/he-0.3.6.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "optional": true + }, + "readable-stream": { + "version": "1.1.14", + "from": "readable-stream@>=1.1.9 <1.2.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "optional": true + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "optional": true + } + } + }, + "nomnom": { + "version": "1.5.2", + "from": "nomnom@1.5.2", + "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.5.2.tgz", + "dependencies": { + "colors": { + "version": "0.5.1", + "from": "colors@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz" + }, + "underscore": { + "version": "1.1.7", + "from": "underscore@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.1.7.tgz" + } + } + }, + "nopt": { + "version": "3.0.6", + "from": "nopt@>=3.0.1 <3.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz" + }, + "normalize-path": { + "version": "2.1.1", + "from": "normalize-path@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz" + }, + "npmlog": { + "version": "4.0.0", + "from": "npmlog@^4.0.0", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.0.0.tgz" + }, + "nth-check": { + "version": "1.0.1", + "from": "nth-check@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz" + }, + "number-is-nan": { + "version": "1.0.1", + "from": "number-is-nan@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz" + }, + "oauth-sign": { + "version": "0.8.2", + "from": "oauth-sign@>=0.8.1 <0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz" + }, + "oauth2orize": { + "version": "1.8.0", + "from": "oauth2orize@1.8.0", + "resolved": "https://registry.npmjs.org/oauth2orize/-/oauth2orize-1.8.0.tgz" + }, + "object-assign": { + "version": "4.1.1", + "from": "object-assign@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" + }, + "object.omit": { + "version": "2.0.1", + "from": "object.omit@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz" + }, + "on-finished": { + "version": "2.3.0", + "from": "on-finished@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" + }, + "on-headers": { + "version": "1.0.1", + "from": "on-headers@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz" + }, + "once": { + "version": "1.4.0", + "from": "once@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz" + }, + "optimist": { + "version": "0.6.1", + "from": "optimist@>=0.6.1 <0.7.0", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz" + }, + "os-homedir": { + "version": "1.0.2", + "from": "os-homedir@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz" + }, + "os-locale": { + "version": "1.4.0", + "from": "os-locale@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz" + }, + "package-json-validator": { + "version": "0.6.0", + "from": "package-json-validator@0.6.0", + "resolved": "https://registry.npmjs.org/package-json-validator/-/package-json-validator-0.6.0.tgz" + }, + "packet-reader": { + "version": "0.2.0", + "from": "packet-reader@0.2.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.2.0.tgz", + "optional": true + }, + "parse-glob": { + "version": "3.0.4", + "from": "parse-glob@>=3.0.4 <4.0.0", + "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz" + }, + "parse-passwd": { + "version": "1.0.0", + "from": "parse-passwd@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz" + }, + "parseurl": { + "version": "1.3.1", + "from": "parseurl@>=1.3.1 <1.4.0", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz" + }, + "passport": { + "version": "0.3.2", + "from": "passport@0.3.2", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz" + }, + "passport-http-bearer": { + "version": "1.0.1", + "from": "passport-http-bearer@1.0.1", + "resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz" + }, + "passport-oauth2-client-password": { + "version": "0.1.2", + "from": "passport-oauth2-client-password@0.1.2", + "resolved": "https://registry.npmjs.org/passport-oauth2-client-password/-/passport-oauth2-client-password-0.1.2.tgz" + }, + "passport-strategy": { + "version": "1.0.0", + "from": "passport-strategy@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" + }, + "path-is-absolute": { + "version": "1.0.1", + "from": "path-is-absolute@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" + }, + "path-match": { + "version": "1.2.4", + "from": "path-match@1.2.4", + "resolved": "https://registry.npmjs.org/path-match/-/path-match-1.2.4.tgz", + "dependencies": { + "http-errors": { + "version": "1.4.0", + "from": "http-errors@>=1.4.0 <1.5.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.4.0.tgz" + }, + "inherits": { + "version": "2.0.1", + "from": "inherits@2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "path-to-regexp": { + "version": "1.7.0", + "from": "path-to-regexp@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz" + } + } + }, + "path-parse": { + "version": "1.0.5", + "from": "path-parse@>=1.0.5 <2.0.0", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz" + }, + "path-to-regexp": { + "version": "0.1.7", + "from": "path-to-regexp@0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz" + }, + "pause": { + "version": "0.0.1", + "from": "pause@0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz" + }, + "pend": { + "version": "1.2.0", + "from": "pend@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz" + }, + "pg": { + "version": "6.1.2", + "from": "pg@6.1.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-6.1.2.tgz", + "optional": true, + "dependencies": { + "semver": { + "version": "4.3.2", + "from": "semver@4.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz", + "optional": true + } + } + }, + "pg-connection-string": { + "version": "0.1.3", + "from": "pg-connection-string@>=0.1.3 <0.2.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz" + }, + "pg-pool": { + "version": "1.7.1", + "from": "pg-pool@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-1.7.1.tgz", + "optional": true, + "dependencies": { + "generic-pool": { + "version": "2.4.3", + "from": "generic-pool@2.4.3", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.3.tgz", + "optional": true + }, + "object-assign": { + "version": "4.1.0", + "from": "object-assign@4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "optional": true + } + } + }, + "pg-types": { + "version": "1.12.0", + "from": "pg-types@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.12.0.tgz", + "optional": true + }, + "pgpass": { + "version": "1.0.2", + "from": "pgpass@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", + "optional": true + }, + "pinkie": { + "version": "2.0.4", + "from": "pinkie@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" + }, + "pinkie-promise": { + "version": "2.0.1", + "from": "pinkie-promise@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" + }, + "postgres-array": { + "version": "1.0.2", + "from": "postgres-array@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.2.tgz", + "optional": true + }, + "postgres-bytea": { + "version": "1.0.0", + "from": "postgres-bytea@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "optional": true + }, + "postgres-date": { + "version": "1.0.3", + "from": "postgres-date@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", + "optional": true + }, + "postgres-interval": { + "version": "1.1.0", + "from": "postgres-interval@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.0.tgz", + "optional": true + }, + "posthtml": { + "version": "0.9.2", + "from": "posthtml@>=0.9.0 <0.10.0", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.9.2.tgz" + }, + "posthtml-parser": { + "version": "0.2.1", + "from": "posthtml-parser@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.2.1.tgz" + }, + "posthtml-render": { + "version": "1.0.6", + "from": "posthtml-render@>=1.0.5 <2.0.0", + "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-1.0.6.tgz" + }, + "preserve": { + "version": "0.2.0", + "from": "preserve@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz" + }, + "prettyjson": { + "version": "1.1.3", + "from": "prettyjson@1.1.3", + "resolved": "https://registry.npmjs.org/prettyjson/-/prettyjson-1.1.3.tgz", + "dependencies": { + "minimist": { + "version": "1.2.0", + "from": "minimist@>=1.1.3 <2.0.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" + } + } + }, + "process-nextick-args": { + "version": "1.0.7", + "from": "process-nextick-args@>=1.0.6 <1.1.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" + }, + "propagate": { + "version": "0.4.0", + "from": "propagate@0.4.0", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-0.4.0.tgz" + }, + "proto-list": { + "version": "1.2.4", + "from": "proto-list@>=1.2.1 <1.3.0", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz" + }, + "proxy-addr": { + "version": "1.1.4", + "from": "proxy-addr@>=1.1.3 <1.2.0", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.1.4.tgz" + }, + "pseudomap": { + "version": "1.0.2", + "from": "pseudomap@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz" + }, + "public-address": { + "version": "0.1.2", + "from": "public-address@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/public-address/-/public-address-0.1.2.tgz" + }, + "punycode": { + "version": "1.4.1", + "from": "punycode@>=1.4.1 <2.0.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" + }, + "qs": { + "version": "6.4.0", + "from": "qs@>=6.0.2 <7.0.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz" + }, + "rai": { + "version": "0.1.12", + "from": "rai@>=0.1.11 <0.2.0", + "resolved": "https://registry.npmjs.org/rai/-/rai-0.1.12.tgz" + }, + "randomatic": { + "version": "1.1.7", + "from": "randomatic@>=1.1.3 <2.0.0", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz", + "dependencies": { + "is-number": { + "version": "3.0.0", + "from": "is-number@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "dependencies": { + "kind-of": { + "version": "3.2.2", + "from": "kind-of@^3.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz" + } + } + }, + "kind-of": { + "version": "4.0.0", + "from": "kind-of@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz" + } + } + }, + "range-parser": { + "version": "1.2.0", + "from": "range-parser@>=1.2.0 <1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz" + }, + "raw-body": { + "version": "2.2.0", + "from": "raw-body@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.2.0.tgz" + }, + "rc": { + "version": "1.1.6", + "from": "rc@~1.1.6", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.1.6.tgz", + "dependencies": { + "minimist": { + "version": "1.2.0", + "from": "minimist@^1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" + } + } + }, + "readable-stream": { + "version": "2.2.11", + "from": "readable-stream@>=2.0.2 <3.0.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.11.tgz" + }, + "readdirp": { + "version": "2.1.0", + "from": "readdirp@2.1.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz" + }, + "rechoir": { + "version": "0.6.2", + "from": "rechoir@>=0.6.2 <0.7.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz" + }, + "regenerator-runtime": { + "version": "0.10.5", + "from": "regenerator-runtime@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz" + }, + "regex-cache": { + "version": "0.4.3", + "from": "regex-cache@>=0.4.2 <0.5.0", + "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz" + }, + "regexp-quote": { + "version": "0.0.0", + "from": "regexp-quote@0.0.0", + "resolved": "https://registry.npmjs.org/regexp-quote/-/regexp-quote-0.0.0.tgz" + }, + "remove-trailing-separator": { + "version": "1.0.2", + "from": "remove-trailing-separator@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.0.2.tgz" + }, + "repeat-element": { + "version": "1.1.2", + "from": "repeat-element@>=1.1.2 <2.0.0", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz" + }, + "repeat-string": { + "version": "1.6.1", + "from": "repeat-string@>=1.5.2 <2.0.0", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz" + }, + "request": { + "version": "2.75.0", + "from": "request@>=2.75.0 <2.76.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.75.0.tgz", + "dependencies": { + "bl": { + "version": "1.1.2", + "from": "bl@>=1.1.2 <1.2.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz" + }, + "node-uuid": { + "version": "1.4.8", + "from": "node-uuid@>=1.4.7 <1.5.0", + "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.8.tgz" + }, + "qs": { + "version": "6.2.3", + "from": "qs@>=6.2.0 <6.3.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.3.tgz" + }, + "readable-stream": { + "version": "2.0.6", + "from": "readable-stream@>=2.0.5 <2.1.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@>=0.10.0 <0.11.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + } + } + }, + "require-all": { + "version": "0.0.3", + "from": "require-all@0.0.3", + "resolved": "https://registry.npmjs.org/require-all/-/require-all-0.0.3.tgz", + "optional": true + }, + "require-dir": { + "version": "0.1.0", + "from": "require-dir@0.1.0", + "resolved": "https://registry.npmjs.org/require-dir/-/require-dir-0.1.0.tgz" + }, + "resolve": { + "version": "1.3.3", + "from": "resolve@>=1.1.7 <2.0.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.3.3.tgz" + }, + "resolve-dir": { + "version": "0.1.1", + "from": "resolve-dir@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-0.1.1.tgz" + }, + "rewire": { + "version": "2.5.2", + "from": "rewire@>=2.5.2 <3.0.0", + "resolved": "https://registry.npmjs.org/rewire/-/rewire-2.5.2.tgz" + }, + "right-align": { + "version": "0.1.3", + "from": "right-align@>=0.1.1 <0.2.0", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz" + }, + "rimraf": { + "version": "2.5.1", + "from": "rimraf@2.5.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.1.tgz", + "dependencies": { + "glob": { + "version": "6.0.4", + "from": "glob@>=6.0.1 <7.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz" + } + } + }, + "rss": { + "version": "1.2.2", + "from": "rss@1.2.2", + "resolved": "https://registry.npmjs.org/rss/-/rss-1.2.2.tgz", + "dependencies": { + "mime-db": { + "version": "1.25.0", + "from": "mime-db@>=1.25.0 <1.26.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.25.0.tgz" + }, + "mime-types": { + "version": "2.1.13", + "from": "mime-types@2.1.13", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.13.tgz" + } + } + }, + "safe-buffer": { + "version": "5.0.1", + "from": "safe-buffer@>=5.0.1 <5.1.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz" + }, + "safe-json-stringify": { + "version": "1.0.4", + "from": "safe-json-stringify@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz", + "optional": true + }, + "sanitize-html": { + "version": "1.14.1", + "from": "sanitize-html@1.14.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.14.1.tgz" + }, + "sax": { + "version": "0.4.2", + "from": "sax@0.4.2", + "resolved": "https://registry.npmjs.org/sax/-/sax-0.4.2.tgz" + }, + "secure-keys": { + "version": "1.0.0", + "from": "secure-keys@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz" + }, + "semver": { + "version": "5.3.0", + "from": "semver@5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz" + }, + "send": { + "version": "0.15.0", + "from": "send@0.15.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.15.0.tgz", + "dependencies": { + "debug": { + "version": "2.6.1", + "from": "debug@2.6.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz" + }, + "ms": { + "version": "0.7.2", + "from": "ms@0.7.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz" + } + } + }, + "serve-static": { + "version": "1.12.0", + "from": "serve-static@1.12.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.12.0.tgz" + }, + "set-blocking": { + "version": "2.0.0", + "from": "set-blocking@~2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" + }, + "set-immediate-shim": { + "version": "1.0.1", + "from": "set-immediate-shim@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz" + }, + "setprototypeof": { + "version": "1.0.3", + "from": "setprototypeof@1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz" + }, + "showdown-ghost": { + "version": "0.3.6", + "from": "showdown-ghost@0.3.6", + "resolved": "https://registry.npmjs.org/showdown-ghost/-/showdown-ghost-0.3.6.tgz" + }, + "sigmund": { + "version": "1.0.1", + "from": "sigmund@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" + }, + "simplesmtp": { + "version": "0.3.35", + "from": "simplesmtp@>=0.2.0 <0.3.0||>=0.3.30 <0.4.0", + "resolved": "https://registry.npmjs.org/simplesmtp/-/simplesmtp-0.3.35.tgz" + }, + "sntp": { + "version": "1.0.9", + "from": "sntp@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz" + }, + "source-map": { + "version": "0.4.4", + "from": "source-map@>=0.4.4 <0.5.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" + }, + "split": { + "version": "1.0.0", + "from": "split@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.0.tgz", + "optional": true + }, + "split2": { + "version": "2.1.1", + "from": "split2@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.1.1.tgz" + }, + "sprintf-js": { + "version": "1.1.1", + "from": "sprintf-js@>=1.0.3 <2.0.0", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.1.tgz" + }, + "sqlite3": { + "version": "3.1.8", + "from": "sqlite3@3.1.8", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-3.1.8.tgz", + "dependencies": { + "nan": { + "version": "2.4.0", + "from": "nan@>=2.4.0 <2.5.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.4.0.tgz" + } + } + }, + "sshpk": { + "version": "1.13.1", + "from": "sshpk@>=1.7.0 <2.0.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", + "dependencies": { + "assert-plus": { + "version": "1.0.0", + "from": "assert-plus@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" + } + } + }, + "static-eval": { + "version": "0.2.3", + "from": "static-eval@0.2.3", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-0.2.3.tgz", + "dependencies": { + "escodegen": { + "version": "0.0.28", + "from": "escodegen@>=0.0.24 <0.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-0.0.28.tgz" + }, + "esprima": { + "version": "1.0.4", + "from": "esprima@>=1.0.2 <1.1.0", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz" + }, + "estraverse": { + "version": "1.3.2", + "from": "estraverse@>=1.3.0 <1.4.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.3.2.tgz" + } + } + }, + "statuses": { + "version": "1.3.1", + "from": "statuses@>=1.3.1 <2.0.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" + }, + "streamsearch": { + "version": "0.1.2", + "from": "streamsearch@0.1.2", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz" + }, + "string_decoder": { + "version": "1.0.2", + "from": "string_decoder@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.2.tgz" + }, + "string-width": { + "version": "1.0.2", + "from": "string-width@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz" + }, + "stringstream": { + "version": "0.0.5", + "from": "stringstream@>=0.0.4 <0.1.0", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz" + }, + "strip-ansi": { + "version": "3.0.1", + "from": "strip-ansi@>=3.0.0 <4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" + }, + "strip-json-comments": { + "version": "1.0.4", + "from": "strip-json-comments@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz" + }, + "superagent": { + "version": "3.5.2", + "from": "superagent@3.5.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.5.2.tgz", + "dependencies": { + "form-data": { + "version": "2.2.0", + "from": "form-data@>=2.1.1 <3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.2.0.tgz" + } + } + }, + "supports-color": { + "version": "2.0.0", + "from": "supports-color@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" + }, + "tar": { + "version": "2.2.1", + "from": "tar@~2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz" + }, + "tar-pack": { + "version": "3.3.0", + "from": "tar-pack@~3.3.0", + "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.3.0.tgz", + "dependencies": { + "debug": { + "version": "2.2.0", + "from": "debug@~2.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", + "dependencies": { + "ms": { + "version": "0.7.1", + "from": "ms@0.7.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" + } + } + }, + "once": { + "version": "1.3.3", + "from": "once@~1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "dependencies": { + "wrappy": { + "version": "1.0.2", + "from": "wrappy@1", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + } + } + }, + "readable-stream": { + "version": "2.1.5", + "from": "readable-stream@~2.1.4", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz", + "dependencies": { + "buffer-shims": { + "version": "1.0.0", + "from": "buffer-shims@^1.0.0", + "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz" + }, + "core-util-is": { + "version": "1.0.2", + "from": "core-util-is@~1.0.0", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" + }, + "inherits": { + "version": "2.0.3", + "from": "inherits@~2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" + }, + "isarray": { + "version": "1.0.0", + "from": "isarray@~1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + }, + "process-nextick-args": { + "version": "1.0.7", + "from": "process-nextick-args@~1.0.6", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" + }, + "string_decoder": { + "version": "0.10.31", + "from": "string_decoder@~0.10.x", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" + }, + "util-deprecate": { + "version": "1.0.2", + "from": "util-deprecate@~1.0.1", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + } + } + } + } + }, + "tar-stream": { + "version": "1.5.4", + "from": "tar-stream@>=1.5.0 <2.0.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.5.4.tgz" + }, + "through": { + "version": "2.3.8", + "from": "through@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "optional": true + }, + "through2": { + "version": "2.0.3", + "from": "through2@>=2.0.3 <3.0.0", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz" + }, + "tildify": { + "version": "1.0.0", + "from": "tildify@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.0.0.tgz" + }, + "timespan": { + "version": "2.3.0", + "from": "timespan@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/timespan/-/timespan-2.3.0.tgz" + }, + "tough-cookie": { + "version": "2.3.2", + "from": "tough-cookie@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz" + }, + "tunnel-agent": { + "version": "0.4.3", + "from": "tunnel-agent@>=0.4.1 <0.5.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.4.3.tgz" + }, + "tweetnacl": { + "version": "0.14.5", + "from": "tweetnacl@>=0.14.0 <0.15.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "optional": true + }, + "type-detect": { + "version": "1.0.0", + "from": "type-detect@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz" + }, + "type-is": { + "version": "1.6.15", + "from": "type-is@>=1.6.14 <1.7.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz" + }, + "typedarray": { + "version": "0.0.6", + "from": "typedarray@>=0.0.5 <0.1.0", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" + }, + "uglify-js": { + "version": "2.8.29", + "from": "uglify-js@>=2.6.0 <3.0.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "optional": true, + "dependencies": { + "source-map": { + "version": "0.5.6", + "from": "source-map@>=0.5.1 <0.6.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "optional": true + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "from": "uglify-to-browserify@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz" + }, + "uid-number": { + "version": "0.0.6", + "from": "uid-number@~0.0.6", + "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz" + }, + "uid2": { + "version": "0.0.3", + "from": "uid2@>=0.0.0 <0.1.0", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz" + }, + "underscore": { + "version": "1.8.3", + "from": "underscore@>=1.8.3 <2.0.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz" + }, + "underscore.string": { + "version": "3.3.4", + "from": "underscore.string@>=3.2.3 <4.0.0", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.4.tgz" + }, + "unidecode": { + "version": "0.1.8", + "from": "unidecode@0.1.8", + "resolved": "https://registry.npmjs.org/unidecode/-/unidecode-0.1.8.tgz" + }, + "unpipe": { + "version": "1.0.0", + "from": "unpipe@1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" + }, + "user-home": { + "version": "1.1.1", + "from": "user-home@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz" + }, + "util-deprecate": { + "version": "1.0.2", + "from": "util-deprecate@>=1.0.1 <1.1.0", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" + }, + "utils-merge": { + "version": "1.0.0", + "from": "utils-merge@1.0.0", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz" + }, + "uuid": { + "version": "3.0.0", + "from": "uuid@3.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.0.0.tgz" + }, + "v8flags": { + "version": "2.1.1", + "from": "v8flags@>=2.0.2 <3.0.0", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz" + }, + "validator": { + "version": "6.3.0", + "from": "validator@6.3.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-6.3.0.tgz" + }, + "vary": { + "version": "1.1.1", + "from": "vary@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.1.tgz" + }, + "verror": { + "version": "1.3.6", + "from": "verror@1.3.6", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz" + }, + "walkdir": { + "version": "0.0.11", + "from": "walkdir@>=0.0.11 <0.0.12", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.0.11.tgz" + }, + "which": { + "version": "1.2.14", + "from": "which@>=1.2.12 <2.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz" + }, + "wide-align": { + "version": "1.1.0", + "from": "wide-align@^1.1.0", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.0.tgz" + }, + "window-size": { + "version": "0.1.0", + "from": "window-size@0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" + }, + "wordwrap": { + "version": "0.0.3", + "from": "wordwrap@>=0.0.2 <0.1.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" + }, + "wrap-ansi": { + "version": "2.1.0", + "from": "wrap-ansi@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz" + }, + "wrappy": { + "version": "1.0.2", + "from": "wrappy@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" + }, + "xml": { + "version": "1.0.1", + "from": "xml@1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz" + }, + "xml2js": { + "version": "0.2.6", + "from": "xml2js@0.2.6", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.2.6.tgz" + }, + "xmlbuilder": { + "version": "0.4.2", + "from": "xmlbuilder@0.4.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-0.4.2.tgz" + }, + "xoauth2": { + "version": "0.1.8", + "from": "xoauth2@>=0.1.8 <0.2.0", + "resolved": "https://registry.npmjs.org/xoauth2/-/xoauth2-0.1.8.tgz" + }, + "xregexp": { + "version": "2.0.0", + "from": "xregexp@2.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz" + }, + "xtend": { + "version": "4.0.1", + "from": "xtend@>=4.0.0 <5.0.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" + }, + "y18n": { + "version": "3.2.1", + "from": "y18n@>=3.2.0 <4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz" + }, + "yallist": { + "version": "2.1.2", + "from": "yallist@>=2.1.2 <3.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz" + }, + "yargs": { + "version": "3.10.0", + "from": "yargs@>=3.10.0 <3.11.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz" + }, + "yauzl": { + "version": "2.4.1", + "from": "yauzl@2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz" + }, + "zip-stream": { + "version": "1.1.1", + "from": "zip-stream@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.1.1.tgz" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5f347d --- /dev/null +++ b/package.json @@ -0,0 +1,122 @@ +{ + "name": "ghost", + "version": "0.11.10", + "description": "Just a blogging platform.", + "author": "Ghost Foundation", + "homepage": "http://ghost.org", + "keywords": [ + "ghost", + "blog", + "cms" + ], + "repository": { + "type": "git", + "url": "git://github.com/TryGhost/Ghost.git" + }, + "bugs": "https://github.com/TryGhost/Ghost/issues", + "contributors": "https://github.com/TryGhost/Ghost/graphs/contributors", + "license": "MIT", + "main": "./core/index", + "scripts": { + "preinstall": "node core/server/utils/npm/preinstall.js", + "start": "node index", + "test": "grunt validate --verbose" + }, + "engines": { + "node": "^4.5.0 || ^6.9.0" + }, + "dependencies": { + "amperize": "0.3.4", + "archiver": "1.3.0", + "bcryptjs": "2.4.3", + "bluebird": "3.5.0", + "body-parser": "1.17.0", + "bookshelf": "0.10.2", + "chalk": "1.1.3", + "cheerio": "0.22.0", + "compression": "1.6.2", + "connect-slashes": "1.3.1", + "cookie-session": "1.2.0", + "cors": "2.8.3", + "csv-parser": "1.11.0", + "downsize": "0.0.8", + "express": "4.15.0", + "express-hbs": "1.0.4", + "extract-zip-fork": "1.5.1", + "fs-extra": "2.1.2", + "ghost-gql": "0.0.6", + "glob": "5.0.15", + "gscan": "0.2.4", + "html-to-text": "3.2.0", + "image-size": "0.5.1", + "intl": "1.2.5", + "intl-messageformat": "1.3.0", + "jsonpath": "0.2.11", + "knex": "0.12.9", + "lodash": "4.17.4", + "moment": "2.18.1", + "moment-timezone": "0.5.13", + "morgan": "1.7.0", + "multer": "1.3.0", + "netjet": "1.1.3", + "nodemailer": "0.7.1", + "oauth2orize": "1.8.0", + "passport": "0.3.2", + "passport-http-bearer": "1.0.1", + "passport-oauth2-client-password": "0.1.2", + "path-match": "1.2.4", + "rimraf": "2.5.1", + "rss": "1.2.2", + "sanitize-html": "1.14.1", + "semver": "5.3.0", + "showdown-ghost": "0.3.6", + "sqlite3": "3.1.8", + "superagent": "3.5.2", + "unidecode": "0.1.8", + "uuid": "3.0.0", + "validator": "6.3.0", + "xml": "1.0.1" + }, + "optionalDependencies": { + "mysql": "2.1.1", + "pg": "6.1.2" + }, + "devDependencies": { + "grunt": "1.0.1", + "grunt-bg-shell": "2.3.3", + "grunt-cli": "1.2.0", + "grunt-contrib-clean": "1.0.0", + "grunt-contrib-compress": "1.3.0", + "grunt-contrib-copy": "1.0.0", + "grunt-contrib-jshint": "1.0.0", + "grunt-contrib-uglify": "2.0.0", + "grunt-contrib-watch": "1.0.0", + "grunt-docker": "0.0.11", + "grunt-express-server": "0.5.3", + "grunt-jscs": "3.0.1", + "grunt-mocha-cli": "2.1.0", + "grunt-mocha-istanbul": "5.0.2", + "grunt-shell": "1.3.1", + "grunt-subgrunt": "1.2.0", + "grunt-update-submodules": "0.4.1", + "istanbul": "0.4.5", + "matchdep": "1.0.1", + "mocha": "3.2.0", + "nock": "9.0.13", + "rewire": "2.5.2", + "should": "11.2.1", + "should-http": "0.1.1", + "supertest": "3.0.0", + "sinon": "1.17.7", + "tmp": "0.0.31" + }, + "greenkeeper": { + "ignore": [ + "glob", + "mysql", + "nodemailer", + "pg", + "showdown-ghost" + ] + } +}