Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Björn Kaiser
2016-02-12 20:37:10 +00:00
85 changed files with 3343 additions and 1287 deletions

5
.babelrc Normal file
View File

@@ -0,0 +1,5 @@
{
"presets": [
"es2015"
]
}

5
.gitignore vendored
View File

@@ -30,4 +30,7 @@ node_modules
*~
# WebStorm/IntelliJ
.idea
.idea
# Babel.js
lib/

33
.npmignore Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# Emacs
*~
# WebStorm/IntelliJ
.idea

View File

@@ -3,9 +3,11 @@ branches:
- master
language: node_js
node_js:
- "4.1"
- "4.2"
- "4.3"
env:
- MONGODB_VERSION=2.6.11
- MONGODB_VERSION=3.0.8
cache:
directories:
- $HOME/.mongodb/versions/downloads
after_success: ./node_modules/.bin/codecov

95
APNS.js
View File

@@ -1,95 +0,0 @@
var Parse = require('parse/node').Parse;
// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1,
// but probably we will replace it in the future.
var apn = require('apn');
/**
* Create a new connection to the APN service.
* @constructor
* @param {Object} args Arguments to config APNS connection
* @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem
* @param {String} args.key The filename of the connection key to load from disk, default is key.pem
* @param {String} args.passphrase The passphrase for the connection key, if required
* @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox
*/
function APNS(args) {
this.sender = new apn.connection(args);
this.sender.on('connected', function() {
console.log('APNS Connected');
});
this.sender.on('transmissionError', function(errCode, notification, device) {
console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification);
// TODO: For error caseud by invalid deviceToken, we should mark those installations.
});
this.sender.on("timeout", function () {
console.log("APNS Connection Timeout");
});
this.sender.on("disconnected", function() {
console.log("APNS Disconnected");
});
this.sender.on("socketError", console.error);
}
/**
* Send apns request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} deviceTokens A array of device tokens
* @returns {Object} A promise which is resolved immediately
*/
APNS.prototype.send = function(data, deviceTokens) {
var coreData = data.data;
var expirationTime = data['expiration_time'];
var notification = generateNotification(coreData, expirationTime);
this.sender.pushNotification(notification, deviceTokens);
// TODO: pushNotification will push the notification to apn's queue.
// We do not handle error in V1, we just relies apn to auto retry and send the
// notifications.
return Parse.Promise.as();
}
/**
* Generate the apns notification from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @returns {Object} A apns notification
*/
var generateNotification = function(coreData, expirationTime) {
var notification = new apn.notification();
var payload = {};
for (key in coreData) {
switch (key) {
case 'alert':
notification.setAlertText(coreData.alert);
break;
case 'badge':
notification.badge = coreData.badge;
break;
case 'sound':
notification.sound = coreData.sound;
break;
case 'content-available':
notification.setNewsstandAvailable(true);
var isAvailable = coreData['content-available'] === 1;
notification.setContentAvailable(isAvailable);
break;
case 'category':
notification.category = coreData.category;
break;
default:
payload[key] = coreData[key];
break;
}
}
notification.payload = payload;
notification.expiry = expirationTime;
return notification;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
APNS.generateNotification = generateNotification;
}
module.exports = APNS;

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
## Parse Server Changelog
### 2.0.8 (2/11/2016)
* Add: support for Android and iOS push notifications
* Experimental: Cloud Code validation hooks (can mark as non-experimental after we have docs)
* Experimental: support for schemas API (GET and POST only)
* Experimental: support for Parse Config (GET and POST only)
* Fix: Querying objects with equality constraint on array column
* Fix: User logout will remove session token
* Fix: Various files related bugs
* Fix: Force minimum node version 4.3 due to security issues in earlier version
* Performance Improvement: Improved caching

View File

@@ -7,7 +7,7 @@ We really want Parse to be yours, to see it grow and thrive in the open source c
##### Please Do's
* Take testing seriously! Aim to increase the test coverage with every pull request.
* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/MyFile.spec.js`
* Run the tests for the file you are working on with `npm test spec/MyFile.spec.js`
* Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html
##### Code of Conduct

View File

@@ -1,29 +0,0 @@
// Files Adapter
//
// Allows you to change the file storage mechanism.
//
// 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
// database adapter.
var GridStoreAdapter = require('./GridStoreAdapter');
var adapter = GridStoreAdapter;
function setAdapter(filesAdapter) {
adapter = filesAdapter;
}
function getAdapter() {
return adapter;
}
module.exports = {
getAdapter: getAdapter,
setAdapter: setAdapter
};

82
GCM.js
View File

@@ -1,82 +0,0 @@
var Parse = require('parse/node').Parse;
var gcm = require('node-gcm');
var randomstring = require('randomstring');
var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks
var GCMRegistrationTokensMax = 1000;
function GCM(apiKey) {
this.sender = new gcm.Sender(apiKey);
}
/**
* Send gcm request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} registrationTokens A array of registration tokens
* @returns {Object} A promise which is resolved after we get results from gcm
*/
GCM.prototype.send = function (data, registrationTokens) {
if (registrationTokens.length >= GCMRegistrationTokensMax) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Too many registration tokens for a GCM request.');
}
var pushId = randomstring.generate({
length: 10,
charset: 'alphanumeric'
});
var timeStamp = Date.now();
var expirationTime;
// We handle the expiration_time convertion in push.js, so expiration_time is a valid date
// in Unix epoch time in milliseconds here
if (data['expiration_time']) {
expirationTime = data['expiration_time'];
}
// Generate gcm payload
var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime);
// Make and send gcm request
var message = new gcm.Message(gcmPayload);
var promise = new Parse.Promise();
this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) {
// TODO: Use the response from gcm to generate and save push report
// TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation
promise.resolve();
});
return promise;
}
/**
* Generate the gcm payload from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @param {String} pushId A random string
* @param {Number} timeStamp A number whose format is the Unix Epoch
* @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined
* @returns {Object} A promise which is resolved after we get results from gcm
*/
var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) {
var payloadData = {
'time': new Date(timeStamp).toISOString(),
'push_id': pushId,
'data': JSON.stringify(coreData)
}
var payload = {
priority: 'normal',
data: payloadData
};
if (expirationTime) {
// The timeStamp and expiration is in milliseconds but gcm requires second
var timeToLive = Math.floor((expirationTime - timeStamp) / 1000);
if (timeToLive < 0) {
timeToLive = 0;
}
if (timeToLive >= GCMTimeToLiveMax) {
timeToLive = GCMTimeToLiveMax;
}
payload.timeToLive = timeToLive;
}
return payload;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
GCM.generateGCMPayload = generateGCMPayload;
}
module.exports = GCM;

View File

@@ -1,48 +0,0 @@
// GridStoreAdapter
//
// Stores files in Mongo using GridStore
// 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
function create(config, filename, data) {
return config.database.connect().then(() => {
var gridStore = new GridStore(config.database.db, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
return gridStore.write(data);
}).then((gridStore) => {
return gridStore.close();
});
}
// Search for and return a file if found by filename
// Resolves a promise that succeeds with the buffer result
// from GridStore
function get(config, filename) {
return config.database.connect().then(() => {
return GridStore.exist(config.database.db, filename);
}).then(() => {
var gridStore = new GridStore(config.database.db, filename, 'r');
return gridStore.open();
}).then((gridStore) => {
return gridStore.read();
});
}
// 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,
location: location
};

View File

@@ -25,10 +25,11 @@ We also have an [example project](https://github.com/ParsePlatform/parse-server-
* fileKey - For migrated apps, this is necessary to provide access to files already hosted on Parse.
* facebookAppIds - An array of valid Facebook application IDs.
* serverURL - URL which will be used by Cloud Code functions to make requests against.
* push - Configuration options for APNS and GCM push. See the [wiki entry](https://github.com/ParsePlatform/parse-server/wiki/Push).
#### Client key options:
The client keys used with Parse are no longer necessary with parse-server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at intialization time. Setting any of these keys will require all requests to provide one of the configured keys.
The client keys used with Parse are no longer necessary with parse-server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys.
* clientKey
* javascriptKey
@@ -37,8 +38,9 @@ The client keys used with Parse are no longer necessary with parse-server. If y
#### Advanced options:
* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see `FilesAdapter.js`)
* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js))
* databaseAdapter (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`)
* loggerAdapter - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js))
---
@@ -122,6 +124,7 @@ Now you can just run `$ parse-server` from your command line.
* Pointers
* Users, including Facebook login and anonymous users
* Files
* Push Notifications - See the [wiki entry](https://github.com/ParsePlatform/parse-server/wiki/Push).
* Installations
* Sessions
* Geopoints
@@ -133,4 +136,4 @@ You can also set up an app on Parse, providing the connection string for your mo
### Not supported
* Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community.
* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need the user's session token, which is available in `request.user.getSessionToken()`.

View File

@@ -1,77 +0,0 @@
// 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, (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;

34
bin/dev Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
var nodemon = require('nodemon');
var babel = require("babel-core");
var gaze = require('gaze');
var fs = require('fs');
// Watch the src and transpile when changed
gaze('src/**/*', function(err, watcher) {
if (err) throw err;
watcher.on('changed', function(file) {
console.log(file + " has changed");
try {
fs.writeFile(file.replace(/\/src\//, "/lib/"), babel.transformFileSync(file).code);
} catch (e) {
console.error(e.message, e.stack);
}
});
});
try {
// Run and watch dist
nodemon({
script: 'bin/parse-server',
ext: 'js json',
watch: 'lib'
});
} catch (e) {
console.error(e.message, e.stack);
}
process.once('SIGINT', function() {
process.exit(0);
});

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
var express = require('express');
var ParseServer = require("../index").ParseServer;
var ParseServer = require("../lib/index").ParseServer;
var app = express();
@@ -21,7 +21,6 @@ if (process.env.PARSE_SERVER_OPTIONS) {
options.restAPIKey = process.env.PARSE_SERVER_REST_API_KEY;
options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY;
options.javascriptKey = process.env.PARSE_SERVER_JAVASCRIPT_KEY;
options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY;
options.masterKey = process.env.PARSE_SERVER_MASTER_KEY;
options.fileKey = process.env.PARSE_SERVER_FILE_KEY;
// Comma separated list of facebook app ids

View File

@@ -1,101 +0,0 @@
// These methods handle the 'classes' routes.
// Methods of the form 'handleX' return promises and are intended to
// be used with the PromiseRouter.
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
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 (body.skip) {
options.skip = Number(body.skip);
}
if (body.limit) {
options.limit = Number(body.limit);
}
if (body.order) {
options.order = String(body.order);
}
if (body.count) {
options.count = true;
}
if (typeof body.keys == 'string') {
options.keys = body.keys;
}
if (body.include) {
options.include = String(body.include);
}
if (body.redirectClassNameForKey) {
options.redirectClassNameForKey = String(body.redirectClassNameForKey);
}
if(typeof body.where === 'string') {
body.where = JSON.parse(body.where);
}
return rest.find(req.config, req.auth,
req.params.className, body.where, options)
.then((response) => {
if (response && response.results) {
for (result of response.results) {
if (result.sessionToken) {
result.sessionToken = req.info.sessionToken || result.sessionToken;
}
}
response.results.sessionToken
}
return {response: response};
});
}
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
return rest.create(req.config, req.auth,
req.params.className, req.body);
}
// Returns a promise for a {response} object.
function handleGet(req) {
return rest.find(req.config, req.auth,
req.params.className, {objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
// Returns a promise for a {response} object.
function handleDelete(req) {
return rest.del(req.config, req.auth,
req.params.className, req.params.objectId)
.then(() => {
return {response: {}};
});
}
// Returns a promise for a {response} object.
function handleUpdate(req) {
return rest.update(req.config, req.auth,
req.params.className, req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
router.route('GET', '/classes/:className', handleFind);
router.route('POST', '/classes/:className', handleCreate);
router.route('GET', '/classes/:className/:objectId', handleGet);
router.route('DELETE', '/classes/:className/:objectId', handleDelete);
router.route('PUT', '/classes/:className/:objectId', handleUpdate);
module.exports = router;

View File

@@ -1,85 +0,0 @@
// files.js
var bodyParser = require('body-parser'),
Config = require('./Config'),
express = require('express'),
FilesAdapter = require('./FilesAdapter'),
middlewares = require('./middlewares.js'),
mime = require('mime'),
Parse = require('parse/node').Parse,
rack = require('hat').rack();
var router = express.Router();
var processCreate = function(req, res, next) {
if (!req.body || !req.body.length) {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR,
'Invalid file upload.'));
return;
}
if (req.params.filename.length > 128) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename too long.'));
return;
}
if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename contains invalid characters.'));
return;
}
// If a content-type is included, we'll add an extension so we can
// return the same content-type.
var extension = '';
var hasExtension = req.params.filename.indexOf('.') > 0;
var contentType = req.get('Content-type');
if (!hasExtension && contentType && mime.extension(contentType)) {
extension = '.' + mime.extension(contentType);
}
var filename = rack() + '_' + req.params.filename + extension;
FilesAdapter.getAdapter().create(req.config, filename, req.body)
.then(() => {
res.status(201);
var location = FilesAdapter.getAdapter().location(req.config, req, filename);
res.set('Location', location);
res.json({ url: location, name: filename });
}).catch((error) => {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR,
'Could not store file.'));
});
};
var processGet = function(req, res) {
var config = new Config(req.params.appId);
FilesAdapter.getAdapter().get(config, req.params.filename)
.then((data) => {
res.status(200);
var contentType = mime.lookup(req.params.filename);
res.set('Content-type', contentType);
res.end(data);
}).catch((error) => {
res.status(404);
res.set('Content-type', 'text/plain');
res.end('File not found.');
});
};
router.get('/files/:appId/:filename', processGet);
router.post('/files', function(req, res, next) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename not provided.'));
});
router.post('/files/:filename',
middlewares.allowCrossDomain,
bodyParser.raw({type: '*/*', limit: '20mb'}),
middlewares.handleParseHeaders,
processCreate);
module.exports = {
router: router
};

View File

