diff --git a/.eslintrc.js b/.eslintrc.js index c28d18b71..4bead8d9c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { extends: "eslint:recommended", rules: { "arrow-body-style": [ERROR, "as-needed"], - "array-bracket-spacing": [ ERROR, "always" ], + "array-bracket-spacing": [ ERROR, "never" ], "arrow-parens": [ ERROR, "always" ], "arrow-spacing": [ ERROR ], "block-spacing": [ ERROR, "always" ], @@ -68,7 +68,7 @@ module.exports = { "no-var": [ ERROR ], "no-void": [ ERROR ], "no-with": [ ERROR ], - "object-curly-spacing": [ ERROR, "always" ], + "object-curly-spacing": [ ERROR, "never" ], "one-var": [ ERROR, { "uninitialized": "always", "initialized": "never" } ], "operator-assignment": [ ERROR, "always" ], "operator-linebreak": [ ERROR, "after" ], diff --git a/.gitignore b/.gitignore index d8a67a7ef..85740e6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -client/dist/ client/lib/ rethinkdb_data_test rethinkdb_data/ @@ -11,3 +10,10 @@ node_modules/ .hz/ config.toml **/.vscode +**/.*.swp + +**/dist/ + +# RethinkDB stuff +**/rethinkdb_data/ +**/rethinkdb_data_test/ diff --git a/Dockerfile b/Dockerfile index df0c06170..16449c6f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN apt update && apt install -y git COPY . /usr/horizon/ WORKDIR /usr/horizon -RUN cd test; ./setupDev.sh +RUN ./setupDev.sh EXPOSE 8181 diff --git a/babelify.sh b/babelify.sh new file mode 100755 index 000000000..16dfe80cf --- /dev/null +++ b/babelify.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +dirs="server plugins plugin-router plugin-utils test" +for path in `find $dirs -name .babelrc | grep -v node_modules`; do + { + babel ${path%%.babelrc}src -d ${path%%.babelrc}dist -s true -w + } & +done + +wait diff --git a/cli/package.json b/cli/package.json index b39e03300..0444dfce8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "horizon", - "version": "2.0.0", + "version": "3.0.0-alpha-0", "description": "An open-source developer platform for building realtime, scalable web apps.", "main": "src/main.js", "repository": { @@ -23,7 +23,7 @@ }, "homepage": "https://github.com/rethinkdb/horizon#readme", "dependencies": { - "@horizon/server": "2.0.0", + "@horizon/server": "3.0.0-alpha-0", "argparse": "^1.0.3", "bluebird": "^3.4.1", "chalk": "^1.1.3", @@ -37,9 +37,9 @@ }, "devDependencies": { "chai": "^3.5.0", - "eslint": "^2.3.0", + "eslint": "^3.1.0", "istanbul": "^0.4.3", - "mocha": "2.4.5", + "mocha": "^2.5.3", "mock-fs": "^3.10.0", "sinon": "1.17.3", "strip-ansi": "^3.0.1", diff --git a/cli/src/create-cert.js b/cli/src/create-cert.js index 740ea8cda..41f6894d9 100644 --- a/cli/src/create-cert.js +++ b/cli/src/create-cert.js @@ -18,7 +18,7 @@ const run = (args) => { }; // generate the arguments to the command - const binArgs = [ 'req', '-x509', '-nodes', '-batch', + const binArgs = ['req', '-x509', '-nodes', '-batch', '-newkey', `${settings.algo}:${settings.bits}`, '-keyout', settings.keyOutName, '-out', settings.certOutName, @@ -39,8 +39,8 @@ const run = (args) => { const sslProc = spawn(settings.binaryName, binArgs); // pipe output appropriately - sslProc.stdout.pipe(process.stdout, { end: false }); - sslProc.stderr.pipe(process.stderr, { end: false }); + sslProc.stdout.pipe(process.stdout, {end: false}); + sslProc.stderr.pipe(process.stderr, {end: false}); // say nice things to the user when it's done sslProc.on('error', reject); diff --git a/cli/src/init.js b/cli/src/init.js index abca628d6..8417b5c3e 100644 --- a/cli/src/init.js +++ b/cli/src/init.js @@ -165,9 +165,9 @@ rethinkdb_data `; const parseArguments = (args) => { - const parser = new argparse.ArgumentParser({ prog: 'hz init' }); - parser.addArgument([ 'projectName' ], - { action: 'store', + const parser = new argparse.ArgumentParser({prog: 'hz init'}); + parser.addArgument(['projectName'], + {action: 'store', help: 'Name of directory to create. Defaults to current directory', nargs: '?', } diff --git a/cli/src/make-token.js b/cli/src/make-token.js index f64f06bf0..b586dde85 100644 --- a/cli/src/make-token.js +++ b/cli/src/make-token.js @@ -1,28 +1,24 @@ 'use strict'; -const interrupt = require('./utils/interrupt'); const config = require('./utils/config'); -const horizon_server = require('@horizon/server'); const path = require('path'); const jwt = require('jsonwebtoken'); -const r = horizon_server.r; -const logger = horizon_server.logger; const argparse = require('argparse'); const parseArguments = (args) => { - const parser = new argparse.ArgumentParser({ prog: 'hz make-token' }); + const parser = new argparse.ArgumentParser({prog: 'hz make-token'}); parser.addArgument( - [ '--token-secret' ], - { type: 'string', metavar: 'SECRET', - help: 'Secret key for signing the token.' }); + ['--token-secret'], + {type: 'string', metavar: 'SECRET', + help: 'Secret key for signing the token.'}); parser.addArgument( - [ 'user' ], - { type: 'string', metavar: 'USER_ID', - help: 'The ID of the user to issue a token for.' }); + ['user'], + {type: 'string', metavar: 'USER_ID', + help: 'The ID of the user to issue a token for.'}); return parser.parseArgs(args); }; @@ -43,7 +39,7 @@ const processConfig = (parsed) => { options.project_name = path.basename(path.resolve(options.project_path)); } - return Object.assign(options, { user: parsed.user }); + return Object.assign(options, {user: parsed.user}); }; const run = (args) => Promise.resolve().then(() => { @@ -53,9 +49,9 @@ const run = (args) => Promise.resolve().then(() => { throw new Error('No token secret specified, unable to sign the token.'); } const token = jwt.sign( - { id: options.user, provider: null }, + {id: options.user, provider: null}, new Buffer(options.token_secret, 'base64'), - { expiresIn: '1d', algorithm: 'HS512' } + {expiresIn: '1d', algorithm: 'HS512'} ); console.log(`${token}`); }); diff --git a/cli/src/migrate.js b/cli/src/migrate.js index cfaaca64c..592596495 100644 --- a/cli/src/migrate.js +++ b/cli/src/migrate.js @@ -14,12 +14,12 @@ const parse_yes_no_option = require('./utils/parse_yes_no_option'); const start_rdb_server = require('./utils/start_rdb_server'); const NiceError = require('./utils/nice_error.js'); -const VERSION_2_0 = [ 2, 0, 0 ]; +const VERSION_2_0 = [2, 0, 0]; function run(cmdArgs) { const options = processConfig(cmdArgs); interrupt.on_interrupt(() => teardown()); - return Promise.resolve().bind({ options }) + return Promise.resolve().bind({options}) .then(setup) .then(validateMigration) .then(makeBackup) @@ -45,37 +45,37 @@ function white() { function processConfig(cmdArgs) { // do boilerplate to get config args :/ - const parser = new argparse.ArgumentParser({ prog: 'hz migrate' }); + const parser = new argparse.ArgumentParser({prog: 'hz migrate'}); - parser.addArgument([ 'project_path' ], { + parser.addArgument(['project_path'], { default: '.', nargs: '?', help: 'Change to this directory before migrating', }); - parser.addArgument([ '--project-name', '-n' ], { + parser.addArgument(['--project-name', '-n'], { help: 'Name of the Horizon project server', }); - parser.addArgument([ '--connect', '-c' ], { + parser.addArgument(['--connect', '-c'], { metavar: 'host:port', default: undefined, help: 'Host and port of the RethinkDB server to connect to.', }); - parser.addArgument([ '--rdb-user' ], { + parser.addArgument(['--rdb-user'], { default: 'admin', metavar: 'USER', help: 'RethinkDB User', }); - parser.addArgument([ '--rdb-password' ], { + parser.addArgument(['--rdb-password'], { default: undefined, metavar: 'PASSWORD', help: 'RethinkDB Password', }); - parser.addArgument([ '--start-rethinkdb' ], { + parser.addArgument(['--start-rethinkdb'], { metavar: 'yes|no', default: 'yes', constant: 'yes', @@ -83,7 +83,7 @@ function processConfig(cmdArgs) { help: 'Start up a RethinkDB server in the current directory', }); - parser.addArgument([ '--skip-backup' ], { + parser.addArgument(['--skip-backup'], { metavar: 'yes|no', default: 'no', constant: 'yes', @@ -92,7 +92,7 @@ function processConfig(cmdArgs) { ' before migrating', }); - parser.addArgument([ '--nonportable-backup' ], { + parser.addArgument(['--nonportable-backup'], { metavar: 'yes|no', default: 'no', constant: 'yes', @@ -149,7 +149,7 @@ function setup() { // start rethinkdb server if necessary if (this.options.start_rethinkdb) { green(' ├── Starting RethinkDB server'); - return start_rdb_server({ quiet: true }).then((server) => { + return start_rdb_server({quiet: true}).then((server) => { this.rdb_server = server; this.options.rdb_host = 'localhost'; this.options.rdb_port = server.driver_port; @@ -193,15 +193,15 @@ function validateMigration() { const tablesHaveHzPrefix = `Some tables in ${project} have an hz_ prefix`; const checkForHzTables = r.db('rethinkdb') .table('table_config') - .filter({ db: project })('name') + .filter({db: project})('name') .contains((x) => x.match('^hz_')) .branch(r.error(tablesHaveHzPrefix), true); const waitForCollections = r.db(`${project}_internal`) .table('collections') - .wait({ timeout: 30 }) + .wait({timeout: 30}) .do(() => r.db(project).tableList()) .forEach((tableName) => - r.db(project).table(tableName).wait({ timeout: 30 }) + r.db(project).table(tableName).wait({timeout: 30}) ); return Promise.resolve().then(() => { @@ -292,11 +292,11 @@ function renameUserTables() { const project = this.options.project_name; return Promise.resolve().then(() => { white('Removing suffix from user tables'); - return r.db(`${project}_internal`).wait({ timeout: 30 }). + return r.db(`${project}_internal`).wait({timeout: 30}). do(() => r.db(`${project}_internal`).table('collections') .forEach((collDoc) => r.db('rethinkdb').table('table_config') - .filter({ db: project, name: collDoc('table') }) - .update({ name: collDoc('id') })) + .filter({db: project, name: collDoc('table')}) + .update({name: collDoc('id')})) ).run(this.conn) .then(() => green(' └── Suffixes removed')); }); @@ -310,7 +310,7 @@ function moveInternalTables() { return Promise.resolve().then(() => { white(`Moving internal tables from ${project}_internal to ${project}`); return r.db('rethinkdb').table('table_config') - .filter({ db: `${project}_internal` }) + .filter({db: `${project}_internal`}) .update((table) => ({ db: project, name: r.branch( @@ -336,11 +336,9 @@ function renameIndices() { white('Renaming indices to new JSON format'); return r.db(project).tableList().forEach((tableName) => r.db(project).table(tableName).indexList().forEach((indexName) => - r.db(project).table(tableName) - .indexRename(indexName, rename(indexName)) + r.db(project).table(tableName).indexRename(indexName, rename(indexName)) ) - ).run(this.conn) - .then(() => green(' └── Indices renamed.')); + ).run(this.conn).then(() => green(' └── Indices renamed.')); }); function rename(name) { @@ -348,7 +346,7 @@ function renameIndices() { const initialState = { escaped: false, field: '', - fields: [ ], + fields: [], }; return name.split('') .fold(initialState, (acc, c) => @@ -359,22 +357,20 @@ function renameIndices() { field: acc('field').add(c), }), c.eq('\\'), - acc.merge({ escaped: true }), + acc.merge({escaped: true}), c.eq('_'), acc.merge({ fields: acc('fields').append(acc('field')), field: '', }), - acc.merge({ field: acc('field').add(c) }) + acc.merge({field: acc('field').add(c)}) ) ).do((state) => // last field needs to be appended to running list state('fields').append(state('field')) // wrap each field in an array - .map((field) => [ field ]) - ) - .toJSON() - .do((x) => r('hz_').add(x)); + .map((field) => [field]) + ).toJSON().do((x) => r('hz_').add(x)); } } @@ -385,15 +381,15 @@ function rewriteHzCollectionDocs() { return Promise.resolve().then(() => { white('Rewriting hz_collections to new format'); return r.db(project).table('hz_collections') - .update({ table: r.literal() }) + .update({table: r.literal()}) .run(this.conn); }).then(() => green(' ├── "table" field removed')) .then(() => r.db(project).table('hz_collections') - .insert({ id: 'users' }) + .insert({id: 'users'}) .run(this.conn)) .then(() => green(' ├── Added document for "users" table')) .then(() => r.db(project).table('hz_collections') - .insert({ id: 'hz_metadata', version: VERSION_2_0 }) + .insert({id: 'hz_metadata', version: VERSION_2_0}) .run(this.conn)) .then(() => green(' └── Adding the metadata document with schema version:' + `${JSON.stringify(VERSION_2_0)}`)); diff --git a/cli/src/schema.js b/cli/src/schema.js index 56c20efca..32ba429dc 100644 --- a/cli/src/schema.js +++ b/cli/src/schema.js @@ -25,68 +25,68 @@ const initialize_metadata = horizon_metadata.initialize_metadata; initialize_joi(Joi); const parseArguments = (args) => { - const parser = new argparse.ArgumentParser({ prog: 'hz schema' }); + const parser = new argparse.ArgumentParser({prog: 'hz schema'}); const subparsers = parser.addSubparsers({ title: 'subcommands', dest: 'subcommand_name', }); - const apply = subparsers.addParser('apply', { addHelp: true }); - const save = subparsers.addParser('save', { addHelp: true }); + const apply = subparsers.addParser('apply', {addHelp: true}); + const save = subparsers.addParser('save', {addHelp: true}); // Set options shared between both subcommands - [ apply, save ].map((subcmd) => { - subcmd.addArgument([ 'project_path' ], - { type: 'string', nargs: '?', - help: 'Change to this directory before serving' }); - - subcmd.addArgument([ '--project-name', '-n' ], - { type: 'string', action: 'store', metavar: 'NAME', - help: 'Name of the Horizon Project server' }); - - subcmd.addArgument([ '--connect', '-c' ], - { type: 'string', metavar: 'HOST:PORT', - help: 'Host and port of the RethinkDB server to connect to.' }); - - subcmd.addArgument([ '--rdb-timeout' ], - { type: 'int', metavar: 'TIMEOUT', - help: 'Timeout period in seconds for the RethinkDB connection to be opened' }); - - subcmd.addArgument([ '--rdb-user' ], - { type: 'string', metavar: 'USER', - help: 'RethinkDB User' }); - - subcmd.addArgument([ '--rdb-password' ], - { type: 'string', metavar: 'PASSWORD', - help: 'RethinkDB Password' }); - - subcmd.addArgument([ '--start-rethinkdb' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Start up a RethinkDB server in the current directory' }); - - subcmd.addArgument([ '--debug' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Enable debug logging.' }); + [apply, save].map((subcmd) => { + subcmd.addArgument(['project_path'], + {type: 'string', nargs: '?', + help: 'Change to this directory before serving'}); + + subcmd.addArgument(['--project-name', '-n'], + {type: 'string', action: 'store', metavar: 'NAME', + help: 'Name of the Horizon Project server'}); + + subcmd.addArgument(['--connect', '-c'], + {type: 'string', metavar: 'HOST:PORT', + help: 'Host and port of the RethinkDB server to connect to.'}); + + subcmd.addArgument(['--rdb-timeout'], + {type: 'int', metavar: 'TIMEOUT', + help: 'Timeout period in seconds for the RethinkDB connection to be opened'}); + + subcmd.addArgument(['--rdb-user'], + {type: 'string', metavar: 'USER', + help: 'RethinkDB User'}); + + subcmd.addArgument(['--rdb-password'], + {type: 'string', metavar: 'PASSWORD', + help: 'RethinkDB Password'}); + + subcmd.addArgument(['--start-rethinkdb'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Start up a RethinkDB server in the current directory'}); + + subcmd.addArgument(['--debug'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Enable debug logging.'}); }); // Options exclusive to HZ SCHEMA APPLY - apply.addArgument([ '--update' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Only add new items and update existing, no removal.' }); + apply.addArgument(['--update'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Only add new items and update existing, no removal.'}); - apply.addArgument([ '--force' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Allow removal of existing collections.' }); + apply.addArgument(['--force'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Allow removal of existing collections.'}); - apply.addArgument([ 'schema_file' ], - { type: 'string', metavar: 'SCHEMA_FILE_PATH', - help: 'File to get the horizon schema from, use "-" for stdin.' }); + apply.addArgument(['schema_file'], + {type: 'string', metavar: 'SCHEMA_FILE_PATH', + help: 'File to get the horizon schema from, use "-" for stdin.'}); // Options exclusive to HZ SCHEMA SAVE - save.addArgument([ '--out-file', '-o' ], - { type: 'string', metavar: 'PATH', defaultValue: '.hz/schema.toml', - help: 'File to write the horizon schema to, defaults to .hz/schema.toml.' }); + save.addArgument(['--out-file', '-o'], + {type: 'string', metavar: 'PATH', defaultValue: '.hz/schema.toml', + help: 'File to write the horizon schema to, defaults to .hz/schema.toml.'}); return parser.parseArgs(args); }; @@ -101,7 +101,7 @@ const schema_schema = Joi.object().unknown(false).keys({ fields: Joi.array().items(Joi.array().items(Joi.string())).required(), }) ) - ).optional().default([ ]), + ).optional().default([]), }) ).optional().default({ }), groups: Joi.object().unknown(true).pattern(/.*/, @@ -120,7 +120,7 @@ const schema_schema = Joi.object().unknown(false).keys({ const v1_0_name_to_fields = (name) => { let escaped = false; let field = ''; - const fields = [ ]; + const fields = []; for (const c of name) { if (escaped) { if (c !== '\\' && c !== '_') { @@ -140,7 +140,7 @@ const v1_0_name_to_fields = (name) => { if (escaped) { throw new Error(`Unexpected index name: "${name}"`); } - fields.push([ field ]); + fields.push([field]); return fields; }; @@ -152,15 +152,15 @@ const parse_schema = (schema_toml) => { throw parsed.error; } - const collections = [ ]; + const collections = []; for (const name in schema.collections) { collections.push({ id: name, indexes: schema.collections[name].indexes.map((index) => { if (typeof index === 'string') { - return { fields: v1_0_name_to_fields(index), multi: false, geo: false }; + return {fields: v1_0_name_to_fields(index), multi: false, geo: false}; } else { - return { fields: index.fields, multi: false, geo: false }; + return {fields: index.fields, multi: false, geo: false}; } }), }); @@ -169,15 +169,15 @@ const parse_schema = (schema_toml) => { // Make sure the 'users' collection is present, as some things depend on // its existence. if (!schema.collections || !schema.collections.users) { - collections.push({ id: 'users', indexes: [ ] }); + collections.push({id: 'users', indexes: []}); } - const groups = [ ]; + const groups = []; for (const name in schema.groups) { - groups.push(Object.assign({ id: name }, schema.groups[name])); + groups.push(Object.assign({id: name}, schema.groups[name])); } - return { groups, collections }; + return {groups, collections}; }; const processApplyConfig = (parsed) => { @@ -192,7 +192,7 @@ const processApplyConfig = (parsed) => { if (parsed.schema_file === '-') { in_file = process.stdin; } else { - in_file = fs.createReadStream(parsed.schema_file, { flags: 'r' }); + in_file = fs.createReadStream(parsed.schema_file, {flags: 'r'}); } if (options.project_name === null) { @@ -247,7 +247,7 @@ const processSaveConfig = (parsed) => { }; const schema_to_toml = (collections, groups) => { - const res = [ '# This is a TOML document' ]; + const res = ['# This is a TOML document']; for (const c of collections) { res.push(''); @@ -281,7 +281,7 @@ const schema_to_toml = (collections, groups) => { const runApplyCommand = (options) => { let conn, schema, rdb_server; - let obsolete_collections = [ ]; + let obsolete_collections = []; const db = options.project_name; const cleanup = () => @@ -307,18 +307,18 @@ const runApplyCommand = (options) => { schema = parse_schema(schema_toml); if (options.start_rethinkdb) { - return start_rdb_server({ quiet: !options.debug }).then((server) => { + return start_rdb_server({quiet: !options.debug}).then((server) => { rdb_server = server; options.rdb_host = 'localhost'; options.rdb_port = server.driver_port; }); } }).then(() => - r.connect({ host: options.rdb_host, + r.connect({host: options.rdb_host, port: options.rdb_port, user: options.rdb_user, password: options.rdb_password, - timeout: options.rdb_timeout }) + timeout: options.rdb_timeout}) ).then((rdb_conn) => { conn = rdb_conn; return initialize_metadata(db, conn); @@ -327,10 +327,10 @@ const runApplyCommand = (options) => { console.log('Initialized new application metadata.'); } // Wait for metadata tables to be writable - return r.expr([ 'hz_collections', 'hz_groups' ]) + return r.expr(['hz_collections', 'hz_groups']) .forEach((table) => r.db(db).table(table) - .wait({ waitFor: 'ready_for_writes', timeout: 30 })) + .wait({waitFor: 'ready_for_writes', timeout: 30})) .run(conn); }).then(() => { // Error if any collections will be removed @@ -388,7 +388,7 @@ const runApplyCommand = (options) => { } }), r.db(db).table('hz_groups') - .insert(schema.groups, { conflict: 'replace' }) + .insert(schema.groups, {conflict: 'replace'}) .run(conn).then((res) => { if (res.errors) { throw new Error(`Failed to write groups: ${res.first_error}`); @@ -398,7 +398,7 @@ const runApplyCommand = (options) => { } }).then(() => { // Ensure all collections exist and remove any obsolete collections - const promises = [ ]; + const promises = []; for (const c of schema.collections) { promises.push( create_collection(db, c.id, conn).then((res) => { @@ -413,7 +413,7 @@ const runApplyCommand = (options) => { r.db(db) .table('hz_collections') .get(c) - .delete({ returnChanges: 'always' })('changes')(0) + .delete({returnChanges: 'always'})('changes')(0) .do((res) => r.branch(res.hasFields('error'), res, @@ -429,7 +429,7 @@ const runApplyCommand = (options) => { return Promise.all(promises); }).then(() => { - const promises = [ ]; + const promises = []; // Ensure all indexes exist for (const c of schema.collections) { @@ -438,7 +438,7 @@ const runApplyCommand = (options) => { promises.push( r.branch(r.db(db).table(c.id).indexList().contains(name), { }, r.db(db).table(c.id).indexCreate(name, horizon_index.info_to_reql(info), - { geo: Boolean(info.geo), multi: (info.multi !== false) })) + {geo: Boolean(info.geo), multi: (info.multi !== false)})) .run(conn) .then((res) => { if (res.errors) { @@ -498,28 +498,28 @@ const runSaveCommand = (options) => { } }).then(() => { if (options.start_rethinkdb) { - return start_rdb_server({ quiet: !options.debug }).then((server) => { + return start_rdb_server({quiet: !options.debug}).then((server) => { rdb_server = server; options.rdb_host = 'localhost'; options.rdb_port = server.driver_port; }); } }).then(() => - r.connect({ host: options.rdb_host, + r.connect({host: options.rdb_host, port: options.rdb_port, user: options.rdb_user, password: options.rdb_password, - timeout: options.rdb_timeout }) + timeout: options.rdb_timeout}) ).then((rdb_conn) => { conn = rdb_conn; - return r.db(db).wait({ waitFor: 'ready_for_reads', timeout: 30 }).run(conn); + return r.db(db).wait({waitFor: 'ready_for_reads', timeout: 30}).run(conn); }).then(() => r.object('collections', r.db(db).table('hz_collections') .filter((row) => row('id').match('^hz_').not()) .coerceTo('array') .map((row) => - row.merge({ indexes: r.db(db).table(row('id')).indexList() })), + row.merge({indexes: r.db(db).table(row('id')).indexList()})), 'groups', r.db(db).table('hz_groups').coerceTo('array')) .run(conn) ).then((res) => @@ -534,7 +534,7 @@ const runSaveCommand = (options) => { } const output = (options.out_file === '-') ? process.stdout : - fs.createWriteStream(options.out_file, { flags: 'w', defaultEncoding: 'utf8' }); + fs.createWriteStream(options.out_file, {flags: 'w', defaultEncoding: 'utf8'}); // Output toml_str to schema.toml const toml_str = schema_to_toml(res.collections, res.groups); diff --git a/cli/src/serve.js b/cli/src/serve.js index f52b0875a..e9695c7ce 100644 --- a/cli/src/serve.js +++ b/cli/src/serve.js @@ -1,7 +1,6 @@ 'use strict'; const chalk = require('chalk'); -const crypto = require('crypto'); const fs = require('fs'); const get_type = require('mime-types').contentType; const http = require('http'); @@ -28,123 +27,123 @@ const default_rdb_port = 28015; const default_rdb_timeout = 20; const parseArguments = (args) => { - const parser = new argparse.ArgumentParser({ prog: 'hz serve' }); - - parser.addArgument([ 'project_path' ], - { type: 'string', nargs: '?', - help: 'Change to this directory before serving' }); - - parser.addArgument([ '--project-name', '-n' ], - { type: 'string', action: 'store', metavar: 'NAME', - help: 'Name of the Horizon project. Determines the name of ' + - 'the RethinkDB database that stores the project data.' }); - - parser.addArgument([ '--bind', '-b' ], - { type: 'string', action: 'append', metavar: 'HOST', - help: 'Local hostname to serve horizon on (repeatable).' }); - - parser.addArgument([ '--port', '-p' ], - { type: 'int', metavar: 'PORT', - help: 'Local port to serve horizon on.' }); - - parser.addArgument([ '--connect', '-c' ], - { type: 'string', metavar: 'HOST:PORT', - help: 'Host and port of the RethinkDB server to connect to.' }); - - parser.addArgument([ '--rdb-timeout' ], - { type: 'int', metavar: 'TIMEOUT', - help: 'Timeout period in seconds for the RethinkDB connection to be opened' }); - - parser.addArgument([ '--rdb-user' ], - { type: 'string', metavar: 'USER', - help: 'RethinkDB User' }); - - parser.addArgument([ '--rdb-password' ], - { type: 'string', metavar: 'PASSWORD', - help: 'RethinkDB Password' }); - - parser.addArgument([ '--key-file' ], - { type: 'string', metavar: 'PATH', - help: 'Path to the key file to use, defaults to "./horizon-key.pem".' }); - - parser.addArgument([ '--cert-file' ], - { type: 'string', metavar: 'PATH', - help: 'Path to the cert file to use, defaults to "./horizon-cert.pem".' }); - - parser.addArgument([ '--token-secret' ], - { type: 'string', metavar: 'SECRET', - help: 'Key for signing jwts' }); - - parser.addArgument([ '--allow-unauthenticated' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Whether to allow unauthenticated Horizon connections.' }); - - parser.addArgument([ '--allow-anonymous' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Whether to allow anonymous Horizon connections.' }); - - parser.addArgument([ '--debug' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Enable debug logging.' }); - - parser.addArgument([ '--secure' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Serve secure websockets, requires --key-file and ' + - '--cert-file if true, on by default.' }); - - parser.addArgument([ '--start-rethinkdb' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Start up a RethinkDB server in the current directory' }); - - parser.addArgument([ '--auto-create-collection' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Create collections used by requests if they do not exist.' }); - - parser.addArgument([ '--auto-create-index' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Create indexes used by requests if they do not exist.' }); - - parser.addArgument([ '--permissions' ], - { type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', - help: 'Enables or disables checking permissions on requests.' }); - - parser.addArgument([ '--serve-static' ], - { type: 'string', metavar: 'PATH', nargs: '?', constant: './dist', - help: 'Serve static files from a directory, defaults to "./dist".' }); - - parser.addArgument([ '--dev' ], - { action: 'storeTrue', - help: 'Runs the server in development mode, this sets ' + - '--secure=no, ' + - '--permissions=no, ' + - '--auto-create-collection=yes, ' + - '--auto-create-index=yes, ' + - '--start-rethinkdb=yes, ' + - '--allow-unauthenticated=yes, ' + - '--allow-anonymous=yes ' + - 'and --serve-static=./dist.' }); - - parser.addArgument([ '--schema-file' ], - { type: 'string', metavar: 'SCHEMA_FILE_PATH', - help: 'Path to the schema file to use, ' + - 'will attempt to apply schema before starting Horizon server".' }); - - parser.addArgument([ '--auth' ], - { type: 'string', action: 'append', metavar: 'PROVIDER,ID,SECRET', defaultValue: [ ], - help: 'Auth provider and options comma-separated, e.g. "facebook,,".' }); - - parser.addArgument([ '--auth-redirect' ], - { type: 'string', metavar: 'URL', - help: 'The URL to redirect to upon completed authentication, defaults to "/".' }); - - parser.addArgument([ '--access-control-allow-origin' ], - { type: 'string', metavar: 'URL', - help: 'The URL of the host that can access auth settings, defaults to "".' }); - - parser.addArgument([ '--open' ], - { action: 'storeTrue', - help: 'Open index.html in the static files folder once Horizon is ready to' + - ' receive connections' }); + const parser = new argparse.ArgumentParser({prog: 'hz serve'}); + + parser.addArgument(['project_path'], + {type: 'string', nargs: '?', + help: 'Change to this directory before serving'}); + + parser.addArgument(['--project-name', '-n'], + {type: 'string', action: 'store', metavar: 'NAME', + help: 'Name of the Horizon project. Determines the name of ' + + 'the RethinkDB database that stores the project data.'}); + + parser.addArgument(['--bind', '-b'], + {type: 'string', action: 'append', metavar: 'HOST', + help: 'Local hostname to serve horizon on (repeatable).'}); + + parser.addArgument(['--port', '-p'], + {type: 'int', metavar: 'PORT', + help: 'Local port to serve horizon on.'}); + + parser.addArgument(['--connect', '-c'], + {type: 'string', metavar: 'HOST:PORT', + help: 'Host and port of the RethinkDB server to connect to.'}); + + parser.addArgument(['--rdb-timeout'], + {type: 'int', metavar: 'TIMEOUT', + help: 'Timeout period in seconds for the RethinkDB connection to be opened'}); + + parser.addArgument(['--rdb-user'], + {type: 'string', metavar: 'USER', + help: 'RethinkDB User'}); + + parser.addArgument(['--rdb-password'], + {type: 'string', metavar: 'PASSWORD', + help: 'RethinkDB Password'}); + + parser.addArgument(['--key-file'], + {type: 'string', metavar: 'PATH', + help: 'Path to the key file to use, defaults to "./horizon-key.pem".'}); + + parser.addArgument(['--cert-file'], + {type: 'string', metavar: 'PATH', + help: 'Path to the cert file to use, defaults to "./horizon-cert.pem".'}); + + parser.addArgument(['--token-secret'], + {type: 'string', metavar: 'SECRET', + help: 'Key for signing jwts'}); + + parser.addArgument(['--allow-unauthenticated'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Whether to allow unauthenticated Horizon connections.'}); + + parser.addArgument(['--allow-anonymous'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Whether to allow anonymous Horizon connections.'}); + + parser.addArgument(['--debug'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Enable debug logging.'}); + + parser.addArgument(['--secure'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Serve secure websockets, requires --key-file and ' + + '--cert-file if true, on by default.'}); + + parser.addArgument(['--start-rethinkdb'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Start up a RethinkDB server in the current directory'}); + + parser.addArgument(['--auto-create-collection'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Create collections used by requests if they do not exist.'}); + + parser.addArgument(['--auto-create-index'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Create indexes used by requests if they do not exist.'}); + + parser.addArgument(['--permissions'], + {type: 'string', metavar: 'yes|no', constant: 'yes', nargs: '?', + help: 'Enables or disables checking permissions on requests.'}); + + parser.addArgument(['--serve-static'], + {type: 'string', metavar: 'PATH', nargs: '?', constant: './dist', + help: 'Serve static files from a directory, defaults to "./dist".'}); + + parser.addArgument(['--dev'], + {action: 'storeTrue', + help: 'Runs the server in development mode, this sets ' + + '--secure=no, ' + + '--permissions=no, ' + + '--auto-create-collection=yes, ' + + '--auto-create-index=yes, ' + + '--start-rethinkdb=yes, ' + + '--allow-unauthenticated=yes, ' + + '--allow-anonymous=yes ' + + 'and --serve-static=./dist.'}); + + parser.addArgument(['--schema-file'], + {type: 'string', metavar: 'SCHEMA_FILE_PATH', + help: 'Path to the schema file to use, ' + + 'will attempt to apply schema before starting Horizon server".'}); + + parser.addArgument(['--auth'], + {type: 'string', action: 'append', metavar: 'PROVIDER,ID,SECRET', defaultValue: [], + help: 'Auth provider and options comma-separated, e.g. "facebook,,".'}); + + parser.addArgument(['--auth-redirect'], + {type: 'string', metavar: 'URL', + help: 'The URL to redirect to upon completed authentication, defaults to "/".'}); + + parser.addArgument(['--access-control-allow-origin'], + {type: 'string', metavar: 'URL', + help: 'The URL of the host that can access auth settings, defaults to "".'}); + + parser.addArgument(['--open'], + {action: 'storeTrue', + help: 'Open index.html in the static files folder once Horizon is ready to' + + ' receive connections'}); return parser.parseArgs(args); }; @@ -154,22 +153,22 @@ const parseArguments = (args) => { const serve_file = (filePath, res) => { fs.access(filePath, fs.R_OK | fs.F_OK, (exists) => { if (exists) { - res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.writeHead(404, {'Content-Type': 'text/plain'}); res.end(`File "${filePath}" not found\n`); } else { fs.lstat(filePath, (err, stats) => { if (err) { - res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.writeHead(500, {'Content-Type': 'text/plain'}); res.end(`${err}\n`); } else if (stats.isFile()) { fs.readFile(filePath, 'binary', (err2, file) => { if (err2) { - res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.writeHead(500, {'Content-Type': 'text/plain'}); res.end(`${err2}\n`); } else { const type = get_type(path.extname(filePath)) || false; if (type) { - res.writeHead(200, { 'Content-Type': type }); + res.writeHead(200, {'Content-Type': type}); } else { res.writeHead(200); } @@ -197,7 +196,7 @@ const file_server = (distDir) => (req, res) => { }; const initialize_servers = (ctor, opts) => { - const servers = [ ]; + const servers = []; let numReady = 0; return new Promise((resolve, reject) => { opts.bind.forEach((host) => { @@ -285,7 +284,7 @@ files in the current directory.`; const create_secure_servers = (opts) => { const cert = read_cert_file(opts.cert_file, 'cert'); const key = read_cert_file(opts.key_file, 'key'); - return initialize_servers(() => new https.Server({ key, cert }), opts); + return initialize_servers(() => new https.Server({key, cert}), opts); }; // Command-line flags have the highest precedence, followed by environment variables, @@ -310,7 +309,7 @@ const processConfig = (parsed) => { } if (options.bind.indexOf('all') !== -1) { - options.bind = [ '0.0.0.0' ]; + options.bind = ['0.0.0.0']; } if (!options.rdb_host) { @@ -444,7 +443,7 @@ const run = (args, interruptor) => { throw new Error(`Unrecognized auth provider "${name}"`); } hz_server.add_auth_provider(provider, - Object.assign({}, { path: name }, opts.auth[name])); + Object.assign({}, {path: name}, opts.auth[name])); } } }).then(() => { diff --git a/cli/src/utils/config.js b/cli/src/utils/config.js index b813766b9..3610105ac 100644 --- a/cli/src/utils/config.js +++ b/cli/src/utils/config.js @@ -21,7 +21,7 @@ const make_default_options = () => ({ // Default to current directory name for project name project_name: null, - bind: [ 'localhost' ], + bind: ['localhost'], port: 8181, start_rethinkdb: false, @@ -54,14 +54,14 @@ const make_default_options = () => ({ const default_options = make_default_options(); -const yes_no_options = [ 'debug', +const yes_no_options = ['debug', 'secure', 'permissions', 'start_rethinkdb', 'auto_create_index', 'auto_create_collection', 'allow_unauthenticated', - 'allow_anonymous' ]; + 'allow_anonymous']; const parse_connect = (connect, config) => { // support rethinkdb:// style connection uri strings @@ -106,7 +106,7 @@ const parse_connect = (connect, config) => { }; const read_from_config_file = (project_path) => { - const config = { auth: { } }; + const config = {auth: {}}; let fileData, configFilename, fileConfig; @@ -161,7 +161,7 @@ ${configFilename}, causing it not be a valid TOML file.`, }; const read_from_secrets_file = (projectPath) => { - const config = { auth: { } }; + const config = {auth: {}}; let fileData, secretsFilename; @@ -195,7 +195,7 @@ const read_from_secrets_file = (projectPath) => { const env_regex = /^HZ_([A-Z]+([_]?[A-Z]+)*)$/; const read_from_env = () => { - const config = { auth: { } }; + const config = {auth: { }}; for (const env_var in process.env) { const matches = env_regex.exec(env_var); if (matches && matches[1]) { @@ -228,7 +228,7 @@ const read_from_env = () => { // Handles reading configuration from the parsed flags const read_from_flags = (parsed) => { - const config = { auth: { } }; + const config = {auth: { }}; // Dev mode if (parsed.dev) { diff --git a/cli/src/utils/initialize_joi.js b/cli/src/utils/initialize_joi.js index 049fed3f2..a93d09f32 100644 --- a/cli/src/utils/initialize_joi.js +++ b/cli/src/utils/initialize_joi.js @@ -4,4 +4,4 @@ // This is used because tests will mock the filesystem, and the lazy // `require`s done by joi will no longer work at that point. module.exports = (joi) => - joi.validate('', joi.any().when('', { is: '', then: joi.any() })); + joi.validate('', joi.any().when('', {is: '', then: joi.any()})); diff --git a/cli/src/utils/interrupt.js b/cli/src/utils/interrupt.js index 289e7b626..69a85c885 100644 --- a/cli/src/utils/interrupt.js +++ b/cli/src/utils/interrupt.js @@ -1,6 +1,6 @@ 'use strict'; -const handlers = [ ]; +const handlers = []; const on_interrupt = (cb) => { handlers.push(cb); @@ -24,4 +24,4 @@ const interrupt = () => { process.on('SIGTERM', interrupt); process.on('SIGINT', interrupt); -module.exports = { on_interrupt, interrupt }; +module.exports = {on_interrupt, interrupt}; diff --git a/cli/src/utils/start_rdb_server.js b/cli/src/utils/start_rdb_server.js index 07b893934..e665d186c 100644 --- a/cli/src/utils/start_rdb_server.js +++ b/cli/src/utils/start_rdb_server.js @@ -2,6 +2,7 @@ const each_line_in_pipe = require('./each_line_in_pipe'); const horizon_server = require('@horizon/server'); +const versionCheck = require('@horizon/plugin-utils').rethinkdbVersionCheck; const execSync = require('child_process').execSync; const spawn = require('child_process').spawn; @@ -13,12 +14,11 @@ const infoLevelLog = (msg) => /^Running/.test(msg) || /^Listening/.test(msg); const r = horizon_server.r; const logger = horizon_server.logger; -const version_check = horizon_server.utils.rethinkdb_version_check; class RethinkdbServer { constructor(options) { const quiet = Boolean(options.quiet); - const bind = options.bind || [ '127.0.0.1' ]; + const bind = options.bind || ['127.0.0.1']; const dataDir = options.dataDir || defaultDatadir; const driverPort = options.rdbPort; const httpPort = options.rdbHttpPort; @@ -30,14 +30,14 @@ class RethinkdbServer { } // Check if RethinkDB is sufficient version for Horizon - version_check(execSync('rethinkdb --version', { timeout: 5000 }).toString()); + versionCheck(execSync('rethinkdb --version', {timeout: 5000}).toString()); - const args = [ '--http-port', String(httpPort || 0), + const args = ['--http-port', String(httpPort || 0), '--cluster-port', '0', '--driver-port', String(driverPort || 0), '--cache-size', String(cacheSize), '--directory', dataDir, - '--no-update-check' ]; + '--no-update-check']; bind.forEach((host) => args.push('--bind', host)); this.proc = spawn('rethinkdb', args); @@ -103,7 +103,7 @@ class RethinkdbServer { // This is only used by tests - cli commands use a more generic method as // the database may be launched elsewhere. connect() { - return r.connect({ host: 'localhost', port: this.driver_port }); + return r.connect({host: 'localhost', port: this.driver_port}); } close() { diff --git a/client/README.md b/client/README.md index 625fbc2be..11e00e257 100644 --- a/client/README.md +++ b/client/README.md @@ -26,7 +26,7 @@ npm run test | Run tests ## Running tests * `npm test` or open `dist/test.html` in your browser after getting setup and while you also have Horizon server with the `--dev` flag running on `localhost`. -* You can spin up a dev server by cloning the horizon repo and running `node serve.js` in `test` directory in repo root. Then tests can be accessed from . Source maps work properly when served via http, not from file system. You can test the production version via `NODE_ENV=production node serve.js`. You may want to use `test/setupDev.sh` to set the needed local npm links for development. +* You can spin up a dev server by cloning the horizon repo and running `node serve.js` in `test` directory in repo root. Then tests can be accessed from . Source maps work properly when served via http, not from file system. You can test the production version via `NODE_ENV=production node serve.js`. You may want to use `setupDev.sh` to set the needed local npm links for development. ## Docs diff --git a/client/package.json b/client/package.json index bcf53c7c2..bba9cc371 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@horizon/client", - "version": "2.0.0", + "version": "3.0.0-alpha-0", "description": "RethinkDB Horizon is an open-source developer platform for building realtime, scalable web apps.", "scripts": { "coverage": "cross-env NODE_ENV=test nyc mocha test/test.js", @@ -33,7 +33,7 @@ "babel-loader": "^6.2.4", "babel-plugin-istanbul": "^1.0.3", "babel-plugin-transform-runtime": "^6.6.0", - "babel-preset-es2015": "^6.6.0", + "babel-preset-es2015": "^6.9.0", "babel-preset-es2015-loose": "^7.0.0", "babel-register": "^6.9.0", "chai": "^3.5.0", @@ -42,7 +42,7 @@ "eslint": "^3.1.0", "exports-loader": "^0.6.3", "imports-loader": "^0.6.5", - "istanbul": "^0.4.2", + "istanbul": "^0.4.3", "lodash.clonedeep": "^4.4.1", "lodash.sortby": "^4.6.1", "mocha": "^2.5.3", diff --git a/client/src/ast.js b/client/src/ast.js index e6420a163..61b84516f 100644 --- a/client/src/ast.js +++ b/client/src/ast.js @@ -41,10 +41,15 @@ function checkIfLegalToChain(key) { // Abstract base class for terms export class TermBase { - constructor(sendRequest, query, legalMethods) { + constructor(capabilities, sendRequest, request) { this._sendRequest = sendRequest - this._query = query - this._legalMethods = legalMethods + this._request = request + + // TODO: do something fancy with prototypes? Might be more efficient + // this should be the same for all requests on the same instance of horizon + for (const name of capabilities) { + this[name] = (...args) => this._addOption(name, ...args) + } } toString() { @@ -72,6 +77,11 @@ export class TermBase { } return string } + + _addOption(name, ...args) { + return new + } + // Returns a sequence of the result set. Every time it changes the // updated sequence will be emitted. If raw change objects are // needed, pass the option 'rawChanges: true'. An observable is @@ -137,7 +147,29 @@ export class TermBase { // `observable` is the base observable with full responses coming from // the HorizonSocket // `query` is the value of `options` in the request -function makePresentable(observable, query) { +function makePresentable(observable) { + const initialAcc = {state: 'initial', type}; + return observable.scan((acc, message) => { + switch (acc.state) { + case 'initial': + if (message.data) { + message.data.forEach((x) => applyChange( + } + break; + case 'synced': + break; + case 'complete': + break; + } + if (message + switch (change.state) { + case 'synced': + break; + case 'complete': + break; + } + }, initialAcc).filter((acc) => acc.state === 'synced').map((acc) => acc.data); + // Whether the entire data structure is in each change const pointQuery = Boolean(query.find) @@ -149,12 +181,6 @@ function makePresentable(observable, query) { .filter(change => !hasEmitted || change.type !== 'state') .scan((previous, change) => { hasEmitted = true - if (change.new_val != null) { - delete change.new_val.$hz_v$ - } - if (change.old_val != null) { - delete change.old_val.$hz_v$ - } if (change.state === 'synced') { return previous } else { @@ -165,12 +191,6 @@ function makePresentable(observable, query) { const seedVal = { emitted: false, val: [] } return observable .scan((state, change) => { - if (change.new_val != null) { - delete change.new_val.$hz_v$ - } - if (change.old_val != null) { - delete change.old_val.$hz_v$ - } if (change.state === 'synced') { state.emitted = true } diff --git a/client/src/socket.js b/client/src/socket.js index 0e501cfdd..9389aa38c 100644 --- a/client/src/socket.js +++ b/client/src/socket.js @@ -14,7 +14,7 @@ import 'rxjs/add/operator/publish' import { serialize, deserialize } from './serialization.js' -const PROTOCOL_VERSION = 'rethinkdb-horizon-v0' +const PROTOCOL_VERSION = 'rethinkdb-horizon-v1' // Before connecting the first time const STATUS_UNCONNECTED = { type: 'unconnected' } diff --git a/examples/health-check-plugin/health-check.js b/examples/health-check-plugin/health-check.js new file mode 100644 index 000000000..b01853d01 --- /dev/null +++ b/examples/health-check-plugin/health-check.js @@ -0,0 +1,45 @@ +'use strict'; + +// RSI: check connection +module.exports = (config) => { + return { + name: 'health-check', + activate: (ctx) => { + ctx.logger.info('Activating health-check module.'); + return { + commands: { + 'health-check': (args) => { + console.log(`Got ${JSON.stringify(args)}.`); + }, + }, + httpRoute: (req, res, next) => { + console.log(`httpRoute: ${[req, res, next]}`) + res.send("healthy"); + }, + methods: { + 'healthCheck': { + requires: ['hz_permissions'], + type: 'terminal', // or `middleware` or `preReq` + impl: (req, res, next) => { + console.log(`healthCheck method: ${[req, res, next]}`) + res.send("healthy"); + }, + }, + }, + onClientEvent: { + connect: (clientCtx) => { + }, + auth: (clientCtx) => { + }, + }, + middleware: (req, res, next) => { + req.healthy = true; + next(); + }, + }; + }, + deactivate: (reason) => { + ctx.logger.info(`Deactivating health-check module (${reason}).`) + }, + }; +} diff --git a/plugin-router/.eslintrc.js b/plugin-router/.eslintrc.js new file mode 100644 index 000000000..bc66210fa --- /dev/null +++ b/plugin-router/.eslintrc.js @@ -0,0 +1,17 @@ +const OFF = 0; +const WARN = 1; +const ERROR = 2; + +module.exports = { + extends: "../.eslintrc.js", + rules: { + "camelcase": [ ERROR ], + "max-len": [ ERROR, 89 ], + "prefer-template": [ OFF ], + }, + env: { + "es6": true, + "node": true, + "mocha": true, + }, +}; diff --git a/plugin-router/base/package.json b/plugin-router/base/package.json new file mode 100644 index 000000000..83846d29d --- /dev/null +++ b/plugin-router/base/package.json @@ -0,0 +1,35 @@ +{ + "name": "@horizon/plugin-router-base", + "version": "1.0.0", + "description": "Base PluginRouter implementation for Horizon.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-router/base/src/index.js b/plugin-router/base/src/index.js new file mode 100644 index 000000000..809b40d1b --- /dev/null +++ b/plugin-router/base/src/index.js @@ -0,0 +1,115 @@ +'use strict'; + +const EventEmitter = require('events'); + +class PluginRouter extends EventEmitter { + constructor(horizon) { + super(); + this.horizon = horizon; + this.plugins = new Map(); + this.readyPlugins = new Set(); + this.context = { + horizon: { + options: horizon.options, + auth: horizon.auth(), + rdbConnection: horizon.rdbConnection, + events: horizon.events, + }, + }; + } + + close() { + return Promise.all(Array.from(this.plugins.keys()).map((p) => this.remove(p))); + } + + add(plugin, options) { + if (!options.name) { + // RSI: make sure plugin names don't contain a '/' + options.name = plugin.name; + } + + if (this.plugins.has(options.name)) { + return Promise.reject( + new Error(`Plugin conflict: "${options.name}" already present.`)); + } + + // Placeholder so we don't say we're ready too soon + this.plugins.set(options.name, null); + this.emit('unready', this); + + const activatePromise = Promise.resolve().then(() => + plugin.activate(this.context, options, + () => this._noteReady(options.name), + () => this._noteUnready(options.name)) + ).then((active) => { + const addedMethods = []; + try { + for (const m in active.methods) { + this.horizon.addMethod(m, active.methods[m]); + addedMethods.push(m); + } + } catch (err) { + // Back out and clean up safely if any methods failed to add + addedMethods.forEach((m) => this.horizon.removeMethod(m)); + throw err; + } + + if (plugin.activate.length < 3) { + this._noteReady(options.name); + } + + // RSI: we probably need to return a few more things in 'active' (for use by + // sub-plugin-router implementations) + active.name = options.name; + active.plugin = plugin; + active.options = options; + + return active; + }); + + this.plugins.set(options.name, activatePromise); + return activatePromise; + } + + remove(name, reason) { + const activatePromise = this.plugins.get(name); + + if (!activatePromise) { + return Promise.reject(new Error(`Plugin "${name}" is not present.`)); + } + + this.plugins.delete(name); + return activatePromise.then((active) => { + for (const m in active.methods) { + this.horizon.removeMethod(m); + } + if (active.plugin.deactivate) { + return active.plugin.deactivate(this.context, active.options, + reason || 'Removed from PluginRouter.'); + } + }); + } + + _noteReady(plugin) { + if (!this.readyPlugins.has(plugin)) { + this.readyPlugins.add(plugin); + this.emit('pluginReady', plugin, this); + if (this.readyPlugins.size === this.plugins.size) { + setImmediate(() => this.emit('ready', this)); + } + } + } + + _noteUnready(plugin) { + if (this.readyPlugins.has(plugin)) { + this.readyPlugins.delete(plugin); + this.emit('pluginUnready', plugin, this); + if (this.readyPlugins.size === this.plugins.size - 1) { + this.emit('unready', this); + } + } + } +} + +module.exports = PluginRouter; + diff --git a/plugin-router/express/package.json b/plugin-router/express/package.json new file mode 100644 index 000000000..4be761e80 --- /dev/null +++ b/plugin-router/express/package.json @@ -0,0 +1,36 @@ +{ + "name": "@horizon/plugin-router-express", + "version": "1.0.0", + "description": "Plugin router for Horizon using Express as the backend.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/server": "3.x", + "express": "4.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-router/express/src/index.js b/plugin-router/express/src/index.js new file mode 100644 index 000000000..33094413f --- /dev/null +++ b/plugin-router/express/src/index.js @@ -0,0 +1,102 @@ +'use strict'; + +const HorizonServer = require('@horizon/server'); +const PluginRouterBase = require('@horizon/plugin-router-base'); + +const express = require('express'); + +function PluginRouterExpress(hz) { + // Using local variables so it can later be removed without leaking things + let horizonServer = hz; + let pluginRouter = new PluginRouterBase(horizonServer); + let expressRouter = express.Router(); + let routes = new Map(); // For recreating the router when a route is removed + + function middleware(req, res, next) { + if (expressRouter) { + expressRouter(req, res, next); + } else { + next(); + } + } + + function addHandler(path, handler) { + routes.set(path, handler); + expressRouter.use(path, handler); + } + + function add(...args) { + return Promise.resolve().then(() => { + if (!pluginRouter) { throw new Error('PluginRouter has been closed.'); } + return pluginRouter.add(...args); + }).then((active) => { + if (active.http) { + addHandler(`/${active.name}/`, active.http); + } + return active; + }); + } + + function remove(name, ...args) { + return Promise.resolve().then(() => { + if (!pluginRouter) { throw new Error('PluginRouter has been closed.'); } + + // Remove the route before deactivating the plugin + const path = `/${name}/`; + if (PluginRouterBase.isValidName(name) && routes.has(path)) { + routes.delete(`/${name}/`); + expressRouter = express.Router(); + routes.forEach((handler, route) => { + expressRouter.use(route, handler); + }); + } + + return pluginRouter.remove(...args); + }); + } + + let closePromise; + function close(...args) { + if (!closePromise) { + expressRouter = null; + horizonServer = null; + routes.clear(); + routes = null; + closePromise = pluginRouter.close(...args); + pluginRouter = null; + } + return closePromise; + } + + addHandler('/horizon.js', (req, res) => { + res.head('Content-Type', 'application/javascript'); + res.send(HorizonServer.clientSource()); + res.end(); + }); + + addHandler('/horizon.js.map', (req, res) => { + res.head('Content-Type', 'application/javascript'); + res.send(HorizonServer.clientSourceMap()); + res.end(); + }); + + addHandler('/horizon-core.js.map', (req, res) => { + res.head('Content-Type', 'application/javascript'); + res.send(HorizonServer.clientSourceCore()); + res.end(); + }); + + addHandler('/horizon-core.js.map', (req, res) => { + res.head('Content-Type', 'application/javascript'); + res.send(HorizonServer.clientSourceCoreMap()); + res.end(); + }); + + middleware.add = add; + middleware.remove = remove; + middleware.close = close; + return middleware; +} + +module.exports = PluginRouterExpress; + diff --git a/plugin-router/hapi/package.json b/plugin-router/hapi/package.json new file mode 100644 index 000000000..7f7a41338 --- /dev/null +++ b/plugin-router/hapi/package.json @@ -0,0 +1,36 @@ +{ + "name": "@horizon/plugin-router-hapi", + "version": "1.0.0", + "description": "Plugin router for Horizon using Hapi as the backend.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/server": "3.x", + "hapi": "15.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-router/hapi/src/index.js b/plugin-router/hapi/src/index.js new file mode 100644 index 000000000..c643ffab6 --- /dev/null +++ b/plugin-router/hapi/src/index.js @@ -0,0 +1,23 @@ +'use strict'; + +const PluginRouterBase = require('@horizon/plugin-router-base'); + +class PluginRouterHapi extends PluginRouterBase { + constructor(hapi, horizon) { + super(horizon); + this.hapi = hapi; + } + + add(...args) { + return super.add(...args); + // RSI: add routes to hapi + } + + remove(...args) { + // RSI: remove routes from hapi + return super.remove(...args); + } +} + +module.exports = PluginRouterHapi; + diff --git a/plugin-router/http/package.json b/plugin-router/http/package.json new file mode 100644 index 000000000..8914dbb62 --- /dev/null +++ b/plugin-router/http/package.json @@ -0,0 +1,35 @@ +{ + "name": "@horizon/plugin-router-http", + "version": "1.0.0", + "description": "Plugin router for Horizon using a basic HTTP backend.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-router/http/src/index.js b/plugin-router/http/src/index.js new file mode 100644 index 000000000..07ee8c75d --- /dev/null +++ b/plugin-router/http/src/index.js @@ -0,0 +1,22 @@ +'use strict'; + +const PluginRouterBase = require('@horizon/plugin-router-base'); + +class PluginRouterHttp extends PluginRouterBase { + constructor(http, horizon) { + super(horizon); + this.http = http; + } + + add(...args) { + return super.add(...args); + // RSI: add routes to http server + } + + remove(...args) { + // RSI: remove routes from http server + return super.remove(...args); + } +} + +module.exports = PluginRouterHttp; diff --git a/plugin-router/koa/package.json b/plugin-router/koa/package.json new file mode 100644 index 000000000..9123f43ad --- /dev/null +++ b/plugin-router/koa/package.json @@ -0,0 +1,36 @@ +{ + "name": "@horizon/plugin-router-koa", + "version": "1.0.0", + "description": "Plugin router for Horizon using Koa as the backend.", + "main": "src/index.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/server": "3.x", + "koa": "1.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-router/koa/src/index.js b/plugin-router/koa/src/index.js new file mode 100644 index 000000000..b8213b994 --- /dev/null +++ b/plugin-router/koa/src/index.js @@ -0,0 +1,22 @@ +'use strict'; + +const PluginRouterBase = require('@horizon/plugin-router-base'); + +class PluginRouterKoa extends PluginRouterBase { + constructor(koa, horizon) { + super(horizon); + this.koa = koa; + } + + add(...args) { + return super.add(...args); + // RSI: add routes to koa server + } + + remove(...args) { + // RSI: remove routes from koa server + return super.remove(...args); + } +} + +module.exports = PluginRouterKoa; diff --git a/plugin-utils/.eslintrc.js b/plugin-utils/.eslintrc.js new file mode 100644 index 000000000..bc66210fa --- /dev/null +++ b/plugin-utils/.eslintrc.js @@ -0,0 +1,17 @@ +const OFF = 0; +const WARN = 1; +const ERROR = 2; + +module.exports = { + extends: "../.eslintrc.js", + rules: { + "camelcase": [ ERROR ], + "max-len": [ ERROR, 89 ], + "prefer-template": [ OFF ], + }, + env: { + "es6": true, + "node": true, + "mocha": true, + }, +}; diff --git a/plugin-utils/package.json b/plugin-utils/package.json new file mode 100644 index 000000000..9aa39ca88 --- /dev/null +++ b/plugin-utils/package.json @@ -0,0 +1,37 @@ +{ + "name": "@horizon/plugin-utils", + "version": "1.0.0", + "description": "Utilities for the default Horizon plugins.", + "main": "src/utils.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "jsonpatch": "^3.0.1" + }, + "peerDependencies": { + "@horizon/plugin-router": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0" + } +} diff --git a/plugin-utils/src/auth.js b/plugin-utils/src/auth.js new file mode 100644 index 000000000..98091b6f4 --- /dev/null +++ b/plugin-utils/src/auth.js @@ -0,0 +1,203 @@ +'use strict'; + +const logger = require('../logger'); + +const cookie = require('cookie'); +const crypto = require('crypto'); +const Joi = require('joi'); +const url = require('url'); + +const doRedirect = (res, redirectUrl) => { + logger.debug(`Redirecting user to ${redirectUrl}`); + res.writeHead(302, {Location: redirectUrl}); + res.end(); +}; + +const extendUrlQuery = (path, query) => { + const pathCopy = Object.assign({}, path); + if (pathCopy.query === null) { + pathCopy.query = query; + } else { + pathCopy.query = Object.assign({}, pathCopy.query); + pathCopy.query = Object.assign({}, pathCopy.query, query); + } + return pathCopy; +}; + +const runRequest = (req, cb) => { + logger.debug(`Initiating request to ${req._headers.host}${req.path}`); + req.once('response', (res) => { + const chunks = []; + res.on('data', (data) => { + chunks.push(data); + }); + res.once('end', () => { + if (res.statusCode !== 200) { + cb(new Error(`Request returned status code: ${res.statusCode} ` + + `(${res.statusMessage}): ${chunks.join('')}`)); + } else { + cb(null, chunks.join('')); + } + }); + }); + req.once('error', (err) => { + cb(err); + }); + req.end(); +}; + +const tryJsonParse = (data) => { + try { + return JSON.parse(data); + } catch (err) { + // Do nothing - just return undefined + } +}; + +const nonceCookie = (name) => `${name}_horizon_nonce`; + +const makeNonce = (cb) => crypto.randomBytes(64, (err, res) => { + if (!err) { + cb(err, res.toString('base64')); + } else { + cb(err, res); + } +}); + +// TODO: this base64 encoding isn't URL-friendly +const nonceToState = (nonce) => + crypto.createHash('sha256').update(nonce, 'base64').digest('base64'); + +const setNonce = (res, name, nonce) => + res.setHeader('set-cookie', + cookie.serialize(nonceCookie(name), nonce, + {maxAge: 3600, secure: true, httpOnly: true})); + +const clearNonce = (res, name) => + res.setHeader('set-cookie', + cookie.serialize(nonceCookie(name), 'invalid', + {maxAge: -1, secure: true, httpOnly: true})); + +const getNonce = (req, name) => { + const field = nonceCookie(name); + if (req.headers.cookie) { + const value = cookie.parse(req.headers.cookie); + return value[field]; + } +}; + +const optionsSchema = Joi.object({ + horizon: Joi.object().required(), + provider: Joi.string().required(), + // makeAcquireUrl takes `state` and `returnUrl`, returns a string url + makeAcquireUrl: Joi.func().arity(2).required(), + // makeTokenRequest takes `code` and `returnUrl`, returns an http request + makeTokenRequest: Joi.func().arity(2).required(), + // makeInspectRequest takes `accessToken`, returns an http request + makeInspectRequest: Joi.func().arity(1).required(), + // extractId takes `userInfo`, returns a unique value for the user from the provider + extractId: Joi.func().arity(1).required(), +}).unknown(false); + +// Attaches an endpoint to the horizon server, providing an oauth2 redirect flow +const oauth2 = (rawOptions) => { + const options = Joi.attempt(rawOptions, optionsSchema); + + const horizon = options.horizon; + const provider = options.provider; + const makeAcquireUrl = options.makeAcquireUrl; + const makeTokenRequest = options.makeTokenRequest; + const makeInspectRequest = options.makeInspectRequest; + const extractId = options.extractId; + + const selfUrl = (host, path) => + url.format({protocol: 'https', host: host, pathname: path}); + + const makeSuccessUrl = (horizonToken) => + url.format(extendUrlQuery(horizon._auth._success_redirect, {horizonToken})); + + const makeFailureUrl = (horizonError) => + url.format(extendUrlQuery(horizon._auth._failure_redirect, {horizonError})); + + horizon.add_http_handler(provider, (req, res) => { + const requestUrl = url.parse(req.url, true); + const returnUrl = selfUrl(req.headers.host, requestUrl.pathname); + const code = requestUrl.query && requestUrl.query.code; + const error = requestUrl.query && requestUrl.query.error; + + logger.debug(`oauth request: ${JSON.stringify(requestUrl)}`); + if (error) { + const description = requestUrl.query.error_description || error; + doRedirect(res, makeFailureUrl(description)); + } else if (!code) { + // We need to redirect to the API to acquire a token, then come back and try again + // Generate a nonce to track this client session to prevent CSRF attacks + makeNonce((nonceErr, nonce) => { + if (nonceErr) { + logger.error(`Error creating nonce for oauth state: ${nonceErr}`); + res.statusCode = 503; + res.end('error generating nonce'); + } else { + setNonce(res, horizon._name, nonce); + doRedirect(res, makeAcquireUrl(nonceToState(nonce), returnUrl)); + } + }); + } else { + // Make sure this is the same client who obtained the code to prevent CSRF attacks + const nonce = getNonce(req, horizon._name); + const state = requestUrl.query.state; + + if (!nonce || !state || state !== nonceToState(nonce)) { + doRedirect(res, makeFailureUrl('session expired')); + } else { + // We have the user code, turn it into an access token + runRequest(makeTokenRequest(code, returnUrl), (err1, body) => { + const info = tryJsonParse(body); + const accessToken = info && info.accessToken; + + if (err1) { + logger.error(`Error contacting oauth API: ${err1}`); + res.statusCode = 503; + res.end('oauth provider error'); + } else if (!accessToken) { + logger.error(`Bad JSON data from oauth API: ${body}`); + res.statusCode = 500; + res.end('unparseable token response'); + } else { + // We have the user access token, get info on it so we can find the user + runRequest(makeInspectRequest(accessToken), (err2, innerBody) => { + const userInfo = tryJsonParse(innerBody); + const userId = userInfo && extractId(userInfo); + + if (err2) { + logger.error(`Error contacting oauth API: ${err2}`); + res.statusCode = 503; + res.end('oauth provider error'); + } else if (!userId) { + logger.error(`Bad JSON data from oauth API: ${innerBody}`); + res.statusCode = 500; + res.end('unparseable inspect response'); + } else { + horizon._auth.generate(provider, userId).nodeify((err3, jwt) => { + // Clear the nonce just so we aren't polluting clients' cookies + clearNonce(res, horizon._name); + doRedirect(res, err3 ? + makeFailureUrl('invalid user') : + makeSuccessUrl(jwt.token)); + }); + } + }); + } + }); + } + } + }); +}; + +module.exports = { + oauth2, + doRedirect, runRequest, + makeNonce, setNonce, getNonce, clearNonce, nonceToState, + extendUrlQuery, + tryJsonParse, +}; diff --git a/plugin-utils/src/reads.js b/plugin-utils/src/reads.js new file mode 100644 index 000000000..87872c59f --- /dev/null +++ b/plugin-utils/src/reads.js @@ -0,0 +1,209 @@ +'use strict'; + +const assert = require('assert'); + +const {r} = require('@horizon/server'); + +// For a given object, returns the array of fields present +function objectToFields(obj) { + return Object.keys(obj).map((key) => { + const value = obj[key]; + if (value !== null && typeof value === 'object' && !value.$reql_type$) { + return objectToFields(value).map((subkeys) => [key].concat(subkeys)); + } else { + return [key]; + } + }); +} + +// Gets the value of a field out of an object, or undefined if it is not present +function getObjectField(obj, field) { + let value = obj; + for (const name of field) { + if (value === undefined) { + return value; + } + value = value[name]; + } + return value; +} + +// Gets the value of a field out of an object, throws an error if it is not present +function guaranteeObjectField(obj, field) { + const res = getObjectField(obj, field); + assert(res !== undefined); + return res; +} + +// Compares two fields, returns true if they are identical, false otherwise +function isSameField(a, b) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < b.length; ++i) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +// RSI: not needed? +// Returns true if the expected field is in the array of fields +// function hasField(fields, expected) { +// for (let i = 0; i < fields.length; ++i) { +// if (isSameField(fields[i], expected)) { +// return true; +// } +// } +// } + +function makeFindReql(collection, find) { + return collection.getMatchingIndex(objectToFields(find), []).then((index) => { + let value = index.fields.map((field) => guaranteeObjectField(find, field)); + + if (index.name === 'id') { + value = value[0]; + } + + return collection.table.getAll(value, {index: index.name}).limit(1); + }); +} + +function getIndexValue(field, obj, bound, def) { + let value = getObjectField(obj, field); + if (value !== undefined) { return value; } + value = getObjectField(bound, field); + if (value !== undefined) { return value; } + return def; +} + +function makeFindAllReql(collection, findAll, fixedFields, above, below, descending) { + return Promise.all(findAll.map((obj) => { + const fuzzyFields = objectToFields(obj); + // RSI: make sure fuzzyFields and fixedFields overlap only in the correct spot + // RSI: come up with some pathological tests that hit these sorts of cases + + return collection.getMatchingIndex(fuzzyFields, fixedFields).then((index) => { + const optargs = { + index: index.name, + leftBound: above ? above.bound : 'closed', + rightBound: below ? below.bound : 'closed', + }; + + let defaultLeftBound = r.minval; + let defaultRightBound = r.maxval; + + if (above && above.bound === 'open') { defaultLeftBound = r.maxval; } + if (below && below.bound === 'closed') { defaultRightBound = r.maxval; } + + let leftValue = index.fields.map((field) => + getIndexValue(field, obj, above && above.value, defaultLeftBound)); + let rightValue = index.fields.map((field) => + getIndexValue(field, obj, below && below.value, defaultRightBound)); + + if (index.name === 'id') { + leftValue = leftValue[0]; + rightValue = rightValue[0]; + } + + return collection.table + .orderBy({index: descending ? r.desc(index.name) : index.name}) + .between(leftValue || r.minval, rightValue || r.maxval, optargs); + }); + })).then((subqueries) => r.union(...subqueries)); +} + +function makeTableScanReql(collection, fixedFields, above, below, descending) { + return collection.getMatchingIndex([], fixedFields).then((index) => { + let leftValue, rightValue; + const optargs = {index: index.name}; + + if (above) { + const defaultLeftBound = above.bound === 'closed' ? r.minval : r.maxval; + leftValue = index.fields.map((field) => + getIndexValue(field, {}, above.value, defaultLeftBound)); + optargs.leftBound = above.bound; + } + if (below) { + const defaultRightBound = below.bound === 'closed' ? r.maxval : r.minval; + rightValue = index.fields.map((field) => + getIndexValue(field, {}, below.value, defaultRightBound)); + optargs.rightBound = below.bound; + } + + if (index.name === 'id') { + if (leftValue) { leftValue = leftValue[0]; } + if (rightValue) { rightValue = rightValue[0]; } + } + + const reqlIndex = descending ? r.desc(index.name) : index.name; + let reql = collection.table.orderBy({index: reqlIndex}); + if (leftValue || rightValue) { + reql = reql.between(leftValue || r.minval, rightValue || r.maxval, optargs); + } + return reql; + }); +} + +function makeReadReql(req) { + return Promise.resolve().then(() => { + const collection = req.getParameter('collection'); + const findAll = req.getParameter('findAll'); + const find = req.getParameter('find'); + + assert(!find || !findAll); + + if (!collection) { + throw new Error('"collection" was not specified.'); + } + + if (find) { + return makeFindReql(collection, find); + } else { + const order = req.getParameter('order'); + const above = req.getParameter('above'); + const below = req.getParameter('below'); + const descending = Boolean(order && order.descending); + + const orderFields = order ? order.fields : []; + + if (above) { + if (order) { + if (!isSameField(above.field, orderFields[0])) { + throw new Error('"above" must be on the same field ' + + 'as the first in "order".'); + } + } else { + orderFields.push(above.field); + } + } + + if (below) { + if (order || above) { + if (!isSameField(below.field, orderFields[0])) { + throw new Error('"below" must be on the same field as ' + + (order ? 'the first in "order"' : '"above"')); + } + } else { + orderFields.push(below.field); + } + } + + let reqlPromise; + if (findAll) { + reqlPromise = makeFindAllReql(collection, findAll, orderFields, + above, below, descending); + } else { + reqlPromise = makeTableScanReql(collection, orderFields, + above, below, descending); + } + + const limit = req.getParameter('limit'); + return limit === undefined ? + reqlPromise : reqlPromise.then((reql) => reql.limit(limit)); + } + }); +} + +module.exports = {makeReadReql, objectToFields}; diff --git a/plugin-utils/src/test.js b/plugin-utils/src/test.js new file mode 100644 index 000000000..ac162eacf --- /dev/null +++ b/plugin-utils/src/test.js @@ -0,0 +1,24 @@ +'use strict'; + +const Response = require('@horizon/server/src/response'); +const jsonpatch = require('jsonpatch'); + +// Mock Response object for use by tests +class MockResponse extends Response { + constructor() { + super((obj) => { + if (obj.patch) { + jsonpatch.apply_patch(this._value, obj.patch); + } + this._messages.push(obj); + }); + + this._value = {}; + this._messages = []; + + this.value = this.complete.then(() => this._value); + this.messages = this.complete.then(() => this._messages); + } +} + +module.exports = {MockResponse}; diff --git a/plugin-utils/src/utils.js b/plugin-utils/src/utils.js new file mode 100644 index 000000000..8f015f543 --- /dev/null +++ b/plugin-utils/src/utils.js @@ -0,0 +1,62 @@ +'use strict'; + +const minRethinkdbVersion = [2, 3, 1]; + +// Recursive version compare, could be flatter but opted for instant return if +// comparison is greater rather than continuing to compare to end. +function versionCompare(actual, minimum) { + for (let i = 0; i < minimum.length; ++i) { + if (actual[i] > minimum[i]) { + return true; + } else if (actual[i] < minimum[i]) { + return false; + } + } + return true; +} + +// Check that RethinkDB matches version requirements +function rethinkdbVersionCheck(versionString) { + const rethinkdbVersionRegex = /^rethinkdb (\d+)\.(\d+)\.(\d+)/i; + const matches = rethinkdbVersionRegex.exec(versionString); + + if (matches) { + // Convert strings to ints and remove first match + const versions = matches.slice(1).map((val) => parseInt(val)); + + if (!versionCompare(versions, minRethinkdbVersion)) { + throw new Error(`RethinkDB (${versions.join('.')}) is below required version ` + + `(${minRethinkdbVersion.join('.')}) for use with Horizon.`); + } + } else { + throw new Error('Unable to determine RethinkDB version, check ' + + `RethinkDB is >= ${minRethinkdbVersion.join('.')}.`); + } +} + +// Used when evaluating things in a different VM context - the errors +// thrown from there will not evaluate as `instanceof Error`, so we recreate them. +function remakeError(err) { + const newErr = new Error(err.message || 'Unknown error when evaluating template.'); + newErr.stack = err.stack || newErr.stack; + throw newErr; +} + +function isObject(x) { + return typeof x === 'object' && !Array.isArray(x) && x !== null; +} + +const reqlOptions = { + timeFormat: 'raw', + binaryFormat: 'raw', +}; + +module.exports = { + rethinkdbVersionCheck, + remakeError, + isObject, + reqlOptions, + reads: require('./reads'), + writes: require('./writes'), + test: require('./test'), +}; diff --git a/plugin-utils/src/writes.js b/plugin-utils/src/writes.js new file mode 100644 index 000000000..774a74c8b --- /dev/null +++ b/plugin-utils/src/writes.js @@ -0,0 +1,191 @@ +'use strict'; + +const assert = require('assert'); + +const {r} = require('@horizon/server'); + +const hzv = '$hz_v$'; + +// Common functionality used by write requests +const invalidatedMsg = 'Write invalidated by another request, try again.'; +const missingMsg = 'The document was missing.'; +const timeoutMsg = 'Operation timed out.'; +const unauthorizedMsg = 'Operation not permitted.'; + +function applyVersion(row, newVersion) { + return row.merge(r.object(hzv, newVersion)); +} + +function makeWriteResponse(data) { + data.forEach((item, index) => { + if (item instanceof Error) { + data[index] = {error: item.message}; + } + }); + return {op: 'replace', path: '', value: {type: 'value', synced: true, val: data}}; +} + +// This function returns a Promise that resolves to an array of responses - +// one for each row in `originalRows`, or rejects with an appropriate error. +// deadline -> a Date object for when to give up retrying, +// or a falsey value for no timeout +// preValidate -> function (rows): +// rows: all pending rows +// return: an array or the promise of an array of info for those rows +// (which will be passed to the validate callback) +// validateRow -> function (validator, row, info): +// validator: The function to validate with +// row: The row from the original query +// info: The info returned by the preValidate step for this row +// return: nothing if successful or an error to be put as the response for this row +// doWrite -> function (rows): +// rows: all pending rows +// return: a (promise of a) ReQL write result object +function retryLoop(originalRows, + permissions, + deadline, + preValidate, + validateRow, + doWrite) { + let firstAttempt = true; + const iterate = (rowDataArg, responseData) => { + let rowData = rowDataArg; + if (rowData.length === 0) { + return responseData; + } else if (!firstAttempt && deadline) { + if (Date.now() > deadline.getTime()) { + responseData.forEach((data, index) => { + if (data === null) { + responseData[index] = new Error(timeoutMsg); + } + }); + return responseData; + } + } + + return Promise.resolve().then(() => { + // The validate callback may clobber the original version field in the row, + // so we have to restore it to the original value. + // This is done because validation only approves moving from one specific + // version of the row to another. Even if the original request did not choose + // the version, we are locked in to the version fetched from the preValidate + // callback until the next iteration. If the version has changed in the meantime, + // it is an invalidated error which may be retried until we hit the deadline. + rowData.forEach((data) => { + if (data.version === undefined) { + delete data.row[hzv]; + } else { + data.row[hzv] = data.version; + } + }); + + // If permissions returns a function, we need to use it to validate + if (permissions()) { + // For the set of rows to write, gather info for the validation step + return Promise.resolve(preValidate(rowData.map((data) => data.row))) + .then((infos) => { + assert(infos.length === rowData.length); + + // For each row to write (and info), validate it with permissions + const validator = permissions(); + if (validator) { + const validRows = []; + rowData.forEach((data, i) => { + const res = validateRow(validator, data.row, infos[i]); + + if (res !== undefined) { + responseData[data.index] = res; + } else { + validRows.push(data); + } + }); + rowData = validRows; + } + }); + } + }).then(() => { // For the set of valid rows, call the write step + if (rowData.length === 0) { + return []; + } + return doWrite(rowData.map((data) => data.row)).then((res) => res.changes); + }).then((changes) => { + assert(changes.length === rowData.length); + + // Remove successful writes and invalidated writes that had an initial version + const retryRows = []; + rowData.forEach((data, index) => { + const res = changes[index]; + if (res.error !== undefined) { + if (res.error.indexOf('Duplicate primary key') === 0) { + responseData[data.index] = {error: 'The document already exists.'}; + } else if (res.error.indexOf(invalidatedMsg) === 0 && + data.version === undefined) { + retryRows.push(data); + } else { + responseData[data.index] = {error: res.error}; + } + } else if (res.new_val === null) { + responseData[data.index] = {id: res.old_val.id, [hzv]: res.old_val[hzv]}; + } else { + responseData[data.index] = {id: res.new_val.id, [hzv]: res.new_val[hzv]}; + } + }); + + // Recurse, after which it will decide if there is more work to be done + firstAttempt = false; + return iterate(retryRows, responseData, deadline); + }); + }; + + return iterate(originalRows.map((row, index) => ({row, index, version: row[hzv]})), + Array(originalRows.length).fill(null)) + .then(makeWriteResponse); +} + +function validateOldRowOptional(validator, original, oldRow, newRow) { + const expectedVersion = original[hzv]; + if (expectedVersion !== undefined && + (!oldRow || expectedVersion !== oldRow[hzv])) { + return new Error(invalidatedMsg); + } else if (!validator(oldRow, newRow)) { + return new Error(unauthorizedMsg); + } + + if (oldRow) { + const oldVersion = oldRow[hzv]; + if (expectedVersion === undefined) { + original[hzv] = oldVersion === undefined ? -1 : oldVersion; + } + } +} + +function validateOldRowRequired(validator, original, oldRow, newRow) { + if (oldRow == null) { + return new Error(missingMsg); + } + + const oldVersion = oldRow[hzv]; + const expectedVersion = original[hzv]; + if (expectedVersion !== undefined && + expectedVersion !== oldVersion) { + return new Error(invalidatedMsg); + } else if (!validator(oldRow, newRow)) { + return new Error(unauthorizedMsg); + } + + if (expectedVersion === undefined) { + original[hzv] = oldVersion === undefined ? -1 : oldVersion; + } +} + +module.exports = { + invalidatedMsg, + missingMsg, + timeoutMsg, + unauthorizedMsg, + applyVersion, + retryLoop, + validateOldRowRequired, + validateOldRowOptional, + versionField: hzv, +}; diff --git a/plugins/.eslintrc.js b/plugins/.eslintrc.js new file mode 100644 index 000000000..bc66210fa --- /dev/null +++ b/plugins/.eslintrc.js @@ -0,0 +1,17 @@ +const OFF = 0; +const WARN = 1; +const ERROR = 2; + +module.exports = { + extends: "../.eslintrc.js", + rules: { + "camelcase": [ ERROR ], + "max-len": [ ERROR, 89 ], + "prefer-template": [ OFF ], + }, + env: { + "es6": true, + "node": true, + "mocha": true, + }, +}; diff --git a/plugins/above/package.json b/plugins/above/package.json new file mode 100644 index 000000000..f3befb466 --- /dev/null +++ b/plugins/above/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/above", + "version": "1.0.0", + "description": "Plugin for the 'above' term in the Horizon Collections API.", + "main": "src/above.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/above/src/above.js b/plugins/above/src/above.js new file mode 100644 index 000000000..8beb59ad4 --- /dev/null +++ b/plugins/above/src/above.js @@ -0,0 +1,41 @@ +'use strict'; + +const {isObject, reads} = require('@horizon/plugin-utils'); +const {objectToFields} = reads; + +function above(req, res, next) { + const args = req.options.above; + if (args.length < 1 || args.length > 2) { + next(new Error(`"above" expected 1 or 2 arguments but found ${args.length}.`)); + } else if (args.length === 2 && (args[1] !== 'open' && args[1] !== 'closed')) { + next(new Error('Second argument to "above" must be "open" or "closed"')); + } else { + const bound = args.length === 1 ? 'open' : args[1]; + if (isObject(args[0])) { + const fields = objectToFields(args[0]); + if (fields.length !== 1) { + next(new Error('Object argument to "above" must have exactly one field.')); + } else { + req.setParameter({field: fields[0], value: args[0], bound}); + next(); + } + } else if (typeof args[0] === 'string') { + req.setParameter({field: ['id'], value: {id: args[0]}, bound}); + next(); + } else { + next(new Error('First argument to "above" must be a string or object.')); + } + } +} + +module.exports = { + name: 'hz_above', + activate: () => ({ + methods: { + above: { + type: 'option', + handler: above, + }, + }, + }), +}; diff --git a/server/src/auth/auth0.js b/plugins/auth0/src/auth0.js similarity index 62% rename from server/src/auth/auth0.js rename to plugins/auth0/src/auth0.js index 838c999da..c506a8d39 100644 --- a/server/src/auth/auth0.js +++ b/plugins/auth0/src/auth0.js @@ -1,7 +1,6 @@ 'use strict'; const auth_utils = require('./utils'); -const logger = require('../logger'); const https = require('https'); const querystring = require('querystring'); @@ -22,28 +21,25 @@ function auth0(horizon, raw_options) { const client_secret = options.secret; const host = options.host; - const self_url = (self_host, path) => - url.format({ protocol: 'https', host: self_host, pathname: path }); - const make_acquire_url = (state, redirect_uri) => - url.format({ protocol: 'https', + url.format({protocol: 'https', host: host, pathname: '/authorize', - query: { response_type: 'code', client_id, redirect_uri, state } }); + query: {response_type: 'code', client_id, redirect_uri, state}}); const make_token_request = (code, redirect_uri) => { - const req = https.request({ method: 'POST', host, path: '/oauth/token', - headers: { 'Content-type': 'application/x-www-form-urlencoded' } }); + const req = https.request({method: 'POST', host, path: '/oauth/token', + headers: {'Content-type': 'application/x-www-form-urlencoded'}}); req.write(querystring.stringify({ - client_id, redirect_uri, client_secret, code, - grant_type: 'authorization_code' - })); + client_id, redirect_uri, client_secret, code, + grant_type: 'authorization_code', + })); return req; }; const make_inspect_request = (access_token) => - https.request({ host, path: '/userinfo', - headers: { Authorization: `Bearer ${access_token}` } }); + https.request({host, path: '/userinfo', + headers: {Authorization: `Bearer ${access_token}`}}); const extract_id = (user_info) => user_info && user_info.user_id; diff --git a/plugins/below/package.json b/plugins/below/package.json new file mode 100644 index 000000000..781ce51dd --- /dev/null +++ b/plugins/below/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/below", + "version": "1.0.0", + "description": "Plugin for the 'below' term in the Horizon Collections API.", + "main": "src/below.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/below/src/below.js b/plugins/below/src/below.js new file mode 100644 index 000000000..db5d1fffa --- /dev/null +++ b/plugins/below/src/below.js @@ -0,0 +1,41 @@ +'use strict'; + +const {isObject, reads} = require('@horizon/plugin-utils'); +const {objectToFields} = reads; + +function below(req, res, next) { + const args = req.options.below; + if (args.length < 1 || args.length > 2) { + next(new Error(`"below" expected 1 or 2 arguments but found ${args.length}.`)); + } else if (args.length === 2 && (args[1] !== 'open' && args[1] !== 'closed')) { + next(new Error('Second argument to "below" must be "open" or "closed"')); + } else { + const bound = args.length === 1 ? 'closed' : args[1]; + if (isObject(args[0])) { + const fields = objectToFields(args[0]); + if (fields.length !== 1) { + next(new Error('Object argument to "below" must have exactly one field.')); + } else { + req.setParameter({field: fields[0], value: args[0], bound}); + next(); + } + } else if (typeof args[0] === 'string') { + req.setParameter({field: ['id'], value: {id: args[0]}, bound}); + next(); + } else { + next(new Error('First argument to "below" must be a string or object.')); + } + } +} + +module.exports = { + name: 'hz_below', + activate: () => ({ + methods: { + below: { + type: 'option', + handler: below, + }, + }, + }), +}; diff --git a/plugins/collection/package.json b/plugins/collection/package.json new file mode 100644 index 000000000..94fdb5eb7 --- /dev/null +++ b/plugins/collection/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/collection", + "version": "1.0.0", + "description": "Plugin for the 'collection' term in the Horizon Collections API.", + "main": "src/collection.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/collection/src/collection.js b/plugins/collection/src/collection.js new file mode 100644 index 000000000..c37e1cb73 --- /dev/null +++ b/plugins/collection/src/collection.js @@ -0,0 +1,78 @@ +'use strict'; + +const ReliableMetadata = require('./types/metadata.js'); +const queries = require('./queries'); +const indexes = require('./indexes'); + +function collection(metadata) { + return (req, res, next) => { + const args = req.options.collection; + if (args.length !== 1) { + next(new Error(`"collection" expected 1 argument but found ${args.length}.`)); + } else if (typeof args[0] !== 'string') { + next(new Error('First argument to "collection" must be a string.')); + } else { + new Promise((resolve, reject) => { + if (metadata.ready) { + resolve(); + } else { + // Wait up to 5 seconds for metadata readiness + // This should only happen if the connection to the database just recovered, + // or there is some invalid data in the metadata. + const timer = setTimeout(() => { + reject(new Error('Timed out waiting for metadata ' + + 'to sync with the database.')); + }, 5000); + + const subs = metadata.subscribe({ + onReady: () => { + subs.close(); + clearTimeout(timer); + resolve(); + }, + }); + } + }).then(() => metadata.collection(args[0])).then((c) => { + req.setParameter(c); + next(); + }).catch(next); + } + }; +} + +module.exports = { + name: 'hz_collection', + activate: (context, options, onReady, onUnready) => { + context[options.name] = new ReliableMetadata( + context, + Boolean(options.auto_create_collection), + Boolean(options.auto_create_index)); + + return new Promise((resolve) => { + context[options.name].subscribe({onUnready, onReady: () => { + resolve({ + methods: { + collection: { + type: 'option', + handler: collection(context[options.name]), + }, + }, + }); + onReady(); + }}); + }); + }, + deactivate: (context, options) => { + const metadata = context[options.name]; + delete context[options.name]; + if (metadata) { + metadata.close(); + } + }, +}; + +module.exports.createCollection = queries.createCollection; +module.exports.initializeMetadata = queries.initializeMetadata; +module.exports.indexNameToInfo = indexes.indexNameToInfo; +module.exports.indexInfoToName = indexes.indexInfoToName; +module.exports.indexInfoToReql = indexes.indexInfoToReql; diff --git a/plugins/collection/src/indexes.js b/plugins/collection/src/indexes.js new file mode 100644 index 000000000..b30f842af --- /dev/null +++ b/plugins/collection/src/indexes.js @@ -0,0 +1,100 @@ +'use strict'; + +const assert = require('assert'); + +const primaryIndexName = 'id'; + +// Index names are of the format "hz_[_]" where may be +// omitted or "multi_" or "geo" (at the moment). is a JSON array +// specifying which fields are indexed in which order. The value at each index +// in the array is either a nested array (for indexing nested fields) or a string +// for a root-level field name. +// +// Example: +// Fields indexed: foo.bar, baz +// Index name: hz_[["foo","bar"],"baz"] +function indexNameToInfo(name) { + if (name === primaryIndexName) { + return {geo: false, multi: false, fields: [['id']]}; + } + + const re = /^hz_(?:(geo)_)?(?:multi_([0-9])+_)?\[/; + + const matches = name.match(re); + assert(matches !== null, `Unexpected index name (invalid format): "${name}"`); + + const jsonOffset = matches[0].length - 1; + + const info = { + name, + geo: Boolean(matches[1]), + multi: isNaN(matches[2]) ? false : Number(matches[2]), + }; + + // Parse remainder as JSON + try { + info.fields = JSON.parse(name.slice(jsonOffset)); + } catch (err) { + assert(false, `Unexpected index name (invalid JSON): "${name}"`); + } + + // Sanity check fields + const validateField = (f) => { + assert(Array.isArray(f), `Unexpected index name (invalid field): "${name}"`); + f.forEach((s) => assert(typeof s === 'string', + `Unexpected index name (invalid field): "${name}"`)); + }; + + assert(Array.isArray(info.fields), + `Unexpected index name (fields are not an array): "${name}"`); + assert((info.multi === false) || (info.multi < info.fields.length), + `Unexpected index name (multi index out of bounds): "${name}"`); + info.fields.forEach(validateField); + return info; +} + +function indexInfoToName(info) { + let res = 'hz_'; + if (info.geo) { + res += 'geo_'; + } + if (info.multi !== false) { + res += 'multi_' + info.multi + '_'; + } + res += JSON.stringify(info.fields); + return res; +} + +function indexInfoToReql(info) { + if (info.geo && (info.multi !== false)) { + throw new Error('multi and geo cannot be specified on the same index'); + } + + if (info.multi !== false) { + const multiField = info.fields[info.multi]; + return (row) => + row(multiField).map((value) => info.fields.map((f, i) => { + if (i === info.multi) { + return value; + } else { + let res = row; + f.forEach((fieldName) => { res = res(fieldName); }); + return res; + } + })); + } else { + return (row) => + info.fields.map((f) => { + let res = row; + f.forEach((fieldName) => { res = res(fieldName); }); + return res; + }); + } +} + +module.exports = { + indexInfoToName, + indexInfoToReql, + indexNameToInfo, + primaryIndexName, +}; diff --git a/plugins/collection/src/queries.js b/plugins/collection/src/queries.js new file mode 100644 index 000000000..b0411e6bb --- /dev/null +++ b/plugins/collection/src/queries.js @@ -0,0 +1,43 @@ +'use strict'; + +const {r} = require('@horizon/server'); + +const metadataVersion = [2, 0, 0]; + +function createCollection(db, name, conn) { + return r.db(db).table('hz_collections').get(name).replace({id: name}).do((res) => + r.branch( + res('errors').ne(0), + r.error(res('first_error')), + res('inserted').eq(1), + r.db(db).tableCreate(name), + res + ) + ).run(conn); +} + +function initializeMetadata(db, conn) { + return r.branch(r.dbList().contains(db), null, r.dbCreate(db)).run(conn) + .then(() => + Promise.all(['hz_collections', 'hz_users_auth', 'hz_groups'].map((table) => + r.branch(r.db(db).tableList().contains(table), + { }, + r.db(db).tableCreate(table)) + .run(conn)))) + .then(() => + r.db(db).table('hz_collections').wait({timeout: 30}).run(conn)) + .then(() => + Promise.all([ + r.db(db).tableList().contains('users').not().run(conn).then(() => + createCollection(db, 'users', conn)), + r.db(db).table('hz_collections') + .insert({id: 'hz_metadata', version: metadataVersion}) + .run(conn), + ]) + ); +} + +module.exports = { + createCollection, + initializeMetadata, +}; diff --git a/plugins/collection/src/types/collection.js b/plugins/collection/src/types/collection.js new file mode 100644 index 000000000..63379bb79 --- /dev/null +++ b/plugins/collection/src/types/collection.js @@ -0,0 +1,109 @@ +'use strict'; + +const Table = require('./table'); + +const {r} = require('@horizon/server'); + +class Collection { + constructor(db, name, reliableConn) { + this.name = name; + this.reliableConn = reliableConn; + this.table = r.db(db).table(name); // This is the ReQL Table object + this._tables = new Map(); // A Map of Horizon Table objects + this._registered = false; // Whether the `hz_collections` table thinks this exists + this._waiters = []; + } + + close() { + this._tables.forEach((table) => { + table._waiters.forEach((w) => w(new Error('collection deleted'))); + table._waiters = []; + table.close(); + }); + this._waiters.forEach((w) => w(new Error('collection deleted'))); + this._waiters = []; + } + + _updateTable(tableId, indexes, conn) { + let table = this._tables.get(tableId); + if (indexes) { + if (!table) { + table = new Table(this.table, conn); + this._tables.set(tableId, table); + } + table.updateIndexes(indexes, conn); + this._waiters.forEach((w) => table.onReady(w)); + this._waiters = []; + } else { + this._tables.delete(tableId); + if (table) { + table._waiters.forEach((w) => this.onReady(w)); + table._waiters = []; + table.close(); + } + } + } + + _register() { + this._registered = true; + } + + _unregister() { + this._registered = false; + } + + _isSafeToRemove() { + return this._tables.size === 0 && !this._registered; + } + + _onReady(done) { + if (this._tables.size === 0) { + this._waiters.push(done); + } else { + this._getTable().onReady(done); + } + } + + _getTable() { + if (this._tables.size === 0) { + throw new Error(`Collection ${this.name} is not ready.`); + } + return this._tables.values().next().value; + } + + _createIndex(fields, done) { + return this._getTable().createIndex(fields, this.reliableConn.connection(), done); + } + + ready() { + if (this._tables.size === 0) { + return false; + } + return this._getTable().ready(); + } + + getMatchingIndex(fuzzyFields, orderedFields) { + return new Promise((resolve, reject) => { + const done = (indexOrErr) => { + if (indexOrErr instanceof Error) { + reject(indexOrErr); + } else { + resolve(indexOrErr); + } + }; + + const match = this._getTable().getMatchingIndex(fuzzyFields, orderedFields); + if (match) { + if (match.ready()) { + resolve(match); + } else { + match.onReady(done); + } + } else { + this._createIndex(fuzzyFields.concat(orderedFields), done); + } + }); + } +} + +module.exports = Collection; diff --git a/plugins/collection/src/types/index.js b/plugins/collection/src/types/index.js new file mode 100644 index 000000000..8d2bbe281 --- /dev/null +++ b/plugins/collection/src/types/index.js @@ -0,0 +1,109 @@ +'use strict'; + +const {indexNameToInfo, primaryIndexName} = require('../indexes'); + +const {logger} = require('@horizon/server'); + +const compareFields = (a, b) => { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +}; + +class Index { + constructor(name, table, conn) { + logger.debug(`${table} index registered: ${name}`); + const info = indexNameToInfo(name); + this.name = name; + this.geo = info.geo; // true or false + this.multi = info.multi; // false or the offset of the multi field + this.fields = info.fields; // array of fields or nested field paths + + this._waiters = []; + this._result = null; + + if (this.geo) { + logger.warn(`Unsupported index (geo): ${this.name}`); + } else if (this.multi !== false) { + logger.warn(`Unsupported index (multi): ${this.name}`); + } + + if (name !== primaryIndexName) { + table.indexWait(name).run(conn).then(() => { + logger.debug(`${table} index ready: ${name}`); + this._result = true; + this._waiters.forEach((w) => w(this)); + this._waiters = []; + }).catch((err) => { + this._result = err; + this._waiters.forEach((w) => w(err)); + this._waiters = []; + }); + } else { + logger.debug(`${table} index ready: ${name}`); + this._result = true; + } + } + + close() { + this._waiters.forEach((w) => w(new Error('index deleted'))); + this._waiters = []; + } + + ready() { + return this._result === true; + } + + onReady(done) { + if (this._result === true) { + done(); + } else if (this._result) { + done(this); + } else { + this._waiters.push(done); + } + } + + // `fuzzyFields` may be in any order at the beginning of the index. + // These must be immediately followed by `orderedFields` in the exact + // order given. There may be no other fields present in the index + // (because the absence of a field would mean that row is not indexed). + // `fuzzyFields` may overlap with `orderedFields`. + isMatch(fuzzyFields, orderedFields) { + // TODO: multi index matching + if (this.geo || this.multi !== false) { + return false; + } + + if (this.fields.length > fuzzyFields.length + orderedFields.length || + this.fields.length < fuzzyFields.length || + this.fields.length < orderedFields.length) { + return false; + } + + for (let i = 0; i < fuzzyFields.length; ++i) { + let found = false; + for (let j = 0; j < fuzzyFields.length && !found; ++j) { + found = compareFields(fuzzyFields[i], this.fields[j]); + } + if (!found) { return false; } + } + + for (let i = 0; i < orderedFields.length; ++i) { + const pos = this.fields.length - orderedFields.length + i; + if (pos < 0 || !compareFields(orderedFields[i], this.fields[pos])) { + return false; + } + } + + return true; + } +} + +module.exports = Index; diff --git a/plugins/collection/src/types/metadata.js b/plugins/collection/src/types/metadata.js new file mode 100644 index 000000000..4a30af265 --- /dev/null +++ b/plugins/collection/src/types/metadata.js @@ -0,0 +1,349 @@ +'use strict'; + +const queries = require('../queries'); +const Collection = require('./collection'); + +const assert = require('assert'); + +const { + r, + logger, + Reliable, + ReliableUnion, + ReliableChangefeed, +} = require('@horizon/server'); + +const {rethinkdbVersionCheck} = require('@horizon/plugin-utils'); + +class StaleAttemptError extends Error { } + +// RSI: fix all this shit. +class ReliableInit extends Reliable { + constructor(db, rdbConnection, auto_create_collection) { + super(); + this.db = db; + this.auto_create_collection = auto_create_collection; + this.connSubs = rdbConnection.subscribe({ + onReady: (conn) => { + this.currentAttempt = Symbol(); + this.doInit(conn, this.currentAttempt); + }, + onUnready: () => { + this.currentAttempt = null; + if (this.ready) { + this.emit('onUnready'); + } + }, + }); + } + + checkAttempt(attempt) { + if (attempt !== this.currentAttempt) { + throw new StaleAttemptError(); + } + } + + doInit(conn, attempt) { + Promise.resolve().then(() => { + this.checkAttempt(attempt); + logger.debug('checking rethinkdb version'); + const q = r.db('rethinkdb').table('server_status').nth(0)('process')('version'); + return q.run(conn).then((res) => rethinkdbVersionCheck(res)); + }).then(() => { + this.checkAttempt(attempt); + logger.debug('checking for old metadata version'); + const oldMetadataDb = `${this.db}_internal`; + return r.dbList().contains(oldMetadataDb).run(conn).then((hasOldDb) => { + if (hasOldDb) { + throw new Error('The Horizon metadata appears to be from v1.x because ' + + `the "${oldMetadataDb}" database exists. Please use ` + + '`hz migrate` to convert your metadata to the new format.'); + } + }); + }).then(() => { + this.checkAttempt(attempt); + logger.debug('checking for internal tables'); + if (this.auto_create_collection) { + return queries.initializeMetadata(this.db, conn); + } else { + return r.dbList().contains(this.db).run(conn).then((hasDb) => { + if (!hasDb) { + throw new Error(`The database ${this.db} does not exist. ` + + 'Run `hz schema apply` to initialize the database, ' + + 'then start the Horizon server.'); + } + }); + } + }).then(() => { + this.checkAttempt(attempt); + logger.debug('waiting for internal tables'); + return r.expr(['hz_collections', 'hz_users_auth', 'hz_groups', 'users']) + .forEach((table) => r.db(this.db).table(table).wait({timeout: 30})).run(conn); + }).then(() => { + this.checkAttempt(attempt); + logger.debug('adding admin user'); + return Promise.all([ + r.db(this.db).table('users').get('admin') + .replace((oldRow) => + r.branch(oldRow.eq(null), + { + id: 'admin', + groups: ['admin'], + }, + oldRow), + {returnChanges: 'always'})('changes')(0) + .do((res) => + r.branch(res('new_val').eq(null), + r.error(res('error')), + res('new_val'))).run(conn), + r.db(this.db).table('hz_groups').get('admin') + .replace((oldRow) => + r.branch(oldRow.eq(null), + { + id: 'admin', + rules: {carte_blanche: {template: 'any()'}}, + }, + oldRow), + {returnChanges: 'always'})('changes')(0) + .do((res) => + r.branch(res('new_val').eq(null), + r.error(res('error')), + res('new_val'))).run(conn), + ]); + }).then(() => { + this.checkAttempt(attempt); + logger.debug('metadata sync complete'); + this.emit('onReady'); + }).catch((err) => { + if (!(err instanceof StaleAttemptError)) { + logger.debug(`Metadata initialization failed: ${err.stack}`); + setTimeout(() => { this.doInit(conn, attempt); }, 1000); + } + }); + } + + close(reason) { + this.currentAttempt = null; + super.close(reason); + this.connSubs.close(reason); + } +} + +class ReliableMetadata extends Reliable { + constructor(context, + auto_create_collection, + auto_create_index) { + super(); + this.db = context.horizon.options.project_name; + this.rdbConnection = context.horizon.rdbConnection; + this.auto_create_collection = auto_create_collection; + this.auto_create_index = auto_create_index; + this.collections = new Map(); + + this.reliableInit = new ReliableInit( + this.db, this.rdbConnection, auto_create_collection); + + this.connSubs = this.rdbConnection.subscribe({ + onReady: (conn) => { + this.connection = conn; + }, + onUnready: () => { + this.connection = null; + }, + }); + + // RSI: stop these from running until after ReliableInit? + this.collectionChangefeed = new ReliableChangefeed( + r.db(this.db) + .table('hz_collections') + .filter((row) => row('id').match('^hzp?_').not()) + .changes({squash: false, includeInitial: true, includeTypes: true}), + this.rdbConnection, + { + onChange: (change) => { + switch (change.type) { + case 'initial': + case 'add': + case 'change': + { + const name = change.new_val.id; + let collection = this.collections.get(name); + if (!collection) { + collection = new Collection( + this.db, name, this.rdbConnection); + this.collections.set(name, collection); + } + collection._register(); + } + break; + case 'uninitial': + case 'remove': + { + const name = change.new_val.id; + const collection = this.collections.get(name); + if (collection) { + collection._unregister(); + if (collection._isSafeToRemove()) { + this.collections.delete(name); + collection.close(); + } + } + } + break; + default: + // log error + break; + } + }, + }); + + this.indexChangefeed = new ReliableChangefeed( + r.db('rethinkdb') + .table('table_config') + .filter((row) => r.and(row('db').eq(this.db), + row('name').match('^hzp?_').not())) + .map((row) => ({ + id: row('id'), + name: row('name'), + indexes: row('indexes').filter((idx) => idx.match('^hz_')), + })) + .changes({squash: true, includeInitial: true, includeTypes: true}), + this.rdbConnection, + { + onChange: (change) => { + if (!this.connection) { return; } + switch (change.type) { + case 'initial': + case 'add': + case 'change': + { + const name = change.new_val.name; + const tableId = change.new_val.id; + + let collection = this.collections.get(name); + if (!collection) { + collection = new Collection( + this.db, name, this.rdbConnection); + this.collections.set(name, collection); + } + collection._updateTable( + tableId, change.new_val.indexes, this.connection); + } + break; + case 'uninitial': + case 'remove': + { + const collection = this.collections.get(change.old_val.name); + if (collection) { + collection._updateTable(change.old_val.id, null, this.connection); + if (collection._isSafeToRemove()) { + this.collections.delete(collection); + collection.close(); + } + } + } + break; + default: + // log error + break; + } + }, + }); + + this.readyUnion = new ReliableUnion({ + reliableInit: this.reliableInit, + collectionChangefeed: this.collectionChangefeed, + indexChangefeed: this.indexChangefeed, + }, { + onReady: () => { + this.emit('onReady'); + }, + onUnready: () => { + // TODO: fill in the reason for `close`. + this.emit('onUnready'); + this.collections.forEach((collection) => collection.close()); + this.collections.clear(); + }, + }); + } + + close(reason) { + return Promise.all([ + super.close(reason), + this.connSubs.close(reason), + this.reliableInit.close(reason), + this.collectionChangefeed.close(reason), + this.indexChangefeed.close(reason), + this.readyUnion.close(reason), + ]); + } + + // Public interface for use by plugins or other classes, + // returns a Promise of a collection object + collection(name) { + return Promise.resolve().then(() => { + if (name.indexOf('hz_') === 0 || name.indexOf('hzp_') === 0) { + throw new Error(`Collection "${name}" is reserved for internal use ` + + 'and cannot be used in requests.'); + } else if (!this.ready) { + throw new Error('Metadata is not synced with the database.'); + } + + const collection = this.collections.get(name); + if (!collection && !this.auto_create_collection) { + throw new Error(`Collection "${name}" does not exist.`); + } else if (collection) { + if (!collection.ready()) { + return new Promise((resolve, reject) => + collection._onReady((maybeErr) => { + if (maybeErr instanceof Error) { + reject(maybeErr); + } else { + resolve(collection); + } + })); + } + return collection; + } + return this.createCollection(name); + }); + } + + createCollection(name) { + let collection; + return Promise.resolve().then(() => { + if (name.indexOf('hz_') === 0 || name.indexOf('hzp_') === 0) { + throw new Error(`Collection "${name}" is reserved for internal use ` + + 'and cannot be used in requests.'); + } else if (!this.ready) { + throw new Error('Metadata is not synced with the database.'); + } else if (this.collections.get(name)) { + throw new Error(`Collection "${name}" already exists.`); + } + + collection = new Collection(this.db, name, this.rdbConnection); + this.collections.set(name, collection); + + return queries.createCollection(this.db, name, this.rdbConnection.connection()); + }).then((res) => { + assert(!res.error, `Collection "${name}" creation failed: ${res.error}`); + logger.warn(`Collection created: "${name}"`); + return new Promise((resolve, reject) => + collection._onReady((maybeErr) => { + if (maybeErr instanceof Error) { + reject(maybeErr); + } else { + resolve(collection); + } + })); + }).catch((err) => { + if (collection && collection._isSafeToRemove()) { + this.collections.delete(name); + collection.close(); + } + throw err; + }); + } +} + +module.exports = ReliableMetadata; diff --git a/server/src/metadata/table.js b/plugins/collection/src/types/table.js similarity index 52% rename from server/src/metadata/table.js rename to plugins/collection/src/types/table.js index 828310ddd..369fa6b00 100644 --- a/server/src/metadata/table.js +++ b/plugins/collection/src/types/table.js @@ -1,36 +1,37 @@ 'use strict'; -const error = require('../error'); -const index = require('./index'); -const logger = require('../logger'); +const Index = require('./index'); +const {primaryIndexName, indexInfoToReql, indexInfoToName} = require('../indexes'); -const r = require('rethinkdb'); +const assert = require('assert'); + +const {r, logger} = require('@horizon/server'); class Table { - constructor(reql_table, conn) { - this.table = reql_table; + constructor(reqlTable, conn) { + this.table = reqlTable; this.indexes = new Map(); - this._waiters = [ ]; + this._waiters = []; this._result = null; this.table - .wait({ waitFor: 'all_replicas_ready' }) + .wait({waitFor: 'all_replicas_ready'}) .run(conn) .then(() => { this._result = true; this._waiters.forEach((w) => w()); - this._waiters = [ ]; + this._waiters = []; }).catch((err) => { this._result = err; this._waiters.forEach((w) => w(err)); - this._waiters = [ ]; + this._waiters = []; }); } close() { this._waiters.forEach((w) => w(new Error('collection deleted'))); - this._waiters = [ ]; + this._waiters = []; this.indexes.forEach((i) => i.close()); this.indexes.clear(); @@ -40,7 +41,7 @@ class Table { return this._result === true; } - on_ready(done) { + onReady(done) { if (this._result === true) { done(); } else if (this._result) { @@ -50,49 +51,50 @@ class Table { } } - update_indexes(indexes, conn) { + updateIndexes(indexes, conn) { logger.debug(`${this.table} indexes changed, reevaluating`); // Initialize the primary index, which won't show up in the changefeed - indexes.push(index.primary_index_name); + indexes.push(primaryIndexName); - const new_index_map = new Map(); + const newIndexMap = new Map(); indexes.forEach((name) => { try { - const old_index = this.indexes.get(name); - const new_index = new index.Index(name, this.table, conn); - if (old_index) { + const oldIndex = this.indexes.get(name); + const newIndex = new Index(name, this.table, conn); + if (oldIndex) { // Steal any waiters from the old index - new_index._waiters = old_index._waiters; - old_index._waiters = [ ]; + newIndex._waiters = oldIndex._waiters; + oldIndex._waiters = []; } - new_index_map.set(name, new_index); + newIndexMap.set(name, newIndex); } catch (err) { logger.warn(`${err}`); } }); this.indexes.forEach((i) => i.close()); - this.indexes = new_index_map; + this.indexes = newIndexMap; logger.debug(`${this.table} indexes updated`); } // TODO: support geo and multi indexes - create_index(fields, conn, done) { - const info = { geo: false, multi: false, fields }; - const index_name = index.info_to_name(info); - error.check(!this.indexes.get(index_name), 'index already exists'); + createIndex(fields, conn, done) { + const info = {geo: false, multi: false, fields}; + const indexName = indexInfoToName(info); + assert(!this.indexes.get(indexName), 'index already exists'); const success = () => { // Create the Index object now so we don't try to create it again before the // feed notifies us of the index creation - const new_index = new index.Index(index_name, this.table, conn); - this.indexes.set(index_name, new_index); // TODO: shouldn't this be done before we go async? - return new_index.on_ready(done); + const newIndex = new Index(indexName, this.table, conn); + // TODO: shouldn't this be done before we go async? + this.indexes.set(indexName, newIndex); + return newIndex.onReady(done); }; - this.table.indexCreate(index_name, index.info_to_reql(info), - { geo: info.geo, multi: (info.multi !== false) }) + this.table.indexCreate(indexName, indexInfoToReql(info), + {geo: info.geo, multi: (info.multi !== false)}) .run(conn) .then(success) .catch((err) => { @@ -106,15 +108,15 @@ class Table { } // Returns a matching (possibly compound) index for the given fields - // fuzzy_fields and ordered_fields should both be arrays - get_matching_index(fuzzy_fields, ordered_fields) { - if (fuzzy_fields.length === 0 && ordered_fields.length === 0) { - return this.indexes.get(index.primary_index_name); + // `fuzzyFields` and `orderedFields` should both be arrays + getMatchingIndex(fuzzyFields, orderedFields) { + if (fuzzyFields.length === 0 && orderedFields.length === 0) { + return this.indexes.get(primaryIndexName); } let match; for (const i of this.indexes.values()) { - if (i.is_match(fuzzy_fields, ordered_fields)) { + if (i.isMatch(fuzzyFields, orderedFields)) { if (i.ready()) { return i; } else if (!match) { @@ -127,4 +129,4 @@ class Table { } } -module.exports = { Table }; +module.exports = Table; diff --git a/plugins/defaults/package.json b/plugins/defaults/package.json new file mode 100644 index 000000000..e9c8c94c9 --- /dev/null +++ b/plugins/defaults/package.json @@ -0,0 +1,55 @@ +{ + "name": "@horizon-plugins/defaults", + "version": "1.0.0", + "description": "Contains all the default plugins for the Horizon Collections API.", + "main": "src/defaults.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon-plugins/above": "1.0.0", + "@horizon-plugins/below": "1.0.0", + "@horizon-plugins/collection": "1.0.0", + "@horizon-plugins/fetch": "1.0.0", + "@horizon-plugins/find": "1.0.0", + "@horizon-plugins/findAll": "1.0.0", + "@horizon-plugins/insert": "1.0.0", + "@horizon-plugins/limit": "1.0.0", + "@horizon-plugins/order": "1.0.0", + "@horizon-plugins/permissions": "1.0.0", + "@horizon-plugins/permit-all": "1.0.0", + "@horizon-plugins/remove": "1.0.0", + "@horizon-plugins/replace": "1.0.0", + "@horizon-plugins/store": "1.0.0", + "@horizon-plugins/timeout": "1.0.0", + "@horizon-plugins/update": "1.0.0", + "@horizon-plugins/upsert": "1.0.0", + "@horizon-plugins/watch": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/defaults/src/defaults.js b/plugins/defaults/src/defaults.js new file mode 100644 index 000000000..2d5cb3871 --- /dev/null +++ b/plugins/defaults/src/defaults.js @@ -0,0 +1,109 @@ +'use strict'; + +// Collections API +const defaultMethods = { + above: require('@horizon-plugins/above'), + below: require('@horizon-plugins/below'), + collection: require('@horizon-plugins/collection'), + insert: require('@horizon-plugins/insert'), + fetch: require('@horizon-plugins/fetch'), + find: require('@horizon-plugins/find'), + findAll: require('@horizon-plugins/findAll'), + limit: require('@horizon-plugins/limit'), + order: require('@horizon-plugins/order'), + remove: require('@horizon-plugins/remove'), + replace: require('@horizon-plugins/replace'), + store: require('@horizon-plugins/store'), + timeout: require('@horizon-plugins/timeout'), + update: require('@horizon-plugins/update'), + upsert: require('@horizon-plugins/upsert'), + watch: require('@horizon-plugins/watch'), +}; + +// Permissions API +const defaultPermissions = { + permissions: require('@horizon-plugins/permissions'), + 'permit-all': require('@horizon-plugins/permit-all'), +}; + +// Combines some subset of the default plugins into a single plugin for ease-of-use. +// `options` may have any or all of these properties: +// `methods`: an array of default methods to include, defaults to all of them +// `permissions`: +// false: no permissions plugin will be loaded (the collections API won't work +// unless some other plugin provides the 'hz_permissions' prereq) +// 'permissions': the standard permissions plugin will be loaded (default) +// 'permit-all': a dummy permissions plugin will be loaded that allows all requests +module.exports = { + name: 'hz_defaults', + activate: (context, options, onReady, onUnready) => { + const subplugins = (options.methods || Object.keys(defaultMethods)).map((name) => { + const plugin = defaultMethods[name]; + if (!plugin) { + throw new Error(`Method "${name}" is not provided by a default Horizon plugin.`); + } + return plugin; + }); + context[options.name] = {subplugins}; + + if (options.permissions === undefined) { + // Use the secure thing by default + subplugins.push(defaultPermissions.permissions); + } else if (options.permissions !== false) { + const plugin = defaultPermissions[options.permissions]; + if (!plugin) { + throw new Error(`Unrecognized permissions plugin name "${options.permissions}", ` + + 'expected "permissions" or "permit-all".'); + } + subplugins.push(plugin); + } + + // Some subplugins may need to notify about readiness + const readyPlugins = new Map(); + function ready(name) { + readyPlugins.set(name); + if (readyPlugins.size === subplugins.length) { + onReady(); + } + } + function unready(name) { + if (readyPlugins.size === subplugins.length) { + onUnready(); + } + readyPlugins.delete(name); + } + + const promises = subplugins.map((plugin) => { + const promise = Promise.resolve().then(() => + // Activate each plugin with their default name rather than the + // name of the defaults plugin + plugin.activate(context, + Object.assign({}, options, {name: plugin.name}), + () => ready(plugin.name), + () => unready(plugin.name)) + ); + if (plugin.activate.length < 3) { + promise.then(() => ready(plugin.name)); + } + return promise; + }); + + return Promise.all(promises).then((results) => ({ + methods: Object.assign({}, ...results.map((i) => i.methods)), + })); + }, + + deactivate: (context, options) => { + const subplugins = context[options.name].subplugins; + delete context[options.name]; + return Promise.all(subplugins.map((plugin) => + Promise.resolve().then(() => { + if (plugin.deactivate) { + plugin.deactivate(context, Object.assign({}, options, {name: plugin.name})); + } + }))); + }, +}; + +module.exports.methods = defaultMethods; +module.exports.permissions = defaultPermissions; diff --git a/server/src/auth/facebook.js b/plugins/facebook/src/facebook.js similarity index 77% rename from server/src/auth/facebook.js rename to plugins/facebook/src/facebook.js index 10d0eb7cf..a92a99626 100644 --- a/server/src/auth/facebook.js +++ b/plugins/facebook/src/facebook.js @@ -25,10 +25,10 @@ function facebook(horizon, raw_options) { const make_app_token_request = () => https.request( - url.format({ protocol: 'https', + url.format({protocol: 'https', host: 'graph.facebook.com', pathname: '/oauth/access_token', - query: { client_id, client_secret, grant_type: 'client_credentials' } })); + query: {client_id, client_secret, grant_type: 'client_credentials'}})); auth_utils.run_request(make_app_token_request(), (err, body) => { const parsed = body && querystring.parse(body); @@ -41,28 +41,28 @@ function facebook(horizon, raw_options) { } }); - const oauth_options = { horizon, provider }; + const oauth_options = {horizon, provider}; oauth_options.make_acquire_url = (state, redirect_uri) => - url.format({ protocol: 'https', + url.format({protocol: 'https', host: 'www.facebook.com', pathname: '/dialog/oauth', - query: { client_id, state, redirect_uri, response_type: 'code' } }); + query: {client_id, state, redirect_uri, response_type: 'code'}}); oauth_options.make_token_request = (code, redirect_uri) => { - const req = https.request({ method: 'POST', + const req = https.request({method: 'POST', host: 'graph.facebook.com', - path: '/v2.3/oauth/access_token' }); - req.write(querystring.stringify({ code, redirect_uri, client_id, client_secret })); + path: '/v2.3/oauth/access_token'}); + req.write(querystring.stringify({code, redirect_uri, client_id, client_secret})); return req; }; oauth_options.make_inspect_request = (input_token) => https.request( - url.format({ protocol: 'https', + url.format({protocol: 'https', host: 'graph.facebook.com', pathname: '/debug_token', - query: { access_token: app_token, input_token } })); + query: {access_token: app_token, input_token}})); oauth_options.extract_id = (user_info) => user_info && user_info.data && user_info.data.user_id; diff --git a/plugins/fetch/package.json b/plugins/fetch/package.json new file mode 100644 index 000000000..f96783ac9 --- /dev/null +++ b/plugins/fetch/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/fetch", + "version": "1.0.0", + "description": "Plugin for the 'fetch' term in the Horizon Collections API.", + "main": "src/fetch.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/fetch/src/fetch.js b/plugins/fetch/src/fetch.js new file mode 100644 index 000000000..d94711fb9 --- /dev/null +++ b/plugins/fetch/src/fetch.js @@ -0,0 +1,71 @@ +'use strict'; + +const {reqlOptions, reads} = require('@horizon/plugin-utils'); + +function fetch(context) { + return (req, res, next) => { + const args = req.options.fetch; + const permissions = req.getParameter('hz_permissions'); + const conn = context.horizon.rdbConnection.connection(); + + if (args.length !== 0) { + next(new Error(`"fetch" expects 0 arguments but found ${args.length}`)); + } else if (!permissions) { + next(new Error('"fetch" requires permissions to run')); + } else { + reads.makeReadReql(req).then((reql) => { + return reql.run(conn, reqlOptions) + }).then((result) => { + if (result !== null && result.constructor.name === 'Cursor') { + const cleanup = () => feed.close().catch(() => {}); + res.complete.then(cleanup).catch(cleanup); + + // RSI: utility functions to make this easier? + res.write({op: 'replace', path: '', value: {type: 'value', synced: false, val: []}}); + + // TODO: reuse cursor batching + return result.eachAsync((item) => { + const validator = permissions(); + if (validator && !validator(item)) { + next(new Error('Operation not permitted.')); + cleanup(); + } else { + res.write({op: 'add', path: '/val/-', value: item}); + } + }).then(() => { + res.end({op: 'replace', path: '/synced', value: true}); + }); + } else { + const validator = permissions(); + if (result !== null && result.constructor.name === 'Array') { + if (validator) { + for (const item of result) { + if (!validator(item)) { + throw new Error('Operation not permitted.'); + } + } + } + res.end({op: 'replace', path: '', value: {type: 'value', synced: true, val: result}}); + } else if (validator && !validator(result)) { + next(new Error('Operation not permitted.')); + } else { + res.end({op: 'replace', path: '', value: {type: 'value', synced: true, val: result}}); + } + } + }).catch(next); + } + }; +} + +module.exports = { + name: 'hz_fetch', + activate: (context) => ({ + methods: { + fetch: { + type: 'terminal', + requires: ['hz_permissions'], + handler: fetch(context), + }, + }, + }), +}; diff --git a/plugins/find/package.json b/plugins/find/package.json new file mode 100644 index 000000000..c07d4a5a4 --- /dev/null +++ b/plugins/find/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/find", + "version": "1.0.0", + "description": "Plugin for the 'find' term in the Horizon Collections API.", + "main": "src/find.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/find/src/find.js b/plugins/find/src/find.js new file mode 100644 index 000000000..46763bb94 --- /dev/null +++ b/plugins/find/src/find.js @@ -0,0 +1,34 @@ +'use strict'; + +const {isObject} = require('@horizon/plugin-utils'); + +function find(req, res, next) { + const args = req.options.find; + if (args.length !== 1) { + next(new Error(`"find" expected 1 argument but found ${args.length}.`)); + } else if (!isObject(args[0])) { + next(new Error('First argument to "find" must be an object.')); + } else if (req.options.findAll || + req.options.limit || + req.options.order || + req.options.above || + req.options.below) { + next(new Error('"find" cannot be used with ' + + '"findAll", "limit", "order", "above", or "below"')); + } else { + req.setParameter(args[0]); + next(); + } +} + +module.exports = { + name: 'hz_find', + activate: () => ({ + methods: { + find: { + type: 'option', + handler: find, + }, + }, + }), +}; diff --git a/plugins/findAll/package.json b/plugins/findAll/package.json new file mode 100644 index 000000000..99e6d3f70 --- /dev/null +++ b/plugins/findAll/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/findAll", + "version": "1.0.0", + "description": "Plugin for the 'findAll' term in the Horizon Collections API.", + "main": "src/findAll.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/findAll/src/findAll.js b/plugins/findAll/src/findAll.js new file mode 100644 index 000000000..259a59d4a --- /dev/null +++ b/plugins/findAll/src/findAll.js @@ -0,0 +1,29 @@ +'use strict'; + +const {isObject} = require('@horizon/plugin-utils'); + +function findAll(req, res, next) { + const args = req.options.findAll; + if (args.length < 1) { + next(new Error(`"findAll" expected 1 or more arguments but found ${args.length}.`)); + } else if (!args.every((val) => isObject(val))) { + next(new Error('All arguments to "findAll" must be objects.')); + } else if (req.options.find) { + next(new Error('"findAll" cannot be used with "find"')); + } else { + req.setParameter(args); + next(); + } +} + +module.exports = { + name: 'hz_findAll', + activate: () => ({ + methods: { + findAll: { + type: 'option', + handler: findAll, + }, + }, + }), +}; diff --git a/server/src/auth/github.js b/plugins/github/src/github.js similarity index 67% rename from server/src/auth/github.js rename to plugins/github/src/github.js index 84bfc79bc..251286af5 100644 --- a/server/src/auth/github.js +++ b/plugins/github/src/github.js @@ -19,29 +19,29 @@ function github(horizon, raw_options) { const client_secret = options.secret; const provider = options.path; - const oauth_options = { horizon, provider }; + const oauth_options = {horizon, provider}; oauth_options.make_acquire_url = (state, redirect_uri) => - url.format({ protocol: 'https', + url.format({protocol: 'https', host: 'github.com', pathname: '/login/oauth/authorize', - query: { client_id, redirect_uri, state } }); + query: {client_id, redirect_uri, state}}); oauth_options.make_token_request = (code, redirect_uri) => { - const req = https.request({ method: 'POST', + const req = https.request({method: 'POST', host: 'github.com', path: '/login/oauth/access_token', - headers: { accept: 'application/json' } }); + headers: {accept: 'application/json'}}); - req.write(querystring.stringify({ code, client_id, client_secret, redirect_uri })); + req.write(querystring.stringify({code, client_id, client_secret, redirect_uri})); return req; }; oauth_options.make_inspect_request = (access_token) => - https.request({ host: 'api.github.com', - path: `/user?${querystring.stringify({ access_token })}`, - headers: { 'user-agent': 'node.js' } }); + https.request({host: 'api.github.com', + path: `/user?${querystring.stringify({access_token})}`, + headers: {'user-agent': 'node.js'}}); oauth_options.extract_id = (user_info) => user_info && user_info.id; diff --git a/server/src/auth/google.js b/plugins/google/src/google.js similarity index 72% rename from server/src/auth/google.js rename to plugins/google/src/google.js index 3af34c164..dad305b3b 100644 --- a/server/src/auth/google.js +++ b/plugins/google/src/google.js @@ -20,26 +20,26 @@ function google(horizon, raw_options) { const client_secret = options.secret; const provider = options.path; - const oauth_options = { horizon, provider }; + const oauth_options = {horizon, provider}; oauth_options.make_acquire_url = (state, redirect_uri) => - url.format({ protocol: 'https', + url.format({protocol: 'https', host: 'accounts.google.com', pathname: '/o/oauth2/v2/auth', - query: { client_id, redirect_uri, state, response_type: 'code', scope: 'profile' } }); + query: {client_id, redirect_uri, state, response_type: 'code', scope: 'profile'}}); oauth_options.make_token_request = (code, redirect_uri) => { const query_params = querystring.stringify({ code, client_id, client_secret, redirect_uri, - grant_type: 'authorization_code' }); + grant_type: 'authorization_code'}); const path = `/oauth2/v4/token?${query_params}`; - return https.request({ method: 'POST', host: 'www.googleapis.com', path }); + return https.request({method: 'POST', host: 'www.googleapis.com', path}); }; oauth_options.make_inspect_request = (access_token) => { logger.debug(`using access token: ${access_token}`); - const path = `/oauth2/v1/userinfo?${querystring.stringify({ access_token })}`; - return https.request({ host: 'www.googleapis.com', path }); + const path = `/oauth2/v1/userinfo?${querystring.stringify({access_token})}`; + return https.request({host: 'www.googleapis.com', path}); }; oauth_options.extract_id = (user_info) => user_info && user_info.id; diff --git a/plugins/insert/package.json b/plugins/insert/package.json new file mode 100644 index 000000000..b9cbaba34 --- /dev/null +++ b/plugins/insert/package.json @@ -0,0 +1,38 @@ +{ + "name": "@horizon-plugins/insert", + "version": "1.0.0", + "description": "Plugin for the 'insert' term in the Horizon Collections API.", + "main": "src/insert.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/insert/src/insert.js b/plugins/insert/src/insert.js new file mode 100644 index 000000000..cfde20aee --- /dev/null +++ b/plugins/insert/src/insert.js @@ -0,0 +1,47 @@ +'use strict'; + +const {r} = require('@horizon/server'); +const {reqlOptions, writes} = require('@horizon/plugin-utils'); + +function insert(context) { + return (req, res, next) => { + const conn = context.horizon.rdbConnection.connection(); + const timeout = req.getParameter('timeout'); + const collection = req.getParameter('collection'); + const permissions = req.getParameter('hz_permissions'); + + if (!collection) { + throw new Error('No collection given for insert operation.'); + } else if (!permissions) { + throw new Error('No permissions given for insert operation.'); + } + + writes.retryLoop(req.options.insert, permissions, timeout, + (rows) => // pre-validation, all rows + Array(rows.length).fill(null), + (validator, row, info) => { // validation, each row + if (!validator(info, row)) { + return new Error(writes.unauthorizedMsg); + } + }, + (rows) => // write to database, all valid rows + collection.table + .insert(rows.map((row) => writes.applyVersion(r.expr(row), 0)), + {returnChanges: 'always'}) + .run(conn, reqlOptions) + ).then((patch) => res.end(patch)).catch(next); + }; +} + +module.exports = { + name: 'hz_insert', + activate: (context) => ({ + methods: { + insert: { + type: 'terminal', + requires: ['hz_permissions'], + handler: insert(context), + }, + }, + }), +}; diff --git a/plugins/limit/package.json b/plugins/limit/package.json new file mode 100644 index 000000000..5a71772a0 --- /dev/null +++ b/plugins/limit/package.json @@ -0,0 +1,37 @@ +{ + "name": "@horizon-plugins/limit", + "version": "1.0.0", + "description": "Plugin for the 'limit' term in the Horizon Collections API.", + "main": "src/limit.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/limit/src/limit.js b/plugins/limit/src/limit.js new file mode 100644 index 000000000..1c412d6c9 --- /dev/null +++ b/plugins/limit/src/limit.js @@ -0,0 +1,25 @@ +'use strict'; + +function limit(req, res, next) { + const args = req.options.limit; + if (args.length !== 1) { + next(new Error(`"limit" expected 1 argument but found ${args.length}.`)); + } else if (typeof args[0] !== 'number') { + next(new Error('First argument to "limit" must be a number.')); + } else { + req.setParameter(args[0]); + next(); + } +} + +module.exports = { + name: 'hz_limit', + activate: () => ({ + methods: { + limit: { + type: 'option', + handler: limit, + }, + }, + }), +}; diff --git a/plugins/order/package.json b/plugins/order/package.json new file mode 100644 index 000000000..60325237f --- /dev/null +++ b/plugins/order/package.json @@ -0,0 +1,37 @@ +{ + "name": "@horizon-plugins/order", + "version": "1.0.0", + "description": "Plugin for the 'order' term in the Horizon Collections API.", + "main": "src/order.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/order/src/order.js b/plugins/order/src/order.js new file mode 100644 index 000000000..aa3817f6e --- /dev/null +++ b/plugins/order/src/order.js @@ -0,0 +1,43 @@ +'use strict'; + +function legalField(value) { + return typeof value === 'string' || + (Array.isArray(value) && value.every((i) => typeof i === 'string')); +} + +function convertField(value) { + return typeof value === 'string' ? [value] : value; +} + +function order(req, res, next) { + const args = req.options.order; + if (args.length < 1 || args.length > 2) { + next(new Error(`"order" expected 1 or 2 arguments but found ${args.length}.`)); + } else if (!Array.isArray(args[0]) && typeof args[0] !== 'string') { + next(new Error('First argument to "order" must be an array or string.')); + } else if (Array.isArray(args[0]) && !args[0].every(legalField)) { + next(new Error('First argument to "order" must be a string or ' + + 'an array of strings or arrays of strings.')); + } else if (args.length === 2 && + (args[1] !== 'ascending' && args[1] !== 'descending')) { + next(new Error('Second argument to "order" must be "ascending" or "descending"')); + } else { + req.setParameter({ + fields: Array.isArray(args[0]) ? args[0].map(convertField) : [convertField(args[0])], + descending: args.length === 1 ? false : (args[1] === 'descending'), + }); + next(); + } +} + +module.exports = { + name: 'hz_order', + activate: () => ({ + methods: { + order: { + type: 'option', + handler: order, + }, + }, + }), +}; diff --git a/plugins/permissions/package.json b/plugins/permissions/package.json new file mode 100644 index 000000000..bb9910668 --- /dev/null +++ b/plugins/permissions/package.json @@ -0,0 +1,40 @@ +{ + "name": "@horizon-plugins/permissions", + "version": "1.0.0", + "description": "Plugin adding user groups and permissions to Horizon.", + "main": "src/permissions.js", + "scripts": { + "lint": "eslint src", + "test": "mocha src/test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rethinkdb/horizon.git" + }, + "author": "RethinkDB", + "license": "MIT", + "bugs": { + "url": "https://github.com/rethinkdb/horizon/issues" + }, + "homepage": "https://github.com/rethinkdb/horizon#readme", + "engines": { + "node": ">=4.0.0" + }, + "dependencies": { + "@horizon/plugin-utils": "1.0.0", + "@horizon/client": "3.0.0-alpha-0", + "joi": "^8.0.4" + }, + "peerDependencies": { + "@horizon/plugin-router-base": "1.x", + "@horizon/server": "3.x" + }, + "devDependencies": { + "eslint": "^3.1.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.3", + "babel-cli": "^6.11.4", + "babel-preset-es2015": "^6.9.0", + "source-map-support": "^0.4.0" + } +} diff --git a/plugins/permissions/src/group.js b/plugins/permissions/src/group.js new file mode 100644 index 000000000..f702ebc21 --- /dev/null +++ b/plugins/permissions/src/group.js @@ -0,0 +1,13 @@ +'use strict'; + +const Rule = require('./rule'); + +class Group { + constructor(rowData) { + this.name = rowData.id; + this.rules = Object.keys(rowData.rules).map((name) => + new Rule(name, rowData.rules[name])); + } +} + +module.exports = Group; diff --git a/plugins/permissions/src/permissions.js b/plugins/permissions/src/permissions.js new file mode 100644 index 000000000..02eac20f4 --- /dev/null +++ b/plugins/permissions/src/permissions.js @@ -0,0 +1,404 @@ +'use strict'; + +const Rule = require('./rule'); + +const assert = require('assert'); + +const Joi = require('joi'); + +const {r, logger, Reliable, ReliableChangefeed} = require('@horizon/server'); + +// auth plugins should set 'request.context.user' +// token/anonymous: this should be the user id +// unauthenticated: this should be null, and will use the default rules + +// RSI: do something drastic when a user's account is deleted + +function addToMapSet(map, name, el) { + let set = map.get(name); + if (!set) { + set = new Set(); + map.set(name, set); + } + set.add(el); +} + +function addToMapSetUnique(map, name, el) { + let set = map.get(name); + if (!set) { + set = new Set(); + map.set(name, set); + } + assert(!set.has(el), `addToMapSet: ${name} already has ${el}`); + set.add(el); +} + +// Returns whether or not it deleted an empty set from the map. +function delFromMapSet(map, name, el) { + const set = map.get(name); + assert(set, `delFromMapSet: ${name} not in map`); + assert(set.has(el), `delFromMapSet: ${name} does not have ${el}`); + set.delete(el); + if (set.size === 0) { + map.delete(name); + return true; + } + return false; +} + +function getMapSet(map, name) { + return map.get(name) || new Set(); +} + +const emptyRulesetSymbol = Symbol(); +class RuleMap { + constructor() { + this.groupToRulenames = new Map(); + this.rulenameToRule = new Map(); + this.groupToUsers = new Map(); + + this.userToRulenames = new Map(); // computed + this.userToRulesetSymbol = new Map(); // updated when user's rules change + } + + getUserRulesetSymbol(user) { + return this.userToRulesetSymbol.get(user) || emptyRulesetSymbol; + } + + forEachUserRule(user, cb) { + const ruleset = this.userToRulenames.get(user); + + if (ruleset) { + ruleset.forEach((rn) => { + const rule = this.rulenameToRule.get(rn); + assert(rule); + cb(rule); + }); + } + } + + addUserGroup(user, group) { + addToMapSet(this.groupToUsers, group, user); + getMapSet(this.groupToRulenames, group).forEach((rn) => { + addToMapSet(this.userToRulenames, user, rn); + }); + this.userToRulesetSymbol.set(user, Symbol()); + } + + delUserGroup(user, group) { + delFromMapSet(this.groupToUsers, group, user); + let clearRuleset = false; + getMapSet(this.groupToRulenames, group).forEach((rn) => { + const deletedEmptySet = delFromMapSet(this.userToRulenames, user, rn); + if (deletedEmptySet) { clearRuleset = true; } + }); + if (clearRuleset) { + this.userToRulesetSymbol.delete(user); + } else { + this.userToRulesetSymbol.set(user, Symbol()); + } + } + + addGroupRule(group, ruleName, rule) { + this.rulenameToRule.set(ruleName, rule); + addToMapSetUnique(this.groupToRulenames, group, ruleName); + getMapSet(this.groupToUsers, group).forEach((user) => { + addToMapSetUnique(this.userToRulenames, user, ruleName); + this.userToRulesetSymbol.set(user, Symbol()); + }); + } + + delGroupRule(group, ruleName) { + assert(this.rulenameToRule.has(ruleName), `unrecognized ${group} rule ${ruleName}`); + this.rulenameToRule.delete(ruleName); + delFromMapSet(this.groupToRulenames, group, ruleName); + getMapSet(this.groupToUsers, group).forEach((user) => { + const deletedEmptySet = delFromMapSet(this.userToRulenames, user, ruleName); + if (deletedEmptySet) { + this.userToRulesetSymbol.delete(user); + } else { + this.userToRulesetSymbol.set(user, Symbol()); + } + }); + } + + // This should be equivalent to calling `delGroupRule` for all rules. + delAllGroupRules() { + this.groupToRulenames.clear(); + this.rulenameToRule.clear(); + this.userToRulenames.clear(); + this.userToRulesetSymbol.clear(); + } +} + +class UserCache { + constructor(context, options) { + this.timeout = options.cacheTimeout; + + this.ruleMap = new RuleMap(); + this.userCfeeds = new Map(); + this.newUserCfeed = (userId) => { + let oldGroups = new Set(); + const cfeed = new ReliableChangefeed( + r.table(options.usersTable).get(userId).changes({includeInitial: true}), + context.horizon.rdbConnection, + { + onUnready: () => { + cfeed.unreadyAt = new Date(); + }, + onChange: (change) => { + cfeed.userRow = change.new_val || null; + cfeed.unreadyAt = null; // We're ready on every change. + const newGroups = new Set((change.new_val && change.new_val.groups) || []); + oldGroups.forEach((g) => { + if (!newGroups.has(g)) { + this.ruleMap.delUserGroup(userId, g); + } + }); + newGroups.forEach((g) => { + if (!oldGroups.has(g)) { + this.ruleMap.addUserGroup(userId, g); + } + }); + oldGroups = newGroups; + }, + } + ); + if (cfeed.unreadyAt === undefined) { + cfeed.unreadyAt = new Date(0); // epoch + } + cfeed.refcount = 0; + cfeed.userRow = null; + return cfeed; + }; + + // Set up a dummy user cfeed for unauthenticated users (null id) + this.ruleMap.addUserGroup(null, 'default'); + this.userCfeeds.set(null, Object.assign(new Reliable(), { + refcount: 1, + unreadyAt: null, + userRow: {id: null, groups: ['default']}, + readyPromise: Promise.resolve(), + })); + + this.queuedGroups = new Map(); + this.groupsUnreadyAt = new Date(0); // epoch + this.groupCfeed = new ReliableChangefeed( + r.table(options.groupsTable).changes({includeInitial: true}), + context.horizon.rdbConnection, + { + onReady: () => { + this.ruleMap.delAllGroupRules(); + this.queuedGroups.forEach( + (rules, groupId) => { + for (const name in rules) { + const ruleId = JSON.stringify([groupId, name]); + this.ruleMap.addGroupRule(groupId, ruleId, new Rule(rules[name])); + } + }); + this.queuedGroups.clear(); + + assert(this.groupsUnreadyAt !== null); + this.groupsUnreadyAt = null; + }, + onUnready: () => { + assert(this.queuedGroups.size === 0); + assert(this.groupsUnreadyAt === null); + this.groupsUnreadyAt = new Date(); + }, + onChange: (change) => { + const id = change.old_val ? change.old_val.id : change.new_val.id; + if (this.groupsUnreadyAt !== null) { + if (change.new_val) { + this.queuedGroups.set(id, change.new_val.rules); + } else { + this.queuedGroups.delete(id); + } + } else { + const oldRules = change.old_val ? change.old_val.rules : {}; + const newRules = change.new_val ? change.new_val.rules : {}; + for (const k in oldRules) { + if (newRules[k] && + oldRules[k].template === newRules[k].template && + oldRules[k].validator === newRules[k].validator) { + delete newRules[k]; + } else { + this.ruleMap.delGroupRule(id, k); + } + } + for (const k in newRules) { + try { + this.ruleMap.addGroupRule(id, k, new Rule(newRules[k])); + } catch (err) { + logger.error(`Failed to evaluate rule ${id}.${k}: ${err}`); + logger.debug(`Contents: ${JSON.stringify(newRules[k])}`); + logger.debug(`Stack: ${err.stack}`); + } + } + } + }, + } + ); + } + + close() { + const promises = [this.groupCfeed.close()]; + this.userCfeeds.forEach((feed) => { + promises.push(feed.close()); + }); + return Promise.all(promises); + } + + subscribe(userId) { + let cfeed = this.userCfeeds.get(userId); + if (!cfeed) { + this.userCfeeds.set(userId, cfeed = this.newUserCfeed(userId)); + // RSI: move this into newUserCfeed? + cfeed.readyPromise = new Promise((resolve, reject) => { + cfeed.subscribe({onReady: () => resolve()}); + setTimeout(() => reject(new Error('timed out')), this.timeout); + }); + } + cfeed.refcount += 1; + + return { + getValidatePromise: (req) => + cfeed.readyPromise.then(() => { + let rulesetSymbol = Symbol(); + let ruleset = []; + let needsValidation = true; + return () => { + const userStale = cfeed.unreadyAt; + const groupsStale = this.groupsUnreadyAt; + if (userStale || groupsStale) { + let staleSince = null; + const curTime = Number(new Date()); + if (userStale && (curTime - Number(userStale) > this.timeout)) { + staleSince = userStale; + } + if (groupsStale && (curTime - Number(groupsStale) > this.timeout)) { + if (!staleSince || Number(groupsStale) < Number(staleSince)) { + staleSince = groupsStale; + } + } + if (staleSince) { + throw new Error(`permissions desynced since ${staleSince}`); + } + } + const curSymbol = this.ruleMap.getUserRulesetSymbol(userId); + if (curSymbol !== rulesetSymbol) { + rulesetSymbol = curSymbol; + ruleset = []; + needsValidation = true; + this.ruleMap.forEachUserRule(userId, (rule) => { + if (rule.isMatch(req.options)) { + if (!rule.validator) { + needsValidation = false; + } + ruleset.push(rule); + } + }); + } + if (!needsValidation) { + return null; + } + + // The validator function returns the matching rule if allowed, or undefined + return (...args) => { + try { + for (const rule of ruleset) { + if (rule.isValid(cfeed.userRow, ...args)) { + return rule; + } + } + } catch (err) { + // We don't want to pass the error message on to the user because + // it might leak information about the data. + logger.error(`Exception in validator function: ${err.stack}`); + } + }; + }; + }), + close: () => { + cfeed.refcount -= 1; + if (cfeed.refcount === 0) { + this.userCfeeds.delete(userId); + return cfeed.close(); + } + }, + }; + } +} + +// `cacheTimeout` - the duration we can be desynced from the database before we +// start rejecting queries. +const optionsSchema = Joi.object().keys({ + name: Joi.any().required(), + usersTable: Joi.string().default('users'), + groupsTable: Joi.string().default('hz_groups'), + cacheTimeout: Joi.number().positive().integer().default(5000), +}).unknown(true); + +module.exports = { + name: 'hz_permissions', + + activate(context, rawOptions, onReady, onUnready) { + const options = Joi.attempt(rawOptions, optionsSchema); + const userSub = Symbol(`${options.name}_userSub`); + + // Save things in the context that we will need at deactivation + const userCache = new UserCache(context, options); + context[options.name] = { + userCache, + authCb: (clientCtx) => { + clientCtx[userSub] = userCache.subscribe(clientCtx.user.id); + }, + disconnectCb: (clientCtx) => { + if (clientCtx[userSub]) { + clientCtx[userSub].close(); + } + }, + }; + + context.horizon.events.on('auth', context[options.name].authCb); + context.horizon.events.on('disconnect', context[options.name].disconnectCb); + + return new Promise((resolve) => { + userCache.groupCfeed.subscribe({onUnready, onReady: () => { + resolve({ + methods: { + hz_permissions: { + type: 'prereq', + handler: (req, res, next) => { + if (!req.clientCtx[userSub]) { + next(new Error('Client connection is not authenticated.')); + } else { + // RSI: test timeout behavior - anecdotal evidence points to 'broken' + req.clientCtx[userSub].getValidatePromise(req).then((validate) => { + req.setParameter(validate); + next(); + }).catch(next); + } + }, + }, + }, + }); + onReady(); + }}); + }); + }, + + deactivate(context, options) { + const pluginData = context[options.name]; + delete context[options.name]; + if (pluginData.authCb) { + context.horizon.events.removeListener('auth', pluginData.authCb); + } + if (pluginData.disconnectCb) { + context.horizon.events.removeListener('disconnect', pluginData.disconnectCb); + } + if (pluginData.userCache) { + pluginData.userCache.close(); + } + }, +}; diff --git a/plugins/permissions/src/rule.js b/plugins/permissions/src/rule.js new file mode 100644 index 000000000..cb2202a58 --- /dev/null +++ b/plugins/permissions/src/rule.js @@ -0,0 +1,26 @@ +'use strict'; + +const Template = require('./template'); +const Validator = require('./validator'); + +class Rule { + constructor(info) { + this.template = new Template(info.template); + if (info.validator) { + this.validator = new Validator(info.validator); + } + } + + isMatch(query, context) { + return this.template.isMatch(query, context); + } + + isValid(...args) { + if (!this.validator) { + return true; + } + return this.validator.isValid(...args); + } +} + +module.exports = Rule; diff --git a/plugins/permissions/src/template.js b/plugins/permissions/src/template.js new file mode 100644 index 000000000..9b0ddc066 --- /dev/null +++ b/plugins/permissions/src/template.js @@ -0,0 +1,229 @@ +'use strict'; + +const assert = require('assert'); +const vm = require('vm'); + +// RSI: don't use the client AST - there are simple rules for generating options +// RSI: where do we get the list of options from? there's no easy way to accept any +// method - we could try to parse the ast of the javascript itself before evaluating +// the template +const {isObject, remakeError} = require('@horizon/plugin-utils'); + +const templateData = Symbol('templateData'); + +function templateCompare(query, template, context) { + if (template === undefined) { + return false; + } else if (template instanceof Any || + template instanceof AnyObject || + template instanceof AnyArray) { + if (!template.matches(query, context)) { + return false; + } + } else if (template instanceof UserId) { + if (query !== context.id) { + return false; + } + } else if (template === null) { + if (query !== null) { + return false; + } + } else if (Array.isArray(template)) { + if (!Array.isArray(query) || + template.length !== query.length) { + return false; + } + for (let i = 0; i < template.length; ++i) { + if (!templateCompare(query[i], template[i], context)) { + return false; + } + } + } else if (typeof template === 'object') { + if (typeof query !== 'object') { + return false; + } + + for (const key in query) { + if (!templateCompare(query[key], template[key], context)) { + return false; + } + } + + // Make sure all template keys were handled + for (const key in template) { + if (query[key] === undefined) { + return false; + } + } + } else if (template !== query) { + return false; + } + + return true; +} + +class Any { + constructor(values) { + this._values = values || []; + } + + matches(value, context) { + if (value === undefined) { + return false; + } else if (this._values.length === 0) { + return true; + } + + for (const item of this._values) { + if (templateCompare(value, item, context)) { + return true; + } + } + + return false; + } +} + +// This works the same as specifying a literal object in a template, except that +// unspecified key/value pairs are allowed. +class AnyObject { + constructor(obj) { + this._obj = obj || { }; + } + + matches(value, context) { + if (value === null || typeof value !== 'object') { + return false; + } + + for (const key in this._obj) { + if (!templateCompare(value[key], this._obj[key], context)) { + return false; + } + } + + return true; + } +} + +// This matches an array where each item matches at least one of the values +// specified at construction. +class AnyArray { + constructor(values) { + this._values = values || []; + } + + matches(value, context) { + if (!Array.isArray(value)) { + return false; + } + + for (const item of value) { + let match = false; + for (const template of this._values) { + if (templateCompare(item, template, context)) { + match = true; + break; + } + } + if (!match) { + return false; + } + } + + return true; + } +} + +class UserId { } + +// The chained object in the template is of the format: +// { +// [templateData]: { +// any: , +// options: { +//