Merge branch 'master' of https://github.com/ParsePlatform/parse-server into mcdonald-gcs-adapter

Get GCSAdapter up to snuff with FilesController + FilesControllerTestFactory

* 'master' of https://github.com/ParsePlatform/parse-server: (102 commits)
  Remove duplicated instructions
  Release and Changelog for 2.1.4
  fixes missing coverage with sh script
  Fix update system schema
  Adds optional COVERAGE
  Allows to pass no where in $select clause
  Sanitize objectId in
  Fix delete schema when actual collection does not exist
  Fix replace query overwrite the existing query object.
  Fix create system class with relation/pointer
  Use throws syntax for errors in SchemasRouter.
  Completely migrate SchemasRouter to new MongoCollection API.
  Add tests that verify installationId in Cloud Code triggers.
  Propagate installationId in all Cloud Code triggers.
  Add test
  expiresAt should be a Date, not a string. Fixes #776
  Fix missing 'let/var' in OneSignalPushAdapter.spec.
  Don't run any afterSave hooks if none are registered.
  Fix : remove query count limit
  Flatten custom operations in request.object in afterSave hooks.
  ...
This commit is contained in:
Mike McDonald
2016-03-03 22:36:25 -08:00
83 changed files with 4170 additions and 1458 deletions

View File