@@ -1,80 +0,0 @@
// installations.js
var Parse = require('parse/node').Parse;
var PromiseRouter = require('./PromiseRouter');
var rest = require('./rest');
var router = new PromiseRouter();
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
return rest.create(req.config,
req.auth, '_Installation', req.body);
}
// Returns a promise that resolves to a {response} object.
function handleFind(req) {
var options = {};
if (req.body.skip) {
options.skip = Number(req.body.skip);
}
if (req.body.limit) {
options.limit = Number(req.body.limit);
}
if (req.body.order) {
options.order = String(req.body.order);
}
if (req.body.count) {
options.count = true;
}
if (req.body.include) {
options.include = String(req.body.include);
}
return rest.find(req.config, req.auth,
'_Installation', req.body.where, options)
.then((response) => {
return {response: response};
});
}
// Returns a promise for a {response} object.
function handleGet(req) {
return rest.find(req.config, req.auth, '_Installation',
{objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
// Returns a promise for a {response} object.
function handleUpdate(req) {
return rest.update(req.config, req.auth,
'_Installation', req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
// Returns a promise for a {response} object.
function handleDelete(req) {
return rest.del(req.config, req.auth,
'_Installation', req.params.objectId)
.then(() => {
return {response: {}};
});
}
router.route('POST','/installations', handleCreate);
router.route('GET','/installations', handleFind);
router.route('GET','/installations/:objectId', handleGet);
router.route('PUT','/installations/:objectId', handleUpdate);
router.route('DELETE','/installations/:objectId', handleDelete);
module.exports = router;

View File

@@ -1,8 +1,8 @@
{
"name": "parse-server",
"version": "2.0.7",
"version": "2.0.8",
"description": "An express module providing a Parse-compatible API server",
"main": "index.js",
"main": "lib/index.js",
"repository": {
"type": "git",
"url": "https://github.com/ParsePlatform/parse-server"
@@ -11,34 +11,44 @@
"dependencies": {
"apn": "^1.7.5",
"aws-sdk": "~2.2.33",
"babel-runtime": "^6.5.0",
"bcrypt-nodejs": "0.0.3",
"body-parser": "^1.14.2",
"deepcopy": "^0.6.1",
"express": "^4.13.4",
"hat": "~0.0.3",
"mime": "^1.3.4",
"mongodb": "~2.1.0",
"multer": "^1.1.0",
"parse": "^1.7.0",
"randomstring": "^1.1.3",
"node-gcm": "^0.14.0",
"request": "^2.65.0"
"parse": "^1.7.0",
"request": "^2.65.0",
"winston": "^2.1.1"
},
"devDependencies": {
"babel-cli": "^6.5.1",
"babel-core": "^6.5.1",
"babel-istanbul": "^0.6.0",
"babel-preset-es2015": "^6.5.0",
"babel-register": "^6.5.1",
"codecov": "^1.0.1",
"cross-env": "^1.0.7",
"deep-diff": "^0.3.3",
"istanbul": "^0.4.2",
"gaze": "^0.5.2",
"jasmine": "^2.3.2",
"mongodb-runner": "^3.1.15"
"mongodb-runner": "^3.1.15",
"nodemon": "^1.8.1"
},
"scripts": {
"pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start",
"test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/istanbul cover --include-all-sources -x **/spec/** ./node_modules/.bin/jasmine",
"dev": "npm run build && bin/dev",
"build": "./node_modules/.bin/babel src/ -d lib/",
"pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start",
"test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/** ./node_modules/jasmine/bin/jasmine.js",
"posttest": "mongodb-runner stop",
"start": "./bin/parse-server"
"start": "./bin/parse-server",
"prepublish": "npm run build"
},
"engines": {
"node": ">=4.1"
"node": ">=4.3"
},
"bin": {
"parse-server": "./bin/parse-server"

View File

@@ -1,48 +0,0 @@
// roles.js
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
var router = new PromiseRouter();
function handleCreate(req) {
return rest.create(req.config, req.auth,
'_Role', req.body);
}
function handleUpdate(req) {
return rest.update(req.config, req.auth, '_Role',
req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
function handleDelete(req) {
return rest.del(req.config, req.auth,
'_Role', req.params.objectId)
.then(() => {
return {response: {}};
});
}
function handleGet(req) {
return rest.find(req.config, req.auth, '_Role',
{objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
router.route('POST','/roles', handleCreate);
router.route('GET','/roles/:objectId', handleGet);
router.route('PUT','/roles/:objectId', handleUpdate);
router.route('DELETE','/roles/:objectId', handleDelete);
module.exports = router;

View File

@@ -1,122 +0,0 @@
// sessions.js
var Auth = require('./Auth'),
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
var router = new PromiseRouter();
function handleCreate(req) {
return rest.create(req.config, req.auth,
'_Session', req.body);
}
function handleUpdate(req) {
return rest.update(req.config, req.auth, '_Session',
req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
function handleDelete(req) {
return rest.del(req.config, req.auth,
'_Session', req.params.objectId)
.then(() => {
return {response: {}};
});
}
function handleGet(req) {
return rest.find(req.config, req.auth, '_Session',
{objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
function handleLogout(req) {
// TODO: Verify correct behavior for logout without token
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.SESSION_MISSING,
'Session token required for logout.');
}
return rest.find(req.config, Auth.master(req.config), '_Session',
{ _session_token: req.info.sessionToken})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN,
'Session token not found.');
}
return rest.del(req.config, Auth.master(req.config), '_Session',
response.results[0].objectId);
}).then(() => {
return {
status: 200,
response: {}
};
});
}
function handleFind(req) {
var options = {};
if (req.body.skip) {
options.skip = Number(req.body.skip);
}
if (req.body.limit) {
options.limit = Number(req.body.limit);
}
if (req.body.order) {
options.order = String(req.body.order);
}
if (req.body.count) {
options.count = true;
}
if (typeof req.body.keys == 'string') {
options.keys = req.body.keys;
}
if (req.body.include) {
options.include = String(req.body.include);
}
return rest.find(req.config, req.auth,
'_Session', req.body.where, options)
.then((response) => {
return {response: response};
});
}
function handleMe(req) {
// TODO: Verify correct behavior
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN,
'Session token required.');
}
return rest.find(req.config, Auth.master(req.config), '_Session',
{ _session_token: req.info.sessionToken})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN,
'Session token not found.');
}
return {
response: response.results[0]
};
});
}
router.route('POST', '/logout', handleLogout);
router.route('POST','/sessions', handleCreate);
router.route('GET','/sessions/me', handleMe);
router.route('GET','/sessions/:objectId', handleGet);
router.route('PUT','/sessions/:objectId', handleUpdate);
router.route('GET','/sessions', handleFind);
router.route('DELETE','/sessions/:objectId', handleDelete);
module.exports = router;

View File

@@ -1,6 +1,65 @@
var APNS = require('../APNS');
var APNS = require('../src/APNS');
describe('APNS', () => {
it('can initialize with single cert', (done) => {
var args = {
cert: 'prodCert.pem',
key: 'prodKey.pem',
production: true,
bundleId: 'bundleId'
}
var apns = new APNS(args);
expect(apns.conns.length).toBe(1);
var apnsConnection = apns.conns[0];
expect(apnsConnection.index).toBe(0);
expect(apnsConnection.bundleId).toBe(args.bundleId);
// TODO: Remove this checking onec we inject APNS
var prodApnsOptions = apnsConnection.options;
expect(prodApnsOptions.cert).toBe(args.cert);
expect(prodApnsOptions.key).toBe(args.key);
expect(prodApnsOptions.production).toBe(args.production);
done();
});
it('can initialize with multiple certs', (done) => {
var args = [
{
cert: 'devCert.pem',
key: 'devKey.pem',
production: false,
bundleId: 'bundleId'
},
{
cert: 'prodCert.pem',
key: 'prodKey.pem',
production: true,
bundleId: 'bundleIdAgain'
}
]
var apns = new APNS(args);
expect(apns.conns.length).toBe(2);
var devApnsConnection = apns.conns[1];
expect(devApnsConnection.index).toBe(1);
var devApnsOptions = devApnsConnection.options;
expect(devApnsOptions.cert).toBe(args[0].cert);
expect(devApnsOptions.key).toBe(args[0].key);
expect(devApnsOptions.production).toBe(args[0].production);
expect(devApnsConnection.bundleId).toBe(args[0].bundleId);
var prodApnsConnection = apns.conns[0];
expect(prodApnsConnection.index).toBe(0);
// TODO: Remove this checking onec we inject APNS
var prodApnsOptions = prodApnsConnection.options;
expect(prodApnsOptions.cert).toBe(args[1].cert);
expect(prodApnsOptions.key).toBe(args[1].key);
expect(prodApnsOptions.production).toBe(args[1].production);
expect(prodApnsOptions.bundleId).toBe(args[1].bundleId);
done();
});
it('can generate APNS notification', (done) => {
//Mock request data
var data = {
@@ -29,12 +88,195 @@ describe('APNS', () => {
done();
});
it('can send APNS notification', (done) => {
var apns = new APNS();
var sender = {
pushNotification: jasmine.createSpy('send')
it('can choose conns for device without appIdentifier', (done) => {
// Mock conns
var conns = [
{
bundleId: 'bundleId'
},
{
bundleId: 'bundleIdAgain'
}
];
// Mock device
var device = {};
var qualifiedConns = APNS.chooseConns(conns, device);
expect(qualifiedConns).toEqual([0, 1]);
done();
});
it('can choose conns for device with valid appIdentifier', (done) => {
// Mock conns
var conns = [
{
bundleId: 'bundleId'
},
{
bundleId: 'bundleIdAgain'
}
];
// Mock device
var device = {
appIdentifier: 'bundleId'
};
apns.sender = sender;
var qualifiedConns = APNS.chooseConns(conns, device);
expect(qualifiedConns).toEqual([0]);
done();
});
it('can choose conns for device with invalid appIdentifier', (done) => {
// Mock conns
var conns = [
{
bundleId: 'bundleId'
},
{
bundleId: 'bundleIdAgain'
}
];
// Mock device
var device = {
appIdentifier: 'invalid'
};
var qualifiedConns = APNS.chooseConns(conns, device);
expect(qualifiedConns).toEqual([]);
done();
});
it('can handle transmission error when notification is not in cache or device is missing', (done) => {
// Mock conns
var conns = [];
var errorCode = 1;
var notification = undefined;
var device = {};
APNS.handleTransmissionError(conns, errorCode, notification, device);
var notification = {};
var device = undefined;
APNS.handleTransmissionError(conns, errorCode, notification, device);
done();
});
it('can handle transmission error when there are other qualified conns', (done) => {
// Mock conns
var conns = [
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId2'
},
];
var errorCode = 1;
var notification = {};
var apnDevice = {
connIndex: 0,
appIdentifier: 'bundleId1'
};
APNS.handleTransmissionError(conns, errorCode, notification, apnDevice);
expect(conns[0].pushNotification).not.toHaveBeenCalled();
expect(conns[1].pushNotification).toHaveBeenCalled();
expect(conns[2].pushNotification).not.toHaveBeenCalled();
done();
});
it('can handle transmission error when there is no other qualified conns', (done) => {
// Mock conns
var conns = [
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId2'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
}
];
var errorCode = 1;
var notification = {};
var apnDevice = {
connIndex: 2,
appIdentifier: 'bundleId1'
};
APNS.handleTransmissionError(conns, errorCode, notification, apnDevice);
expect(conns[0].pushNotification).not.toHaveBeenCalled();
expect(conns[1].pushNotification).not.toHaveBeenCalled();
expect(conns[2].pushNotification).not.toHaveBeenCalled();
expect(conns[3].pushNotification).not.toHaveBeenCalled();
expect(conns[4].pushNotification).toHaveBeenCalled();
done();
});
it('can handle transmission error when device has no appIdentifier', (done) => {
// Mock conns
var conns = [
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId1'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId2'
},
{
pushNotification: jasmine.createSpy('pushNotification'),
bundleId: 'bundleId3'
},
];
var errorCode = 1;
var notification = {};
var apnDevice = {
connIndex: 1,
};
APNS.handleTransmissionError(conns, errorCode, notification, apnDevice);
expect(conns[0].pushNotification).not.toHaveBeenCalled();
expect(conns[1].pushNotification).not.toHaveBeenCalled();
expect(conns[2].pushNotification).toHaveBeenCalled();
done();
});
it('can send APNS notification', (done) => {
var args = {
cert: 'prodCert.pem',
key: 'prodKey.pem',
production: true,
bundleId: 'bundleId'
}
var apns = new APNS(args);
var conn = {
pushNotification: jasmine.createSpy('send'),
bundleId: 'bundleId'
};
apns.conns = [ conn ];
// Mock data
var expirationTime = 1454571491354
var data = {
@@ -43,16 +285,23 @@ describe('APNS', () => {
'alert': 'alert'
}
}
// Mock registrationTokens
var deviceTokens = ['token'];
// Mock devices
var devices = [
{
deviceToken: '112233',
appIdentifier: 'bundleId'
}
];
var promise = apns.send(data, deviceTokens);
expect(sender.pushNotification).toHaveBeenCalled();
var args = sender.pushNotification.calls.first().args;
var promise = apns.send(data, devices);
expect(conn.pushNotification).toHaveBeenCalled();
var args = conn.pushNotification.calls.first().args;
var notification = args[0];
expect(notification.alert).toEqual(data.data.alert);
expect(notification.expiry).toEqual(data['expiration_time']);
expect(args[1]).toEqual(deviceTokens);
var apnDevice = args[1]
expect(apnDevice.connIndex).toEqual(0);
expect(apnDevice.appIdentifier).toEqual('bundleId');
done();
});
});

View File

@@ -1,4 +1,4 @@
var ExportAdapter = require('../ExportAdapter');
var ExportAdapter = require('../src/ExportAdapter');
describe('ExportAdapter', () => {
it('can be constructed', (done) => {

View File

@@ -0,0 +1,64 @@
var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter;
var Parse = require('parse/node').Parse;
var request = require('request');
var fs = require('fs');
var LOGS_FOLDER = './test_logs/';
var deleteFolderRecursive = function(path) {
if( fs.existsSync(path) ) {
fs.readdirSync(path).forEach(function(file,index){
var curPath = path + "/" + file;
if(fs.lstatSync(curPath).isDirectory()) { // recurse
deleteFolderRecursive(curPath);
} else { // delete file
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
};
describe('info logs', () => {
afterEach((done) => {
deleteFolderRecursive(LOGS_FOLDER);
done();
});
it("Verify INFO logs", (done) => {
var fileLoggerAdapter = new FileLoggerAdapter({
logsFolder: LOGS_FOLDER
});
fileLoggerAdapter.info('testing info logs', () => {
fileLoggerAdapter.query({
size: 1,
level: 'info'
}, (results) => {
expect(results[0].message).toEqual('testing info logs');
done();
});
});
});
});
describe('error logs', () => {
afterEach((done) => {
deleteFolderRecursive(LOGS_FOLDER);
done();
});
it("Verify ERROR logs", (done) => {
var fileLoggerAdapter = new FileLoggerAdapter();
fileLoggerAdapter.error('testing error logs', () => {
fileLoggerAdapter.query({
size: 1,
level: 'error'
}, (results) => {
expect(results[0].message).toEqual('testing error logs');
done();
});
});
});
});

View File

@@ -1,6 +1,23 @@
var GCM = require('../GCM');
var GCM = require('../src/GCM');
describe('GCM', () => {
it('can initialize', (done) => {
var args = {
apiKey: 'apiKey'
};
var gcm = new GCM(args);
expect(gcm.sender.key).toBe(args.apiKey);
done();
});
it('can throw on initializing with invalid args', (done) => {
var args = 123
expect(function() {
new GCM(args);
}).toThrow();
done();
});
it('can generate GCM Payload without expiration time', (done) => {
//Mock request data
var data = {
@@ -90,7 +107,9 @@ describe('GCM', () => {
});
it('can send GCM request', (done) => {
var gcm = new GCM('apiKey');
var gcm = new GCM({
apiKey: 'apiKey'
});
// Mock gcm sender
var sender = {
send: jasmine.createSpy('send')
@@ -104,34 +123,37 @@ describe('GCM', () => {
'alert': 'alert'
}
}
// Mock registrationTokens
var registrationTokens = ['token'];
// Mock devices
var devices = [
{
deviceToken: 'token'
}
];
var promise = gcm.send(data, registrationTokens);
gcm.send(data, devices);
expect(sender.send).toHaveBeenCalled();
var args = sender.send.calls.first().args;
// It is too hard to verify message of gcm library, we just verify tokens and retry times
expect(args[1].registrationTokens).toEqual(registrationTokens);
expect(args[1].registrationTokens).toEqual(['token']);
expect(args[2]).toEqual(5);
done();
});
it('can throw on sending when we have too many registration tokens', (done) => {
var gcm = new GCM('apiKey');
// Mock gcm sender
var sender = {
send: jasmine.createSpy('send')
};
gcm.sender = sender;
// Mock registrationTokens
var registrationTokens = [];
for (var i = 0; i <= 2000; i++) {
registrationTokens.push(i.toString());
}
it('can slice devices', (done) => {
// Mock devices
var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)];
expect(function() {
gcm.send({}, registrationTokens);
}).toThrow();
var chunkDevices = GCM.sliceDevices(devices, 3);
expect(chunkDevices).toEqual([
[makeDevice(1), makeDevice(2), makeDevice(3)],
[makeDevice(4)]
]);
done();
});
function makeDevice(deviceToken) {
return {
deviceToken: deviceToken
};
}
});

View File

@@ -0,0 +1,55 @@
var LoggerController = require('../src/Controllers/LoggerController').LoggerController;
var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter;
describe('LoggerController', () => {
it('can check valid master key of request', (done) => {
// Make mock request
var request = {
auth: {
isMaster: true
},
query: {}
};
var loggerController = new LoggerController(new FileLoggerAdapter());
expect(() => {
loggerController.handleGET(request);
}).not.toThrow();
done();
});
it('can check invalid construction of controller', (done) => {
// Make mock request
var request = {
auth: {
isMaster: true
},
query: {}
};
var loggerController = new LoggerController();
expect(() => {
loggerController.handleGET(request);
}).toThrow();
done();
});
it('can check invalid master key of request', (done) => {
// Make mock request
var request = {
auth: {
isMaster: false
},
query: {}
};
var loggerController = new LoggerController(new FileLoggerAdapter());
expect(() => {
loggerController.handleGET(request);
}).toThrow();
done();
});
});

View File

@@ -251,6 +251,9 @@ describe('Parse.ACL', () => {
equal(results.length, 1);
var result = results[0];
ok(result);
if (!result) {
return fail();
}
equal(result.id, object.id);
equal(result.getACL().getReadAccess(user), true);
equal(result.getACL().getWriteAccess(user), true);

View File

@@ -1,7 +1,7 @@
// A bunch of different tests are in here - it isn't very thematic.
// It would probably be better to refactor them into different files.
var DatabaseAdapter = require('../DatabaseAdapter');
var DatabaseAdapter = require('../src/DatabaseAdapter');
var request = require('request');
describe('miscellaneous', function() {

View File

@@ -33,6 +33,95 @@ describe('Parse.File testing', () => {
});
});
it('supports REST end-to-end file create, read, delete, read', done => {
var headers = {
'Content-Type': 'image/jpeg',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest'
};
request.post({
headers: headers,
url: 'http://localhost:8378/1/files/testfile.txt',
body: 'check one two',
}, (error, response, body) => {
expect(error).toBe(null);
var b = JSON.parse(body);
expect(b.name).toMatch(/_testfile.txt$/);
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/);
request.get(b.url, (error, response, body) => {
expect(error).toBe(null);
expect(body).toEqual('check one two');
request.del({
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Master-Key': 'test'
},
url: 'http://localhost:8378/1/files/' + b.name
}, (error, response, body) => {
expect(error).toBe(null);
expect(response.statusCode).toEqual(200);
request.get({
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest'
},
url: b.url
}, (error, response, body) => {
expect(error).toBe(null);
expect(response.statusCode).toEqual(404);
done();
});
});
});
});
});
it('blocks file deletions with missing or incorrect master-key header', done => {
var headers = {
'Content-Type': 'image/jpeg',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest'
};
request.post({
headers: headers,
url: 'http://localhost:8378/1/files/thefile.jpg',
body: 'the file body'
}, (error, response, body) => {
expect(error).toBe(null);
var b = JSON.parse(body);
expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/);
// missing X-Parse-Master-Key header
request.del({
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest'
},
url: 'http://localhost:8378/1/files/' + b.name
}, (error, response, body) => {
expect(error).toBe(null);
var del_b = JSON.parse(body);
expect(response.statusCode).toEqual(403);
expect(del_b.error).toMatch(/unauthorized/);
// incorrect X-Parse-Master-Key header
request.del({
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Master-Key': 'tryagain'
},
url: 'http://localhost:8378/1/files/' + b.name
}, (error, response, body) => {
expect(error).toBe(null);
var del_b2 = JSON.parse(body);
expect(response.statusCode).toEqual(403);
expect(del_b2.error).toMatch(/unauthorized/);
done();
});
});
});
});
it('handles other filetypes', done => {
var headers = {
'Content-Type': 'image/jpeg',

View File

@@ -0,0 +1,81 @@
var request = require('request');
var Parse = require('parse/node').Parse;
var DatabaseAdapter = require('../src/DatabaseAdapter');
var database = DatabaseAdapter.getDatabaseConnection('test');
describe('a GlobalConfig', () => {
beforeEach(function(done) {
database.rawCollection('_GlobalConfig')
.then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true }))
.then(done());
});
it('can be retrieved', (done) => {
request.get({
url: 'http://localhost:8378/1/config',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
}, (error, response, body) => {
expect(response.statusCode).toEqual(200);
expect(body.params.companies).toEqual(['US', 'DK']);
done();
});
});
it('can be updated when a master key exists', (done) => {
request.put({
url: 'http://localhost:8378/1/config',
json: true,
body: { params: { companies: ['US', 'DK', 'SE'] } },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test'
},
}, (error, response, body) => {
expect(response.statusCode).toEqual(200);
expect(body.result).toEqual(true);
done();
});
});
it('fail to update if master key is missing', (done) => {
request.put({
url: 'http://localhost:8378/1/config',
json: true,
body: { params: { companies: [] } },
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest'
},
}, (error, response, body) => {
expect(response.statusCode).toEqual(401);
expect(body.error).toEqual('unauthorized');
done();
});
});
it('failed getting config when it is missing', (done) => {
database.rawCollection('_GlobalConfig')
.then(coll => coll.deleteOne({ '_id': 1}, {}, {}))
.then(_ => {
request.get({
url: 'http://localhost:8378/1/config',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
}, (error, response, body) => {
expect(response.statusCode).toEqual(404);
expect(body.code).toEqual(Parse.Error.INVALID_KEY_NAME);
done();
});
});
});
});

View File

@@ -1,12 +1,12 @@
// These tests check the Installations functionality of the REST API.
// Ported from installation_collection_test.go
var auth = require('../Auth');
var cache = require('../cache');
var Config = require('../Config');
var DatabaseAdapter = require('../DatabaseAdapter');
var auth = require('../src/Auth');
var cache = require('../src/cache');
var Config = require('../src/Config');
var DatabaseAdapter = require('../src/DatabaseAdapter');
var Parse = require('parse/node').Parse;
var rest = require('../rest');
var rest = require('../src/rest');
var config = new Config('test');
var database = DatabaseAdapter.getDatabaseConnection('test');

View File

@@ -0,0 +1,150 @@
var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter');
var APNS = require('../src/APNS');
var GCM = require('../src/GCM');
describe('ParsePushAdapter', () => {
it('can be initialized', (done) => {
// Make mock config
var pushConfig = {
android: {
senderId: 'senderId',
apiKey: 'apiKey'
},
ios: [
{
cert: 'prodCert.pem',
key: 'prodKey.pem',
production: true,
bundleId: 'bundleId'
},
{
cert: 'devCert.pem',
key: 'devKey.pem',
production: false,
bundleId: 'bundleIdAgain'
}
]
};
var parsePushAdapter = new ParsePushAdapter(pushConfig);
// Check ios
var iosSender = parsePushAdapter.senderMap['ios'];
expect(iosSender instanceof APNS).toBe(true);
// Check android
var androidSender = parsePushAdapter.senderMap['android'];
expect(androidSender instanceof GCM).toBe(true);
done();
});
it('can throw on initializing with unsupported push type', (done) => {
// Make mock config
var pushConfig = {
win: {
senderId: 'senderId',
apiKey: 'apiKey'
}
};
expect(function() {
new ParsePushAdapter(pushConfig);
}).toThrow();
done();
});
it('can get valid push types', (done) => {
var parsePushAdapter = new ParsePushAdapter();
expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'android']);
done();
});
it('can classify installation', (done) => {
// Mock installations
var validPushTypes = ['ios', 'android'];
var installations = [
{
deviceType: 'android',
deviceToken: 'androidToken'
},
{
deviceType: 'ios',
deviceToken: 'iosToken'
},
{
deviceType: 'win',
deviceToken: 'winToken'
},
{
deviceType: 'android',
deviceToken: undefined
}
];
var deviceMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes);
expect(deviceMap['android']).toEqual([makeDevice('androidToken')]);
expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]);
expect(deviceMap['win']).toBe(undefined);
done();
});
it('can send push notifications', (done) => {
var parsePushAdapter = new ParsePushAdapter();
// Mock android ios senders
var androidSender = {
send: jasmine.createSpy('send')
};
var iosSender = {
send: jasmine.createSpy('send')
};
var senderMap = {
ios: iosSender,
android: androidSender
};
parsePushAdapter.senderMap = senderMap;
// Mock installations
var installations = [
{
deviceType: 'android',
deviceToken: 'androidToken'
},
{
deviceType: 'ios',
deviceToken: 'iosToken'
},
{
deviceType: 'win',
deviceToken: 'winToken'
},
{
deviceType: 'android',
deviceToken: undefined
}
];
var data = {};
parsePushAdapter.send(data, installations);
// Check android sender
expect(androidSender.send).toHaveBeenCalled();
var args = androidSender.send.calls.first().args;
expect(args[0]).toEqual(data);
expect(args[1]).toEqual([
makeDevice('androidToken')
]);
// Check ios sender
expect(iosSender.send).toHaveBeenCalled();
args = iosSender.send.calls.first().args;
expect(args[0]).toEqual(data);
expect(args[1]).toEqual([
makeDevice('iosToken')
]);
done();
});
function makeDevice(deviceToken, appIdentifier) {
return {
deviceToken: deviceToken,
appIdentifier: appIdentifier
};
}
});

View File

@@ -2056,7 +2056,7 @@ describe('Parse.Query testing', () => {
});
});
it('query match on array value', (done) => {
it('query match on array with single object', (done) => {
var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'};
var obj = new Parse.Object('TestObject');
obj.set('someObjs', [target]);
@@ -2072,4 +2072,20 @@ describe('Parse.Query testing', () => {
});
});
it('query match on array with multiple objects', (done) => {
var target1 = {__type: 'Pointer', className: 'TestObject', objectId: 'abc'};
var target2 = {__type: 'Pointer', className: 'TestObject', objectId: '123'};
var obj= new Parse.Object('TestObject');
obj.set('someObjs', [target1, target2]);
obj.save().then(() => {
var query = new Parse.Query('TestObject');
query.equalTo('someObjs', target1);
return query.find();
}).then((results) => {
expect(results.length).toEqual(1);
done();
}, (error) => {
console.log(error);
});
});
});

View File

@@ -6,7 +6,7 @@
// Tests that involve sending password reset emails.
var request = require('request');
var passwordCrypto = require('../password');
var passwordCrypto = require('../src/password');
describe('Parse.User testing', () => {
it("user sign up class method", (done) => {
@@ -64,6 +64,22 @@ describe('Parse.User testing', () => {
});
});
it("user login with files", (done) => {
"use strict";
let file = new Parse.File("yolo.txt", [1,2,3], "text/plain");
file.save().then((file) => {
return Parse.User.signUp("asdf", "zxcv", { "file" : file });
}).then(() => {
return Parse.User.logIn("asdf", "zxcv");
}).then((user) => {
let fileAgain = user.get('file');
ok(fileAgain.name());
ok(fileAgain.url());
done();
});
});
it("become", (done) => {
var user = null;
var sessionToken = null;
@@ -1576,5 +1592,27 @@ describe('Parse.User testing', () => {
});
});
it('ensure logout works', (done) => {
var user = null;
var sessionToken = null;
Parse.Promise.as().then(function() {
return Parse.User.signUp('log', 'out');
}).then((newUser) => {
user = newUser;
sessionToken = user.getSessionToken();
return Parse.User.logOut();
}).then(() => {
user.set('foo', 'bar');
return user.save(null, { sessionToken: sessionToken });
}).then(() => {
fail('Save should have failed.');
done();
}, (e) => {
expect(e.code).toEqual(Parse.Error.SESSION_MISSING);
done();
});
})
});

View File

@@ -1,6 +1,6 @@
var push = require('../push');
var PushController = require('../src/Controllers/PushController').PushController;
describe('push', () => {
describe('PushController', () => {
it('can check valid master key of request', (done) => {
// Make mock request
var request = {
@@ -13,7 +13,7 @@ describe('push', () => {
}
expect(() => {
push.validateMasterKey(request);
PushController.validateMasterKey(request);
}).not.toThrow();
done();
});
@@ -30,7 +30,7 @@ describe('push', () => {
}
expect(() => {
push.validateMasterKey(request);
PushController.validateMasterKey(request);
}).toThrow();
done();
});
@@ -43,7 +43,7 @@ describe('push', () => {
}
}
var where = push.getQueryCondition(request);
var where = PushController.getQueryCondition(request);
expect(where).toEqual({
'channels': {
'$in': ['Giants', 'Mets']
@@ -62,7 +62,7 @@ describe('push', () => {
}
}
var where = push.getQueryCondition(request);
var where = PushController.getQueryCondition(request);
expect(where).toEqual({
'injuryReports': true
});
@@ -77,7 +77,7 @@ describe('push', () => {
}
expect(function() {
push.getQueryCondition(request);
PushController.getQueryCondition(request);
}).toThrow();
done();
});
@@ -96,7 +96,7 @@ describe('push', () => {
}
expect(function() {
push.getQueryCondition(request);
PushController.getQueryCondition(request);
}).toThrow();
done();
});
@@ -104,10 +104,11 @@ describe('push', () => {
it('can validate device type when no device type is set', (done) => {
// Make query condition
var where = {
}
};
var validPushTypes = ['ios', 'android'];
expect(function(){
push.validateDeviceType(where);
PushController.validatePushType(where, validPushTypes);
}).not.toThrow();
done();
});
@@ -116,10 +117,11 @@ describe('push', () => {
// Make query condition
var where = {
'deviceType': 'ios'
}
};
var validPushTypes = ['ios', 'android'];
expect(function(){
push.validateDeviceType(where);
PushController.validatePushType(where, validPushTypes);
}).not.toThrow();
done();
});
@@ -130,10 +132,11 @@ describe('push', () => {
'deviceType': {
'$in': ['android', 'ios']
}
}
};
var validPushTypes = ['ios', 'android'];
expect(function(){
push.validateDeviceType(where);
PushController.validatePushType(where, validPushTypes);
}).not.toThrow();
done();
});
@@ -142,10 +145,11 @@ describe('push', () => {
// Make query condition
var where = {
'deviceType': 'osx'
}
};
var validPushTypes = ['ios', 'android'];
expect(function(){
push.validateDeviceType(where);
PushController.validatePushType(where, validPushTypes);
}).toThrow();
done();
});
@@ -154,10 +158,11 @@ describe('push', () => {
// Make query condition
var where = {
'deviceType': 'osx'
}
};
var validPushTypes = ['ios', 'android'];
expect(function(){
push.validateDeviceType(where)
PushController.validatePushType(where, validPushTypes);
}).toThrow();
done();
});
@@ -171,7 +176,7 @@ describe('push', () => {
}
}
var time = push.getExpirationTime(request);
var time = PushController.getExpirationTime(request);
expect(time).toEqual(new Date(timeStr).valueOf());
done();
});
@@ -185,7 +190,7 @@ describe('push', () => {
}
}
var time = push.getExpirationTime(request);
var time = PushController.getExpirationTime(request);
expect(time).toEqual(timeNumber * 1000);
done();
});
@@ -199,7 +204,7 @@ describe('push', () => {
}
expect(function(){
push.getExpirationTime(request);
PushController.getExpirationTime(request);
}).toThrow();
done();
});

View File

@@ -1,10 +1,10 @@
// These tests check the "create" functionality of the REST API.
var auth = require('../Auth');
var cache = require('../cache');
var Config = require('../Config');
var DatabaseAdapter = require('../DatabaseAdapter');
var auth = require('../src/Auth');
var cache = require('../src/cache');
var Config = require('../src/Config');
var DatabaseAdapter = require('../src/DatabaseAdapter');
var Parse = require('parse/node').Parse;
var rest = require('../rest');
var rest = require('../src/rest');
var request = require('request');
var config = new Config('test');
@@ -57,6 +57,50 @@ describe('rest create', () => {
});
});
it('handles anonymous user signup', (done) => {
var data1 = {
authData: {
anonymous: {
id: '00000000-0000-0000-0000-000000000001'
}
}
};
var data2 = {
authData: {
anonymous: {
id: '00000000-0000-0000-0000-000000000002'
}
}
};
var username1;
rest.create(config, auth.nobody(config), '_User', data1)
.then((r) => {
expect(typeof r.response.objectId).toEqual('string');
expect(typeof r.response.createdAt).toEqual('string');
expect(typeof r.response.sessionToken).toEqual('string');
return rest.create(config, auth.nobody(config), '_User', data1);
}).then((r) => {
expect(typeof r.response.objectId).toEqual('string');
expect(typeof r.response.createdAt).toEqual('string');
expect(typeof r.response.username).toEqual('string');
expect(typeof r.response.updatedAt).toEqual('string');
username1 = r.response.username;
return rest.create(config, auth.nobody(config), '_User', data2);
}).then((r) => {
expect(typeof r.response.objectId).toEqual('string');
expect(typeof r.response.createdAt).toEqual('string');
expect(typeof r.response.sessionToken).toEqual('string');
return rest.create(config, auth.nobody(config), '_User', data2);
}).then((r) => {
expect(typeof r.response.objectId).toEqual('string');
expect(typeof r.response.createdAt).toEqual('string');
expect(typeof r.response.username).toEqual('string');
expect(typeof r.response.updatedAt).toEqual('string');
expect(r.response.username).not.toEqual(username1);
done();
});
});
it('test facebook signup and login', (done) => {
var data = {
authData: {

View File

@@ -1,8 +1,8 @@
// These tests check the "find" functionality of the REST API.
var auth = require('../Auth');
var cache = require('../cache');
var Config = require('../Config');
var rest = require('../rest');
var auth = require('../src/Auth');
var cache = require('../src/cache');
var Config = require('../src/Config');
var rest = require('../src/rest');
var config = new Config('test');
var nobody = auth.nobody(config);

View File

@@ -1,10 +1,25 @@
// These tests check that the Schema operates correctly.
var Config = require('../Config');
var Schema = require('../Schema');
var Config = require('../src/Config');
var Schema = require('../src/Schema');
var dd = require('deep-diff');
var config = new Config('test');
var hasAllPODobject = () => {
var obj = new Parse.Object('HasAllPOD');
obj.set('aNumber', 5);
obj.set('aString', 'string');
obj.set('aBool', true);
obj.set('aDate', new Date());
obj.set('aObject', {k1: 'value', k2: true, k3: 5});
obj.set('aArray', ['contents', true, 5]);
obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0}));
obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' }));
var objACL = new Parse.ACL();
objACL.setPublicWriteAccess(false);
obj.setACL(objACL);
return obj;
};
describe('Schema', () => {
it('can validate one object', (done) => {
config.database.loadSchema().then((schema) => {
@@ -252,7 +267,7 @@ describe('Schema', () => {
it('refuses to add fields with invalid pointer types', done => {
config.database.loadSchema()
.then(schema => schema.addClassIfNotExists('NewClass', {
foo: {type: 'Pointer'},
foo: {type: 'Pointer'}
}))
.catch(error => {
expect(error.code).toEqual(135);
@@ -398,7 +413,7 @@ describe('Schema', () => {
config.database.loadSchema()
.then(schema => schema.addClassIfNotExists('NewClass', {
geo1: {type: 'GeoPoint'},
geo2: {type: 'GeoPoint'},
geo2: {type: 'GeoPoint'}
}))
.catch(error => {
expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE);
@@ -406,4 +421,153 @@ describe('Schema', () => {
done();
});
});
it('can check if a class exists', done => {
config.database.loadSchema()
.then(schema => {
return schema.addClassIfNotExists('NewClass', {})
.then(() => {
schema.hasClass('NewClass')
.then(hasClass => {
expect(hasClass).toEqual(true);
done();
})
.catch(fail);
schema.hasClass('NonexistantClass')
.then(hasClass => {
expect(hasClass).toEqual(false);
done();
})
.catch(fail);
})
.catch(error => {
fail('Couldn\'t create class');
fail(error);
});
})
.catch(error => fail('Couldn\'t load schema'));
});
it('refuses to delete fields from invalid class names', done => {
config.database.loadSchema()
.then(schema => schema.deleteField('fieldName', 'invalid class name'))
.catch(error => {
expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
done();
});
});
it('refuses to delete invalid fields', done => {
config.database.loadSchema()
.then(schema => schema.deleteField('invalid field name', 'ValidClassName'))
.catch(error => {
expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME);
done();
});
});
it('refuses to delete the default fields', done => {
config.database.loadSchema()
.then(schema => schema.deleteField('installationId', '_Installation'))
.catch(error => {
expect(error.code).toEqual(136);
expect(error.error).toEqual('field installationId cannot be changed');
done();
});
});
it('refuses to delete fields from nonexistant classes', done => {
config.database.loadSchema()
.then(schema => schema.deleteField('field', 'NoClass'))
.catch(error => {
expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME);
expect(error.error).toEqual('class NoClass does not exist');
done();
});
});
it('refuses to delete fields that dont exist', done => {
hasAllPODobject().save()
.then(() => config.database.loadSchema())
.then(schema => schema.deleteField('missingField', 'HasAllPOD'))
.fail(error => {
expect(error.code).toEqual(255);
expect(error.error).toEqual('field missingField does not exist, cannot delete');
done();
});
});
it('drops related collection when deleting relation field', done => {
var obj1 = hasAllPODobject();
obj1.save()
.then(savedObj1 => {
var obj2 = new Parse.Object('HasPointersAndRelations');
obj2.set('aPointer', savedObj1);
var relation = obj2.relation('aRelation');
relation.add(obj1);
return obj2.save();
})
.then(() => {
config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => {
expect(err).toEqual(null);
config.database.loadSchema()
.then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.db, 'test_'))
.then(() => config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => {
expect(err).not.toEqual(null);
done();
}))
});
})
});
it('can delete string fields and resave as number field', done => {
Parse.Object.disableSingleInstance();
var obj1 = hasAllPODobject();
var obj2 = hasAllPODobject();
var p = Parse.Object.saveAll([obj1, obj2])
.then(() => config.database.loadSchema())
.then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.db, 'test_'))
.then(() => new Parse.Query('HasAllPOD').get(obj1.id))
.then(obj1Reloaded => {
expect(obj1Reloaded.get('aString')).toEqual(undefined);
obj1Reloaded.set('aString', ['not a string', 'this time']);
obj1Reloaded.save()
.then(obj1reloadedAgain => {
expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']);
return new Parse.Query('HasAllPOD').get(obj2.id);
})
.then(obj2reloaded => {
expect(obj2reloaded.get('aString')).toEqual(undefined);
done();
Parse.Object.enableSingleInstance();
});
})
});
it('can delete pointer fields and resave as string', done => {
Parse.Object.disableSingleInstance();
var obj1 = new Parse.Object('NewClass');
obj1.save()
.then(() => {
obj1.set('aPointer', obj1);
return obj1.save();
})
.then(obj1 => {
expect(obj1.get('aPointer').id).toEqual(obj1.id);
})
.then(() => config.database.loadSchema())
.then(schema => schema.deleteField('aPointer', 'NewClass', config.database.db, 'test_'))
.then(() => new Parse.Query('NewClass').get(obj1.id))
.then(obj1 => {
expect(obj1.get('aPointer')).toEqual(undefined);
obj1.set('aPointer', 'Now a string');
return obj1.save();
})
.then(obj1 => {
expect(obj1.get('aPointer')).toEqual('Now a string');
done();
Parse.Object.enableSingleInstance();
});
});
});

83
spec/cryptoUtils.spec.js Normal file
View File

@@ -0,0 +1,83 @@
var cryptoUtils = require('../src/cryptoUtils');
function givesUniqueResults(fn, iterations) {
var results = {};
for (var i = 0; i < iterations; i++) {
var s = fn();
if (results[s]) {
return false;
}
results[s] = true;
}
return true;
}
describe('randomString', () => {
it('returns a string', () => {
expect(typeof cryptoUtils.randomString(10)).toBe('string');
});
it('returns result of the given length', () => {
expect(cryptoUtils.randomString(11).length).toBe(11);
expect(cryptoUtils.randomString(25).length).toBe(25);
});
it('throws if requested length is zero', () => {
expect(() => cryptoUtils.randomString(0)).toThrow();
});
it('returns unique results', () => {
expect(givesUniqueResults(() => cryptoUtils.randomString(10), 100)).toBe(true);
});
});
describe('randomHexString', () => {
it('returns a string', () => {
expect(typeof cryptoUtils.randomHexString(10)).toBe('string');
});
it('returns result of the given length', () => {
expect(cryptoUtils.randomHexString(10).length).toBe(10);
expect(cryptoUtils.randomHexString(32).length).toBe(32);
});
it('throws if requested length is zero', () => {
expect(() => cryptoUtils.randomHexString(0)).toThrow();
});
it('throws if requested length is not even', () => {
expect(() => cryptoUtils.randomHexString(11)).toThrow();
});
it('returns unique results', () => {
expect(givesUniqueResults(() => cryptoUtils.randomHexString(20), 100)).toBe(true);
});
});
describe('newObjectId', () => {
it('returns a string', () => {
expect(typeof cryptoUtils.newObjectId()).toBe('string');
});
it('returns result with at least 10 characters', () => {
expect(cryptoUtils.newObjectId().length).toBeGreaterThan(9);
});
it('returns unique results', () => {
expect(givesUniqueResults(() => cryptoUtils.newObjectId(), 100)).toBe(true);
});
});
describe('newToken', () => {
it('returns a string', () => {
expect(typeof cryptoUtils.newToken()).toBe('string');
});
it('returns result with at least 32 characters', () => {
expect(cryptoUtils.newToken().length).toBeGreaterThan(31);
});
it('returns unique results', () => {
expect(givesUniqueResults(() => cryptoUtils.newToken(), 100)).toBe(true);
});
});

View File

@@ -2,11 +2,11 @@
jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000;
var cache = require('../cache');
var DatabaseAdapter = require('../DatabaseAdapter');
var cache = require('../src/cache');
var DatabaseAdapter = require('../src/DatabaseAdapter');
var express = require('express');
var facebook = require('../facebook');
var ParseServer = require('../index').ParseServer;
var facebook = require('../src/facebook');
var ParseServer = require('../src/index').ParseServer;
var databaseURI = process.env.DATABASE_URI;
var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js';

View File

@@ -1,5 +1,7 @@
var Parse = require('parse/node').Parse;
var request = require('request');
var dd = require('deep-diff');
var hasAllPODobject = () => {
var obj = new Parse.Object('HasAllPOD');
obj.set('aNumber', 5);
@@ -14,9 +16,9 @@ var hasAllPODobject = () => {
objACL.setPublicWriteAccess(false);
obj.setACL(objACL);
return obj;
}
};
var expectedResponseForHasAllPOD = {
var plainOldDataSchema = {
className: 'HasAllPOD',
fields: {
//Default fields
@@ -33,10 +35,10 @@ var expectedResponseForHasAllPOD = {
aArray: {type: 'Array'},
aGeoPoint: {type: 'GeoPoint'},
aFile: {type: 'File'}
},
}
};
var expectedResponseforHasPointersAndRelations = {
var pointersAndRelationsSchema = {
className: 'HasPointersAndRelations',
fields: {
//Default fields
@@ -56,17 +58,30 @@ var expectedResponseforHasPointersAndRelations = {
},
}
var noAuthHeaders = {
'X-Parse-Application-Id': 'test',
};
var restKeyHeaders = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};
var masterKeyHeaders = {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
};
describe('schemas', () => {
it('requires the master key to get all schemas', (done) => {
request.get({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
headers: noAuthHeaders,
}, (error, response, body) => {
expect(response.statusCode).toEqual(401);
//api.parse.com uses status code 401, but due to the lack of keys
//being necessary in parse-server, 403 makes more sense
expect(response.statusCode).toEqual(403);
expect(body.error).toEqual('unauthorized');
done();
});
@@ -76,10 +91,7 @@ describe('schemas', () => {
request.get({
url: 'http://localhost:8378/1/schemas/SomeSchema',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
headers: restKeyHeaders,
}, (error, response, body) => {
expect(response.statusCode).toEqual(401);
expect(body.error).toEqual('unauthorized');
@@ -87,14 +99,23 @@ describe('schemas', () => {
});
});
it('asks for the master key if you use the rest key', (done) => {
request.get({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: restKeyHeaders,
}, (error, response, body) => {
expect(response.statusCode).toEqual(401);
expect(body.error).toEqual('master key not specified');
done();
});
});
it('responds with empty list when there are no schemas', done => {
request.get({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
headers: masterKeyHeaders,
}, (error, response, body) => {
expect(body.results).toEqual([]);
done();
@@ -113,13 +134,10 @@ describe('schemas', () => {
request.get({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
headers: masterKeyHeaders,
}, (error, response, body) => {
var expected = {
results: [expectedResponseForHasAllPOD,expectedResponseforHasPointersAndRelations]
results: [plainOldDataSchema,pointersAndRelationsSchema]
};
expect(body).toEqual(expected);
done();
@@ -133,12 +151,9 @@ describe('schemas', () => {
request.get({
url: 'http://localhost:8378/1/schemas/HasAllPOD',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
headers: masterKeyHeaders,
}, (error, response, body) => {
expect(body).toEqual(expectedResponseForHasAllPOD);
expect(body).toEqual(plainOldDataSchema);
done();
});
});
@@ -150,10 +165,7 @@ describe('schemas', () => {
request.get({
url: 'http://localhost:8378/1/schemas/HASALLPOD',
json: true,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
headers: masterKeyHeaders,
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body).toEqual({
@@ -164,4 +176,146 @@ describe('schemas', () => {
});
});
});
it('requires the master key to create a schema', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: noAuthHeaders,
body: {
className: 'MyClass',
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(403);
expect(body.error).toEqual('unauthorized');
done();
});
});
it('asks for the master key if you use the rest key', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
json: true,
headers: restKeyHeaders,
body: {
className: 'MyClass',
},
}, (error, response, body) => {
expect(response.statusCode).toEqual(401);
expect(body.error).toEqual('master key not specified');
done();
});
});
it('sends an error if you use mismatching class names', done => {
request.post({
url: 'http://localhost:8378/1/schemas/A',
headers: masterKeyHeaders,
json: true,
body: {
className: 'B',
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body).toEqual({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class name mismatch between B and A',
});
done();
});
});
it('sends an error if you use no class name', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {},
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body).toEqual({
code: 135,
error: 'POST /schemas needs class name',
});
done();
})
});
it('sends an error if you try to create the same class twice', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {
className: 'A',
},
}, (error, response, body) => {
expect(error).toEqual(null);
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {
className: 'A',
}
}, (error, response, body) => {
expect(response.statusCode).toEqual(400);
expect(body).toEqual({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class A already exists',
});
done();
});
});
});
it('responds with all fields when you create a class', done => {
request.post({
url: 'http://localhost:8378/1/schemas',
headers: masterKeyHeaders,
json: true,
body: {
className: "NewClass",
fields: {
foo: {type: 'Number'},
ptr: {type: 'Pointer', targetClass: 'SomeClass'}
}
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
foo: {type: 'Number'},
ptr: {type: 'Pointer', targetClass: 'SomeClass'},
}
});
done();
});
});
it('lets you specify class name in both places', done => {
request.post({
url: 'http://localhost:8378/1/schemas/NewClass',
headers: masterKeyHeaders,
json: true,
body: {
className: "NewClass",
}
}, (error, response, body) => {
expect(body).toEqual({
className: 'NewClass',
fields: {
ACL: {type: 'ACL'},
createdAt: {type: 'Date'},
updatedAt: {type: 'Date'},
objectId: {type: 'String'},
}
});
done();
});
});
});

View File

@@ -4,7 +4,7 @@
"*spec.js"
],
"helpers": [
"../node_modules/babel-core/register.js",
"helper.js"
]
}

View File

@@ -1,6 +1,6 @@
// These tests are unit tests designed to only test transform.js.
var transform = require('../transform');
var transform = require('../src/transform');
var dummySchema = {
data: {},

208
src/APNS.js Normal file
View File

@@ -0,0 +1,208 @@
"use strict";
const Parse = require('parse/node').Parse;
// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1,
// but probably we will replace it in the future.
const apn = require('apn');
/**
* Create a new connection to the APN service.
* @constructor
* @param {Object|Array} args An argument or a list of arguments to config APNS connection
* @param {String} args.cert The filename of the connection certificate to load from disk
* @param {String} args.key The filename of the connection key to load from disk
* @param {String} args.pfx The filename for private key, certificate and CA certs in PFX or PKCS12 format, it will overwrite cert and key
* @param {String} args.passphrase The passphrase for the connection key, if required
* @param {String} args.bundleId The bundleId for cert
* @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox
*/
function APNS(args) {
// Since for ios, there maybe multiple cert/key pairs,
// typePushConfig can be an array.
let apnsArgsList = [];
if (Array.isArray(args)) {
apnsArgsList = apnsArgsList.concat(args);
} else if (typeof args === 'object') {
apnsArgsList.push(args);
} else {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'APNS Configuration is invalid');
}
this.conns = [];
for (let apnsArgs of apnsArgsList) {
let conn = new apn.Connection(apnsArgs);
if (!apnsArgs.bundleId) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'BundleId is mssing for %j', apnsArgs);
}
conn.bundleId = apnsArgs.bundleId;
// Set the priority of the conns, prod cert has higher priority
if (apnsArgs.production) {
conn.priority = 0;
} else {
conn.priority = 1;
}
// Set apns client callbacks
conn.on('connected', () => {
console.log('APNS Connection %d Connected', conn.index);
});
conn.on('transmissionError', (errCode, notification, apnDevice) => {
handleTransmissionError(this.conns, errCode, notification, apnDevice);
});
conn.on('timeout', () => {
console.log('APNS Connection %d Timeout', conn.index);
});
conn.on('disconnected', () => {
console.log('APNS Connection %d Disconnected', conn.index);
});
conn.on('socketError', () => {
console.log('APNS Connection %d Socket Error', conn.index);
});
conn.on('transmitted', function(notification, device) {
console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex'));
});
this.conns.push(conn);
}
// Sort the conn based on priority ascending, high pri first
this.conns.sort((s1, s2) => {
return s1.priority - s2.priority;
});
// Set index of conns
for (let index = 0; index < this.conns.length; index++) {
this.conns[index].index = index;
}
}
/**
* Send apns request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} devices A array of devices
* @returns {Object} A promise which is resolved immediately
*/
APNS.prototype.send = function(data, devices) {
let coreData = data.data;
let expirationTime = data['expiration_time'];
let notification = generateNotification(coreData, expirationTime);
for (let device of devices) {
let qualifiedConnIndexs = chooseConns(this.conns, device);
// We can not find a valid conn, just ignore this device
if (qualifiedConnIndexs.length == 0) {
continue;
}
let conn = this.conns[qualifiedConnIndexs[0]];
let apnDevice = new apn.Device(device.deviceToken);
apnDevice.connIndex = qualifiedConnIndexs[0];
// Add additional appIdentifier info to apn device instance
if (device.appIdentifier) {
apnDevice.appIdentifier = device.appIdentifier;
}
conn.pushNotification(notification, apnDevice);
}
return Parse.Promise.as();
}
function handleTransmissionError(conns, errCode, notification, apnDevice) {
console.error('APNS Notification caused error: ' + errCode + ' for device ', apnDevice, notification);
// This means the error notification is not in the cache anymore or the recepient is missing,
// we just ignore this case
if (!notification || !apnDevice) {
return
}
// If currentConn can not send the push notification, we try to use the next available conn.
// Since conns is sorted by priority, the next conn means the next low pri conn.
// If there is no conn available, we give up on sending the notification to that device.
let qualifiedConnIndexs = chooseConns(conns, apnDevice);
let currentConnIndex = apnDevice.connIndex;
let newConnIndex = -1;
// Find the next element of currentConnIndex in qualifiedConnIndexs
for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) {
if (qualifiedConnIndexs[index] === currentConnIndex) {
newConnIndex = qualifiedConnIndexs[index + 1];
break;
}
}
// There is no more available conns, we give up in this case
if (newConnIndex < 0 || newConnIndex >= conns.length) {
console.log('APNS can not find vaild connection for %j', apnDevice.token);
return;
}
let newConn = conns[newConnIndex];
// Update device conn info
apnDevice.connIndex = newConnIndex;
// Use the new conn to send the notification
newConn.pushNotification(notification, apnDevice);
}
function chooseConns(conns, device) {
// If device does not have appIdentifier, all conns maybe proper connections.
// Otherwise we try to match the appIdentifier with bundleId
let qualifiedConns = [];
for (let index = 0; index < conns.length; index++) {
let conn = conns[index];
// If the device we need to send to does not have
// appIdentifier, any conn could be a qualified connection
if (!device.appIdentifier || device.appIdentifier === '') {
qualifiedConns.push(index);
continue;
}
if (device.appIdentifier === conn.bundleId) {
qualifiedConns.push(index);
}
}
return qualifiedConns;
}
/**
* Generate the apns notification from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @returns {Object} A apns notification
*/
function generateNotification(coreData, expirationTime) {
let notification = new apn.notification();
let payload = {};
for (let key in coreData) {
switch (key) {
case 'alert':
notification.setAlertText(coreData.alert);
break;
case 'badge':
notification.badge = coreData.badge;
break;
case 'sound':
notification.sound = coreData.sound;
break;
case 'content-available':
notification.setNewsstandAvailable(true);
let isAvailable = coreData['content-available'] === 1;
notification.setContentAvailable(isAvailable);
break;
case 'category':
notification.category = coreData.category;
break;
default:
payload[key] = coreData[key];
break;
}
}
notification.payload = payload;
notification.expiry = expirationTime;
return notification;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
APNS.generateNotification = generateNotification;
APNS.chooseConns = chooseConns;
APNS.handleTransmissionError = handleTransmissionError;
}
module.exports = APNS;

View File

@@ -0,0 +1,24 @@
// Files Adapter
//
// Allows you to change the file storage mechanism.
//
// Adapter classes must implement the following functions:
// * createFile(config, filename, data)
// * getFileData(config, filename)
// * getFileLocation(config, request, filename)
//
// Default is GridStoreAdapter, which requires mongo
// and for the API server to be using the ExportAdapter
// database adapter.
export class FilesAdapter {
createFile(config, filename, data) { }
deleteFile(config, filename) { }
getFileData(config, filename) { }
getFileLocation(config, filename) { }
}
export default FilesAdapter;

View File

@@ -0,0 +1,50 @@
// GridStoreAdapter
//
// Stores files in Mongo using GridStore
// Requires the database adapter to be based on mongoclient
import { GridStore } from 'mongodb';
import { FilesAdapter } from './FilesAdapter';
export class GridStoreAdapter extends FilesAdapter {
// For a given config object, filename, and data, store a file
// Returns a promise
createFile(config, filename, data) {
return config.database.connect().then(() => {
let gridStore = new GridStore(config.database.db, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
return gridStore.write(data);
}).then((gridStore) => {
return gridStore.close();
});
}
deleteFile(config, filename) {
return config.database.connect().then(() => {
let gridStore = new GridStore(config.database.db, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
return gridStore.unlink();
}).then((gridStore) => {
return gridStore.close();
});
}
getFileData(config, filename) {
return config.database.connect().then(() => {
return GridStore.exist(config.database.db, filename);
}).then(() => {
let gridStore = new GridStore(config.database.db, filename, 'r');
return gridStore.open();
}).then((gridStore) => {
return gridStore.read();
});
}
getFileLocation(config, filename) {
return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename));
}
}
export default GridStoreAdapter;

View File

@@ -0,0 +1,97 @@
// S3Adapter
//
// Stores Parse files in AWS S3.
import * as AWS from 'aws-sdk';
import { FilesAdapter } from './FilesAdapter';
const DEFAULT_S3_REGION = "us-east-1";
const DEFAULT_S3_BUCKET = "parse-files";
export class S3Adapter extends FilesAdapter {
// Creates an S3 session.
// Providing AWS access and secret keys is mandatory
// Region and bucket will use sane defaults if omitted
constructor(
accessKey,
secretKey,
{ region = DEFAULT_S3_REGION,
bucket = DEFAULT_S3_BUCKET,
bucketPrefix = '',
directAccess = false } = {}
) {
super();
this._region = region;
this._bucket = bucket;
this._bucketPrefix = bucketPrefix;
this._directAccess = directAccess;
let s3Options = {
accessKeyId: accessKey,
secretAccessKey: secretKey,
params: { Bucket: this._bucket }
};
AWS.config._region = this._region;
this._s3Client = 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
createFile(config, filename, data) {
let params = {
Key: this._bucketPrefix + filename,
Body: data
};
if (this._directAccess) {
params.ACL = "public-read"
}
return new Promise((resolve, reject) => {
this._s3Client.upload(params, (err, data) => {
if (err !== null) {
return reject(err);
}
resolve(data);
});
});
}
deleteFile(config, filename) {
return new Promise((resolve, reject) => {
let params = {
Key: this._bucketPrefix + filename
};
this._s3Client.deleteObject(params, (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
getFileData(config, filename) {
let params = {Key: this._bucketPrefix + filename};
return new Promise((resolve, reject) => {
this._s3Client.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
getFileLocation(config, filename) {
if (this._directAccess) {
return ('https://' + this.bucket + '._s3Client.amazonaws.com' + '/' + this._bucketPrefix + filename);
}
return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename));
}
}
export default S3Adapter;

View File

@@ -0,0 +1,225 @@
// Logger
//
// Wrapper around Winston logging library with custom query
//
// expected log entry to be in the shape of:
// {"level":"info","message":"{ '0': 'Your Message' }","timestamp":"2016-02-04T05:59:27.412Z"}
//
import { LoggerAdapter } from './LoggerAdapter';
import winston from 'winston';
import fs from 'fs';
import { Parse } from 'parse/node';
const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;
const CACHE_TIME = 1000 * 60;
let LOGS_FOLDER = './logs/';
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
LOGS_FOLDER = './test_logs/'
}
let currentDate = new Date();
let simpleCache = {
timestamp: null,
from: null,
until: null,
order: null,
data: [],
level: 'info',
};
// returns Date object rounded to nearest day
let _getNearestDay = (date) => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// returns Date object of previous day
let _getPrevDay = (date) => {
return new Date(date - MILLISECONDS_IN_A_DAY);
}
// returns the iso formatted file name
let _getFileName = () => {
return _getNearestDay(currentDate).toISOString()
}
// check for valid cache when both from and util match.
// cache valid for up to 1 minute
let _hasValidCache = (from, until, level) => {
if (String(from) === String(simpleCache.from) &&
String(until) === String(simpleCache.until) &&
new Date() - simpleCache.timestamp < CACHE_TIME &&
level === simpleCache.level) {
return true;
}
return false;
}
// renews transports to current date
let _renewTransports = ({infoLogger, errorLogger, logsFolder}) => {
if (infoLogger) {
infoLogger.add(winston.transports.File, {
filename: logsFolder + _getFileName() + '.info',
name: 'info-file',
level: 'info'
});
}
if (errorLogger) {
errorLogger.add(winston.transports.File, {
filename: logsFolder + _getFileName() + '.error',
name: 'error-file',
level: 'error'
});
}
};
// check that log entry has valid time stamp based on query
let _isValidLogEntry = (from, until, entry) => {
var _entry = JSON.parse(entry),
timestamp = new Date(_entry.timestamp);
return timestamp >= from && timestamp <= until
? true
: false
};
// ensure that file name is up to date
let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => {
if (_getNearestDay(currentDate) !== _getNearestDay(new Date())) {
currentDate = new Date();
if (infoLogger) {
infoLogger.remove('info-file');
}
if (errorLogger) {
errorLogger.remove('error-file');
}
_renewTransports({infoLogger, errorLogger, logsFolder});
}
}
export class FileLoggerAdapter extends LoggerAdapter {
constructor(options = {}) {
super();
this._logsFolder = options.logsFolder || LOGS_FOLDER;
// check logs folder exists
if (!fs.existsSync(this._logsFolder)) {
fs.mkdirSync(this._logsFolder);
}
this._errorLogger = new (winston.Logger)({
exitOnError: false,
transports: [
new (winston.transports.File)({
filename: this._logsFolder + _getFileName() + '.error',
name: 'error-file',
level: 'error'
})
]
});
this._infoLogger = new (winston.Logger)({
exitOnError: false,
transports: [
new (winston.transports.File)({
filename: this._logsFolder + _getFileName() + '.info',
name: 'info-file',
level: 'info'
})
]
});
}
info() {
_verifyTransports({infoLogger: this._infoLogger, logsFolder: this._logsFolder});
return this._infoLogger.info.apply(undefined, arguments);
}
error() {
_verifyTransports({errorLogger: this._errorLogger, logsFolder: this._logsFolder});
return this._errorLogger.error.apply(undefined, arguments);
}
// custom query as winston is currently limited
query(options, callback) {
if (!options) {
options = {};
}
// defaults to 7 days prior
let from = options.from || new Date(Date.now() - (7 * MILLISECONDS_IN_A_DAY));
let until = options.until || new Date();
let size = options.size || 10;
let order = options.order || 'desc';
let level = options.level || 'info';
let roundedUntil = _getNearestDay(until);
let roundedFrom = _getNearestDay(from);
if (_hasValidCache(roundedFrom, roundedUntil, level)) {
let logs = [];
if (order !== simpleCache.order) {
// reverse order of data
simpleCache.data.forEach((entry) => {
logs.unshift(entry);
});
} else {
logs = simpleCache.data;
}
callback(logs.slice(0, size));
return;
}
let curDate = roundedUntil;
let curSize = 0;
let method = order === 'desc' ? 'push' : 'unshift';
let files = [];
let promises = [];
// current a batch call, all files with valid dates are read
while (curDate >= from) {
files[method](this._logsFolder + curDate.toISOString() + '.' + level);
curDate = _getPrevDay(curDate);
}
// read each file and split based on newline char.
// limitation is message cannot contain newline
// TODO: strip out delimiter from logged message
files.forEach(function(file, i) {
let promise = new Parse.Promise();
fs.readFile(file, 'utf8', function(err, data) {
if (err) {
promise.resolve([]);
} else {
let results = data.split('\n').filter((value) => {
return value.trim() !== '';
});
promise.resolve(results);
}
});
promises[method](promise);
});
Parse.Promise.when(promises).then((results) => {
let logs = [];
results.forEach(function(logEntries, i) {
logEntries.forEach(function(entry) {
if (_isValidLogEntry(from, until, entry)) {
logs[method](JSON.parse(entry));
}
});
});
simpleCache = {
timestamp: new Date(),
from: roundedFrom,
until: roundedUntil,
data: logs,
order,
level,
};
callback(logs.slice(0, size));
});
}
}
export default FileLoggerAdapter;

View File

@@ -0,0 +1,17 @@
// Logger Adapter
//
// Allows you to change the logger mechanism
//
// Adapter classes must implement the following functions:
// * info(obj1 [, obj2, .., objN])
// * error(obj1 [, obj2, .., objN])
// * query(options, callback)
// Default is FileLoggerAdapter.js
export class LoggerAdapter {
info() {}
error() {}
query(options, callback) {}
}
export default LoggerAdapter;

View File

@@ -0,0 +1,88 @@
"use strict";
// ParsePushAdapter is the default implementation of
// PushAdapter, it uses GCM for android push and APNS
// for ios push.
const Parse = require('parse/node').Parse;
const GCM = require('../../GCM');
const APNS = require('../../APNS');
function ParsePushAdapter(pushConfig) {
this.validPushTypes = ['ios', 'android'];
this.senderMap = {};
pushConfig = pushConfig || {};
let pushTypes = Object.keys(pushConfig);
for (let pushType of pushTypes) {
if (this.validPushTypes.indexOf(pushType) < 0) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Push to ' + pushTypes + ' is not supported');
}
switch (pushType) {
case 'ios':
this.senderMap[pushType] = new APNS(pushConfig[pushType]);
break;
case 'android':
this.senderMap[pushType] = new GCM(pushConfig[pushType]);
break;
}
}
}
/**
* Get an array of valid push types.
* @returns {Array} An array of valid push types
*/
ParsePushAdapter.prototype.getValidPushTypes = function() {
return this.validPushTypes;
}
ParsePushAdapter.prototype.send = function(data, installations) {
let deviceMap = classifyInstallation(installations, this.validPushTypes);
let sendPromises = [];
for (let pushType in deviceMap) {
let sender = this.senderMap[pushType];
if (!sender) {
console.log('Can not find sender for push type %s, %j', pushType, data);
continue;
}
let devices = deviceMap[pushType];
sendPromises.push(sender.send(data, devices));
}
return Parse.Promise.when(sendPromises);
}
/**g
* Classify the device token of installations based on its device type.
* @param {Object} installations An array of installations
* @param {Array} validPushTypes An array of valid push types(string)
* @returns {Object} A map whose key is device type and value is an array of device
*/
function classifyInstallation(installations, validPushTypes) {
// Init deviceTokenMap, create a empty array for each valid pushType
let deviceMap = {};
for (let validPushType of validPushTypes) {
deviceMap[validPushType] = [];
}
for (let installation of installations) {
// No deviceToken, ignore
if (!installation.deviceToken) {
continue;
}
let pushType = installation.deviceType;
if (deviceMap[pushType]) {
deviceMap[pushType].push({
deviceToken: installation.deviceToken,
appIdentifier: installation.appIdentifier
});
} else {
console.log('Unknown push type from installation %j', installation);
}
}
return deviceMap;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
ParsePushAdapter.classifyInstallation = classifyInstallation;
}
module.exports = ParsePushAdapter;

View File

@@ -0,0 +1,17 @@
// Push Adapter
//
// Allows you to change the push notification mechanism.
//
// Adapter classes must implement the following functions:
// * getValidPushTypes()
// * send(devices, installations)
//
// Default is ParsePushAdapter, which uses GCM for
// android push and APNS for ios push.
export class PushAdapter {
send(devices, installations) { }
getValidPushTypes() { }
}
export default PushAdapter;

View File

@@ -13,7 +13,6 @@ function Config(applicationId, mount) {
this.applicationId = applicationId;
this.collectionPrefix = cacheInfo.collectionPrefix || '';
this.database = DatabaseAdapter.getDatabaseConnection(applicationId);
this.masterKey = cacheInfo.masterKey;
this.clientKey = cacheInfo.clientKey;
this.javascriptKey = cacheInfo.javascriptKey;
@@ -21,6 +20,10 @@ function Config(applicationId, mount) {
this.restAPIKey = cacheInfo.restAPIKey;
this.fileKey = cacheInfo.fileKey;
this.facebookAppIds = cacheInfo.facebookAppIds;
this.database = DatabaseAdapter.getDatabaseConnection(applicationId);
this.filesController = cacheInfo.filesController;
this.mount = mount;
}

View File

@@ -0,0 +1,144 @@
// FilesController.js
import express from 'express';
import mime from 'mime';
import { Parse } from 'parse/node';
import BodyParser from 'body-parser';
import * as Middlewares from '../middlewares';
import Config from '../Config';
import { randomHexString } from '../cryptoUtils';
export class FilesController {
constructor(filesAdapter) {
this._filesAdapter = filesAdapter;
}
getHandler() {
return (req, res) => {
let config = new Config(req.params.appId);
let filename = req.params.filename;
this._filesAdapter.getFileData(config, filename).then((data) => {
res.status(200);
var contentType = mime.lookup(filename);
res.set('Content-type', contentType);
res.end(data);
}).catch((error) => {
res.status(404);
res.set('Content-type', 'text/plain');
res.end('File not found.');
});
};
}
createHandler() {
return (req, res, next) => {
if (!req.body || !req.body.length) {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR,
'Invalid file upload.'));
return;
}
if (req.params.filename.length > 128) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename too long.'));
return;
}
if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename contains invalid characters.'));
return;
}
// If a content-type is included, we'll add an extension so we can
// return the same content-type.
let extension = '';
let hasExtension = req.params.filename.indexOf('.') > 0;
let contentType = req.get('Content-type');
if (!hasExtension && contentType && mime.extension(contentType)) {
extension = '.' + mime.extension(contentType);
}
let filename = randomHexString(32) + '_' + req.params.filename + extension;
this._filesAdapter.createFile(req.config, filename, req.body).then(() => {
res.status(201);
var location = this._filesAdapter.getFileLocation(req.config, filename);
res.set('Location', location);
res.json({ url: location, name: filename });
}).catch((error) => {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR,
'Could not store file.'));
});
};
}
deleteHandler() {
return (req, res, next) => {
this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => {
res.status(200);
// TODO: return useful JSON here?
res.end();
}).catch((error) => {
next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR,
'Could not delete file.'));
});
};
}
/**
* Find file references in REST-format object and adds the url key
* with the current mount point and app id.
* Object may be a single object or list of REST-format objects.
*/
expandFilesInObject(config, object) {
if (object instanceof Array) {
object.map((obj) => this.expandFilesInObject(config, obj));
return;
}
if (typeof object !== 'object') {
return;
}
for (let key in object) {
let fileObject = object[key];
if (fileObject && fileObject['__type'] === 'File') {
if (fileObject['url']) {
continue;
}
let filename = fileObject['name'];
if (filename.indexOf('tfss-') === 0) {
fileObject['url'] = 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename);
} else {
fileObject['url'] = this._filesAdapter.getFileLocation(config, filename);
}
}
}
}
getExpressRouter() {
let router = express.Router();
router.get('/files/:appId/:filename', this.getHandler());
router.post('/files', function(req, res, next) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME,
'Filename not provided.'));
});
router.post('/files/:filename',
Middlewares.allowCrossDomain,
BodyParser.raw({type: '*/*', limit: '20mb'}),
Middlewares.handleParseHeaders,
this.createHandler()
);
router.delete('/files/:filename',
Middlewares.allowCrossDomain,
Middlewares.handleParseHeaders,
Middlewares.enforceMasterKeyAccess,
this.deleteHandler()
);
return router;
}
}
export default FilesController;

