diff --git a/spec/ParseServerRESTController.spec.js b/spec/ParseServerRESTController.spec.js new file mode 100644 index 0000000000..0c24cffffe --- /dev/null +++ b/spec/ParseServerRESTController.spec.js @@ -0,0 +1,119 @@ +const ParseServerRESTController = require('../src/ParseServerRESTController').ParseServerRESTController; +const ParseServer = require('../src/ParseServer').default; +let RESTController; + +describe('ParseServerRESTController', () => { + + beforeEach(() => { + RESTController = ParseServerRESTController(Parse.applicationId, ParseServer.promiseRouter({appId: Parse.applicationId})); + }) + + it('should handle a get request', (done) => { + RESTController.request("GET", "/classes/MyObject").then((res) => { + expect(res.results.length).toBe(0); + done(); + }, (err) => { + console.log(err); + jfail(err); + done(); + }); + }); + + it('should handle a get request with full serverURL mount path', (done) => { + RESTController.request("GET", "/1/classes/MyObject").then((res) => { + expect(res.results.length).toBe(0); + done(); + }, (err) => { + jfail(err); + done(); + }); + }); + + it('should handle a POST batch', (done) => { + RESTController.request("POST", "batch", { + requests: [ + { + method: 'GET', + path: '/classes/MyObject' + }, + { + method: 'POST', + path: '/classes/MyObject', + body: {"key": "value"} + }, + { + method: 'GET', + path: '/classes/MyObject' + } + ] + }).then((res) => { + expect(res.length).toBe(3); + done(); + }, (err) => { + jfail(err); + done(); + }); + }); + + it('should handle a POST request', (done) => { + RESTController.request("POST", "/classes/MyObject", {"key": "value"}).then((res) => { + return RESTController.request("GET", "/classes/MyObject"); + }).then((res) => { + expect(res.results.length).toBe(1); + expect(res.results[0].key).toEqual("value"); + done(); + }).fail((err) => { + console.log(err); + jfail(err); + done(); + }); + }); + + it('ensures sessionTokens are properly handled', (done) => { + let userId; + Parse.User.signUp('user', 'pass').then((user) => { + userId = user.id; + let sessionToken = user.getSessionToken(); + return RESTController.request("GET", "/users/me", undefined, {sessionToken}); + }).then((res) => { + // Result is in JSON format + expect(res.objectId).toEqual(userId); + done(); + }).fail((err) => { + console.log(err); + jfail(err); + done(); + }); + }); + + it('ensures masterKey is properly handled', (done) => { + let userId; + Parse.User.signUp('user', 'pass').then((user) => { + userId = user.id; + let sessionToken = user.getSessionToken(); + return Parse.User.logOut().then(() => { + return RESTController.request("GET", "/classes/_User", undefined, {useMasterKey: true}); + }); + }).then((res) => { + expect(res.results.length).toBe(1); + expect(res.results[0].objectId).toEqual(userId); + done(); + }, (err) => { + jfail(err); + done(); + }); + }); + + it('ensures no session token is created on creating users', (done) => { + RESTController.request("POST", "/classes/_User", {username: "hello", password: "world"}).then(() => { + let query = new Parse.Query('_Session'); + return query.find({useMasterKey: true}); + }).then(sessions => { + expect(sessions.length).toBe(0); + done(); + }, (err) => { + jfail(err); + done(); + }); + }); +}); \ No newline at end of file diff --git a/src/ParseServer.js b/src/ParseServer.js index 8d0fa539dc..39ded4a53b 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -58,6 +58,8 @@ import DatabaseController from './Controllers/DatabaseController'; import SchemaCache from './Controllers/SchemaCache'; import ParsePushAdapter from 'parse-server-push-adapter'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; + +import { ParseServerRESTController } from './ParseServerRESTController'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -273,6 +275,29 @@ class ParseServer { api.use(bodyParser.json({ 'type': '*/*' , limit: maxUploadSize })); api.use(middlewares.allowMethodOverride); + let appRouter = ParseServer.promiseRouter({ appId }); + api.use(appRouter.expressRouter()); + + api.use(middlewares.handleParseErrors); + + //This causes tests to spew some useless warnings, so disable in test + if (!process.env.TESTING) { + process.on('uncaughtException', (err) => { + if ( err.code === "EADDRINUSE" ) { // user-friendly message for this common error + console.error(`Unable to listen on port ${err.port}. The port is already in use.`); + process.exit(0); + } else { + throw err; + } + }); + } + if (process.env.PARSE_SERVER_ENABLE_EXPERIMENTAL_DIRECT_ACCESS === '1') { + Parse.CoreManager.setRESTController(ParseServerRESTController(appId, appRouter)); + } + return api; + } + + static promiseRouter({appId}) { let routers = [ new ClassesRouter(), new UsersRouter(), @@ -301,23 +326,7 @@ class ParseServer { appRouter.use(middlewares.handleParseHeaders); batch.mountOnto(appRouter); - - api.use(appRouter.expressRouter()); - - api.use(middlewares.handleParseErrors); - - //This causes tests to spew some useless warnings, so disable in test - if (!process.env.TESTING) { - process.on('uncaughtException', (err) => { - if ( err.code === "EADDRINUSE" ) { // user-friendly message for this common error - console.error(`Unable to listen on port ${err.port}. The port is already in use.`); - process.exit(0); - } else { - throw err; - } - }); - } - return api; + return appRouter; } static createLiveQueryServer(httpServer, config) { diff --git a/src/ParseServerRESTController.js b/src/ParseServerRESTController.js new file mode 100644 index 0000000000..1d60710558 --- /dev/null +++ b/src/ParseServerRESTController.js @@ -0,0 +1,99 @@ +const Config = require('./Config'); +const Auth = require('./Auth'); +const RESTController = require('parse/lib/node/RESTController'); +const URL = require('url'); +const Parse = require('parse/node'); + +function getSessionToken(options) { + if (options && typeof options.sessionToken === 'string') { + return Parse.Promise.as(options.sessionToken); + } + return Parse.Promise.as(null); +} + +function getAuth(options, config) { + if (options.useMasterKey) { + return Parse.Promise.as(new Auth.Auth({config, isMaster: true, installationId: 'cloud' })); + } + return getSessionToken(options).then((sessionToken) => { + if (sessionToken) { + options.sessionToken = sessionToken; + return Auth.getAuthForSessionToken({ + config, + sessionToken: sessionToken, + installationId: 'cloud' + }); + } else { + return Parse.Promise.as(new Auth.Auth({ config, installationId: 'cloud' })); + } + }) +} + +function ParseServerRESTController(applicationId, router) { + function handleRequest(method, path, data = {}, options = {}) { + // Store the arguments, for later use if internal fails + let args = arguments; + + let config = new Config(applicationId); + let serverURL = URL.parse(config.serverURL); + if (path.indexOf(serverURL.path) === 0) { + path = path.slice(serverURL.path.length, path.length); + } + + if (path[0] !== "/") { + path = "/" + path; + } + + if (path === '/batch') { + let promises = data.requests.map((request) => { + return handleRequest(request.method, request.path, request.body, options).then((response) => { + return Parse.Promise.as({success: response}); + }, (error) => { + return Parse.Promise.as({error: {code: error.code, error: error.message}}); + }); + }); + return Parse.Promise.all(promises); + } + + let query; + if (method === 'GET') { + query = data; + } + + return new Parse.Promise((resolve, reject) => { + getAuth(options, config).then((auth) => { + let request = { + body: data, + config, + auth, + info: { + applicationId: applicationId, + sessionToken: options.sessionToken + }, + query + }; + return Promise.resolve().then(() => { + return router.tryRouteRequest(method, path, request); + }).then((response) => { + resolve(response.response, response.status, response); + }, (err) => { + if (err instanceof Parse.Error && + err.code == Parse.Error.INVALID_JSON && + err.message == `cannot route ${method} ${path}`) { + RESTController.request.apply(null, args).then(resolve, reject); + } else { + reject(err); + } + }); + }, reject); + }); + }; + + return { + request: handleRequest, + ajax: RESTController.ajax + }; +}; + +export default ParseServerRESTController; +export { ParseServerRESTController }; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 2886aa06fd..f3482b6c1f 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -10,6 +10,22 @@ import express from 'express'; import url from 'url'; import log from './logger'; import {inspect} from 'util'; +const Layer = require('express/lib/router/layer'); + +function validateParameter(key, value) { + if (key == 'className') { + if (value.match(/_?[A-Za-z][A-Za-z_0-9]*/)) { + return value; + } + } else if (key == 'objectId') { + if (value.match(/[A-Za-z0-9]+/)) { + return value; + } + } else { + return value; + } +} + export default class PromiseRouter { // Each entry should be an object with: @@ -70,7 +86,8 @@ export default class PromiseRouter { this.routes.push({ path: path, method: method, - handler: handler + handler: handler, + layer: new Layer(path, null, handler) }); }; @@ -83,30 +100,15 @@ export default class PromiseRouter { if (route.method != method) { continue; } - // NOTE: we can only route the specific wildcards :className and - // :objectId, and in that order. - // This is pretty hacky but I don't want to rebuild the entire - // express route matcher. Maybe there's a way to reuse its logic. - var pattern = '^' + route.path + '$'; - - pattern = pattern.replace(':className', - '(_?[A-Za-z][A-Za-z_0-9]*)'); - pattern = pattern.replace(':objectId', - '([A-Za-z0-9]+)'); - var re = new RegExp(pattern); - var m = path.match(re); - if (!m) { - continue; - } - var params = {}; - if (m[1]) { - params.className = m[1]; - } - if (m[2]) { - params.objectId = m[2]; + let layer = route.layer || new Layer(route.path, null, route.handler); + let match = layer.match(path); + if (match) { + let params = layer.params; + Object.keys(params).forEach((key) => { + params[key] = validateParameter(key, params[key]); + }); + return {params: params, handler: route.handler}; } - - return {params: params, handler: route.handler}; } }; @@ -124,6 +126,19 @@ export default class PromiseRouter { expressRouter() { return this.mountOnto(express.Router()); } + + tryRouteRequest(method, path, request) { + var match = this.match(method, path); + if (!match) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'cannot route ' + method + ' ' + path); + } + request.params = match.params; + return new Promise((resolve, reject) => { + match.handler(request).then(resolve, reject); + }); + } } // A helper function to make an express handler out of a a promise diff --git a/src/RestWrite.js b/src/RestWrite.js index 93de9ba668..05c4bf9ec2 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -436,6 +436,11 @@ RestWrite.prototype.createSessionTokenIfNeeded = function() { } RestWrite.prototype.createSessionToken = function() { + // cloud installationId from Cloud Code, + // never create session tokens from there. + if (this.auth.installationId && this.auth.installationId === 'cloud') { + return; + } var token = 'r:' + cryptoUtils.newToken(); var expiresAt = this.config.generateSessionExpiresAt(); diff --git a/src/batch.js b/src/batch.js index bf61b9e999..712c5805e1 100644 --- a/src/batch.js +++ b/src/batch.js @@ -29,8 +29,7 @@ function handleBatch(router, req) { var apiPrefixLength = req.originalUrl.length - batchPath.length; var apiPrefix = req.originalUrl.slice(0, apiPrefixLength); - var promises = []; - for (var restRequest of req.body.requests) { + const promises = req.body.requests.map((restRequest) => { // The routablePath is the path minus the api prefix if (restRequest.path.slice(0, apiPrefixLength) != apiPrefix) { throw new Parse.Error( @@ -38,30 +37,20 @@ function handleBatch(router, req) { 'cannot route batch path ' + restRequest.path); } var routablePath = restRequest.path.slice(apiPrefixLength); - - // Use the router to figure out what handler to use - var match = router.match(restRequest.method, routablePath); - if (!match) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'cannot route ' + restRequest.method + ' ' + routablePath); - } - // Construct a request that we can send to a handler var request = { body: restRequest.body, - params: match.params, config: req.config, auth: req.auth, info: req.info }; - promises.push(match.handler(request).then((response) => { + return router.tryRouteRequest(restRequest.method, routablePath, request).then((response) => { return {success: response.response}; }, (error) => { return {error: {code: error.code, error: error.message}}; - })); - } + }); + }); return Promise.all(promises).then((results) => { return {response: results};