@@ -1,35 +1,43 @@
export function loadAdapter(adapter, defaultAdapter, options) {
export function loadAdapter(options, defaultAdapter) {
let adapter;
// We have options and options have adapter key
if (options) {
// Pass an adapter as a module name, a function or an instance
if (typeof options == "string" || typeof options == "function" || options.constructor != Object) {
adapter = options;
if (!adapter)
{
if (!defaultAdapter) {
return options;
}
if (options.adapter) {
adapter = options.adapter;
// Load from the default adapter when no adapter is set
return loadAdapter(defaultAdapter, undefined, options);
} else if (typeof adapter === "function") {
try {
return adapter(options);
} catch(e) {
var Adapter = adapter;
return new Adapter(options);
}
}
if (!adapter) {
adapter = defaultAdapter;
}
// This is a string, require the module
if (typeof adapter === "string") {
} else if (typeof adapter === "string") {
adapter = require(adapter);
// If it's define as a module, get the default
if (adapter.default) {
adapter = adapter.default;
}
return loadAdapter(adapter, undefined, options);
} else if (adapter.module) {
return loadAdapter(adapter.module, undefined, adapter.options);
} else if (adapter.class) {
return loadAdapter(adapter.class, undefined, adapter.options);
} else if (adapter.adapter) {
return loadAdapter(adapter.adapter, undefined, adapter.options);
} else {
// Try to load the defaultAdapter with the options
// The default adapter should throw if the options are
// incompatible
try {
return loadAdapter(defaultAdapter, undefined, adapter);
} catch (e) {};
}
// From there it's either a function or an object
// if it's an function, instanciate and pass the options
if (typeof adapter === "function") {
var Adapter = adapter;
adapter = new Adapter(options);
}
return adapter;
// return the adapter as is as it's unusable otherwise
return adapter;
}
export default loadAdapter;

View File

@@ -0,0 +1,23 @@
/*
Mail Adapter prototype
A MailAdapter should implement at least sendMail()
*/
export class MailAdapter {
/*
* A method for sending mail
* @param options would have the parameters
* - to: the recipient
* - text: the raw text of the message
* - subject: the subject of the email
*/
sendMail(options) {}
/* You can implement those methods if you want
* to provide HTML templates etc...
*/
// sendVerificationEmail({ link, appName, user }) {}
// sendPasswordResetEmail({ link, appName, user }) {}
}
export default MailAdapter;

View File

@@ -0,0 +1,32 @@
import Mailgun from 'mailgun-js';
let SimpleMailgunAdapter = mailgunOptions => {
if (!mailgunOptions || !mailgunOptions.apiKey || !mailgunOptions.domain) {
throw 'SimpleMailgunAdapter requires an API Key and domain.';
}
let mailgun = Mailgun(mailgunOptions);
let sendMail = ({to, subject, text}) => {
let data = {
from: mailgunOptions.fromAddress,
to: to,
subject: subject,
text: text,
}
return new Promise((resolve, reject) => {
mailgun.messages().send(data, (err, body) => {
if (typeof err !== 'undefined') {
reject(err);
}
resolve(body);
});
});
}
return Object.freeze({
sendMail: sendMail
});
}
module.exports = SimpleMailgunAdapter

View File

@@ -8,11 +8,23 @@
// * getFileLocation(config, request, filename)
//
// Default is GridStoreAdapter, which requires mongo
// and for the API server to be using the ExportAdapter
// and for the API server to be using the DatabaseController with Mongo
// database adapter.
export class FilesAdapter {
createFile(config, filename, data) { }
/* this method is responsible to store the file in order to be retrived later by it's file name
*
*
* @param config the current config
* @param filename the filename to save
* @param data the buffer of data from the file
* @param contentType the supposed contentType
* @discussion the contentType can be undefined if the controller was not able to determine it
*
* @return a promise that should fail if the storage didn't succeed
*
*/
createFile(config, filename: string, data, contentType: string) { }
deleteFile(config, filename) { }

View File

@@ -1,16 +1,18 @@
// GCSAdapter
// Store Parse Files in Google Cloud Storage: https://cloud.google.com/storage
import * as gcloud from 'gcloud';
import { storage } from 'gcloud';
import { FilesAdapter } from './FilesAdapter';
import requiredParameter from '../../requiredParameter';
export class GCSAdapter extends FilesAdapter {
// GCS Project ID and the name of a corresponding Keyfile are required.
// Unlike the S3 adapter, you must create a new Cloud Storage bucket, as this is not created automatically.
// See https://googlecloudplatform.github.io/gcloud-node/#/docs/master/guides/authentication
// for more details.
constructor(
projectId,
keyFilename,
bucket,
projectId = requiredParameter('GCSAdapter requires a GCP Project ID'),
keyFilename = requiredParameter('GCSAdapter requires a GCP keyfile'),
bucket = requiredParameter('GCSAdapter requires a GCS bucket name'),
{ bucketPrefix = '',
directAccess = false } = {}
) {
@@ -20,21 +22,25 @@ export class GCSAdapter extends FilesAdapter {
this._bucketPrefix = bucketPrefix;
this._directAccess = directAccess;
let gcsOptions = {
let options = {
projectId: projectId,
keyFilename: keyFilename
};
this._gcsClient = new gcloud.storage(gcsOptions);
this._gcsClient = new storage(options);
}
// For a given config object, filename, and data, store a file in GCS.
// Resolves the promise or fails with an error.
createFile(config, filename, data) {
createFile(config, filename, data, contentType) {
let params = {
contentType: contentType || 'application/octet-stream'
};
return new Promise((resolve, reject) => {
let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename);
// gcloud supports upload(file) not upload(bytes), so we need to stream.
var uploadStream = file.createWriteStream();
var uploadStream = file.createWriteStream(params);
uploadStream.on('error', (err) => {
return reject(err);
}).on('finish', () => {
@@ -61,6 +67,7 @@ export class GCSAdapter extends FilesAdapter {
return new Promise((resolve, reject) => {
let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename);
file.delete((err, res) => {
console.log("delete: ", filename, err, res);
if(err !== null) {
return reject(err);
}
@@ -74,11 +81,19 @@ export class GCSAdapter extends FilesAdapter {
getFileData(config, filename) {
return new Promise((resolve, reject) => {
let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename);
file.download((err, data) => {
if (err !== null) {
return reject(err);
// Check for existence, since gcloud-node seemed to be caching the result
file.exists((err, exists) => {
if (exists) {
file.download((err, data) => {
console.log("get: ", filename, err, data);
if (err !== null) {
return reject(err);
}
return resolve(data);
});
} else {
reject(err);
}
resolve(data);
});
});
}

View File

@@ -1,28 +1,47 @@
// GridStoreAdapter
//
// Stores files in Mongo using GridStore
// Requires the database adapter to be based on mongoclient
/**
GridStoreAdapter
Stores files in Mongo using GridStore
Requires the database adapter to be based on mongoclient
import { GridStore } from 'mongodb';
@flow weak
*/
import { MongoClient, GridStore, Db} from 'mongodb';
import { FilesAdapter } from './FilesAdapter';
export class GridStoreAdapter extends FilesAdapter {
_databaseURI: string;
_connectionPromise: Promise<Db>;
constructor(mongoDatabaseURI: string) {
super();
this._databaseURI = mongoDatabaseURI;
this._connect();
}
_connect() {
if (!this._connectionPromise) {
this._connectionPromise = MongoClient.connect(this._databaseURI);
}
return this._connectionPromise;
}
// 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');
createFile(config, filename: string, data, contentType) {
return this._connect().then(database => {
let gridStore = new GridStore(database, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
}).then(gridStore => {
return gridStore.write(data);
}).then((gridStore) => {
}).then(gridStore => {
return gridStore.close();
});
}
deleteFile(config, filename) {
return config.database.connect().then(() => {
let gridStore = new GridStore(config.database.db, filename, 'w');
deleteFile(config, filename: string) {
return this._connect().then(database => {
let gridStore = new GridStore(database, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
return gridStore.unlink();
@@ -31,13 +50,14 @@ export class GridStoreAdapter extends FilesAdapter {
});
}
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) => {
getFileData(config, filename: string) {
return this._connect().then(database => {
return GridStore.exist(database, filename)
.then(() => {
let gridStore = new GridStore(database, filename, 'r');
return gridStore.open();
});
}).then(gridStore => {
return gridStore.read();
});
}

View File

@@ -4,23 +4,38 @@
import * as AWS from 'aws-sdk';
import { FilesAdapter } from './FilesAdapter';
import requiredParameter from '../../requiredParameter';
const DEFAULT_S3_REGION = "us-east-1";
function parseS3AdapterOptions(...options) {
if (options.length === 1 && typeof options[0] == "object") {
return options;
}
const additionalOptions = options[3] || {};
return {
accessKey: options[0],
secretKey: options[1],
bucket: options[2],
region: additionalOptions.region
}
}
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,
bucket,
{ region = DEFAULT_S3_REGION,
bucketPrefix = '',
directAccess = false } = {}
) {
accessKey = requiredParameter('S3Adapter requires an accessKey'),
secretKey = requiredParameter('S3Adapter requires a secretKey'),
bucket,
{ region = DEFAULT_S3_REGION,
bucketPrefix = '',
directAccess = false } = {}) {
super();
this._region = region;
this._bucket = bucket;
this._bucketPrefix = bucketPrefix;
@@ -33,11 +48,27 @@ export class S3Adapter extends FilesAdapter {
};
AWS.config._region = this._region;
this._s3Client = new AWS.S3(s3Options);
this._hasBucket = false;
}
createBucket() {
var promise;
if (this._hasBucket) {
promise = Promise.resolve();
} else {
promise = new Promise((resolve, reject) => {
this._s3Client.createBucket(() => {
this._hasBucket = true;
resolve();
});
});
}
return promise;
}
// 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) {
createFile(config, filename, data, contentType) {
let params = {
Key: this._bucketPrefix + filename,
Body: data
@@ -45,26 +76,33 @@ export class S3Adapter extends FilesAdapter {
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);
if (contentType) {
params.ContentType = contentType;
}
return this.createBucket().then(() => {
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);
return this.createBucket().then(() => {
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);
});
});
});
}
@@ -73,12 +111,18 @@ export class S3Adapter extends FilesAdapter {
// 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);
return this.createBucket().then(() => {
return new Promise((resolve, reject) => {
this._s3Client.getObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
// Something happend here...
if (data && !data.Body) {
return reject(data);
}
resolve(data.Body);
});
});
});
}

View File

@@ -101,7 +101,6 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => {
export class FileLoggerAdapter extends LoggerAdapter {
constructor(options = {}) {
super();
this._logsFolder = options.logsFolder || LOGS_FOLDER;
// check logs folder exists

View File

@@ -18,6 +18,10 @@ export class OneSignalPushAdapter extends PushAdapter {
this.validPushTypes = ['ios', 'android'];
this.senderMap = {};
this.OneSignalConfig = {};
const { oneSignalAppId, oneSignalApiKey } = pushConfig;
if (!oneSignalAppId || !oneSignalApiKey) {
throw "Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey";
}
this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId'];
this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey'];

View File

@@ -14,6 +14,10 @@ export class ParsePushAdapter extends PushAdapter {
super(pushConfig);
this.validPushTypes = ['ios', 'android'];
this.senderMap = {};
// used in PushController for Dashboard Features
this.feature = {
immediatePush: true
};
let pushTypes = Object.keys(pushConfig);
for (let pushType of pushTypes) {

View File

@@ -0,0 +1,76 @@
let mongodb = require('mongodb');
let Collection = mongodb.Collection;
export default class MongoCollection {
_mongoCollection:Collection;
constructor(mongoCollection:Collection) {
this._mongoCollection = mongoCollection;
}
// Does a find with "smart indexing".
// Currently this just means, if it needs a geoindex and there is
// none, then build the geoindex.
// This could be improved a lot but it's not clear if that's a good
// idea. Or even if this behavior is a good idea.
find(query, { skip, limit, sort } = {}) {
return this._rawFind(query, { skip, limit, sort })
.catch(error => {
// Check for "no geoindex" error
if (error.code != 17007 ||
!error.message.match(/unable to find index for .geoNear/)) {
throw error;
}
// Figure out what key needs an index
let key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
if (!key) {
throw error;
}
var index = {};
index[key] = '2d';
//TODO: condiser moving index creation logic into Schema.js
return this._mongoCollection.createIndex(index)
// Retry, but just once.
.then(() => this._rawFind(query, { skip, limit, sort }));
});
}
_rawFind(query, { skip, limit, sort } = {}) {
return this._mongoCollection
.find(query, { skip, limit, sort })
.toArray();
}
count(query, { skip, limit, sort } = {}) {
return this._mongoCollection.count(query, { skip, limit, sort });
}
// Atomically finds and updates an object based on query.
// The result is the promise with an object that was in the database !AFTER! changes.
// Postgres Note: Translates directly to `UPDATE * SET * ... RETURNING *`, which will return data after the change is done.
findOneAndUpdate(query, update) {
// arguments: query, sort, update, options(optional)
// Setting `new` option to true makes it return the after document, not the before one.
return this._mongoCollection.findAndModify(query, [], update, { new: true }).then(document => {
// Value is the object where mongo returns multiple fields.
return document.value;
})
}
// Atomically find and delete an object based on query.
// The result is the promise with an object that was in the database before deleting.
// Postgres Note: Translates directly to `DELETE * FROM ... RETURNING *`, which will return data after delete is done.
findOneAndDelete(query) {
// arguments: query, sort
return this._mongoCollection.findAndRemove(query, []).then(document => {
// Value is the object where mongo returns multiple fields.
return document.value;
});
}
drop() {
return this._mongoCollection.drop();
}
}

View File

@@ -0,0 +1,68 @@
import MongoCollection from './MongoCollection';
let mongodb = require('mongodb');
let MongoClient = mongodb.MongoClient;
export class MongoStorageAdapter {
// Private
_uri: string;
// Public
connectionPromise;
database;
constructor(uri: string) {
this._uri = uri;
}
connect() {
if (this.connectionPromise) {
return this.connectionPromise;
}
this.connectionPromise = MongoClient.connect(this._uri).then(database => {
this.database = database;
});
return this.connectionPromise;
}
collection(name: string) {
return this.connect().then(() => {
return this.database.collection(name);
});
}
adaptiveCollection(name: string) {
return this.connect()
.then(() => this.database.collection(name))
.then(rawCollection => new MongoCollection(rawCollection));
}
collectionExists(name: string) {
return this.connect().then(() => {
return this.database.listCollections({ name: name }).toArray();
}).then(collections => {
return collections.length > 0;
});
}
dropCollection(name: string) {
return this.collection(name).then(collection => collection.drop());
}
// Used for testing only right now.
collectionsContaining(match: string) {
return this.connect().then(() => {
return this.database.collections();
}).then(collections => {
return collections.filter(collection => {
if (collection.namespace.match(/\.system\./)) {
return false;
}
return (collection.collectionName.indexOf(match) == 0);
});
});
}
}
export default MongoStorageAdapter;
module.exports = MongoStorageAdapter; // Required for tests

View File

@@ -7,10 +7,11 @@ import cache from './cache';
// An Auth object tells you who is requesting something and whether
// the master key was used.
// userObject is a Parse.User and can be null if there's no user.
function Auth(config, isMaster, userObject) {
function Auth({ config, isMaster = false, user, installationId } = {}) {
this.config = config;
this.installationId = installationId;
this.isMaster = isMaster;
this.user = userObject;
this.user = user;
// Assuming a users roles won't change during a single request, we'll
// only load them once.
@@ -33,19 +34,19 @@ Auth.prototype.couldUpdateUserId = function(userId) {
// A helper to get a master-level Auth object
function master(config) {
return new Auth(config, true, null);
return new Auth({ config, isMaster: true });
}
// A helper to get a nobody-level Auth object
function nobody(config) {
return new Auth(config, false, null);
return new Auth({ config, isMaster: false });
}
// Returns a promise that resolves to an Auth object
var getAuthForSessionToken = function(config, sessionToken) {
var cachedUser = cache.getUser(sessionToken);
var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) {
var cachedUser = cache.users.get(sessionToken);
if (cachedUser) {
return Promise.resolve(new Auth(config, false, cachedUser));
return Promise.resolve(new Auth({ config, isMaster: false, installationId, user: cachedUser }));
}
var restOptions = {
limit: 1,
@@ -65,9 +66,9 @@ var getAuthForSessionToken = function(config, sessionToken) {
delete obj.password;
obj['className'] = '_User';
obj['sessionToken'] = sessionToken;
var userObject = Parse.Object.fromJSON(obj);
cache.setUser(sessionToken, userObject);
return new Auth(config, false, userObject);
let userObject = Parse.Object.fromJSON(obj);
cache.users.set(sessionToken, userObject);
return new Auth({ config, isMaster: false, installationId, user: userObject });
});
};
@@ -159,6 +160,22 @@ Auth.prototype._getAllRoleNamesForId = function(roleID) {
return Promise.resolve([]);
}
var roleIDs = results.map(r => r.objectId);
var parentRolesPromises = roleIDs.map( (roleId) => {
return this._getAllRoleNamesForId(roleId);
});
parentRolesPromises.push(Promise.resolve(roleIDs));
return Promise.all(parentRolesPromises);
}).then(function(results){
// Flatten
let roleIDs = results.reduce( (memo, result) => {
if (typeof result == "object") {
memo = memo.concat(result);
} else {
memo.push(result);
}
return memo;
}, []);
return Promise.resolve(roleIDs);
});
};

View File

@@ -7,15 +7,12 @@ import cache from './cache';
export class Config {
constructor(applicationId: string, mount: string) {
let DatabaseAdapter = require('./DatabaseAdapter');
let cacheInfo = cache.apps[applicationId];
this.valid = !!cacheInfo;
if (!this.valid) {
let cacheInfo = cache.apps.get(applicationId);
if (!cacheInfo) {
return;
}
this.applicationId = applicationId;
this.collectionPrefix = cacheInfo.collectionPrefix || '';
this.masterKey = cacheInfo.masterKey;
this.clientKey = cacheInfo.clientKey;
this.javascriptKey = cacheInfo.javascriptKey;
@@ -25,16 +22,64 @@ export class Config {
this.facebookAppIds = cacheInfo.facebookAppIds;
this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers;
this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, this.collectionPrefix);
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix);
this.serverURL = cacheInfo.serverURL;
this.publicServerURL = cacheInfo.publicServerURL;
this.verifyUserEmails = cacheInfo.verifyUserEmails;
this.appName = cacheInfo.appName;
this.hooksController = cacheInfo.hooksController;
this.filesController = cacheInfo.filesController;
this.pushController = cacheInfo.pushController;
this.pushController = cacheInfo.pushController;
this.loggerController = cacheInfo.loggerController;
this.userController = cacheInfo.userController;
this.oauth = cacheInfo.oauth;
this.customPages = cacheInfo.customPages || {};
this.mount = mount;
}
}
static validate(options) {
this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails,
appName: options.appName,
publicServerURL: options.publicServerURL})
}
static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) {
if (verifyUserEmails) {
if (typeof appName !== 'string') {
throw 'An app name is required when using email verification.';
}
if (typeof publicServerURL !== 'string') {
throw 'A public server url is required when using email verification.';
}
}
}
get invalidLinkURL() {
return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`;
}
get verifyEmailSuccessURL() {
return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`;
}
get choosePasswordURL() {
return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`;
}
get requestResetPasswordURL() {
return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`;
}
get passwordResetSuccessURL() {
return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`;
}
get verifyEmailURL() {
return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`;
}
};
export default Config;
module.exports = Config;

View File

@@ -10,13 +10,20 @@ based on the parameters passed
// _adapter is private, use Symbol
var _adapter = Symbol();
import Config from '../Config';
export class AdaptableController {
constructor(adapter) {
constructor(adapter, appId, options) {
this.options = options;
this.appId = appId;
this.adapter = adapter;
this.setFeature();
}
// sets features for Dashboard to consume from features router
setFeature() {}
set adapter(adapter) {
this.validateAdapter(adapter);
this[_adapter] = adapter;
@@ -26,12 +33,15 @@ export class AdaptableController {
return this[_adapter];
}
get config() {
return new Config(this.appId);
}
expectedAdapterType() {
throw new Error("Subclasses should implement expectedAdapterType()");
}
validateAdapter(adapter) {
if (!adapter) {
throw new Error(this.constructor.name+" requires an adapter");
}
@@ -56,10 +66,9 @@ export class AdaptableController {
}, {});
if (Object.keys(mismatches).length > 0) {
console.error(adapter, mismatches);
throw new Error("Adapter prototype don't match expected prototype");
throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches);
}
}
}
export default AdaptableController;
export default AdaptableController;

View File

@@ -2,18 +2,17 @@
// Parse database.
var mongodb = require('mongodb');
var MongoClient = mongodb.MongoClient;
var Parse = require('parse/node').Parse;
var Schema = require('./Schema');
var transform = require('./transform');
var Schema = require('./../Schema');
var transform = require('./../transform');
// options can contain:
// collectionPrefix: the string to put in front of every collection name.
function ExportAdapter(mongoURI, options = {}) {
this.mongoURI = mongoURI;
function DatabaseController(adapter, { collectionPrefix } = {}) {
this.adapter = adapter;
this.collectionPrefix = options.collectionPrefix;
this.collectionPrefix = collectionPrefix;
// We don't want a mutable this.schema, because then you could have
// one request that uses different schemas for different parts of
@@ -25,25 +24,13 @@ function ExportAdapter(mongoURI, options = {}) {
// Connects to the database. Returns a promise that resolves when the
// connection is successful.
// this.db will be populated with a Mongo "Db" object when the
// promise resolves successfully.
ExportAdapter.prototype.connect = function() {
if (this.connectionPromise) {
// There's already a connection in progress.
return this.connectionPromise;
}
this.connectionPromise = Promise.resolve().then(() => {
return MongoClient.connect(this.mongoURI);
}).then((db) => {
this.db = db;
});
return this.connectionPromise;
DatabaseController.prototype.connect = function() {
return this.adapter.connect();
};
// Returns a promise for a Mongo collection.
// Generally just for internal use.
ExportAdapter.prototype.collection = function(className) {
DatabaseController.prototype.collection = function(className) {
if (!Schema.classNameIsValid(className)) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME,
'invalid className: ' + className);
@@ -51,10 +38,20 @@ ExportAdapter.prototype.collection = function(className) {
return this.rawCollection(className);
};
ExportAdapter.prototype.rawCollection = function(className) {
return this.connect().then(() => {
return this.db.collection(this.collectionPrefix + className);
});
DatabaseController.prototype.adaptiveCollection = function(className) {
return this.adapter.adaptiveCollection(this.collectionPrefix + className);
};
DatabaseController.prototype.collectionExists = function(className) {
return this.adapter.collectionExists(this.collectionPrefix + className);
};
DatabaseController.prototype.rawCollection = function(className) {
return this.adapter.collection(this.collectionPrefix + className);
};
DatabaseController.prototype.dropCollection = function(className) {
return this.adapter.dropCollection(this.collectionPrefix + className);
};
function returnsTrue() {
@@ -64,7 +61,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 = returnsTrue) {
DatabaseController.prototype.loadSchema = function(acceptor = returnsTrue) {
if (!this.schemaPromise) {
this.schemaPromise = this.collection('_SCHEMA').then((coll) => {
@@ -88,8 +85,8 @@ ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) {
// Returns a promise for the classname that is related to the given
// classname through the key.
// TODO: make this not in the ExportAdapter interface
ExportAdapter.prototype.redirectClassNameForKey = function(className, key) {
// TODO: make this not in the DatabaseController interface
DatabaseController.prototype.redirectClassNameForKey = function(className, key) {
return this.loadSchema().then((schema) => {
var t = schema.getExpectedType(className, key);
var match = t.match(/^relation<(.*)>$/);
@@ -105,7 +102,7 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) {
// Returns a promise that resolves to the new schema.
// This does not update this.schema, because in a situation like a
// batch request, that could confuse other users of the schema.
ExportAdapter.prototype.validateObject = function(className, object, query) {
DatabaseController.prototype.validateObject = function(className, object, query) {
return this.loadSchema().then((schema) => {
return schema.validateObject(className, object, query);
});
@@ -113,7 +110,7 @@ ExportAdapter.prototype.validateObject = function(className, object, query) {
// Like transform.untransformObject but you need to provide a className.
// Filters out any data that shouldn't be on this REST-formatted object.
ExportAdapter.prototype.untransformObject = function(
DatabaseController.prototype.untransformObject = function(
schema, isMaster, aclGroup, className, mongoObject) {
var object = transform.untransformObject(schema, className, mongoObject);
@@ -138,65 +135,59 @@ ExportAdapter.prototype.untransformObject = function(
// 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.update = function(className, query, update, options) {
DatabaseController.prototype.update = function(className, query, update, options) {
var acceptor = function(schema) {
return schema.hasKeys(className, Object.keys(query));
};
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
var mongoUpdate, schema;
return this.loadSchema(acceptor).then((s) => {
schema = s;
if (!isMaster) {
return schema.validatePermission(className, aclGroup, 'update');
}
return Promise.resolve();
}).then(() => {
return this.handleRelationUpdates(className, query.objectId, update);
}).then(() => {
return this.collection(className);
}).then((coll) => {
var mongoWhere = transform.transformWhere(schema, className, query);
if (options.acl) {
var writePerms = [
{_wperm: {'$exists': false}}
];
for (var entry of options.acl) {
writePerms.push({_wperm: {'$in': [entry]}});
return this.loadSchema(acceptor)
.then(s => {
schema = s;
if (!isMaster) {
return schema.validatePermission(className, aclGroup, 'update');
}
mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]};
}
mongoUpdate = transform.transformUpdate(schema, className, update);
return coll.findAndModify(mongoWhere, {}, mongoUpdate, {});
}).then((result) => {
if (!result.value) {
return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.'));
}
if (result.lastErrorObject.n != 1) {
return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.'));
}
var response = {};
var inc = mongoUpdate['$inc'];
if (inc) {
for (var key in inc) {
response[key] = (result.value[key] || 0) + inc[key];
return Promise.resolve();
})
.then(() => this.handleRelationUpdates(className, query.objectId, update))
.then(() => this.adaptiveCollection(className))
.then(collection => {
var mongoWhere = transform.transformWhere(schema, className, query);
if (options.acl) {
var writePerms = [
{_wperm: {'$exists': false}}
];
for (var entry of options.acl) {
writePerms.push({_wperm: {'$in': [entry]}});
}
mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]};
}
}
return response;
});
mongoUpdate = transform.transformUpdate(schema, className, update);
return collection.findOneAndUpdate(mongoWhere, mongoUpdate);
})
.then(result => {
if (!result) {
return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found.'));
}
let response = {};
let inc = mongoUpdate['$inc'];
if (inc) {
Object.keys(inc).forEach(key => {
response[key] = result[key];
});
}
return response;
});
};
// Processes relation-updating operations from a REST-format update.
// Returns a promise that resolves successfully when these are
// processed.
// This mutates update.
ExportAdapter.prototype.handleRelationUpdates = function(className,
DatabaseController.prototype.handleRelationUpdates = function(className,
objectId,
update) {
var pending = [];
@@ -243,7 +234,7 @@ ExportAdapter.prototype.handleRelationUpdates = function(className,
// Adds a relation.
// Returns a promise that resolves successfully iff the add was successful.
ExportAdapter.prototype.addRelation = function(key, fromClassName,
DatabaseController.prototype.addRelation = function(key, fromClassName,
fromId, toId) {
var doc = {
relatedId: toId,
@@ -258,7 +249,7 @@ ExportAdapter.prototype.addRelation = function(key, fromClassName,
// Removes a relation.
// Returns a promise that resolves successfully iff the remove was
// successful.
ExportAdapter.prototype.removeRelation = function(key, fromClassName,
DatabaseController.prototype.removeRelation = function(key, fromClassName,
fromId, toId) {
var doc = {
relatedId: toId,
@@ -277,7 +268,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 = {}) {
DatabaseController.prototype.destroy = function(className, query, options = {}) {
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
@@ -320,7 +311,7 @@ ExportAdapter.prototype.destroy = function(className, query, options = {}) {
// Inserts an object into the database.
// Returns a promise that resolves successfully iff the object saved.
ExportAdapter.prototype.create = function(className, object, options) {
DatabaseController.prototype.create = function(className, object, options) {
var schema;
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
@@ -346,28 +337,21 @@ 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 = {}) {
return this.collection(className).then((coll) => {
return coll.find(query, options).toArray();
});
DatabaseController.prototype.mongoFind = function(className, query, options = {}) {
return this.adaptiveCollection(className)
.then(collection => collection.find(query, options));
};
// Deletes everything in the database matching the current collectionPrefix
// Won't delete collections in the system namespace
// Returns a promise.
ExportAdapter.prototype.deleteEverything = function() {
DatabaseController.prototype.deleteEverything = function() {
this.schemaPromise = null;
return this.connect().then(() => {
return this.db.collections();
}).then((colls) => {
var promises = [];
for (var coll of colls) {
if (!coll.namespace.match(/\.system\./) &&
coll.collectionName.indexOf(this.collectionPrefix) === 0) {
promises.push(coll.drop());
}
}
return this.adapter.collectionsContaining(this.collectionPrefix).then(collections => {
let promises = collections.map(collection => {
return collection.drop();
});
return Promise.all(promises);
});
};
@@ -376,13 +360,11 @@ ExportAdapter.prototype.deleteEverything = function() {
function keysForQuery(query) {
var sublist = query['$and'] || query['$or'];
if (sublist) {
var answer = new Set();
for (var subquery of sublist) {
for (var key of keysForQuery(subquery)) {
answer.add(key);
}
}
return answer;
let answer = sublist.reduce((memo, subquery) => {
return memo.concat(keysForQuery(subquery));
}, []);
return new Set(answer);
}
return new Set(Object.keys(query));
@@ -390,59 +372,74 @@ function keysForQuery(query) {
// Returns a promise for a list of related ids given an owning id.
// className here is the owning className.
ExportAdapter.prototype.relatedIds = function(className, key, owningId) {
var joinTable = '_Join:' + key + ':' + className;
return this.collection(joinTable).then((coll) => {
return coll.find({owningId: owningId}).toArray();
}).then((results) => {
return results.map(r => r.relatedId);
});
DatabaseController.prototype.relatedIds = function(className, key, owningId) {
return this.adaptiveCollection(joinTableName(className, key))
.then(coll => coll.find({owningId : owningId}))
.then(results => results.map(r => r.relatedId));
};
// Returns a promise for a list of owning ids given some related ids.
// className here is the owning className.
ExportAdapter.prototype.owningIds = function(className, key, relatedIds) {
var joinTable = '_Join:' + key + ':' + className;
return this.collection(joinTable).then((coll) => {
return coll.find({relatedId: {'$in': relatedIds}}).toArray();
}).then((results) => {
return results.map(r => r.owningId);
});
DatabaseController.prototype.owningIds = function(className, key, relatedIds) {
return this.adaptiveCollection(joinTableName(className, key))
.then(coll => coll.find({ relatedId: { '$in': relatedIds } }))
.then(results => results.map(r => r.owningId));
};
// Modifies query so that it no longer has $in on relation fields, or
// equal-to-pointer constraints on relation fields.
// Returns a promise that resolves when query is mutated
// TODO: this only handles one of these at a time - make it handle more
ExportAdapter.prototype.reduceInRelation = function(className, query, schema) {
DatabaseController.prototype.reduceInRelation = function(className, query, schema) {
// Search for an in-relation or equal-to-relation
for (var key in query) {
if (query[key] &&
(query[key]['$in'] || query[key].__type == 'Pointer')) {
var t = schema.getExpectedType(className, key);
var match = t ? t.match(/^relation<(.*)>$/) : false;
// Make it sequential for now, not sure of paralleization side effects
if (query['$or']) {
let ors = query['$or'];
return Promise.all(ors.map((aQuery, index) => {
return this.reduceInRelation(className, aQuery, schema).then((aQuery) => {
query['$or'][index] = aQuery;
})
}));
}
let promises = Object.keys(query).map((key) => {
if (query[key] && (query[key]['$in'] || query[key].__type == 'Pointer')) {
let t = schema.getExpectedType(className, key);
let match = t ? t.match(/^relation<(.*)>$/) : false;
if (!match) {
continue;
return Promise.resolve(query);
}
var relatedClassName = match[1];
var relatedIds;
let relatedClassName = match[1];
let relatedIds;
if (query[key]['$in']) {
relatedIds = query[key]['$in'].map(r => r.objectId);
} else {
relatedIds = [query[key].objectId];
}
return this.owningIds(className, key, relatedIds).then((ids) => {
delete query[key];
query.objectId = {'$in': ids};
delete query[key];
this.addInObjectIdsIds(ids, query);
return Promise.resolve(query);
});
}
}
return Promise.resolve();
return Promise.resolve(query);
})
return Promise.all(promises).then(() => {
return Promise.resolve(query);
})
};
// Modifies query so that it no longer has $relatedTo
// Returns a promise that resolves when query is mutated
ExportAdapter.prototype.reduceRelationKeys = function(className, query) {
DatabaseController.prototype.reduceRelationKeys = function(className, query) {
if (query['$or']) {
return Promise.all(query['$or'].map((aQuery) => {
return this.reduceRelationKeys(className, aQuery);
}));
}
var relatedTo = query['$relatedTo'];
if (relatedTo) {
return this.relatedIds(
@@ -450,43 +447,22 @@ ExportAdapter.prototype.reduceRelationKeys = function(className, query) {
relatedTo.key,
relatedTo.object.objectId).then((ids) => {
delete query['$relatedTo'];
query['objectId'] = {'$in': ids};
this.addInObjectIdsIds(ids, query);
return this.reduceRelationKeys(className, query);
});
}
};
// Does a find with "smart indexing".
// Currently this just means, if it needs a geoindex and there is
// none, then build the geoindex.
// This could be improved a lot but it's not clear if that's a good
// idea. Or even if this behavior is a good idea.
ExportAdapter.prototype.smartFind = function(coll, where, options) {
return coll.find(where, options).toArray()
.then((result) => {
return result;
}, (error) => {
// Check for "no geoindex" error
if (!error.message.match(/unable to find index for .geoNear/) ||
error.code != 17007) {
throw error;
}
// Figure out what key needs an index
var key = error.message.match(/field=([A-Za-z_0-9]+) /)[1];
if (!key) {
throw error;
}
var index = {};
index[key] = '2d';
//TODO: condiser moving index creation logic into Schema.js
return coll.createIndex(index).then(() => {
// Retry, but just once.
return coll.find(where, options).toArray();
});
});
};
DatabaseController.prototype.addInObjectIdsIds = function(ids, query) {
if (typeof query.objectId == 'string') {
query.objectId = {'$in': [query.objectId]};
}
query.objectId = query.objectId || {};
let queryIn = [].concat(query.objectId['$in'] || [], ids || []);
// make a set and spread to remove duplicates
query.objectId = {'$in': [...new Set(queryIn)]};
return query;
}
// Runs a query on the database.
// Returns a promise that resolves to a list of items.
@@ -502,7 +478,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 = {}) {
DatabaseController.prototype.find = function(className, query, options = {}) {
var mongoOptions = {};
if (options.skip) {
mongoOptions.skip = options.skip;
@@ -541,8 +517,8 @@ ExportAdapter.prototype.find = function(className, query, options = {}) {
}).then(() => {
return this.reduceInRelation(className, query, schema);
}).then(() => {
return this.collection(className);
}).then((coll) => {
return this.adaptiveCollection(className);
}).then(collection => {
var mongoWhere = transform.transformWhere(schema, className, query);
if (!isMaster) {
var orParts = [
@@ -555,9 +531,10 @@ ExportAdapter.prototype.find = function(className, query, options = {}) {
mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]};
}
if (options.count) {
return coll.count(mongoWhere, mongoOptions);
delete mongoOptions.limit;
return collection.count(mongoWhere, mongoOptions);
} else {
return this.smartFind(coll, mongoWhere, mongoOptions)
return collection.find(mongoWhere, mongoOptions)
.then((mongoResults) => {
return mongoResults.map((r) => {
return this.untransformObject(
@@ -568,4 +545,8 @@ ExportAdapter.prototype.find = function(className, query, options = {}) {
});
};
module.exports = ExportAdapter;
function joinTableName(className, key) {
return `_Join:${key}:${className}`;
}
module.exports = DatabaseController;

View File

@@ -3,6 +3,8 @@ import { Parse } from 'parse/node';
import { randomHexString } from '../cryptoUtils';
import AdaptableController from './AdaptableController';
import { FilesAdapter } from '../Adapters/Files/FilesAdapter';
import path from 'path';
import mime from 'mime';
export class FilesController extends AdaptableController {
@@ -10,10 +12,22 @@ export class FilesController extends AdaptableController {
return this.adapter.getFileData(config, filename);
}
createFile(config, filename, data) {
createFile(config, filename, data, contentType) {
let extname = path.extname(filename);
const hasExtension = extname.length > 0;
if (!hasExtension && contentType && mime.extension(contentType)) {
filename = filename + '.' + mime.extension(contentType);
} else if (hasExtension && !contentType) {
contentType = mime.lookup(filename);
}
filename = randomHexString(32) + '_' + filename;
var location = this.adapter.getFileLocation(config, filename);
return this.adapter.createFile(config, filename, data).then(() => {
return this.adapter.createFile(config, filename, data, contentType).then(() => {
return Promise.resolve({
url: location,
name: filename

View File

@@ -3,9 +3,18 @@ import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import AdaptableController from './AdaptableController';
import { PushAdapter } from '../Adapters/Push/PushAdapter';
import deepcopy from 'deepcopy';
import features from '../features';
const FEATURE_NAME = 'push';
const UNSUPPORTED_BADGE_KEY = "unsupported";
export class PushController extends AdaptableController {
setFeature() {
features.setFeature(FEATURE_NAME, this.adapter.feature || {});
}
/**
* Check whether the deviceType parameter in qury condition is valid or not.
* @param {Object} where A query condition
@@ -51,7 +60,55 @@ export class PushController extends AdaptableController {
body['expiration_time'] = PushController.getExpirationTime(body);
// 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(config, auth, '_Installation', where).then(function(response) {
let badgeUpdate = Promise.resolve();
if (body.badge) {
var op = {};
if (body.badge == "Increment") {
op = {'$inc': {'badge': 1}}
} else if (Number(body.badge)) {
op = {'$set': {'badge': body.badge } }
} else {
throw "Invalid value for badge, expected number or 'Increment'";
}
let updateWhere = deepcopy(where);
// Only on iOS!
updateWhere.deviceType = 'ios';
// TODO: @nlutsenko replace with better thing
badgeUpdate = config.database.rawCollection("_Installation").then((coll) => {
return coll.update(updateWhere, op, { multi: true });
});
}
return badgeUpdate.then(() => {
return rest.find(config, auth, '_Installation', where)
}).then((response) => {
if (body.badge && body.badge == "Increment") {
// Collect the badges to reduce the # of calls
let badgeInstallationsMap = response.results.reduce((map, installation) => {
let badge = installation.badge;
if (installation.deviceType != "ios") {
badge = UNSUPPORTED_BADGE_KEY;
}
map[badge] = map[badge] || [];
map[badge].push(installation);
return map;
}, {});
// Map the on the badges count and return the send result
let promises = Object.keys(badgeInstallationsMap).map((badge) => {
let payload = deepcopy(body);
if (badge == UNSUPPORTED_BADGE_KEY) {
delete payload.badge;
} else {
payload.badge = parseInt(badge);
}
return pushAdapter.send(payload, badgeInstallationsMap[badge]);
});
return Promise.all(promises);
}
return pushAdapter.send(body, response.results);
});
}

View File

@@ -0,0 +1,203 @@
import { randomString } from '../cryptoUtils';
import { inflate } from '../triggers';
import AdaptableController from './AdaptableController';
import MailAdapter from '../Adapters/Email/MailAdapter';
var DatabaseAdapter = require('../DatabaseAdapter');
var RestWrite = require('../RestWrite');
var RestQuery = require('../RestQuery');
var hash = require('../password').hash;
var Auth = require('../Auth');
export class UserController extends AdaptableController {
constructor(adapter, appId, options = {}) {
super(adapter, appId, options);
}
validateAdapter(adapter) {
// Allow no adapter
if (!adapter && !this.shouldVerifyEmails) {
return;
}
super.validateAdapter(adapter);
}
expectedAdapterType() {
return MailAdapter;
}
get shouldVerifyEmails() {
return this.options.verifyUserEmails;
}
setEmailVerifyToken(user) {
if (this.shouldVerifyEmails) {
user._email_verify_token = randomString(25);
user.emailVerified = false;
}
}
verifyEmail(username, token) {
if (!this.shouldVerifyEmails) {
// Trying to verify email when not enabled
// TODO: Better error here.
return Promise.reject();
}
return this.config.database
.adaptiveCollection('_User')
.then(collection => {
// Need direct database access because verification token is not a parse field
return collection.findOneAndUpdate({
username: username,
_email_verify_token: token
}, {$set: {emailVerified: true}});
})
.then(document => {
if (!document) {
return Promise.reject();
}
return document;
});
}
checkResetTokenValidity(username, token) {
return this.config.database.adaptiveCollection('_User')
.then(collection => {
return collection.find({
username: username,
_perishable_token: token
}, { limit: 1 });
})
.then(results => {
if (results.length != 1) {
return Promise.reject();
}
return results[0];
});
}
getUserIfNeeded(user) {
if (user.username && user.email) {
return Promise.resolve(user);
}
var where = {};
if (user.username) {
where.username = user.username;
}
if (user.email) {
where.email = user.email;
}
var query = new RestQuery(this.config, Auth.master(this.config), '_User', where);
return query.execute().then(function(result){
if (result.results.length != 1) {
return Promise.reject();
}
return result.results[0];
})
}
sendVerificationEmail(user) {
if (!this.shouldVerifyEmails) {
return;
}
// We may need to fetch the user in case of update email
this.getUserIfNeeded(user).then((user) => {
const token = encodeURIComponent(user._email_verify_token);
const username = encodeURIComponent(user.username);
let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`;
let options = {
appName: this.config.appName,
link: link,
user: inflate('_User', user),
};
if (this.adapter.sendVerificationEmail) {
this.adapter.sendVerificationEmail(options);
} else {
this.adapter.sendMail(this.defaultVerificationEmail(options));
}
});
}
setPasswordResetToken(email) {
let token = randomString(25);
return this.config.database
.adaptiveCollection('_User')
.then(collection => {
// Need direct database access because verification token is not a parse field
return collection.findOneAndUpdate(
{ email: email}, // query
{ $set: { _perishable_token: token } } // update
);
});
}
sendPasswordResetEmail(email) {
if (!this.adapter) {
throw "Trying to send a reset password but no adapter is set";
// TODO: No adapter?
return;
}
return this.setPasswordResetToken(email).then((user) => {
const token = encodeURIComponent(user._perishable_token);
const username = encodeURIComponent(user.username);
let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}`
let options = {
appName: this.config.appName,
link: link,
user: inflate('_User', user),
};
if (this.adapter.sendPasswordResetEmail) {
this.adapter.sendPasswordResetEmail(options);
} else {
this.adapter.sendMail(this.defaultResetPasswordEmail(options));
}
return Promise.resolve(user);
});
}
updatePassword(username, token, password, config) {
return this.checkResetTokenValidity(username, token).then(() => {
return updateUserPassword(username, token, password, this.config);
});
}
defaultVerificationEmail({link, user, appName, }) {
let text = "Hi,\n\n" +
"You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" +
"" +
"Click here to confirm it:\n" + link;
let to = user.get("email");
let subject = 'Please verify your e-mail for ' + appName;
return { text, to, subject };
}
defaultResetPasswordEmail({link, user, appName, }) {
let text = "Hi,\n\n" +
"You requested to reset your password for " + appName + ".\n\n" +
"" +
"Click here to reset it:\n" + link;
let to = user.get("email");
let subject = 'Password Reset for ' + appName;
return { text, to, subject };
}
}
// Mark this private
function updateUserPassword(username, token, password, config) {
var write = new RestWrite(config, Auth.master(config), '_User', {
username: username,
_perishable_token: token
}, {password: password, _perishable_token: null }, undefined);
return write.execute();
}
export default UserController;

View File

@@ -13,14 +13,17 @@
// * destroy(className, query, options)
// * This list is incomplete and the database process is not fully modularized.
//
// Default is ExportAdapter, which uses mongo.
// Default is MongoStorageAdapter.
var ExportAdapter = require('./ExportAdapter');
import DatabaseController from './Controllers/DatabaseController';
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
var adapter = ExportAdapter;
var dbConnections = {};
var databaseURI = 'mongodb://localhost:27017/parse';
var appDatabaseURIs = {};
const DefaultDatabaseURI = 'mongodb://localhost:27017/parse';
let adapter = MongoStorageAdapter;
let dbConnections = {};
let databaseURI = DefaultDatabaseURI;
let appDatabaseURIs = {};
function setAdapter(databaseAdapter) {
adapter = databaseAdapter;
@@ -46,10 +49,11 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) {
}
var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI);
dbConnections[appId] = new adapter(dbURI, {
let storageAdapter = new adapter(dbURI);
dbConnections[appId] = new DatabaseController(storageAdapter, {
collectionPrefix: collectionPrefix
});
dbConnections[appId].connect();
return dbConnections[appId];
}
@@ -59,5 +63,6 @@ module.exports = {
setAdapter: setAdapter,
setDatabaseURI: setDatabaseURI,
setAppDatabaseURI: setAppDatabaseURI,
clearDatabaseURIs: clearDatabaseURIs
clearDatabaseURIs: clearDatabaseURIs,
defaultDatabaseURI: databaseURI
};

View File

@@ -5,6 +5,8 @@
// themselves use our routing information, without disturbing express
// components that external developers may be modifying.
import express from 'express';
export default class PromiseRouter {
// Each entry should be an object with:
// path: the path to route, in express format
@@ -15,8 +17,8 @@ export default class PromiseRouter {
// status: optional. the http status code. defaults to 200
// response: a json object with the content of the response
// location: optional. a location header
constructor() {
this.routes = [];
constructor(routes = []) {
this.routes = routes;
this.mountRoutes();
}
@@ -47,17 +49,11 @@ export default class PromiseRouter {
if (handlers.length > 1) {
const length = handlers.length;
handler = function(req) {
var next = function(i, req, res) {
if (i == length) {
return res;
}
let result = handlers[i](req);
if (!result || typeof result.then !== "function") {
result = Promise.resolve(result);
}
return result.then((res) => (next(i+1, req, res)));
}
return next(0, req);
return handlers.reduce((promise, handler) => {
return promise.then((result) => {
return handler(req);
});
}, Promise.resolve());
}
}
@@ -125,6 +121,29 @@ export default class PromiseRouter {
}
}
};
expressApp() {
var expressApp = express();
for (var route of this.routes) {
switch(route.method) {
case 'POST':
expressApp.post(route.path, makeExpressHandler(route.handler));
break;
case 'GET':
expressApp.get(route.path, makeExpressHandler(route.handler));
break;
case 'PUT':
expressApp.put(route.path, makeExpressHandler(route.handler));
break;
case 'DELETE':
expressApp.delete(route.path, makeExpressHandler(route.handler));
break;
default:
throw 'unexpected code branch';
}
}
return expressApp;
}
}
// Global flag. Set this to true to log every request and response.
@@ -142,15 +161,24 @@ function makeExpressHandler(promiseHandler) {
JSON.stringify(req.body, null, 2));
}
promiseHandler(req).then((result) => {
if (!result.response) {
console.log('BUG: the handler did not include a "response" field');
if (!result.response && !result.location && !result.text) {
console.log('BUG: the handler did not include a "response" or a "location" field');
throw 'control should not get here';
}
if (PromiseRouter.verbose) {
console.log('response:', JSON.stringify(result.response, null, 2));
console.log('response:', JSON.stringify(result, null, 2));
}
var status = result.status || 200;
res.status(status);
if (result.text) {
return res.send(result.text);
}
if (result.location && !result.response) {
return res.redirect(result.location);
}
if (result.location) {
res.set('Location', result.location);
}

View File

@@ -165,7 +165,9 @@ RestQuery.prototype.redirectClassNameForKey = function() {
// Validates this operation against the allowClientClassCreation config.
RestQuery.prototype.validateClientClassCreation = function() {
if (this.config.allowClientClassCreation === false && !this.auth.isMaster) {
let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product'];
if (this.config.allowClientClassCreation === false && !this.auth.isMaster
&& sysClass.indexOf(this.className) === -1) {
return this.config.database.loadSchema().then((schema) => {
return schema.hasClass(this.className)
}).then((hasClass) => {
@@ -212,7 +214,11 @@ RestQuery.prototype.replaceInQuery = function() {
});
}
delete inQueryObject['$inQuery'];
inQueryObject['$in'] = values;
if (Array.isArray(inQueryObject['$in'])) {
inQueryObject['$in'] = inQueryObject['$in'].concat(values);
} else {
inQueryObject['$in'] = values;
}
// Recurse to repeat
return this.replaceInQuery();
@@ -249,7 +255,11 @@ RestQuery.prototype.replaceNotInQuery = function() {
});
}
delete notInQueryObject['$notInQuery'];
notInQueryObject['$nin'] = values;
if (Array.isArray(notInQueryObject['$nin'])) {
notInQueryObject['$nin'] = notInQueryObject['$nin'].concat(values);
} else {
notInQueryObject['$nin'] = values;
}
// Recurse to repeat
return this.replaceNotInQuery();
@@ -269,11 +279,11 @@ RestQuery.prototype.replaceSelect = function() {
// The select value must have precisely two keys - query and key
var selectValue = selectObject['$select'];
// iOS SDK don't send where if not set, let it pass
if (!selectValue.query ||
!selectValue.key ||
typeof selectValue.query !== 'object' ||
!selectValue.query.className ||
!selectValue.query.where ||
Object.keys(selectValue).length !== 2) {
throw new Parse.Error(Parse.Error.INVALID_QUERY,
'improper usage of $select');
@@ -288,7 +298,11 @@ RestQuery.prototype.replaceSelect = function() {
values.push(result[selectValue.key]);
}
delete selectObject['$select'];
selectObject['$in'] = values;
if (Array.isArray(selectObject['$in'])) {
selectObject['$in'] = selectObject['$in'].concat(values);
} else {
selectObject['$in'] = values;
}
// Keep replacing $select clauses
return this.replaceSelect();
@@ -327,7 +341,11 @@ RestQuery.prototype.replaceDontSelect = function() {
values.push(result[dontSelectValue.key]);
}
delete dontSelectObject['$dontSelect'];
dontSelectObject['$nin'] = values;
if (Array.isArray(dontSelectObject['$nin'])) {
dontSelectObject['$nin'] = dontSelectObject['$nin'].concat(values);
} else {
dontSelectObject['$nin'] = values;
}
// Keep replacing $dontSelect clauses
return this.replaceDontSelect();
@@ -507,7 +525,7 @@ function replacePointers(object, path, replace) {
}
if (path.length == 0) {
if (object.__type == 'Pointer' && replace[object.objectId]) {
if (object.__type == 'Pointer') {
return replace[object.objectId];
}
return object;

View File

@@ -67,12 +67,12 @@ RestWrite.prototype.execute = function() {
return this.handleInstallation();
}).then(() => {
return this.handleSession();
}).then(() => {
return this.validateAuthData();
}).then(() => {
return this.runBeforeTrigger();
}).then(() => {
return this.setRequiredFieldsIfNeeded();
}).then(() => {
return this.validateAuthData();
}).then(() => {
return this.transformUser();
}).then(() => {
@@ -109,7 +109,9 @@ RestWrite.prototype.getUserAndRoleACL = function() {
// Validates this operation against the allowClientClassCreation config.
RestWrite.prototype.validateClientClassCreation = function() {
if (this.config.allowClientClassCreation === false && !this.auth.isMaster) {
let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product'];
if (this.config.allowClientClassCreation === false && !this.auth.isMaster
&& sysClass.indexOf(this.className) === -1) {
return this.config.database.loadSchema().then((schema) => {
return schema.hasClass(this.className)
}).then((hasClass) => {
@@ -134,6 +136,10 @@ RestWrite.prototype.validateSchema = function() {
// Runs any beforeSave triggers against this operation.
// Any change leads to our data being mutated.
RestWrite.prototype.runBeforeTrigger = function() {
if (this.response) {
return;
}
// Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class.
if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) {
return Promise.resolve();
@@ -459,12 +465,18 @@ RestWrite.prototype.transformUser = function() {
'address');
}
return Promise.resolve();
});
}).then(() => {
// We updated the email, send a new validation
this.storage['sendVerificationEmail'] = true;
this.config.userController.setEmailVerifyToken(this.data);
return Promise.resolve();
})
});
};
// Handles any followup logic
RestWrite.prototype.handleFollowup = function() {
if (this.storage && this.storage['clearSessions']) {
var sessionQuery = {
user: {
@@ -474,9 +486,16 @@ RestWrite.prototype.handleFollowup = function() {
}
};
delete this.storage['clearSessions'];
return this.config.database.destroy('_Session', sessionQuery)
this.config.database.destroy('_Session', sessionQuery)
.then(this.handleFollowup.bind(this));
}
if (this.storage && this.storage['sendVerificationEmail']) {
delete this.storage['sendVerificationEmail'];
// Fire and forget!
this.config.userController.sendVerificationEmail(this.data);
this.handleFollowup.bind(this);
}
};
// Handles the _Role class specialness.
@@ -592,6 +611,9 @@ RestWrite.prototype.handleInstallation = function() {
var promise = Promise.resolve();
var idMatch; // Will be a match on either objectId or installationId
var deviceTokenMatches = [];
if (this.query && this.query.objectId) {
promise = promise.then(() => {
return this.config.database.find('_Installation', {
@@ -601,22 +623,22 @@ RestWrite.prototype.handleInstallation = function() {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
'Object not found for update.');
}
var existing = results[0];
if (this.data.installationId && existing.installationId &&
this.data.installationId !== existing.installationId) {
idMatch = results[0];
if (this.data.installationId && idMatch.installationId &&
this.data.installationId !== idMatch.installationId) {
throw new Parse.Error(136,
'installationId may not be changed in this ' +
'operation');
}
if (this.data.deviceToken && existing.deviceToken &&
this.data.deviceToken !== existing.deviceToken &&
!this.data.installationId && !existing.installationId) {
if (this.data.deviceToken && idMatch.deviceToken &&
this.data.deviceToken !== idMatch.deviceToken &&
!this.data.installationId && !idMatch.installationId) {
throw new Parse.Error(136,
'deviceToken may not be changed in this ' +
'operation');
}
if (this.data.deviceType && this.data.deviceType &&
this.data.deviceType !== existing.deviceType) {
this.data.deviceType !== idMatch.deviceType) {
throw new Parse.Error(136,
'deviceType may not be changed in this ' +
'operation');
@@ -627,8 +649,6 @@ RestWrite.prototype.handleInstallation = function() {
}
// Check if we already have installations for the installationId/deviceToken
var installationMatch;
var deviceTokenMatches = [];
promise = promise.then(() => {
if (this.data.installationId) {
return this.config.database.find('_Installation', {
@@ -639,7 +659,7 @@ RestWrite.prototype.handleInstallation = function() {
}).then((results) => {
if (results && results.length) {
// We only take the first match by installationId
installationMatch = results[0];
idMatch = results[0];
}
if (this.data.deviceToken) {
return this.config.database.find(
@@ -651,7 +671,7 @@ RestWrite.prototype.handleInstallation = function() {
if (results) {
deviceTokenMatches = results;
}
if (!installationMatch) {
if (!idMatch) {
if (!deviceTokenMatches.length) {
return;
} else if (deviceTokenMatches.length == 1 &&
@@ -689,14 +709,14 @@ RestWrite.prototype.handleInstallation = function() {
// Exactly one device token match and it doesn't have an installation
// ID. This is the one case where we want to merge with the existing
// object.
var delQuery = {objectId: installationMatch.objectId};
var delQuery = {objectId: idMatch.objectId};
return this.config.database.destroy('_Installation', delQuery)
.then(() => {
return deviceTokenMatches[0]['objectId'];
});
} else {
if (this.data.deviceToken &&
installationMatch.deviceToken != this.data.deviceToken) {
idMatch.deviceToken != this.data.deviceToken) {
// We're setting the device token on an existing installation, so
// we should try cleaning out old installations that match this
// device token.
@@ -712,7 +732,7 @@ RestWrite.prototype.handleInstallation = function() {
this.config.database.destroy('_Installation', delQuery);
}
// In non-merge scenarios, just return the installation match id
return installationMatch.objectId;
return idMatch.objectId;
}
}
}).then((objId) => {
@@ -762,8 +782,10 @@ RestWrite.prototype.runDatabaseOperation = function() {
// Run an update
return this.config.database.update(
this.className, this.query, this.data, this.runOptions).then((resp) => {
this.response = resp;
this.response.updatedAt = this.updatedAt;
resp.updatedAt = this.updatedAt;
this.response = {
response: resp
};
});
} else {
// Set the default ACL for the new _User
@@ -794,22 +816,33 @@ RestWrite.prototype.runDatabaseOperation = function() {
// Returns nothing - doesn't wait for the trigger.
RestWrite.prototype.runAfterTrigger = function() {
if (!this.response || !this.response.response) {
return;
}
// Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class.
if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) {
return Promise.resolve();
}
var extraData = {className: this.className};
if (this.query && this.query.objectId) {
extraData.objectId = this.query.objectId;
}
// Build the inflated object, different from beforeSave, originalData is not empty
// since developers can change data in the beforeSave.
var inflatedObject = triggers.inflate(extraData, this.originalData);
inflatedObject._finishFetch(this.data);
// Build the original object, we only do this for a update write.
var originalObject;
let originalObject;
if (this.query && this.query.objectId) {
originalObject = triggers.inflate(extraData, this.originalData);
}
triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, inflatedObject, originalObject, this.config.applicationId);
// Build the inflated object, different from beforeSave, originalData is not empty
// since developers can change data in the beforeSave.
let updatedObject = triggers.inflate(extraData, this.originalData);
updatedObject.set(Parse._decode(undefined, this.data));
updatedObject._handleSaveResponse(this.response.response, this.response.status || 200);
triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId);
};
// A helper to figure out what location this operation happens at.
@@ -825,4 +858,5 @@ RestWrite.prototype.objectId = function() {
return this.data.objectId || this.query.objectId;
};
export default RestWrite;
module.exports = RestWrite;

View File

@@ -85,10 +85,7 @@ export class ClassesRouter extends PromiseRouter {
}
handleUpdate(req) {
return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body);
}
handleDelete(req) {

View File

@@ -0,0 +1,15 @@
import { version } from '../../package.json';
import PromiseRouter from '../PromiseRouter';
import * as middleware from "../middlewares";
import { getFeatures } from '../features';
export class FeaturesRouter extends PromiseRouter {
mountRoutes() {
this.route('GET','/serverInfo', middleware.promiseEnforceMasterKeyAccess, () => {
return { response: {
features: getFeatures(),
parseServerVersion: version,
} };
});
}
}

View File

@@ -2,8 +2,8 @@ import express from 'express';
import BodyParser from 'body-parser';
import * as Middlewares from '../middlewares';
import { randomHexString } from '../cryptoUtils';
import mime from 'mime';
import Config from '../Config';
import mime from 'mime';
export class FilesRouter {
@@ -41,7 +41,7 @@ export class FilesRouter {
var contentType = mime.lookup(filename);
res.set('Content-Type', contentType);
res.end(data);
}).catch(() => {
}).catch((err) => {
res.status(404);
res.set('Content-Type', 'text/plain');
res.end('File not found.');
@@ -66,20 +66,13 @@ export class FilesRouter {
'Filename contains invalid characters.'));
return;
}
let extension = '';
// Not very safe there.
const hasExtension = req.params.filename.indexOf('.') > 0;
const filename = req.params.filename;
const contentType = req.get('Content-type');
if (!hasExtension && contentType && mime.extension(contentType)) {
extension = '.' + mime.extension(contentType);
}
const filename = req.params.filename + extension;
const config = req.config;
const filesController = config.filesController;
filesController.createFile(config, filename, req.body).then((result) => {
filesController.createFile(config, filename, req.body, contentType).then((result) => {
res.status(201);
res.set('Location', result.url);
res.json(result);

View File

@@ -0,0 +1,42 @@
// global_config.js
var Parse = require('parse/node').Parse;
import PromiseRouter from '../PromiseRouter';
import * as middleware from "../middlewares";
export class GlobalConfigRouter extends PromiseRouter {
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',
}
}));
}
updateGlobalConfig(req) {
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',
}
}));
}
mountRoutes() {
this.route('GET', '/config', req => { return this.getGlobalConfig(req) });
this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { return this.updateGlobalConfig(req) });
}
}
export default GlobalConfigRouter;

View File

@@ -1,15 +1,9 @@
import { Parse } from 'parse/node';
import PromiseRouter from '../PromiseRouter';
import { HooksController } from '../Controllers/HooksController';
function enforceMasterKeyAccess(req) {
if (!req.auth.isMaster) {
throw new Parse.Error(403, "unauthorized: master key is required");
}
}
import * as middleware from "../middlewares";
export class HooksRouter extends PromiseRouter {
createHook(aHook, config) {
return config.hooksController.createHook(aHook).then( (hook) => ({response: hook}));
};
@@ -93,14 +87,14 @@ export class HooksRouter extends PromiseRouter {
}
mountRoutes() {
this.route('GET', '/hooks/functions', enforceMasterKeyAccess, this.handleGetFunctions.bind(this));
this.route('GET', '/hooks/triggers', enforceMasterKeyAccess, this.handleGetTriggers.bind(this));
this.route('GET', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handleGetFunctions.bind(this));
this.route('GET', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handleGetTriggers.bind(this));
this.route('POST', '/hooks/functions', enforceMasterKeyAccess, this.handlePost.bind(this));
this.route('POST', '/hooks/triggers', enforceMasterKeyAccess, this.handlePost.bind(this));
this.route('PUT', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handlePut.bind(this));
this.route('PUT', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handlePut.bind(this));
this.route('GET', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this));
this.route('GET', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this));
this.route('GET', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this));
this.route('GET', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this));
this.route('POST', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this));
this.route('POST', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this));
this.route('PUT', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this));
this.route('PUT', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this));
}
}

View File

@@ -1,25 +1,22 @@
import { Parse } from 'parse/node';
import PromiseRouter from '../PromiseRouter';
// 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.'
);
}
}
import * as middleware from "../middlewares";
export class LogsRouter extends PromiseRouter {
mountRoutes() {
this.route('GET','/logs', (req) => {
this.route('GET','/scriptlog', middleware.promiseEnforceMasterKeyAccess, this.validateRequest, (req) => {
return this.handleGET(req);
});
}
validateRequest(req) {
if (!req.config || !req.config.loggerController) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Logger adapter is not availabe');
}
}
// Returns a promise for a {response} object.
// query params:
// level (optional) Level of logging you want to query for (info || error)
@@ -27,28 +24,25 @@ export class LogsRouter extends PromiseRouter {
// 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
// n same as size, overrides size if set
handleGET(req) {
if (!req.config || !req.config.loggerController) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Logger adapter is not availabe');
}
let promise = new Parse.Promise();
let from = req.query.from;
let until = req.query.until;
const from = req.query.from;
const until = req.query.until;
let size = req.query.size;
let order = req.query.order
let level = req.query.level;
enforceSecurity(req.auth);
if (req.query.n) {
size = req.query.n;
}
const order = req.query.order
const level = req.query.level;
const options = {
from,
until,
size,
order,
level,
}
level
};
return req.config.loggerController.getLogs(options).then((result) => {
return Promise.resolve({
response: result

View File

@@ -0,0 +1,159 @@
import PromiseRouter from '../PromiseRouter';
import UserController from '../Controllers/UserController';
import Config from '../Config';
import express from 'express';
import path from 'path';
import fs from 'fs';
let public_html = path.resolve(__dirname, "../../public_html");
let views = path.resolve(__dirname, '../../views');
export class PublicAPIRouter extends PromiseRouter {
verifyEmail(req) {
let { token, username }= req.query;
let appId = req.params.appId;
let config = new Config(appId);
if (!config.publicServerURL) {
return this.missingPublicServerURL();
}
if (!token || !username) {
return this.invalidLink(req);
}
let userController = config.userController;
return userController.verifyEmail(username, token).then( () => {
return Promise.resolve({
status: 302,
location: `${config.verifyEmailSuccessURL}?username=${username}`
});
}, ()=> {
return this.invalidLink(req);
})
}
changePassword(req) {
return new Promise((resolve, reject) => {
let config = new Config(req.query.id);
if (!config.publicServerURL) {
return resolve({
status: 404,
text: 'Not found.'
});
}
// Should we keep the file in memory or leave like that?
fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => {
if (err) {
return reject(err);
}
data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`);
resolve({
text: data
})
});
});
}
requestResetPassword(req) {
let config = req.config;
if (!config.publicServerURL) {
return this.missingPublicServerURL();
}
let { username, token } = req.query;
if (!username || !token) {
return this.invalidLink(req);
}
return config.userController.checkResetTokenValidity(username, token).then( (user) => {
return Promise.resolve({
status: 302,
location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&app=${config.appName}`
})
}, () => {
return this.invalidLink(req);
})
}
resetPassword(req) {
let config = req.config;
if (!config.publicServerURL) {
return this.missingPublicServerURL();
}
let {
username,
token,
new_password
} = req.body;
if (!username || !token || !new_password) {
return this.invalidLink(req);
}
return config.userController.updatePassword(username, token, new_password).then((result) => {
return Promise.resolve({
status: 302,
location: config.passwordResetSuccessURL
});
}, (err) => {
return Promise.resolve({
status: 302,
location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}`
});
});
}
invalidLink(req) {
return Promise.resolve({
status: 302,
location: req.config.invalidLinkURL
});
}
missingPublicServerURL() {
return Promise.resolve({
text: 'Not found.',
status: 404
});
}
setConfig(req) {
req.config = new Config(req.params.appId);
return Promise.resolve();
}
mountRoutes() {
this.route('GET','/apps/:appId/verify_email',
req => { this.setConfig(req) },
req => { return this.verifyEmail(req); });
this.route('GET','/apps/choose_password',
req => { return this.changePassword(req); });
this.route('POST','/apps/:appId/request_password_reset',
req => { this.setConfig(req) },
req => { return this.resetPassword(req); });
this.route('GET','/apps/:appId/request_password_reset',
req => { this.setConfig(req) },
req => { return this.requestResetPassword(req); });
}
expressApp() {
let router = express();
router.use("/apps", express.static(public_html));
router.use("/", super.expressApp());
return router;
}
}
export default PublicAPIRouter;

View File

@@ -1,27 +1,17 @@
// schemas.js
var express = require('express'),
Parse = require('parse/node').Parse,
Schema = require('../Schema');
Parse = require('parse/node').Parse,
Schema = require('../Schema');
import PromiseRouter from '../PromiseRouter';
// TODO: refactor in a SchemaController at one point...
function masterKeyRequiredResponse() {
return Promise.resolve({
status: 401,
response: {error: 'master key not specified'},
})
}
import * as middleware from "../middlewares";
function classNameMismatchResponse(bodyClass, pathClass) {
return Promise.resolve({
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class name mismatch between ' + bodyClass + ' and ' + pathClass,
}
});
throw new Parse.Error(
Parse.Error.INVALID_CLASS_NAME,
`Class name mismatch between ${bodyClass} and ${pathClass}.`
);
}
function mongoSchemaAPIResponseFields(schema) {
@@ -45,65 +35,43 @@ function mongoSchemaToSchemaAPIResponse(schema) {
}
function getAllSchemas(req) {
if (!req.auth.isMaster) {
return masterKeyRequiredResponse();
}
return req.config.database.collection('_SCHEMA')
.then(coll => coll.find({}).toArray())
.then(schemas => ({response: {
results: schemas.map(mongoSchemaToSchemaAPIResponse)
}}));
return req.config.database.adaptiveCollection('_SCHEMA')
.then(collection => collection.find({}))
.then(schemas => schemas.map(mongoSchemaToSchemaAPIResponse))
.then(schemas => ({ response: { results: schemas }}));
}
function getOneSchema(req) {
if (!req.auth.isMaster) {
return masterKeyRequiredResponse();
}
return req.config.database.collection('_SCHEMA')
.then(coll => coll.findOne({'_id': req.params.className}))
.then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)}))
.catch(() => ({
status: 400,
response: {
code: 103,
error: 'class ' + req.params.className + ' does not exist',
}
}));
const className = req.params.className;
return req.config.database.adaptiveCollection('_SCHEMA')
.then(collection => collection.find({ '_id': className }, { limit: 1 }))
.then(results => {
if (results.length != 1) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
}
return results[0];
})
.then(schema => ({ response: mongoSchemaToSchemaAPIResponse(schema) }));
}
function createSchema(req) {
if (!req.auth.isMaster) {
return masterKeyRequiredResponse();
}
if (req.params.className && req.body.className) {
if (req.params.className != req.body.className) {
return classNameMismatchResponse(req.body.className, req.params.className);
}
}
var className = req.params.className || req.body.className;
const className = req.params.className || req.body.className;
if (!className) {
return Promise.resolve({
status: 400,
response: {
code: 135,
error: 'POST ' + req.path + ' needs class name',
},
});
throw new Parse.Error(135, `POST ${req.path} needs a 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,
}));
.then(schema => schema.addClassIfNotExists(className, req.body.fields))
.then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) }));
}
function modifySchema(req) {
if (!req.auth.isMaster) {
return masterKeyRequiredResponse();
}
if (req.body.className && req.body.className != req.params.className) {
return classNameMismatchResponse(req.body.className, req.params.className);
}
@@ -112,168 +80,115 @@ function modifySchema(req) {
var className = req.params.className;
return req.config.database.loadSchema()
.then(schema => {
if (!schema.data[className]) {
return Promise.resolve({
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + req.params.className + ' does not exist',
.then(schema => {
if (!schema.data[className]) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`);
}
let existingFields = schema.data[className];
Object.keys(submittedFields).forEach(name => {
let field = submittedFields[name];
if (existingFields[name] && field.__op !== 'Delete') {
throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
}
if (!existingFields[name] && field.__op === 'Delete') {
throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`);
}
});
}
var existingFields = schema.data[className];
for (var submittedFieldName in submittedFields) {
if (existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op !== 'Delete') {
return Promise.resolve({
status: 400,
response: {
code: 255,
error: 'field ' + submittedFieldName + ' exists, cannot update',
}
});
let newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields);
let mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className);
if (!mongoObject.result) {
throw new Parse.Error(mongoObject.code, mongoObject.error);
}
if (!existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op === 'Delete') {
return Promise.resolve({
status: 400,
response: {
code: 255,
error: 'field ' + submittedFieldName + ' does not exist, cannot delete',
}
});
}
}
var newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields);
var mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className);
if (!mongoObject.result) {
return Promise.resolve({
status: 400,
response: mongoObject,
// Finally we have checked to make sure the request is valid and we can start deleting fields.
// Do all deletions first, then a single save to _SCHEMA collection to handle all additions.
let deletionPromises = [];
Object.keys(submittedFields).forEach(submittedFieldName => {
if (submittedFields[submittedFieldName].__op === 'Delete') {
let promise = schema.deleteField(submittedFieldName, className, req.config.database);
deletionPromises.push(promise);
}
});
}
// Finally we have checked to make sure the request is valid and we can start deleting fields.
// Do all deletions first, then a single save to _SCHEMA collection to handle all additions.
var deletionPromises = []
Object.keys(submittedFields).forEach(submittedFieldName => {
if (submittedFields[submittedFieldName].__op === 'Delete') {
var promise = req.config.database.connect()
.then(() => schema.deleteField(
submittedFieldName,
className,
req.config.database.db,
req.config.database.collectionPrefix
));
deletionPromises.push(promise);
}
return Promise.all(deletionPromises)
.then(() => new Promise((resolve, reject) => {
schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => {
if (err) {
reject(err);
}
resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)});
})
}));
});
return Promise.all(deletionPromises)
.then(() => new Promise((resolve, reject) => {
schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => {
if (err) {
reject(err);
}
resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)});
})
}));
});
}
// A helper function that removes all join tables for a schema. Returns a promise.
var removeJoinTables = (database, prefix, mongoSchema) => {
var removeJoinTables = (database, mongoSchema) => {
return Promise.all(Object.keys(mongoSchema)
.filter(field => mongoSchema[field].startsWith('relation<'))
.map(field => {
var joinCollectionName = prefix + '_Join:' + field + ':' + mongoSchema._id;
return new Promise((resolve, reject) => {
database.dropCollection(joinCollectionName, (err, results) => {
if (err) {
reject(err);
} else {
resolve();
}
})
});
let collectionName = `_Join:${field}:${mongoSchema._id}`;
return database.dropCollection(collectionName);
})
);
};
function deleteSchema(req) {
if (!req.auth.isMaster) {
return masterKeyRequiredResponse();
}
if (!Schema.classNameIsValid(req.params.className)) {
return Promise.resolve({
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: Schema.invalidClassNameMessage(req.params.className),
}
});
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className));
}
return req.config.database.collection(req.params.className)
.then(coll => new Promise((resolve, reject) => {
coll.count((err, count) => {
if (err) {
reject(err);
} else if (count > 0) {
resolve({
status: 400,
response: {
code: 255,
error: 'class ' + req.params.className + ' not empty, contains ' + count + ' objects, cannot drop schema',
}
});
} else {
coll.drop((err, reply) => {
if (err) {
reject(err);
} else {
// We've dropped the collection now, so delete the item from _SCHEMA
// and clear the _Join collections
req.config.database.collection('_SCHEMA')
.then(coll => new Promise((resolve, reject) => {
coll.findAndRemove({ _id: req.params.className }, [], (err, doc) => {
if (err) {
reject(err);
} else if (doc.value === null) {
//tried to delete non-existant class
resolve({ response: {}});
} else {
removeJoinTables(req.config.database.db, req.config.database.collectionPrefix, doc.value)
.then(resolve, reject);
}
});
}))
.then(resolve.bind(undefined, {response: {}}), reject);
}
});
return req.config.database.collectionExists(req.params.className)
.then(exist => {
if (!exist) {
return Promise.resolve();
}
return req.config.database.adaptiveCollection(req.params.className)
.then(collection => {
return collection.count()
.then(count => {
if (count > 0) {
throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`);
}
return collection.drop();
})
})
})
.then(() => {
// We've dropped the collection now, so delete the item from _SCHEMA
// and clear the _Join collections
return req.config.database.adaptiveCollection('_SCHEMA')
.then(coll => coll.findOneAndDelete({_id: req.params.className}))
.then(document => {
if (document === null) {
//tried to delete non-existent class
return Promise.resolve();
}
return removeJoinTables(req.config.database, document);
});
})
.then(() => {
// Success
return { response: {} };
}, error => {
if (error.message == 'ns not found') {
// If they try to delete a non-existent class, that's fine, just let them.
return { response: {} };
}
return Promise.reject(error);
});
}))
.catch( (error) => {
if (error.message == 'ns not found') {
// If they try to delete a non-existant class, thats fine, just let them.
return Promise.resolve({ response: {} });
}
return Promise.reject(error);
});
}
export class SchemasRouter extends PromiseRouter {
mountRoutes() {
this.route('GET', '/schemas', getAllSchemas);
this.route('GET', '/schemas/:className', getOneSchema);
this.route('POST', '/schemas', createSchema);
this.route('POST', '/schemas/:className', createSchema);
this.route('PUT', '/schemas/:className', modifySchema);
this.route('DELETE', '/schemas/:className', deleteSchema);
this.route('GET', '/schemas', middleware.promiseEnforceMasterKeyAccess, getAllSchemas);
this.route('GET', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, getOneSchema);
this.route('POST', '/schemas', middleware.promiseEnforceMasterKeyAccess, createSchema);
this.route('POST', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, createSchema);
this.route('PUT', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, modifySchema);
this.route('DELETE', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, deleteSchema);
}
}

View File

@@ -1,14 +1,15 @@
// These methods handle the User-related routes.
import deepcopy from 'deepcopy';
import deepcopy from 'deepcopy';
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
import Auth from '../Auth';
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';
import RestWrite from '../RestWrite';
let cryptoUtils = require('../cryptoUtils');
let triggers = require('../triggers');
export class UsersRouter extends ClassesRouter {
handleFind(req) {
@@ -25,7 +26,18 @@ export class UsersRouter extends ClassesRouter {
let data = deepcopy(req.body);
req.body = data;
req.params.className = '_User';
//req.config.userController.setEmailVerifyToken(req.body);
return super.handleCreate(req);
// if (req.config.verifyUserEmails) {
// // Send email as fire-and-forget once the user makes it into the DB.
// p.then(() => {
// req.config.userController.sendVerificationEmail(req.body);
// });
// }
// return p;
}
handleUpdate(req) {
@@ -75,7 +87,7 @@ export class UsersRouter extends ClassesRouter {
}
let user;
return req.database.find('_User', { username: req.body.username })
return req.config.database.find('_User', { username: req.body.username })
.then((results) => {
if (!results.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
@@ -87,7 +99,7 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
let token = 'r:' + newToken();
let token = 'r:' + cryptoUtils.newToken();
user.sessionToken = token;
delete user.password;
@@ -140,6 +152,23 @@ export class UsersRouter extends ClassesRouter {
}
return Promise.resolve(success);
}
handleResetRequest(req) {
let { email } = req.body;
if (!email) {
throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email");
}
let userController = req.config.userController;
return userController.sendPasswordResetEmail(email).then((token) => {
return Promise.resolve({
response: {}
});
}, (err) => {
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`);
});
}
mountRoutes() {
this.route('GET', '/users', req => { return this.handleFind(req); });
@@ -150,9 +179,7 @@ export class UsersRouter extends ClassesRouter {
this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); });
this.route('GET', '/login', req => { return this.handleLogIn(req); });
this.route('POST', '/logout', req => { return this.handleLogOut(req); });
this.route('POST', '/requestPasswordReset', () => {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.');
});
this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); })
}
}

View File

@@ -10,7 +10,7 @@
// keeping it this way for now.
//
// In API-handling code, you should only use the Schema class via the
// ExportAdapter. This will let us replace the schema logic for
// DatabaseController. This will let us replace the schema logic for
// different databases.
// TODO: hide all schema logic inside the database adapter.
@@ -48,13 +48,13 @@ var defaultColumns = {
// 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'}
"users": {type:'Relation', targetClass:'_User'},
"roles": {type:'Relation', targetClass:'_Role'}
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Session: {
"restricted": {type:'Boolean'},
"user": {type:'Pointer', className:'_User'},
"user": {type:'Pointer', targetClass:'_User'},
"installationId": {type:'String'},
"sessionToken": {type:'String'},
"expiresAt": {type:'Date'},
@@ -73,7 +73,8 @@ var defaultColumns = {
var requiredColumns = {
_Product: ["productIdentifier", "icon", "order", "title", "subtitle"]
_Product: ["productIdentifier", "icon", "order", "title", "subtitle"],
_Role: ["name", "ACL"]
}
// Valid classes must:
@@ -307,8 +308,12 @@ function mongoFieldTypeToSchemaAPIType(type) {
// is done in mongoSchemaFromFieldsAndClassName.
function buildMergedSchemaObject(mongoObject, putRequest) {
var newSchema = {};
let sysSchemaField = Object.keys(defaultColumns).indexOf(mongoObject._id) === -1 ? [] : Object.keys(defaultColumns[mongoObject._id]);
for (var oldField in mongoObject) {
if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') {
if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) {
continue;
}
var fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete'
if (!fieldIsDeleted) {
newSchema[oldField] = mongoFieldTypeToSchemaAPIType(mongoObject[oldField]);
@@ -317,6 +322,9 @@ function buildMergedSchemaObject(mongoObject, putRequest) {
}
for (var newField in putRequest) {
if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') {
if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) {
continue;
}
newSchema[newField] = putRequest[newField];
}
}
@@ -332,29 +340,22 @@ function buildMergedSchemaObject(mongoObject, putRequest) {
// enabled) before calling this function.
Schema.prototype.addClassIfNotExists = function(className, fields) {
if (this.data[className]) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' already exists',
});
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
}
var mongoObject = mongoSchemaFromFieldsAndClassName(fields, className);
let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className);
if (!mongoObject.result) {
return Promise.reject(mongoObject);
}
return this.collection.insertOne(mongoObject.result)
.then(result => result.ops[0])
.catch(error => {
if (error.code === 11000) { //Mongo's duplicate key error
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' already exists',
});
}
return Promise.reject(error);
});
.then(result => result.ops[0])
.catch(error => {
if (error.code === 11000) { //Mongo's duplicate key error
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
}
return Promise.reject(error);
});
};
// Returns a promise that resolves successfully to the new schema
@@ -500,80 +501,47 @@ Schema.prototype.validateField = function(className, key, type, freeze) {
// 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) {
// a database adapter and this function would close over it or access it via member.
Schema.prototype.deleteField = function(fieldName, className, database) {
if (!classNameIsValid(className)) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
});
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className));
}
if (!fieldNameIsValid(fieldName)) {
return Promise.reject({
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
});
throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `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',
});
throw new Parse.Error(136, `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',
});
}
.then(schema => {
return schema.hasClass(className)
.then(hasClass => {
if (!hasClass) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`);
}
if (!schema.data[className][fieldName]) {
throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`);
}
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(`_Join:${fieldName}:${className}`);
}
if (schema.data[className][fieldName].startsWith('relation<')) {
//For relations, drop the _Join table
return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className)
//Save the _SCHEMA object
// 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 database.collection(className)
.then(collection => {
var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName;
return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true });
});
})
// 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.
@@ -626,7 +594,7 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) {
if (!columns || columns.length == 0) {
return Promise.resolve(this);
}
var missingColumns = columns.filter(function(column){
if (query && query.objectId) {
if (object[column] && typeof object[column] === "object") {
@@ -636,15 +604,15 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) {
// Not trying to do anything there
return false;
}
return !object[column]
return !object[column]
});
if (missingColumns.length > 0) {
throw new Parse.Error(
Parse.Error.INCORRECT_TYPE,
missingColumns[0]+' is required.');
}
return Promise.resolve(this);
}
@@ -731,19 +699,31 @@ function getObjectType(obj) {
if (obj instanceof Array) {
return 'array';
}
if (obj.__type === 'Pointer' && obj.className) {
return '*' + obj.className;
}
if (obj.__type === 'File' && obj.name) {
return 'file';
}
if (obj.__type === 'Date' && obj.iso) {
return 'date';
}
if (obj.__type == 'GeoPoint' &&
obj.latitude != null &&
obj.longitude != null) {
return 'geopoint';
if (obj.__type){
switch(obj.__type) {
case 'Pointer' :
if(obj.className) {
return '*' + obj.className;
}
case 'File' :
if(obj.name) {
return 'file';
}
case 'Date' :
if(obj.iso) {
return 'date';
}
case 'GeoPoint' :
if(obj.latitude != null && obj.longitude != null) {
return 'geopoint';
}
case 'Bytes' :
if(obj.base64) {
return;
}
default:
throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type);
}
}
if (obj['$ne']) {
return getObjectType(obj['$ne']);

View File

@@ -1,45 +1,35 @@
export var apps = {};
export var stats = {};
export var isLoaded = false;
export var users = {};
/** @flow weak */
export function getApp(app, callback) {
if (apps[app]) return callback(true, apps[app]);
return callback(false);
export function CacheStore<KeyType, ValueType>() {
let dataStore: {[id:KeyType]:ValueType} = {};
return {
get: (key: KeyType): ValueType => {
return dataStore[key];
},
set(key: KeyType, value: ValueType): void {
dataStore[key] = value;
},
remove(key: KeyType): void {
delete dataStore[key];
},
clear(): void {
dataStore = {};
}
};
}
export function updateStat(key, value) {
stats[key] = value;
}
export function getUser(sessionToken) {
if (users[sessionToken]) return users[sessionToken];
return undefined;
}
export function setUser(sessionToken, userObject) {
users[sessionToken] = userObject;
}
export function clearUser(sessionToken) {
delete users[sessionToken];
}
const apps = CacheStore();
const users = CacheStore();
//So far used only in tests
export function clearCache() {
apps = {};
stats = {};
users = {};
export function clearCache(): void {
apps.clear();
users.clear();
}
export default {
apps,
stats,
isLoaded,
getApp,
updateStat,
clearUser,
getUser,
setUser,
users,
clearCache,
CacheStore
};

View File

@@ -42,14 +42,6 @@ if (program.args.length > 0 ) {
console.log(`Configuation loaded from ${jsonPath}`)
}
if (!program.appId || !program.masterKey || !program.serverURL) {
program.outputHelp();
console.error("");
console.error(colors.red("ERROR: appId, masterKey and serverURL are required"));
console.error("");
process.exit(1);
}
options = Object.keys(definitions).reduce(function (options, key) {
if (program[key]) {
options[key] = program[key];
@@ -61,6 +53,14 @@ if (!options.serverURL) {
options.serverURL = `http://localhost:${options.port}${options.mountPath}`;
}
if (!options.appId || !options.masterKey || !options.serverURL) {
program.outputHelp();
console.error("");
console.error(colors.red("ERROR: appId, masterKey and serverURL are required"));
console.error("");
process.exit(1);
}
const app = express();
const api = new ParseServer(options);
app.use(options.mountPath, api);

View File

@@ -36,6 +36,12 @@ module.exports = function(options) {
options.followRedirect = options.followRedirects == true;
request(options, (error, response, body) => {
if (error) {
if (callbacks.error) {
callbacks.error(error);
}
return promise.reject(error);
}
var httpResponse = {};
httpResponse.status = response.statusCode;
httpResponse.headers = response.headers;
@@ -46,7 +52,7 @@ module.exports = function(options) {
httpResponse.data = JSON.parse(response.body);
} catch (e) {}
// Consider <200 && >= 400 as errors
if (error || httpResponse.status <200 || httpResponse.status >=400) {
if (httpResponse.status < 200 || httpResponse.status >= 400) {
if (callbacks.error) {
callbacks.error(httpResponse);
}

91
src/features.js Normal file
View File

@@ -0,0 +1,91 @@
/**
* features.js
* Feature config file that holds information on the features that are currently
* available on Parse Server. This is primarily created to work with an UI interface
* like the web dashboard. The list of features will change depending on the your
* app, choice of adapter as well as Parse Server version. This approach will enable
* the dashboard to be built independently and still support these use cases.
*
*
* Default features and feature options are listed in the features object.
*
* featureSwitch is a convenient way to turn on/off features without changing the config
*
* Features that use Adapters should specify the feature options through
* the setFeature method in your controller and feature
* Reference PushController and ParsePushAdapter as an example.
*
* NOTE: When adding new endpoints be sure to update this list both (features, featureSwitch)
* if you are planning to have a UI consume it.
*/
// default features
let features = {
globalConfig: {
create: false,
read: false,
update: false,
delete: false,
},
hooks: {
create: false,
read: false,
update: false,
delete: false,
},
logs: {
level: false,
size: false,
order: false,
until: false,
from: false,
},
push: {
immediatePush: false,
scheduledPush: false,
storedPushData: false,
pushAudiences: false,
},
schemas: {
addField: true,
removeField: true,
addClass: true,
removeClass: true,
clearAllDataFromClass: false,
exportClass: false,
},
};
// master switch for features
let featuresSwitch = {
globalConfig: true,
hooks: true,
logs: true,
push: true,
schemas: true,
};
/**
* set feature config options
*/
function setFeature(key, value) {
features[key] = value;
}
/**
* get feature config options
*/
function getFeatures() {
let result = {};
Object.keys(features).forEach((key) => {
if (featuresSwitch[key] && features[key]) {
result[key] = features[key];
}
});
return result;
}
module.exports = {
getFeatures,
setFeature,
};

View File

@@ -1,46 +0,0 @@
// global_config.js
var Parse = require('parse/node').Parse;
import PromiseRouter from './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

@@ -10,43 +10,48 @@ var batch = require('./batch'),
multer = require('multer'),
Parse = require('parse/node').Parse;
//import passwordReset from './passwordReset';
import cache from './cache';
import PromiseRouter from './PromiseRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
import { FilesController } from './Controllers/FilesController';
import Config from './Config';
import parseServerPackage from '../package.json';
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 { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { PushRouter } from './Routers/PushRouter';
import { FilesRouter } from './Routers/FilesRouter';
import { LogsRouter } from './Routers/LogsRouter';
import { HooksRouter } from './Routers/HooksRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { LoggerController } from './Controllers/LoggerController';
import { HooksController } from './Controllers/HooksController';
import PromiseRouter from './PromiseRouter';
import requiredParameter from './requiredParameter';
import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { ClassesRouter } from './Routers/ClassesRouter';
import { FeaturesRouter } from './Routers/FeaturesRouter';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { FilesController } from './Controllers/FilesController';
import { FilesRouter } from './Routers/FilesRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter';
import { GCSAdapter } from './Adapters/Files/GCSAdapter';
import { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { HooksController } from './Controllers/HooksController';
import { HooksRouter } from './Routers/HooksRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { InstallationsRouter } from './Routers/InstallationsRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { LoggerController } from './Controllers/LoggerController';
import { LogsRouter } from './Routers/LogsRouter';
import { PublicAPIRouter } from './Routers/PublicAPIRouter';
import { PushController } from './Controllers/PushController';
import { PushRouter } from './Routers/PushRouter';
import { randomString } from './cryptoUtils';
import { RolesRouter } from './Routers/RolesRouter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { SessionsRouter } from './Routers/SessionsRouter';
import { setFeature } from './features';
import { UserController } from './Controllers/UserController';
import { UsersRouter } from './Routers/UsersRouter';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
// ParseServer works like a constructor of an express app.
// The args that we understand are:
// "databaseAdapter": a class like ExportAdapter providing create, find,
// "databaseAdapter": a class like DatabaseController providing create, find,
// update, and delete
// "filesAdapter": a class like GridStoreAdapter providing create, get,
// and delete
@@ -73,11 +78,12 @@ addParseCloud();
function ParseServer({
appId = requiredParameter('You must provide an appId!'),
masterKey = requiredParameter('You must provide a masterKey!'),
appName,
databaseAdapter,
filesAdapter,
push,
loggerAdapter,
databaseURI,
databaseURI = DatabaseAdapter.defaultDatabaseURI,
cloud,
collectionPrefix = '',
clientKey,
@@ -90,9 +96,18 @@ function ParseServer({
allowClientClassCreation = true,
oauth = {},
serverURL = requiredParameter('You must provide a serverURL!'),
maxUploadSize = '20mb'
maxUploadSize = '20mb',
verifyUserEmails = false,
emailAdapter,
publicServerURL,
customPages = {
invalidLink: undefined,
verifyEmailSuccess: undefined,
choosePassword: undefined,
passwordResetSuccess: undefined
},
}) {
setFeature('serverVersion', parseServerPackage.version);
// Initialize the node client SDK automatically
Parse.initialize(appId, javascriptKey || 'unused', masterKey);
Parse.serverURL = serverURL;
@@ -116,19 +131,24 @@ function ParseServer({
}
}
const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter);
const filesControllerAdapter = loadAdapter(filesAdapter, () => {
return new GridStoreAdapter(databaseURI);
});
const pushControllerAdapter = loadAdapter(push, ParsePushAdapter);
const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter);
const emailControllerAdapter = loadAdapter(emailAdapter);
// We pass the options and the base class for the adatper,
// Note that passing an instance would work too
const filesController = new FilesController(filesControllerAdapter);
const pushController = new PushController(pushControllerAdapter);
const loggerController = new LoggerController(loggerControllerAdapter);
const filesController = new FilesController(filesControllerAdapter, appId);
const pushController = new PushController(pushControllerAdapter, appId);
const loggerController = new LoggerController(loggerControllerAdapter, appId);
const hooksController = new HooksController(appId, collectionPrefix);
const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails });
cache.apps[appId] = {
cache.apps.set(appId, {
masterKey: masterKey,
serverURL: serverURL,
collectionPrefix: collectionPrefix,
clientKey: clientKey,
javascriptKey: javascriptKey,
@@ -140,25 +160,34 @@ function ParseServer({
pushController: pushController,
loggerController: loggerController,
hooksController: hooksController,
userController: userController,
verifyUserEmails: verifyUserEmails,
enableAnonymousUsers: enableAnonymousUsers,
allowClientClassCreation: allowClientClassCreation,
oauth: oauth
};
oauth: oauth,
appName: appName,
publicServerURL: publicServerURL,
customPages: customPages,
});
// To maintain compatibility. TODO: Remove in v2.1
// To maintain compatibility. TODO: Remove in some version that breaks backwards compatability
if (process.env.FACEBOOK_APP_ID) {
cache.apps[appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID);
cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID);
}
Config.validate(cache.apps.get(appId));
// This app serves the Parse API directly.
// It's the equivalent of https://api.parse.com/1 in the hosted Parse API.
var api = express();
//api.use("/apps", express.static(__dirname + "/public"));
// File handling needs to be before default middlewares are applied
api.use('/', new FilesRouter().getExpressRouter({
maxUploadSize: maxUploadSize
}));
api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp());
// TODO: separate this from the regular ParseServer object
if (process.env.TESTING == 1) {
api.use('/', require('./testing-routes').router);
@@ -180,24 +209,27 @@ function ParseServer({
new SchemasRouter(),
new PushRouter(),
new LogsRouter(),
new IAPValidationRouter()
new IAPValidationRouter(),
new FeaturesRouter(),
];
if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) {
routers.push(require('./global_config'));
routers.push(new GlobalConfigRouter());
}
if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) {
routers.push(new HooksRouter());
}
let appRouter = new PromiseRouter();
routers.forEach((router) => {
appRouter.merge(router);
});
let routes = routers.reduce((memo, router) => {
return memo.concat(router.routes);
}, []);
let appRouter = new PromiseRouter(routes);
batch.mountOnto(appRouter);
appRouter.mountOnto(api);
api.use(appRouter.expressApp());
api.use(middlewares.handleParseErrors);
@@ -221,13 +253,6 @@ function addParseCloud() {
global.Parse = Parse;
}
function getClassName(parseClass) {
if (parseClass && parseClass.className) {
return parseClass.className;
}
return parseClass;
}
module.exports = {
ParseServer: ParseServer,
S3Adapter: S3Adapter,

View File

@@ -35,7 +35,7 @@ function handleParseHeaders(req, res, next) {
var fileViaJSON = false;
if (!info.appId || !cache.apps[info.appId]) {
if (!info.appId || !cache.apps.get(info.appId)) {
// See if we can find the app id on the body.
if (req.body instanceof Buffer) {
// The only chance to find the app id is if this is a file
@@ -44,12 +44,10 @@ function handleParseHeaders(req, res, next) {
fileViaJSON = true;
}
if (req.body && req.body._ApplicationId
&& cache.apps[req.body._ApplicationId]
&& (
!info.masterKey
||
cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey)
if (req.body &&
req.body._ApplicationId &&
cache.apps.get(req.body._ApplicationId) &&
(!info.masterKey || cache.apps.get(req.body._ApplicationId).masterKey === info.masterKey)
) {
info.appId = req.body._ApplicationId;
info.javascriptKey = req.body._JavaScriptKey || '';
@@ -84,15 +82,14 @@ function handleParseHeaders(req, res, next) {
req.body = new Buffer(base64, 'base64');
}
info.app = cache.apps[info.appId];
info.app = cache.apps.get(info.appId);
req.config = new Config(info.appId, mount);
req.database = req.config.database;
req.info = info;
var isMaster = (info.masterKey === req.config.masterKey);
if (isMaster) {
req.auth = new auth.Auth(req.config, true);
req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true });
next();
return;
}
@@ -117,23 +114,23 @@ function handleParseHeaders(req, res, next) {
}
if (!info.sessionToken) {
req.auth = new auth.Auth(req.config, false);
req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: false });
next();
return;
}
return auth.getAuthForSessionToken(
req.config, info.sessionToken).then((auth) => {
return auth.getAuthForSessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken })
.then((auth) => {
if (auth) {
req.auth = auth;
next();
}
}).catch((error) => {
})
.catch((error) => {
// TODO: Determine the correct error scenario.
console.log(error);
throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error);
});
}
var allowCrossDomain = function(req, res, next) {
@@ -177,6 +174,9 @@ var handleParseErrors = function(err, req, res, next) {
res.status(httpStatus);
res.json({code: err.code, error: err.message});
} else if (err.status && err.message) {
res.status(err.status);
res.json({error: err.message});
} else {
console.log('Uncaught internal server error.', err, err.stack);
res.status(500);
@@ -194,6 +194,16 @@ function enforceMasterKeyAccess(req, res, next) {
next();
}
function promiseEnforceMasterKeyAccess(request) {
if (!request.auth.isMaster) {
let error = new Error();
error.status = 403;
error.message = "unauthorized: master key is required";
throw error;
}
return Promise.resolve();
}
function invalidRequest(req, res) {
res.status(403);
res.end('{"error":"unauthorized"}');
@@ -204,5 +214,6 @@ module.exports = {
allowMethodOverride: allowMethodOverride,
handleParseErrors: handleParseErrors,
handleParseHeaders: handleParseHeaders,
enforceMasterKeyAccess: enforceMasterKeyAccess
enforceMasterKeyAccess: enforceMasterKeyAccess,
promiseEnforceMasterKeyAccess
};

View File

@@ -1,2 +1,2 @@
/* @flow */
export default (errorMessage: string) => {throw errorMessage}
/** @flow */
export default (errorMessage: string): any => { throw errorMessage }

View File

@@ -46,7 +46,7 @@ function del(config, auth, className, objectId) {
.then((response) => {
if (response && response.results && response.results.length) {
response.results[0].className = className;
cache.clearUser(response.results[0].sessionToken);
cache.users.remove(response.results[0].sessionToken);
inflatedObject = Parse.Object.fromJSON(response.results[0]);
return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId);
}

View File

@@ -10,10 +10,11 @@ var router = express.Router();
// creates a unique app in the cache, with a collection prefix
function createApp(req, res) {
var appId = cryptoUtils.randomHexString(32);
cache.apps[appId] = {
// TODO: (nlutsenko) This doesn't work and should die, since there are no controllers on this configuration.
cache.apps.set(appId, {
'collectionPrefix': appId + '_',
'masterKey': 'master'
};
});
var keys = {
'application_id': appId,
'client_key': 'unused',
@@ -31,7 +32,7 @@ function clearApp(req, res) {
if (!req.auth.isMaster) {
return res.status(401).send({"error": "unauthorized"});
}
req.database.deleteEverything().then(() => {
return req.config.database.deleteEverything().then(() => {
res.status(200).send({});
});
}
@@ -41,8 +42,8 @@ function dropApp(req, res) {
if (!req.auth.isMaster) {
return res.status(401).send({"error": "unauthorized"});
}
req.database.deleteEverything().then(() => {
delete cache.apps[req.config.applicationId];
return req.config.database.deleteEverything().then(() => {
cache.apps.remove(req.config.applicationId);
res.status(200).send({});
});
}

View File

@@ -42,6 +42,12 @@ export function transformKeyValue(schema, className, restKey, restValue, options
key = '_updated_at';
timeField = true;
break;
case '_email_verify_token':
key = "_email_verify_token";
break;
case '_perishable_token':
key = "_perishable_token";
break;
case 'sessionToken':
case '_session_token':
key = '_session_token';
@@ -638,7 +644,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
break;
case 'expiresAt':
case '_expiresAt':
restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso;
restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key]));
break;
default:
// Check other auth data keys
@@ -649,7 +655,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals
restObject['authData'][provider] = mongoObject[key];
break;
}
if (key.indexOf('_p_') == 0) {
var newKey = key.substring(3);
var expected;

View File

@@ -110,12 +110,11 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb
if (auth.user) {
request['user'] = auth.user;
}
// TODO: Add installation to Auth?
if (auth.installationId) {
request['installationId'] = auth.installationId;
}
return request;
};
}
// Creates the response object, and uses the request object to pass data
// The API will call this with REST API formatted objects, this will
@@ -157,8 +156,8 @@ export function maybeRunTrigger(triggerType, auth, parseObject, originalParseObj
var response = getResponseObject(request, resolve, reject);
// Force the current Parse app before the trigger
Parse.applicationId = applicationId;
Parse.javascriptKey = cache.apps[applicationId].javascriptKey || '';
Parse.masterKey = cache.apps[applicationId].masterKey;
Parse.javascriptKey = cache.apps.get(applicationId).javascriptKey || '';
Parse.masterKey = cache.apps.get(applicationId).masterKey;
trigger(request, response);
});
};