View File

@@ -0,0 +1,78 @@
import { Parse } from 'parse/node';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
const Promise = Parse.Promise;
const INFO = 'info';
const ERROR = 'error';
const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000;
// only allow request with master key
let enforceSecurity = (auth) => {
if (!auth || !auth.isMaster) {
throw new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'Clients aren\'t allowed to perform the ' +
'get' + ' operation on logs.'
);
}
}
// check that date input is valid
let isValidDateTime = (date) => {
if (!date || isNaN(Number(date))) {
return false;
}
}
export class LoggerController {
constructor(loggerAdapter) {
this._loggerAdapter = loggerAdapter;
}
// Returns a promise for a {response} object.
// query params:
// level (optional) Level of logging you want to query for (info || error)
// from (optional) Start time for the search. Defaults to 1 week ago.
// until (optional) End time for the search. Defaults to current time.
// order (optional) Direction of results returned, either “asc” or “desc”. Defaults to “desc”.
// size (optional) Number of rows returned by search. Defaults to 10
handleGET(req) {
if (!this._loggerAdapter) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Logger adapter is not availabe');
}
let promise = new Parse.Promise();
let from = (isValidDateTime(req.query.from) && new Date(req.query.from)) ||
new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY);
let until = (isValidDateTime(req.query.until) && new Date(req.query.until)) || new Date();
let size = Number(req.query.size) || 10;
let order = req.query.order || 'desc';
let level = req.query.level || INFO;
enforceSecurity(req.auth);
this._loggerAdapter.query({
from,
until,
size,
order,
level,
}, (result) => {
promise.resolve({
response: result
});
});
return promise;
}
getExpressRouter() {
let router = new PromiseRouter();
router.route('GET','/logs', (req) => {
return this.handleGET(req);
});
return router;
}
}
export default LoggerController;

