diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..faaae4e388 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +### Contributing to Parse Server + +#### Pull Requests Welcome! + +We really want Parse to be yours, to see it grow and thrive in the open source community. + +##### Please Do's + +* Please write tests to cover new methods. +* Please run the tests and make sure you didn't break anything. + +##### Code of Conduct + +This project adheres to the [Open Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. +[code-of-conduct]: http://todogroup.org/opencodeofconduct/#Parse Server/fjm@fb.com + + diff --git a/DatabaseAdapter.js b/DatabaseAdapter.js index 82efb8fd73..4967d5665d 100644 --- a/DatabaseAdapter.js +++ b/DatabaseAdapter.js @@ -20,6 +20,7 @@ var adapter = ExportAdapter; var cache = require('./cache'); var dbConnections = {}; var databaseURI = 'mongodb://localhost:27017/parse'; +var appDatabaseURIs = {}; function setAdapter(databaseAdapter) { adapter = databaseAdapter; @@ -29,11 +30,17 @@ function setDatabaseURI(uri) { databaseURI = uri; } +function setAppDatabaseURI(appId, uri) { + appDatabaseURIs[appId] = uri; +} + function getDatabaseConnection(appId) { if (dbConnections[appId]) { return dbConnections[appId]; } - dbConnections[appId] = new adapter(databaseURI, { + + var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); + dbConnections[appId] = new adapter(dbURI, { collectionPrefix: cache.apps[appId]['collectionPrefix'] }); dbConnections[appId].connect(); @@ -44,5 +51,6 @@ module.exports = { dbConnections: dbConnections, getDatabaseConnection: getDatabaseConnection, setAdapter: setAdapter, - setDatabaseURI: setDatabaseURI + setDatabaseURI: setDatabaseURI, + setAppDatabaseURI: setAppDatabaseURI }; diff --git a/FilesAdapter.js b/FilesAdapter.js index 7b952ed031..427e20d9bb 100644 --- a/FilesAdapter.js +++ b/FilesAdapter.js @@ -5,6 +5,7 @@ // Adapter classes must implement the following functions: // * create(config, filename, data) // * get(config, filename) +// * location(config, req, filename) // // Default is GridStoreAdapter, which requires mongo // and for the API server to be using the ExportAdapter diff --git a/GridStoreAdapter.js b/GridStoreAdapter.js index 3168de066a..0d1e896578 100644 --- a/GridStoreAdapter.js +++ b/GridStoreAdapter.js @@ -4,6 +4,7 @@ // Requires the database adapter to be based on mongoclient var GridStore = require('mongodb').GridStore; +var path = require('path'); // For a given config object, filename, and data, store a file // Returns a promise @@ -32,7 +33,16 @@ function get(config, filename) { }); } +// Generates and returns the location of a file stored in GridStore for the +// given request and filename +function location(config, req, filename) { + return (req.protocol + '://' + req.get('host') + + path.dirname(req.originalUrl) + '/' + req.config.applicationId + + '/' + encodeURIComponent(filename)); +} + module.exports = { create: create, - get: get + get: get, + location: location }; diff --git a/RestWrite.js b/RestWrite.js index 2e57f8fcc8..ea7b2225e2 100644 --- a/RestWrite.js +++ b/RestWrite.js @@ -2,13 +2,14 @@ // that writes to the database. // This could be either a "create" or an "update". +var crypto = require('crypto'); var deepcopy = require('deepcopy'); var rack = require('hat').rack(); var Auth = require('./Auth'); var cache = require('./cache'); var Config = require('./Config'); -var crypto = require('./crypto'); +var passwordCrypto = require('./password'); var facebook = require('./facebook'); var Parse = require('parse/node'); var triggers = require('./triggers'); @@ -299,7 +300,7 @@ RestWrite.prototype.transformUser = function() { if (this.query) { this.storage['clearSessions'] = true; } - return crypto.hash(this.data.password).then((hashedPassword) => { + return passwordCrypto.hash(this.data.password).then((hashedPassword) => { this.data._hashed_password = hashedPassword; delete this.data.password; }); @@ -701,15 +702,18 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; -// Returns a string that's usable as an object id. -// Probably unique. Good enough? Probably! +// Returns a unique string that's usable as an object id. function newObjectId() { var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'); var objectId = ''; - for (var i = 0; i < 10; ++i) { - objectId += chars[Math.floor(Math.random() * chars.length)]; + var bytes = crypto.randomBytes(10); + for (var i = 0; i < bytes.length; ++i) { + // Note: there is a slight modulo bias, because chars length + // of 62 doesn't divide the number of all bytes (256) evenly. + // It is acceptable for our purposes. + objectId += chars[bytes.readUInt8(i) % chars.length]; } return objectId; } diff --git a/S3Adapter.js b/S3Adapter.js new file mode 100644 index 0000000000..aeea44efb6 --- /dev/null +++ b/S3Adapter.js @@ -0,0 +1,77 @@ +// S3Adapter +// +// Stores Parse files in AWS S3. + +var AWS = require('aws-sdk'); +var path = require('path'); + +var DEFAULT_REGION = "us-east-1"; +var DEFAULT_BUCKET = "parse-files"; + +// Creates an S3 session. +// Providing AWS access and secret keys is mandatory +// Region and bucket will use sane defaults if omitted +function S3Adapter(accessKey, secretKey, options) { + options = options || {}; + + this.region = options.region || DEFAULT_REGION; + this.bucket = options.bucket || DEFAULT_BUCKET; + this.bucketPrefix = options.bucketPrefix || ""; + this.directAccess = options.directAccess || false; + + s3Options = { + accessKeyId: accessKey, + secretAccessKey: secretKey, + params: {Bucket: this.bucket} + }; + AWS.config.region = this.region; + this.s3 = new AWS.S3(s3Options); +} + +// For a given config object, filename, and data, store a file in S3 +// Returns a promise containing the S3 object creation response +S3Adapter.prototype.create = function(config, filename, data) { + var params = { + Key: this.bucketPrefix + filename, + Body: data, + }; + if (this.directAccess) { + params.ACL = "public-read" + } + + return new Promise((resolve, reject) => { + this.s3.upload(params, function(err, data) { + if (err !== null) return reject(err); + resolve(data); + }); + }); +} + +// Search for and return a file if found by filename +// Returns a promise that succeeds with the buffer result from S3 +S3Adapter.prototype.get = function(config, filename) { + var params = {Key: this.bucketPrefix + filename}; + + return new Promise((resolve, reject) => { + this.s3.getObject(params, (err, data) => { + if (err !== null) return reject(err); + resolve(data.Body); + }); + }); +} + +// Generates and returns the location of a file stored in S3 for the given request and +// filename +// The location is the direct S3 link if the option is set, otherwise we serve +// the file through parse-server +S3Adapter.prototype.location = function(config, req, filename) { + if (this.directAccess) { + return ('https://' + this.bucket + '.s3.amazonaws.com' + '/' + + this.bucketPrefix + filename); + } + return (req.protocol + '://' + req.get('host') + + path.dirname(req.originalUrl) + '/' + req.config.applicationId + + '/' + encodeURIComponent(filename)); +} + +module.exports = S3Adapter; diff --git a/classes.js b/classes.js index 92d2a220ee..dc33eab09f 100644 --- a/classes.js +++ b/classes.js @@ -10,35 +10,36 @@ var router = new PromiseRouter(); // Returns a promise that resolves to a {response} object. function handleFind(req) { + var body = Object.assign(req.body, req.query); var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); + if (body.skip) { + options.skip = Number(body.skip); } - if (req.body.limit) { - options.limit = Number(req.body.limit); + if (body.limit) { + options.limit = Number(body.limit); } - if (req.body.order) { - options.order = String(req.body.order); + if (body.order) { + options.order = String(body.order); } - if (req.body.count) { + if (body.count) { options.count = true; } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; + if (typeof body.keys == 'string') { + options.keys = body.keys; } - if (req.body.include) { - options.include = String(req.body.include); + if (body.include) { + options.include = String(body.include); } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); + if (body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(body.redirectClassNameForKey); } - if(typeof req.body.where === 'string') { - req.body.where = JSON.parse(req.body.where); + if(typeof body.where === 'string') { + body.where = JSON.parse(body.where); } return rest.find(req.config, req.auth, - req.params.className, req.body.where, options) + req.params.className, body.where, options) .then((response) => { return {response: response}; }); diff --git a/files.js b/files.js index 2c36e3417c..e2575a5d7e 100644 --- a/files.js +++ b/files.js @@ -7,7 +7,6 @@ var bodyParser = require('body-parser'), middlewares = require('./middlewares.js'), mime = require('mime'), Parse = require('parse/node').Parse, - path = require('path'), rack = require('hat').rack(); var router = express.Router(); @@ -44,10 +43,7 @@ var processCreate = function(req, res, next) { FilesAdapter.getAdapter().create(req.config, filename, req.body) .then(() => { res.status(201); - var location = (req.protocol + '://' + req.get('host') + - path.dirname(req.originalUrl) + '/' + - req.config.applicationId + '/' + - encodeURIComponent(filename)); + var location = FilesAdapter.getAdapter().location(req.config, req, filename); res.set('Location', location); res.json({ url: location, name: filename }); }).catch((error) => { diff --git a/index.js b/index.js index 79a321986f..59b5155c0f 100644 --- a/index.js +++ b/index.js @@ -47,7 +47,7 @@ function ParseServer(args) { FilesAdapter.setAdapter(args.filesAdapter); } if (args.databaseURI) { - DatabaseAdapter.setDatabaseURI(args.databaseURI); + DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } if (args.cloud) { addParseCloud(); @@ -150,6 +150,9 @@ function addParseCloud() { options.uri = options.url; delete options.url; } + if (typeof options.body === 'object') { + options.body = JSON.stringify(options.body); + } request(options, (error, response, body) => { if (error) { if (callbacks.error) { @@ -178,4 +181,3 @@ function getClassName(parseClass) { module.exports = { ParseServer: ParseServer }; - diff --git a/package.json b/package.json index 1f6560f3bf..c8dd3b94b1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "license": "BSD-3-Clause", "dependencies": { - "bcrypt": "~0.8", + "aws-sdk": "~2.2.33", + "bcrypt-nodejs": "0.0.3", "body-parser": "~1.12.4", "deepcopy": "^0.5.0", "express": "~4.2.x", diff --git a/crypto.js b/password.js similarity index 87% rename from crypto.js rename to password.js index fdbcdf9ab7..f1154c96e6 100644 --- a/crypto.js +++ b/password.js @@ -1,11 +1,11 @@ // Tools for encrypting and decrypting passwords. // Basically promise-friendly wrappers for bcrypt. -var bcrypt = require('bcrypt'); +var bcrypt = require('bcrypt-nodejs'); // Returns a promise for a hashed password string. function hash(password) { return new Promise(function(fulfill, reject) { - bcrypt.hash(password, 8, function(err, hashedPassword) { + bcrypt.hash(password, null, null, function(err, hashedPassword) { if (err) { reject(err); } else { diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index cd7e850f74..458b43eef4 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -6,7 +6,7 @@ // Tests that involve sending password reset emails. var request = require('request'); -var crypto = require('../crypto'); +var passwordCrypto = require('../password'); describe('Parse.User testing', () => { it("user sign up class method", (done) => { @@ -1560,7 +1560,7 @@ describe('Parse.User testing', () => { it('password format matches hosted parse', (done) => { var hashed = '$2a$10$8/wZJyEuiEaobBBqzTG.jeY.XSFJd0rzaN//ososvEI4yLqI.4aie'; - crypto.compare('test', hashed) + passwordCrypto.compare('test', hashed) .then((pass) => { expect(pass).toBe(true); done(); @@ -1574,7 +1574,7 @@ describe('Parse.User testing', () => { var sessionToken = null; Parse.Promise.as().then(function() { - return Parse.User.signUp("fosco", "parse"); + return Parse.User.signUp("fosco", "parse"); }).then(function(newUser) { equal(Parse.User.current(), newUser); sessionToken = newUser.getSessionToken(); diff --git a/spec/transform.spec.js b/spec/transform.spec.js index 559d787b50..c581c5d6c3 100644 --- a/spec/transform.spec.js +++ b/spec/transform.spec.js @@ -2,16 +2,18 @@ var transform = require('../transform'); -var dummyConfig = { - schema: { +var dummySchema = { data: {}, getExpectedType: function(className, key) { if (key == 'userPointer') { return '*_User'; + } else if (key == 'picture') { + return 'file'; + } else if (key == 'location') { + return 'geopoint'; } return; } - } }; @@ -19,7 +21,7 @@ describe('transformCreate', () => { it('a basic number', (done) => { var input = {five: 5}; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); jequal(input, output); done(); }); @@ -29,7 +31,7 @@ describe('transformCreate', () => { createdAt: "2015-10-06T21:24:50.332Z", updatedAt: "2015-10-06T21:24:50.332Z" }; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); expect(output._created_at instanceof Date).toBe(true); expect(output._updated_at instanceof Date).toBe(true); done(); @@ -41,21 +43,21 @@ describe('transformCreate', () => { objectId: 'myId', className: 'Blah', }; - var out = transform.transformCreate(dummyConfig, null, {pointers: [pointer]}); + var out = transform.transformCreate(dummySchema, null, {pointers: [pointer]}); jequal([pointer], out.pointers); done(); }); it('a delete op', (done) => { var input = {deleteMe: {__op: 'Delete'}}; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); jequal(output, {}); done(); }); it('basic ACL', (done) => { var input = {ACL: {'0123': {'read': true, 'write': true}}}; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); // This just checks that it doesn't crash, but it should check format. done(); }); @@ -63,7 +65,7 @@ describe('transformCreate', () => { describe('transformWhere', () => { it('objectId', (done) => { - var out = transform.transformWhere(dummyConfig, null, {objectId: 'foo'}); + var out = transform.transformWhere(dummySchema, null, {objectId: 'foo'}); expect(out._id).toEqual('foo'); done(); }); @@ -72,7 +74,7 @@ describe('transformWhere', () => { var input = { objectId: {'$in': ['one', 'two', 'three']}, }; - var output = transform.transformWhere(dummyConfig, null, input); + var output = transform.transformWhere(dummySchema, null, input); jequal(input.objectId, output._id); done(); }); @@ -81,17 +83,53 @@ describe('transformWhere', () => { describe('untransformObject', () => { it('built-in timestamps', (done) => { var input = {createdAt: new Date(), updatedAt: new Date()}; - var output = transform.untransformObject(dummyConfig, null, input); + var output = transform.untransformObject(dummySchema, null, input); expect(typeof output.createdAt).toEqual('string'); expect(typeof output.updatedAt).toEqual('string'); done(); }); + + it('pointer', (done) => { + var input = {_p_userPointer: '_User$123'}; + var output = transform.untransformObject(dummySchema, null, input); + expect(typeof output.userPointer).toEqual('object'); + expect(output.userPointer).toEqual( + {__type: 'Pointer', className: '_User', objectId: '123'} + ); + done(); + }); + + it('null pointer', (done) => { + var input = {_p_userPointer: null}; + var output = transform.untransformObject(dummySchema, null, input); + expect(output.userPointer).toBeUndefined(); + done(); + }); + + it('file', (done) => { + var input = {picture: 'pic.jpg'}; + var output = transform.untransformObject(dummySchema, null, input); + expect(typeof output.picture).toEqual('object'); + expect(output.picture).toEqual({__type: 'File', name: 'pic.jpg'}); + done(); + }); + + it('geopoint', (done) => { + var input = {location: [180, -180]}; + var output = transform.untransformObject(dummySchema, null, input); + expect(typeof output.location).toEqual('object'); + expect(output.location).toEqual( + {__type: 'GeoPoint', longitude: 180, latitude: -180} + ); + done(); + }); + }); describe('transformKey', () => { it('throws out _password', (done) => { try { - transform.transformKey(dummyConfig, '_User', '_password'); + transform.transformKey(dummySchema, '_User', '_password'); fail('should have thrown'); } catch (e) { done(); @@ -105,7 +143,7 @@ describe('transform schema key changes', () => { var input = { somePointer: {__type: 'Pointer', className: 'Micro', objectId: 'oft'} }; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); expect(typeof output._p_somePointer).toEqual('string'); expect(output._p_somePointer).toEqual('Micro$oft'); done(); @@ -115,7 +153,7 @@ describe('transform schema key changes', () => { var input = { userPointer: {__type: 'Pointer', className: '_User', objectId: 'qwerty'} }; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); expect(typeof output._p_userPointer).toEqual('string'); expect(output._p_userPointer).toEqual('_User$qwerty'); done(); @@ -128,7 +166,7 @@ describe('transform schema key changes', () => { "Kevin": { "write": true } } }; - var output = transform.transformCreate(dummyConfig, null, input); + var output = transform.transformCreate(dummySchema, null, input); expect(typeof output._rperm).toEqual('object'); expect(typeof output._wperm).toEqual('object'); expect(output.ACL).toBeUndefined(); @@ -142,7 +180,7 @@ describe('transform schema key changes', () => { _rperm: ["*"], _wperm: ["Kevin"] }; - var output = transform.untransformObject(dummyConfig, null, input); + var output = transform.untransformObject(dummySchema, null, input); expect(typeof output.ACL).toEqual('object'); expect(output._rperm).toBeUndefined(); expect(output._wperm).toBeUndefined(); diff --git a/transform.js b/transform.js index 7e19ba7035..01346d13a1 100644 --- a/transform.js +++ b/transform.js @@ -676,6 +676,9 @@ function untransformObject(schema, className, mongoObject) { console.log('Found a pointer in a non-pointer column, dropping it.', className, key); break; } + if (mongoObject[key] === null) { + break; + } var objData = mongoObject[key].split('$'); var newClass = (expected ? expected.substring(1) : objData[0]); if (objData[0] !== newClass) { @@ -689,6 +692,8 @@ function untransformObject(schema, className, mongoObject) { break; } else if (key[0] == '_' && key != '__type') { throw ('bad key in untransform: ' + key); + //} else if (mongoObject[key] === null) { + //break; } else { var expected = schema.getExpectedType(className, key); if (expected == 'file') { diff --git a/users.js b/users.js index 642474b867..007808543e 100644 --- a/users.js +++ b/users.js @@ -5,7 +5,7 @@ var Parse = require('parse/node').Parse; var rack = require('hat').rack(); var Auth = require('./Auth'); -var crypto = require('./crypto'); +var passwordCrypto = require('./password'); var facebook = require('./facebook'); var PromiseRouter = require('./PromiseRouter'); var rest = require('./rest'); @@ -45,7 +45,7 @@ function handleLogIn(req) { 'Invalid username/password.'); } user = results[0]; - return crypto.compare(req.body.password, user.password); + return passwordCrypto.compare(req.body.password, user.password); }).then((correct) => { if (!correct) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, @@ -70,9 +70,13 @@ function handleLogIn(req) { 'authProvider': 'password' }, restricted: false, - expiresAt: Parse._encode(expiresAt).iso, - installationId: req.info.installationId + expiresAt: Parse._encode(expiresAt) }; + + if (req.info.installationId) { + sessionData.installationId = req.info.installationId + } + var create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData); return create.execute();