Merge remote-tracking branch 'github/master'

This commit is contained in:
Peter Theill
2016-02-11 00:44:13 +01:00
16 changed files with 370 additions and 313 deletions

33
.npmignore Normal file
View File

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

View File

@@ -21,7 +21,6 @@ if (process.env.PARSE_SERVER_OPTIONS) {
options.restAPIKey = process.env.PARSE_SERVER_REST_API_KEY;
options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY;
options.javascriptKey = process.env.PARSE_SERVER_JAVASCRIPT_KEY;
options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY;
options.masterKey = process.env.PARSE_SERVER_MASTER_KEY;
options.fileKey = process.env.PARSE_SERVER_FILE_KEY;
// Comma separated list of facebook app ids

View File

@@ -64,6 +64,22 @@ describe('Parse.User testing', () => {
});
});
it("user login with files", (done) => {
"use strict";
let file = new Parse.File("yolo.txt", [1,2,3], "text/plain");
file.save().then((file) => {
return Parse.User.signUp("asdf", "zxcv", { "file" : file });
}).then(() => {
return Parse.User.logIn("asdf", "zxcv");
}).then((user) => {
let fileAgain = user.get('file');
ok(fileAgain.name());
ok(fileAgain.url());
done();
});
});
it("become", (done) => {
var user = null;
var sessionToken = null;

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
// S3Adapter
//
// Stores Parse files in AWS S3.
import * as AWS from 'aws-sdk';
import { FilesAdapter } from './FilesAdapter';
const DEFAULT_S3_REGION = "us-east-1";
const DEFAULT_S3_BUCKET = "parse-files";
export class S3Adapter extends FilesAdapter {
// Creates an S3 session.
// Providing AWS access and secret keys is mandatory
// Region and bucket will use sane defaults if omitted
constructor(
accessKey,
secretKey,
{ region = DEFAULT_S3_REGION,
bucket = DEFAULT_S3_BUCKET,
bucketPrefix = '',
directAccess = false } = {}
) {
super();
this._region = region;
this._bucket = bucket;
this._bucketPrefix = bucketPrefix;
this._directAccess = directAccess;
let s3Options = {
accessKeyId: accessKey,
secretAccessKey: secretKey,
params: { Bucket: this._bucket }
};
AWS.config._region = this._region;
this._s3Client = new AWS.S3(s3Options);
}
// For a given config object, filename, and data, store a file in S3
// Returns a promise containing the S3 object creation response
createFile(config, filename, data) {
let params = {
Key: this._bucketPrefix + filename,
Body: data
};
if (this._directAccess) {
params.ACL = "public-read"
}
return new Promise((resolve, reject) => {
this._s3Client.upload(params, (err, data) => {
if (err !== null) {
return reject(err);
}
resolve(data);
});
});
}
// Search for and return a file if found by filename
// Returns a promise that succeeds with the buffer result from S3
getFileData(config, filename) {
let params = {Key: this._bucketPrefix + filename};
return new Promise((resolve, reject) => {
this._s3Client.getObject(params, (err, data) => {
if (err !== null) {
return reject(err);
}
resolve(data.Body);
});
});
}
// Generates and returns the location of a file stored in S3 for the given request and filename
// The location is the direct S3 link if the option is set, otherwise we serve the file through parse-server
getFileLocation(config, filename) {
if (this._directAccess) {
return ('https://' + this.bucket + '._s3Client.amazonaws.com' + '/' + this._bucketPrefix + filename);
}
return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename));
}
}
export default S3Adapter;

View File

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

View File

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

View File