View File

@@ -1,28 +1,52 @@
// push.js
import { Parse } from 'parse/node';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
export class PushController {
var validPushTypes = ['ios', 'android'];
constructor(pushAdapter) {
this._pushAdapter = pushAdapter;
}
function handlePushWithoutQueue(req) {
validateMasterKey(req);
var where = getQueryCondition(req);
validateDeviceType(where);
// Replace the expiration_time with a valid Unix epoch milliseconds time
req.body['expiration_time'] = getExpirationTime(req);
return rest.find(req.config, req.auth, '_Installation', where).then(function(response) {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE,
'This path is not implemented yet.');
});
handlePOST(req) {
if (!this._pushAdapter) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Push adapter is not availabe');
}
validateMasterKey(req);
var where = getQueryCondition(req);
var pushAdapter = this._pushAdapter;
validatePushType(where, pushAdapter.getValidPushTypes());
// Replace the expiration_time with a valid Unix epoch milliseconds time
req.body['expiration_time'] = getExpirationTime(req);
// TODO: If the req can pass the checking, we return immediately instead of waiting
// pushes to be sent. We probably change this behaviour in the future.
rest.find(req.config, req.auth, '_Installation', where).then(function(response) {
return pushAdapter.send(req.body, response.results);
});
return Parse.Promise.as({
response: {
'result': true
}
});
}
getExpressRouter() {
var router = new PromiseRouter();
router.route('POST','/push', (req) => {
return this.handlePOST(req);
});
return router;
}
}
/**
* Check whether the deviceType parameter in qury condition is valid or not.
* @param {Object} where A query condition
* @param {Array} validPushTypes An array of valid push types(string)
*/
function validateDeviceType(where) {
function validatePushType(where, validPushTypes) {
var where = where || {};
var deviceTypeField = where.deviceType || {};
var deviceTypes = [];
@@ -109,16 +133,11 @@ function validateMasterKey(req) {
}
}
var router = new PromiseRouter();
router.route('POST','/push', handlePushWithoutQueue);
module.exports = {
router: router
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
module.exports.getQueryCondition = getQueryCondition;
module.exports.validateMasterKey = validateMasterKey;
module.exports.getExpirationTime = getExpirationTime;
module.exports.validateDeviceType = validateDeviceType;
PushController.getQueryCondition = getQueryCondition;
PushController.validateMasterKey = validateMasterKey;
PushController.getExpirationTime = getExpirationTime;
PushController.validatePushType = validatePushType;
}
export default PushController;

View File

@@ -10,9 +10,8 @@ var transform = require('./transform');
// options can contain:
// collectionPrefix: the string to put in front of every collection name.
function ExportAdapter(mongoURI, options) {
function ExportAdapter(mongoURI, options = {}) {
this.mongoURI = mongoURI;
options = options || {};
this.collectionPrefix = options.collectionPrefix;
@@ -34,21 +33,8 @@ ExportAdapter.prototype.connect = function() {
return this.connectionPromise;
}
//http://regexr.com/3cncm
if (!this.mongoURI.match(/^mongodb:\/\/((.+):(.+)@)?([^:@]+):{0,1}([^:]+)\/(.+?)$/gm)) {
throw new Error("Invalid mongoURI: " + this.mongoURI)
}
var usernameStart = this.mongoURI.indexOf('://') + 3;
var lastAtIndex = this.mongoURI.lastIndexOf('@');
var encodedMongoURI = this.mongoURI;
var split = null;
if (lastAtIndex > 0) {
split = this.mongoURI.slice(usernameStart, lastAtIndex).split(':');
encodedMongoURI = this.mongoURI.slice(0, usernameStart) + encodeURIComponent(split[0]) + ':' + encodeURIComponent(split[1]) + this.mongoURI.slice(lastAtIndex);
}
this.connectionPromise = Promise.resolve().then(() => {
return MongoClient.connect(encodedMongoURI, {uri_decode_auth:true});
return MongoClient.connect(this.mongoURI);
}).then((db) => {
this.db = db;
});
@@ -57,13 +43,15 @@ ExportAdapter.prototype.connect = function() {
// Returns a promise for a Mongo collection.
// Generally just for internal use.
var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
ExportAdapter.prototype.collection = function(className) {
if (!Schema.classNameIsValid(className)) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME,
'invalid className: ' + className);
}
return this.rawCollection(className);
};
ExportAdapter.prototype.rawCollection = function(className) {
return this.connect().then(() => {
return this.db.collection(this.collectionPrefix + className);
});
@@ -76,8 +64,7 @@ function returnsTrue() {
// Returns a promise for a schema object.
// If we are provided a acceptor, then we run it on the schema.
// If the schema isn't accepted, we reload it at most once.
ExportAdapter.prototype.loadSchema = function(acceptor) {
acceptor = acceptor || returnsTrue;
ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) {
if (!this.schemaPromise) {
this.schemaPromise = this.collection('_SCHEMA').then((coll) => {
@@ -290,8 +277,7 @@ ExportAdapter.prototype.removeRelation = function(key, fromClassName,
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
ExportAdapter.prototype.destroy = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.destroy = function(className, query, options = {}) {
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
@@ -359,8 +345,7 @@ ExportAdapter.prototype.create = function(className, object, options) {
// This should only be used for testing - use 'find' for normal code
// to avoid Mongo-format dependencies.
// Returns a promise that resolves to a list of items.
ExportAdapter.prototype.mongoFind = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.mongoFind = function(className, query, options = {}) {
return this.collection(className).then((coll) => {
return coll.find(query, options).toArray();
});
@@ -516,8 +501,7 @@ ExportAdapter.prototype.smartFind = function(coll, where, options) {
// TODO: make userIds not needed here. The db adapter shouldn't know
// anything about users, ideally. Then, improve the format of the ACL
// arg to work like the others.
ExportAdapter.prototype.find = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.find = function(className, query, options = {}) {
var mongoOptions = {};
if (options.skip) {
mongoOptions.skip = options.skip;

113
src/GCM.js Normal file
View File

@@ -0,0 +1,113 @@
"use strict";
const Parse = require('parse/node').Parse;
const gcm = require('node-gcm');
const cryptoUtils = require('./cryptoUtils');
const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks
const GCMRegistrationTokensMax = 1000;
function GCM(args) {
if (typeof args !== 'object' || !args.apiKey) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'GCM Configuration is invalid');
}
this.sender = new gcm.Sender(args.apiKey);
}
/**
* Send gcm request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} devices A array of devices
* @returns {Object} A promise which is resolved after we get results from gcm
*/
GCM.prototype.send = function(data, devices) {
let pushId = cryptoUtils.newObjectId();
let timeStamp = Date.now();
let expirationTime;
// We handle the expiration_time convertion in push.js, so expiration_time is a valid date
// in Unix epoch time in milliseconds here
if (data['expiration_time']) {
expirationTime = data['expiration_time'];
}
// Generate gcm payload
let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime);
// Make and send gcm request
let message = new gcm.Message(gcmPayload);
let sendPromises = [];
// For android, we can only have 1000 recepients per send, so we need to slice devices to
// chunk if necessary
let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax);
for (let chunkDevice of chunkDevices) {
let sendPromise = new Parse.Promise();
let registrationTokens = []
for (let device of chunkDevice) {
registrationTokens.push(device.deviceToken);
}
this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => {
// TODO: Use the response from gcm to generate and save push report
// TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation
console.log('GCM request and response %j', {
request: message,
response: response
});
sendPromise.resolve();
});
sendPromises.push(sendPromise);
}
return Parse.Promise.when(sendPromises);
}
/**
* Generate the gcm payload from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @param {String} pushId A random string
* @param {Number} timeStamp A number whose format is the Unix Epoch
* @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined
* @returns {Object} A promise which is resolved after we get results from gcm
*/
function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) {
let payloadData = {
'time': new Date(timeStamp).toISOString(),
'push_id': pushId,
'data': JSON.stringify(coreData)
}
let payload = {
priority: 'normal',
data: payloadData
};
if (expirationTime) {
// The timeStamp and expiration is in milliseconds but gcm requires second
let timeToLive = Math.floor((expirationTime - timeStamp) / 1000);
if (timeToLive < 0) {
timeToLive = 0;
}
if (timeToLive >= GCMTimeToLiveMax) {
timeToLive = GCMTimeToLiveMax;
}
payload.timeToLive = timeToLive;
}
return payload;
}
/**
* Slice a list of devices to several list of devices with fixed chunk size.
* @param {Array} devices An array of devices
* @param {Number} chunkSize The size of the a chunk
* @returns {Array} An array which contaisn several arries of devices with fixed chunk size
*/
function sliceDevices(devices, chunkSize) {
let chunkDevices = [];
while (devices.length > 0) {
chunkDevices.push(devices.splice(0, chunkSize));
}
return chunkDevices;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
GCM.generateGCMPayload = generateGCMPayload;
GCM.sliceDevices = sliceDevices;
}
module.exports = GCM;

View File

@@ -3,6 +3,8 @@
var Parse = require('parse/node').Parse;
import { default as FilesController } from './Controllers/FilesController';
// restOptions can include:
// skip
// limit
@@ -11,13 +13,12 @@ var Parse = require('parse/node').Parse;
// include
// keys
// redirectClassNameForKey
function RestQuery(config, auth, className, restWhere, restOptions) {
restOptions = restOptions || {};
function RestQuery(config, auth, className, restWhere = {}, restOptions = {}) {
this.config = config;
this.auth = auth;
this.className = className;
this.restWhere = restWhere || {};
this.restWhere = restWhere;
this.response = null;
this.findOptions = {};
@@ -317,35 +318,35 @@ RestQuery.prototype.replaceDontSelect = function() {
RestQuery.prototype.runFind = function() {
return this.config.database.find(
this.className, this.restWhere, this.findOptions).then((results) => {
if (this.className == '_User') {
for (var result of results) {
delete result.password;
}
if (this.className == '_User') {
for (var result of results) {
delete result.password;
}
}
updateParseFiles(this.config, results);
this.config.filesController.expandFilesInObject(this.config, results);
if (this.keys) {
var keySet = this.keys;
results = results.map((object) => {
var newObject = {};
for (var key in object) {
if (keySet.has(key)) {
newObject[key] = object[key];
}
if (this.keys) {
var keySet = this.keys;
results = results.map((object) => {
var newObject = {};
for (var key in object) {
if (keySet.has(key)) {
newObject[key] = object[key];
}
return newObject;
});
}
if (this.redirectClassName) {
for (var r of results) {
r.className = this.redirectClassName;
}
}
return newObject;
});
}
this.response = {results: results};
});
if (this.redirectClassName) {
for (var r of results) {
r.className = this.redirectClassName;
}
}
this.response = {results: results};
});
};
// Returns a promise for whether it was successful.
@@ -498,35 +499,6 @@ function replacePointers(object, path, replace) {
return answer;
}
// Find file references in REST-format object and adds the url key
// with the current mount point and app id
// Object may be a single object or list of REST-format objects
function updateParseFiles(config, object) {
if (object instanceof Array) {
object.map((obj) => updateParseFiles(config, obj));
return;
}
if (typeof object !== 'object') {
return;
}
for (var key in object) {
if (object[key] && object[key]['__type'] &&
object[key]['__type'] == 'File') {
var filename = object[key]['name'];
var encoded = encodeURIComponent(filename);
encoded = encoded.replace('%40', '@');
if (filename.indexOf('tfss-') === 0) {
object[key]['url'] = 'http://files.parsetfss.com/' +
config.fileKey + '/' + encoded;
} else {
object[key]['url'] = config.mount + '/files/' +
config.applicationId + '/' +
encoded;
}
}
}
}
// Finds a subobject that has the given key, if there is one.
// Returns undefined otherwise.
function findObjectWithKey(root, key) {

View File

@@ -2,13 +2,12 @@
// 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 cryptoUtils = require('./cryptoUtils');
var passwordCrypto = require('./password');
var facebook = require('./facebook');
var Parse = require('parse/node');
@@ -56,7 +55,7 @@ function RestWrite(config, auth, className, query, data, originalData) {
this.data.updatedAt = this.updatedAt;
if (!this.query) {
this.data.createdAt = this.updatedAt;
this.data.objectId = newObjectId();
this.data.objectId = cryptoUtils.newObjectId();
}
}
}
@@ -252,7 +251,7 @@ RestWrite.prototype.handleFacebookAuthData = function() {
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED,
'this auth is already used');
} else {
this.data.username = rack();
this.data.username = cryptoUtils.newToken();
}
// This FB auth does not already exist, so transform it to a
@@ -273,7 +272,7 @@ RestWrite.prototype.transformUser = function() {
var promise = Promise.resolve();
if (!this.query) {
var token = 'r:' + rack();
var token = 'r:' + cryptoUtils.newToken();
this.storage['token'] = token;
promise = promise.then(() => {
var expiresAt = new Date();
@@ -319,8 +318,7 @@ RestWrite.prototype.transformUser = function() {
// Check for username uniqueness
if (!this.data.username) {
if (!this.query) {
// TODO: what's correct behavior here
this.data.username = '';
this.data.username = cryptoUtils.randomString(25);
}
return;
}
@@ -413,7 +411,7 @@ RestWrite.prototype.handleSession = function() {
}
if (!this.query && !this.auth.isMaster) {
var token = 'r:' + rack();
var token = 'r:' + cryptoUtils.newToken();
var expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
var sessionData = {
@@ -637,7 +635,7 @@ RestWrite.prototype.runDatabaseOperation = function() {
this.query &&
!this.auth.couldUpdateUserId(this.query.objectId)) {
throw new Parse.Error(Parse.Error.SESSION_MISSING,
'cannot modify user ' + this.objectId);
'cannot modify user ' + this.query.objectId);
}
// TODO: Add better detection for ACL, ensuring a user can't be locked from
@@ -714,20 +712,4 @@ RestWrite.prototype.objectId = function() {
return this.data.objectId || this.query.objectId;
};
// Returns a unique string that's usable as an object id.
function newObjectId() {
var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
'abcdefghijklmnopqrstuvwxyz' +
'0123456789');
var objectId = '';
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;
}
module.exports = RestWrite;

View File

@@ -0,0 +1,87 @@
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
export class ClassesRouter {
// Returns a promise that resolves to a {response} object.
handleFind(req) {
let body = Object.assign(req.body, req.query);
let options = {};
if (body.skip) {
options.skip = Number(body.skip);
}
if (body.limit) {
options.limit = Number(body.limit);
}
if (body.order) {
options.order = String(body.order);
}
if (body.count) {
options.count = true;
}
if (typeof body.keys == 'string') {
options.keys = body.keys;
}
if (body.include) {
options.include = String(body.include);
}
if (body.redirectClassNameForKey) {
options.redirectClassNameForKey = String(body.redirectClassNameForKey);
}
if (typeof body.where === 'string') {
body.where = JSON.parse(body.where);
}
return rest.find(req.config, req.auth, req.params.className, body.where, options)
.then((response) => {
if (response && response.results) {
for (let result of response.results) {
if (result.sessionToken) {
result.sessionToken = req.info.sessionToken || result.sessionToken;
}
}
}
return { response: response };
});
}
// Returns a promise for a {response} object.
handleGet(req) {
return rest.find(req.config, req.auth, req.params.className, {objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.');
}
return { response: response.results[0] };
});
}
handleCreate(req) {
return rest.create(req.config, req.auth, req.params.className, req.body);
}
handleUpdate(req) {
return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
handleDelete(req) {
return rest.del(req.config, req.auth, req.params.className, req.params.objectId)
.then(() => {
return {response: {}};
});
}
getExpressRouter() {
var router = new PromiseRouter();
router.route('GET', '/classes/:className', (req) => { return this.handleFind(req); });
router.route('GET', '/classes/:className/:objectId', (req) => { return this.handleGet(req); });
router.route('POST', '/classes/:className', (req) => { return this.handleCreate(req); });
router.route('PUT', '/classes/:className/:objectId', (req) => { return this.handleUpdate(req); });
router.route('DELETE', '/classes/:className/:objectId', (req) => { return this.handleDelete(req); });
return router;
}
}
export default ClassesRouter;

View File

@@ -0,0 +1,64 @@
// InstallationsRouter.js
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
export class InstallationsRouter extends ClassesRouter {
handleFind(req) {
var options = {};
if (req.body.skip) {
options.skip = Number(req.body.skip);
}
if (req.body.limit) {
options.limit = Number(req.body.limit);
}
if (req.body.order) {
options.order = String(req.body.order);
}
if (req.body.count) {
options.count = true;
}
if (req.body.include) {
options.include = String(req.body.include);
}
return rest.find(req.config, req.auth,
'_Installation', req.body.where, options)
.then((response) => {
return {response: response};
});
}
handleGet(req) {
req.params.className = '_Installation';
return super.handleGet(req);
}
handleCreate(req) {
req.params.className = '_Installation';
return super.handleCreate(req);
}
handleUpdate(req) {
req.params.className = '_Installation';
return super.handleUpdate(req);
}
handleDelete(req) {
req.params.className = '_Installation';
return super.handleDelete(req);
}
getExpressRouter() {
let router = new PromiseRouter();
router.route('GET','/installations', req => { return this.handleFind(req); });
router.route('GET','/installations/:objectId', req => { return this.handleGet(req); });
router.route('POST','/installations', req => { return this.handleCreate(req); });
router.route('PUT','/installations/:objectId', req => { return this.handleUpdate(req); });
router.route('DELETE','/installations/:objectId', req => { return this.handleDelete(req); });
return router;
}
}
export default InstallationsRouter;

View File

@@ -0,0 +1,43 @@
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
export class RolesRouter extends ClassesRouter {
handleFind(req) {
req.params.className = '_Role';
return super.handleFind(req);
}
handleGet(req) {
req.params.className = '_Role';
return super.handleGet(req);
}
handleCreate(req) {
req.params.className = '_Role';
return super.handleCreate(req);
}
handleUpdate(req) {
req.params.className = '_Role';
return super.handleUpdate(req);
}
handleDelete(req) {
req.params.className = '_Role';
return super.handleDelete(req);
}
getExpressRouter() {
let router = new PromiseRouter();
router.route('GET','/roles', req => { return this.handleFind(req); });
router.route('GET','/roles/:objectId', req => { return this.handleGet(req); });
router.route('POST','/roles', req => { return this.handleCreate(req); });
router.route('PUT','/roles/:objectId', req => { return this.handleUpdate(req); });
router.route('DELETE','/roles/:objectId', req => { return this.handleDelete(req); });
return router;
}
}
export default RolesRouter;

View File

@@ -0,0 +1,63 @@
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import Auth from '../Auth';
export class SessionsRouter extends ClassesRouter {
handleFind(req) {
req.params.className = '_Session';
return super.handleFind(req);
}
handleGet(req) {
req.params.className = '_Session';
return super.handleGet(req);
}
handleCreate(req) {
req.params.className = '_Session';
return super.handleCreate(req);
}
handleUpdate(req) {
req.params.className = '_Session';
return super.handleUpdate(req);
}
handleDelete(req) {
req.params.className = '_Session';
return super.handleDelete(req);
}
handleMe(req) {
// TODO: Verify correct behavior
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN,
'Session token required.');
}
return rest.find(req.config, Auth.master(req.config), '_Session', { _session_token: req.info.sessionToken })
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN,
'Session token not found.');
}
return {
response: response.results[0]
};
});
}
getExpressRouter() {
let router = new PromiseRouter();
router.route('GET','/sessions/me', req => { return this.handleMe(req); });
router.route('GET', '/sessions', req => { return this.handleFind(req); });
router.route('GET', '/sessions/:objectId', req => { return this.handleGet(req); });
router.route('POST', '/sessions', req => { return this.handleCreate(req); });
router.route('PUT', '/sessions/:objectId', req => { return this.handleUpdate(req); });
router.route('DELETE', '/sessions/:objectId', req => { return this.handleDelete(req); });
return router;
}
}
export default SessionsRouter;

161
src/Routers/UsersRouter.js Normal file
View File

@@ -0,0 +1,161 @@
// These methods handle the User-related routes.
import deepcopy from 'deepcopy';
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import Auth from '../Auth';
import passwordCrypto from '../password';
import RestWrite from '../RestWrite';
import { newToken } from '../cryptoUtils';
export class UsersRouter extends ClassesRouter {
handleFind(req) {
req.params.className = '_User';
return super.handleFind(req);
}
handleGet(req) {
req.params.className = '_User';
return super.handleGet(req);
}
handleCreate(req) {
let data = deepcopy(req.body);
data.installationId = req.info.installationId;
req.body = data;
req.params.className = '_User';
return super.handleCreate(req);
}
handleUpdate(req) {
req.params.className = '_User';
return super.handleUpdate(req);
}
handleDelete(req) {
req.params.className = '_User';
return super.handleDelete(req);
}
handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
}
return rest.find(req.config, Auth.master(req.config), '_Session',
{ _session_token: req.info.sessionToken },
{ include: 'user' })
.then((response) => {
if (!response.results ||
response.results.length == 0 ||
!response.results[0].user) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
let user = response.results[0].user;
return { response: user };
}
});
}
handleLogIn(req) {
// Use query parameters instead if provided in url
if (!req.body.username && req.query.username) {
req.body = req.query;
}
// TODO: use the right error codes / descriptions.
if (!req.body.username) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username is required.');
}
if (!req.body.password) {
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.');
}
let user;
return req.database.find('_User', { username: req.body.username })
.then((results) => {
if (!results.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
user = results[0];
return passwordCrypto.compare(req.body.password, user.password);
}).then((correct) => {
if (!correct) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
let token = 'r:' + newToken();
user.sessionToken = token;
delete user.password;
req.config.filesController.expandFilesInObject(req.config, user);
let expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
let sessionData = {
sessionToken: token,
user: {
__type: 'Pointer',
className: '_User',
objectId: user.objectId
},
createdWith: {
'action': 'login',
'authProvider': 'password'
},
restricted: false,
expiresAt: Parse._encode(expiresAt)
};
if (req.info.installationId) {
sessionData.installationId = req.info.installationId
}
let create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData);
return create.execute();
}).then(() => {
return { response: user };
});
}
handleLogOut(req) {
let success = {response: {}};
if (req.info && req.info.sessionToken) {
return rest.find(req.config, Auth.master(req.config), '_Session',
{ _session_token: req.info.sessionToken }
).then((records) => {
if (records.results && records.results.length) {
return rest.del(req.config, Auth.master(req.config), '_Session',
records.results[0].objectId
).then(() => {
return Promise.resolve(success);
});
}
return Promise.resolve(success);
});
}
return Promise.resolve(success);
}
getExpressRouter() {
let router = new PromiseRouter();
router.route('GET', '/users', req => { return this.handleFind(req); });
router.route('POST', '/users', req => { return this.handleCreate(req); });
router.route('GET', '/users/:objectId', req => { return this.handleGet(req); });
router.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); });
router.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); });
router.route('GET', '/users/me', req => { return this.handleMe(req); });
router.route('GET', '/login', req => { return this.handleLogIn(req); });
router.route('POST', '/logout', req => { return this.handleLogOut(req); });
router.route('POST', '/requestPasswordReset', () => {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.');
});
return router;
}
}
export default UsersRouter;

View File

@@ -17,7 +17,7 @@
var Parse = require('parse/node').Parse;
var transform = require('./transform');
defaultColumns = {
var defaultColumns = {
// Contain the default columns for every parse object type (except _Join collection)
_Default: {
"objectId": {type:'String'},
@@ -43,13 +43,13 @@ defaultColumns = {
"GCMSenderId": {type:'String'},
"timeZone": {type:'String'},
"localeIdentifier": {type:'String'},
"badge": {type:'Number'},
"badge": {type:'Number'}
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Role: {
"name": {type:'String'},
"users": {type:'Relation',className:'_User'},
"roles": {type:'Relation',className:'_Role'},
"roles": {type:'Relation',className:'_Role'}
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Session: {
@@ -58,9 +58,9 @@ defaultColumns = {
"installationId": {type:'String'},
"sessionToken": {type:'String'},
"expiresAt": {type:'Date'},
"createdWith": {type:'Object'},
},
}
"createdWith": {type:'Object'}
}
};
// Valid classes must:
// Be one of _User, _Installation, _Role, _Session OR
@@ -221,7 +221,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) {
error: invalidClassNameMessage(className),
});
}
for (fieldName in fields) {
for (var fieldName in fields) {
if (!fieldNameIsValid(fieldName)) {
return Promise.reject({
code: Parse.Error.INVALID_KEY_NAME,
@@ -240,18 +240,18 @@ Schema.prototype.addClassIfNotExists = function(className, fields) {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string',
createdAt: 'string'
};
for (fieldName in defaultColumns[className]) {
validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]);
for (var fieldName in defaultColumns[className]) {
var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
mongoObject[fieldName] = validatedField.result;
}
for (fieldName in fields) {
validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]);
for (var fieldName in fields) {
var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
@@ -259,7 +259,6 @@ Schema.prototype.addClassIfNotExists = function(className, fields) {
}
var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint');
if (geoPoints.length > 1) {
return Promise.reject({
code: Parse.Error.INCORRECT_TYPE,
@@ -278,7 +277,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) {
}
return Promise.reject(error);
});
}
};
// Returns a promise that resolves successfully to the new schema
// object or fails with a reason.
@@ -410,6 +409,88 @@ Schema.prototype.validateField = function(className, key, type, freeze) {
});
};
// Delete a field, and remove that data from all objects. This is intended
// to remove unused fields, if other writers are writing objects that include
// this field, the field may reappear. Returns a Promise that resolves with
// no object on success, or rejects with { code, error } on failure.
// Passing the database and prefix is necessary in order to drop relation collections
// and remove fields from objects. Ideally the database would belong to
// a database adapter and this fuction would close over it or access it via member.
Schema.prototype.deleteField = function(fieldName, className, database, prefix) {
if (!classNameIsValid(className)) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
});
}
if (!fieldNameIsValid(fieldName)) {
return Promise.reject({
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
});
}
//Don't allow deleting the default fields.
if (!fieldNameIsValidForClass(fieldName, className)) {
return Promise.reject({
code: 136,
error: 'field ' + fieldName + ' cannot be changed',
});
}
return this.reload()
.then(schema => {
return schema.hasClass(className)
.then(hasClass => {
if (!hasClass) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' does not exist',
});
}
if (!schema.data[className][fieldName]) {
return Promise.reject({
code: 255,
error: 'field ' + fieldName + ' does not exist, cannot delete',
});
}
if (schema.data[className][fieldName].startsWith('relation')) {
//For relations, drop the _Join table
return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className)
//Save the _SCHEMA object
.then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }}));
} else {
//for non-relations, remove all the data. This is necessary to ensure that the data is still gone
//if they add the same field.
return new Promise((resolve, reject) => {
database.collection(prefix + className, (err, coll) => {
if (err) {
reject(err);
} else {
var mongoFieldName = schema.data[className][fieldName].startsWith('*') ?
'_p_' + fieldName :
fieldName;
return coll.update({}, {
"$unset": { [mongoFieldName] : null },
}, {
multi: true,
})
//Save the _SCHEMA object
.then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }}))
.then(resolve)
.catch(reject);
}
});
});
}
});
});
}
// Given a schema promise, construct another schema promise that
// validates this field once the schema loads.
function thenValidateField(schemaPromise, className, key, type) {
@@ -478,6 +559,13 @@ Schema.prototype.getExpectedType = function(className, key) {
return undefined;
};
// Checks if a given class is in the schema. Needs to load the
// schema first, which is kinda janky. Hopefully we can refactor
// and make this be a regular value.
Schema.prototype.hasClass = function(className) {
return this.reload().then(newSchema => !!newSchema.data[className]);
}
// Helper function to check if a field is a pointer, returns true or false.
Schema.prototype.isPointer = function(className, key) {
var expected = this.getExpectedType(className, key);

44
src/cryptoUtils.js Normal file
View File

@@ -0,0 +1,44 @@
import { randomBytes } from 'crypto';
// Returns a new random hex string of the given even size.
export function randomHexString(size) {
if (size === 0) {
throw new Error('Zero-length randomHexString is useless.');
}
if (size % 2 !== 0) {
throw new Error('randomHexString size must be divisible by 2.')
}
return randomBytes(size/2).toString('hex');
}
// Returns a new random alphanumeric string of the given size.
//
// Note: to simplify implementation, the result has slight modulo bias,
// because chars length of 62 doesn't divide the number of all bytes
// (256) evenly. Such bias is acceptable for most cases when the output
// length is long enough and doesn't need to be uniform.
export function randomString(size) {
if (size === 0) {
throw new Error('Zero-length randomString is useless.');
}
var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
'abcdefghijklmnopqrstuvwxyz' +
'0123456789');
var objectId = '';
var bytes = randomBytes(size);
for (var i = 0; i < bytes.length; ++i) {
objectId += chars[bytes.readUInt8(i) % chars.length];
}
return objectId;
}
// Returns a new random alphanumeric string suitable for object ID.
export function newObjectId() {
//TODO: increase length to better protect against collisions.
return randomString(10);
}
// Returns a new random hex string suitable for secure tokens.
export function newToken() {
return randomHexString(32);
}

View File

@@ -22,6 +22,7 @@ function handleCloudFunction(req) {
params: req.body || {},
master: req.auth && req.auth.isMaster,
user: req.auth && req.auth.user,
installationId: req.info.installationId
};
Parse.Cloud.Functions[req.params.functionName](request, response);
});

46
src/global_config.js Normal file
View File

@@ -0,0 +1,46 @@
// global_config.js
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter');
var router = new PromiseRouter();
function getGlobalConfig(req) {
return req.config.database.rawCollection('_GlobalConfig')
.then(coll => coll.findOne({'_id': 1}))
.then(globalConfig => ({response: { params: globalConfig.params }}))
.catch(() => ({
status: 404,
response: {
code: Parse.Error.INVALID_KEY_NAME,
error: 'config does not exist',
}
}));
}
function updateGlobalConfig(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'unauthorized'},
});
}
return req.config.database.rawCollection('_GlobalConfig')
.then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body }))
.then(response => {
return { response: { result: true } }
})
.catch(() => ({
status: 404,
response: {
code: Parse.Error.INVALID_KEY_NAME,
error: 'config cannot be updated',
}
}));
}
router.route('GET', '/config', getGlobalConfig);
router.route('PUT', '/config', updateGlobalConfig);
module.exports = router;

View File

@@ -5,14 +5,28 @@ var batch = require('./batch'),
cache = require('./cache'),
DatabaseAdapter = require('./DatabaseAdapter'),
express = require('express'),
FilesAdapter = require('./FilesAdapter'),
S3Adapter = require('./S3Adapter'),
middlewares = require('./middlewares'),
multer = require('multer'),
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
httpRequest = require('./httpRequest');
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { FilesController } from './Controllers/FilesController';
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
import { PushController } from './Controllers/PushController';
import { ClassesRouter } from './Routers/ClassesRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { UsersRouter } from './Routers/UsersRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { RolesRouter } from './Routers/RolesRouter';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { LoggerController } from './Controllers/LoggerController';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
@@ -38,6 +52,8 @@ addParseCloud();
// "dotNetKey": optional key from Parse dashboard
// "restAPIKey": optional key from Parse dashboard
// "javascriptKey": optional key from Parse dashboard
// "push": optional key from configure push
function ParseServer(args) {
if (!args.appId || !args.masterKey) {
throw 'You must provide an appId and masterKey!';
@@ -46,9 +62,22 @@ function ParseServer(args) {
if (args.databaseAdapter) {
DatabaseAdapter.setAdapter(args.databaseAdapter);
}
if (args.filesAdapter) {
FilesAdapter.setAdapter(args.filesAdapter);
// Make files adapter
let filesAdapter = args.filesAdapter || new GridStoreAdapter();
// Make push adapter
let pushConfig = args.push;
let pushAdapter;
if (pushConfig && pushConfig.adapter) {
pushAdapter = pushConfig.adapter;
} else if (pushConfig) {
pushAdapter = new ParsePushAdapter(pushConfig)
}
// Make logger adapter
let loggerAdapter = args.loggerAdapter || new FileLoggerAdapter();
if (args.databaseURI) {
DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI);
}
@@ -64,6 +93,8 @@ function ParseServer(args) {
}
let filesController = new FilesController(filesAdapter);
cache.apps[args.appId] = {
masterKey: args.masterKey,
collectionPrefix: args.collectionPrefix || '',
@@ -72,7 +103,8 @@ function ParseServer(args) {
dotNetKey: args.dotNetKey || '',
restAPIKey: args.restAPIKey || '',
fileKey: args.fileKey || 'invalid-file-key',
facebookAppIds: args.facebookAppIds || []
facebookAppIds: args.facebookAppIds || [],
filesController: filesController
};
// To maintain compatibility. TODO: Remove in v2.1
@@ -91,7 +123,7 @@ function ParseServer(args) {
var api = express();
// File handling needs to be before default middlewares are applied
api.use('/', require('./files').router);
api.use('/', filesController.getExpressRouter());
// TODO: separate this from the regular ParseServer object
if (process.env.TESTING == 1) {
@@ -104,21 +136,29 @@ function ParseServer(args) {
api.use(middlewares.allowMethodOverride);
api.use(middlewares.handleParseHeaders);
var router = new PromiseRouter();
let routers = [
new ClassesRouter().getExpressRouter(),
new UsersRouter().getExpressRouter(),
new SessionsRouter().getExpressRouter(),
new RolesRouter().getExpressRouter(),
require('./analytics'),
new InstallationsRouter().getExpressRouter(),
require('./functions'),
require('./schemas'),
new PushController(pushAdapter).getExpressRouter(),
new LoggerController(loggerAdapter).getExpressRouter()
];
if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) {
routers.push(require('./global_config'));
}
router.merge(require('./classes'));
router.merge(require('./users'));
router.merge(require('./sessions'));
router.merge(require('./roles'));
router.merge(require('./analytics'));
router.merge(require('./push').router);
router.merge(require('./installations'));
router.merge(require('./functions'));
router.merge(require('./schemas'));
let appRouter = new PromiseRouter();
routers.forEach((router) => {
appRouter.merge(router);
});
batch.mountOnto(appRouter);
batch.mountOnto(router);
router.mountOnto(api);
appRouter.mountOnto(api);
api.use(middlewares.handleParseErrors);

View File

@@ -178,15 +178,24 @@ var handleParseErrors = function(err, req, res, next) {
}
};
function enforceMasterKeyAccess(req, res, next) {
if (!req.auth.isMaster) {
res.status(403);
res.end('{"error":"unauthorized: master key is required"}');
return;
}
next();
}
function invalidRequest(req, res) {
res.status(403);
res.end('{"error":"unauthorized"}');
}
module.exports = {
allowCrossDomain: allowCrossDomain,
allowMethodOverride: allowMethodOverride,
handleParseErrors: handleParseErrors,
handleParseHeaders: handleParseHeaders
handleParseHeaders: handleParseHeaders,
enforceMasterKeyAccess: enforceMasterKeyAccess
};

View File

@@ -1,7 +1,9 @@
// schemas.js
var express = require('express'),
PromiseRouter = require('./PromiseRouter');
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
Schema = require('./Schema');
var router = new PromiseRouter();
@@ -23,6 +25,7 @@ function mongoFieldTypeToSchemaAPIType(type) {
case 'string': return {type: 'String'};
case 'boolean': return {type: 'Boolean'};
case 'date': return {type: 'Date'};
case 'map':
case 'object': return {type: 'Object'};
case 'array': return {type: 'Array'};
case 'geopoint': return {type: 'GeoPoint'};
@@ -31,8 +34,8 @@ function mongoFieldTypeToSchemaAPIType(type) {
}
function mongoSchemaAPIResponseFields(schema) {
fieldNames = Object.keys(schema).filter(key => key !== '_id');
response = fieldNames.reduce((obj, fieldName) => {
var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata');
var response = fieldNames.reduce((obj, fieldName) => {
obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName])
return obj;
}, {});
@@ -54,7 +57,7 @@ function getAllSchemas(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'unauthorized'},
response: {error: 'master key not specified'},
});
}
return req.config.database.collection('_SCHEMA')
@@ -83,7 +86,46 @@ function getOneSchema(req) {
}));
}
function createSchema(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'master key not specified'},
});
}
if (req.params.className && req.body.className) {
if (req.params.className != req.body.className) {
return Promise.resolve({
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class name mismatch between ' + req.body.className + ' and ' + req.params.className,
},
});
}
}
var className = req.params.className || req.body.className;
if (!className) {
return Promise.resolve({
status: 400,
response: {
code: 135,
error: 'POST ' + req.path + ' needs class name',
},
});
}
return req.config.database.loadSchema()
.then(schema => schema.addClassIfNotExists(className, req.body.fields))
.then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) }))
.catch(error => ({
status: 400,
response: error,
}));
}
router.route('GET', '/schemas', getAllSchemas);
router.route('GET', '/schemas/:className', getOneSchema);
router.route('POST', '/schemas', createSchema);
router.route('POST', '/schemas/:className', createSchema);
module.exports = router;