@@ -10,9 +10,8 @@ var transform = require('./transform');
// options can contain:
// collectionPrefix: the string to put in front of every collection name.
function ExportAdapter(mongoURI, options) {
function ExportAdapter(mongoURI, options = {}) {
this.mongoURI = mongoURI;
options = options || {};
this.collectionPrefix = options.collectionPrefix;
@@ -67,8 +66,7 @@ function returnsTrue() {
// Returns a promise for a schema object.
// If we are provided a acceptor, then we run it on the schema.
// If the schema isn't accepted, we reload it at most once.
ExportAdapter.prototype.loadSchema = function(acceptor) {
acceptor = acceptor || returnsTrue;
ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) {
if (!this.schemaPromise) {
this.schemaPromise = this.collection('_SCHEMA').then((coll) => {
@@ -281,8 +279,7 @@ ExportAdapter.prototype.removeRelation = function(key, fromClassName,
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
ExportAdapter.prototype.destroy = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.destroy = function(className, query, options = {}) {
var isMaster = !('acl' in options);
var aclGroup = options.acl || [];
@@ -350,8 +347,7 @@ ExportAdapter.prototype.create = function(className, object, options) {
// This should only be used for testing - use 'find' for normal code
// to avoid Mongo-format dependencies.
// Returns a promise that resolves to a list of items.
ExportAdapter.prototype.mongoFind = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.mongoFind = function(className, query, options = {}) {
return this.collection(className).then((coll) => {
return coll.find(query, options).toArray();
});
@@ -507,8 +503,7 @@ ExportAdapter.prototype.smartFind = function(coll, where, options) {
// TODO: make userIds not needed here. The db adapter shouldn't know
// anything about users, ideally. Then, improve the format of the ACL
// arg to work like the others.
ExportAdapter.prototype.find = function(className, query, options) {
options = options || {};
ExportAdapter.prototype.find = function(className, query, options = {}) {
var mongoOptions = {};
if (options.skip) {
mongoOptions.skip = options.skip;

View File

@@ -1,29 +0,0 @@
// Files Adapter
//
// Allows you to change the file storage mechanism.
//
// Adapter classes must implement the following functions:
// * create(config, filename, data)
// * get(config, filename)
// * location(config, req, filename)
//
// Default is GridStoreAdapter, which requires mongo
// and for the API server to be using the ExportAdapter
// database adapter.
var GridStoreAdapter = require('./GridStoreAdapter');
var adapter = GridStoreAdapter;
function setAdapter(filesAdapter) {
adapter = filesAdapter;
}
function getAdapter() {
return adapter;
}
module.exports = {
getAdapter: getAdapter,
setAdapter: setAdapter
};

View File

@@ -1,48 +0,0 @@
// GridStoreAdapter
//
// Stores files in Mongo using GridStore
// Requires the database adapter to be based on mongoclient
var GridStore = require('mongodb').GridStore;
var path = require('path');
// For a given config object, filename, and data, store a file
// Returns a promise
function create(config, filename, data) {
return config.database.connect().then(() => {
var gridStore = new GridStore(config.database.db, filename, 'w');
return gridStore.open();
}).then((gridStore) => {
return gridStore.write(data);
}).then((gridStore) => {
return gridStore.close();
});
}
// Search for and return a file if found by filename
// Resolves a promise that succeeds with the buffer result
// from GridStore
function get(config, filename) {
return config.database.connect().then(() => {
return GridStore.exist(config.database.db, filename);
}).then(() => {
var gridStore = new GridStore(config.database.db, filename, 'r');
return gridStore.open();
}).then((gridStore) => {
return gridStore.read();
});
}
// Generates and returns the location of a file stored in GridStore for the
// given request and filename
function location(config, req, filename) {
return (req.protocol + '://' + req.get('host') +
path.dirname(req.originalUrl) + '/' + req.config.applicationId +
'/' + encodeURIComponent(filename));
}
module.exports = {
create: create,
get: get,
location: location
};

View File

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

View File

@@ -1,77 +0,0 @@
// S3Adapter
//
// Stores Parse files in AWS S3.
var AWS = require('aws-sdk');
var path = require('path');
var DEFAULT_REGION = "us-east-1";
var DEFAULT_BUCKET = "parse-files";
// Creates an S3 session.
// Providing AWS access and secret keys is mandatory
// Region and bucket will use sane defaults if omitted
function S3Adapter(accessKey, secretKey, options) {
options = options || {};
this.region = options.region || DEFAULT_REGION;
this.bucket = options.bucket || DEFAULT_BUCKET;
this.bucketPrefix = options.bucketPrefix || "";
this.directAccess = options.directAccess || false;
s3Options = {
accessKeyId: accessKey,
secretAccessKey: secretKey,
params: {Bucket: this.bucket}
};
AWS.config.region = this.region;
this.s3 = new AWS.S3(s3Options);
}
// For a given config object, filename, and data, store a file in S3
// Returns a promise containing the S3 object creation response
S3Adapter.prototype.create = function(config, filename, data) {
var params = {
Key: this.bucketPrefix + filename,
Body: data,
};
if (this.directAccess) {
params.ACL = "public-read"
}
return new Promise((resolve, reject) => {
this.s3.upload(params, (err, data) => {
if (err !== null) return reject(err);
resolve(data);
});
});
}
// Search for and return a file if found by filename
// Returns a promise that succeeds with the buffer result from S3
S3Adapter.prototype.get = function(config, filename) {
var params = {Key: this.bucketPrefix + filename};
return new Promise((resolve, reject) => {
this.s3.getObject(params, (err, data) => {
if (err !== null) return reject(err);
resolve(data.Body);
});
});
}
// Generates and returns the location of a file stored in S3 for the given request and
// filename
// The location is the direct S3 link if the option is set, otherwise we serve
// the file through parse-server
S3Adapter.prototype.location = function(config, req, filename) {
if (this.directAccess) {
return ('https://' + this.bucket + '.s3.amazonaws.com' + '/' +
this.bucketPrefix + filename);
}
return (req.protocol + '://' + req.get('host') +
path.dirname(req.originalUrl) + '/' + req.config.applicationId +
'/' + encodeURIComponent(filename));
}
module.exports = S3Adapter;

View File

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

View File

@@ -5,14 +5,17 @@ var batch = require('./batch'),
cache = require('./cache'),
DatabaseAdapter = require('./DatabaseAdapter'),
express = require('express'),
FilesAdapter = require('./FilesAdapter'),
S3Adapter = require('./S3Adapter'),
middlewares = require('./middlewares'),
multer = require('multer'),
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
httpRequest = require('./httpRequest');
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { FilesController } from './Controllers/FilesController';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
@@ -46,9 +49,9 @@ function ParseServer(args) {
if (args.databaseAdapter) {
DatabaseAdapter.setAdapter(args.databaseAdapter);
}
if (args.filesAdapter) {
FilesAdapter.setAdapter(args.filesAdapter);
}
let filesAdapter = args.filesAdapter || new GridStoreAdapter();
if (args.databaseURI) {
DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI);
}
@@ -64,6 +67,8 @@ function ParseServer(args) {
}
let filesController = new FilesController(filesAdapter);
cache.apps[args.appId] = {
masterKey: args.masterKey,
collectionPrefix: args.collectionPrefix || '',
@@ -72,7 +77,8 @@ function ParseServer(args) {
dotNetKey: args.dotNetKey || '',
restAPIKey: args.restAPIKey || '',
fileKey: args.fileKey || 'invalid-file-key',
facebookAppIds: args.facebookAppIds || []
facebookAppIds: args.facebookAppIds || [],
filesController: filesController
};
// To maintain compatibility. TODO: Remove in v2.1
@@ -91,7 +97,7 @@ function ParseServer(args) {
var api = express();
// File handling needs to be before default middlewares are applied
api.use('/', require('./files').router);
api.use('/', filesController.getExpressRouter());
// TODO: separate this from the regular ParseServer object
if (process.env.TESTING == 1) {

View File

@@ -58,6 +58,8 @@ function handleLogIn(req) {
user.sessionToken = token;
delete user.password;
req.config.filesController.expandFilesInObject(req.config, user);
var expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);