View File

@@ -3,13 +3,13 @@
var express = require('express'),
cache = require('./cache'),
middlewares = require('./middlewares'),
rack = require('hat').rack();
cryptoUtils = require('./cryptoUtils');
var router = express.Router();
// creates a unique app in the cache, with a collection prefix
function createApp(req, res) {
var appId = rack();
var appId = cryptoUtils.randomHexString(32);
cache.apps[appId] = {
'collectionPrefix': appId + '_',
'masterKey': 'master'
@@ -70,4 +70,4 @@ router.post('/rest_configure_app',
module.exports = {
router: router
};
};

View File

@@ -21,7 +21,7 @@ var Parse = require('parse/node').Parse;
// validate: true indicates that key names are to be validated.
//
// Returns an object with {key: key, value: value}.
function transformKeyValue(schema, className, restKey, restValue, options) {
export function transformKeyValue(schema, className, restKey, restValue, options) {
options = options || {};
// Check if the schema is known since it's a built-in field.
@@ -126,7 +126,7 @@ function transformKeyValue(schema, className, restKey, restValue, options) {
if (inArray && options.query && !(restValue instanceof Array)) {
return {
key: key, value: [restValue]
key: key, value: { '$all' : [restValue] }
};
}

207
users.js
View File

@@ -1,207 +0,0 @@
// These methods handle the User-related routes.
var mongodb = require('mongodb');
var Parse = require('parse/node').Parse;
var rack = require('hat').rack();
var Auth = require('./Auth');
var passwordCrypto = require('./password');
var facebook = require('./facebook');
var PromiseRouter = require('./PromiseRouter');
var rest = require('./rest');
var RestWrite = require('./RestWrite');
var deepcopy = require('deepcopy');
var router = new PromiseRouter();
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
var data = deepcopy(req.body);
data.installationId = req.info.installationId;
return rest.create(req.config, req.auth,
'_User', data);
}
// Returns a promise for a {response} object.
function handleLogIn(req) {
// Use query parameters instead if provided in url
if (!req.body.username && req.query.username) {
req.body = req.query;
}
// TODO: use the right error codes / descriptions.
if (!req.body.username) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING,
'username is required.');
}
if (!req.body.password) {
throw new Parse.Error(Parse.Error.PASSWORD_MISSING,
'password is required.');
}
var user;
return req.database.find('_User', {username: req.body.username})
.then((results) => {
if (!results.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Invalid username/password.');
}
user = results[0];
return passwordCrypto.compare(req.body.password, user.password);
}).then((correct) => {
if (!correct) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Invalid username/password.');
}
var token = 'r:' + rack();
user.sessionToken = token;
delete user.password;
var expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
var sessionData = {
sessionToken: token,
user: {
__type: 'Pointer',
className: '_User',
objectId: user.objectId
},
createdWith: {
'action': 'login',
'authProvider': 'password'
},
restricted: false,
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();
}).then(() => {
return {response: user};
});
}
// Returns a promise that resolves to a {response} object.
// TODO: share code with classes.js
function handleFind(req) {
var options = {};
if (req.body.skip) {
options.skip = Number(req.body.skip);
}
if (req.body.limit) {
options.limit = Number(req.body.limit);
}
if (req.body.order) {
options.order = String(req.body.order);
}
if (req.body.count) {
options.count = true;
}
if (typeof req.body.keys == 'string') {
options.keys = req.body.keys;
}
if (req.body.include) {
options.include = String(req.body.include);
}
if (req.body.redirectClassNameForKey) {
options.redirectClassNameForKey = String(req.body.redirectClassNameForKey);
}
return rest.find(req.config, req.auth,
'_User', req.body.where, options)
.then((response) => {
return {response: response};
});
}
// Returns a promise for a {response} object.
function handleGet(req) {
return rest.find(req.config, req.auth, '_User',
{objectId: req.params.objectId})
.then((response) => {
if (!response.results || response.results.length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
function handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
}
return rest.find(req.config, Auth.master(req.config), '_Session',
{_session_token: req.info.sessionToken},
{include: 'user'})
.then((response) => {
if (!response.results || response.results.length == 0 ||
!response.results[0].user) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.');
} else {
var user = response.results[0].user;
return {response: user};
}
});
}
function handleDelete(req) {
return rest.del(req.config, req.auth,
req.params.className, req.params.objectId)
.then(() => {
return {response: {}};
});
}
function handleLogOut(req) {
var success = {response: {}};
if (req.info && req.info.sessionToken) {
rest.find(req.config, Auth.master(req.config), '_Session',
{_session_token: req.info.sessionToken}
).then((records) => {
if (records.results && records.results.length) {
rest.del(req.config, Auth.master(req.config), '_Session',
records.results[0].id
);
}
});
}
return Promise.resolve(success);
}
function handleUpdate(req) {
return rest.update(req.config, req.auth, '_User',
req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
function notImplementedYet(req) {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE,
'This path is not implemented yet.');
}
router.route('POST', '/users', handleCreate);
router.route('GET', '/login', handleLogIn);
router.route('POST', '/logout', handleLogOut);
router.route('GET', '/users/me', handleMe);
router.route('GET', '/users/:objectId', handleGet);
router.route('PUT', '/users/:objectId', handleUpdate);
router.route('GET', '/users', handleFind);
router.route('DELETE', '/users/:objectId', handleDelete);
router.route('POST', '/requestPasswordReset', notImplementedYet);
module.exports = router;