"use strict"; // The MIT License (MIT) // // Copyright (c) 2017 Firebase // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); const _ = require("lodash"); const cors = require("cors"); const apps_1 = require("../apps"); const cloud_functions_1 = require("../cloud-functions"); /** * Handle HTTP requests. * @param handler A function that takes a request and response object, * same signature as an Express app. */ function onRequest(handler) { return _onRequestWithOpts(handler, {}); } exports.onRequest = onRequest; /** * Declares a callable method for clients to call using a Firebase SDK. * @param handler A method that takes a data and context and returns a value. */ function onCall(handler) { return _onCallWithOpts(handler, {}); } exports.onCall = onCall; /** @internal */ function _onRequestWithOpts(handler, opts) { // lets us add __trigger without altering handler: let cloudFunction = (req, res) => { handler(req, res); }; cloudFunction.__trigger = _.assign(cloud_functions_1.optsToTrigger(opts), { httpsTrigger: {} }); // TODO parse the opts return cloudFunction; } exports._onRequestWithOpts = _onRequestWithOpts; /** * Standard error codes for different ways a request can fail, as defined by: * https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto * * This map is used primarily to convert from a client error code string to * to the HTTP format error code string, and make sure it's in the supported set. */ const errorCodeMap = { ok: 'OK', cancelled: 'CANCELLED', unknown: 'UNKNOWN', 'invalid-argument': 'INVALID_ARGUMENT', 'deadline-exceeded': 'DEADLINE_EXCEEDED', 'not-found': 'NOT_FOUND', 'already-exists': 'ALREADY_EXISTS', 'permission-denied': 'PERMISSION_DENIED', unauthenticated: 'UNAUTHENTICATED', 'resource-exhausted': 'RESOURCE_EXHAUSTED', 'failed-precondition': 'FAILED_PRECONDITION', aborted: 'ABORTED', 'out-of-range': 'OUT_OF_RANGE', unimplemented: 'UNIMPLEMENTED', internal: 'INTERNAL', unavailable: 'UNAVAILABLE', 'data-loss': 'DATA_LOSS', }; /** * An explicit error that can be thrown from a handler to send an error to the * client that called the function. */ class HttpsError extends Error { constructor(code, message, details) { super(message); // This is a workaround for a bug in TypeScript when extending Error: // tslint:disable-next-line // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work Object.setPrototypeOf(this, HttpsError.prototype); if (!errorCodeMap[code]) { throw new Error('Unknown error status: ' + code); } this.code = code; this.details = details; } /** * @internal * A string representation of the Google error code for this error for HTTP. */ get status() { return errorCodeMap[this.code]; } /** * @internal * Returns the canonical http status code for the given error. */ get httpStatus() { switch (this.code) { case 'ok': return 200; case 'cancelled': return 499; case 'unknown': return 500; case 'invalid-argument': return 400; case 'deadline-exceeded': return 504; case 'not-found': return 404; case 'already-exists': return 409; case 'permission-denied': return 403; case 'unauthenticated': return 401; case 'resource-exhausted': return 429; case 'failed-precondition': return 400; case 'aborted': return 409; case 'out-of-range': return 400; case 'unimplemented': return 501; case 'internal': return 500; case 'unavailable': return 503; case 'data-loss': return 500; // This should never happen as long as the type system is doing its job. default: throw 'Invalid error code: ' + this.code; } } /** @internal */ toJSON() { const json = { status: this.status, message: this.message, }; if (!_.isUndefined(this.details)) { json.details = this.details; } return json; } } exports.HttpsError = HttpsError; // Returns true if req is a properly formatted callable request. function isValidRequest(req) { // The body must not be empty. if (!req.body) { console.warn('Request is missing body.'); return false; } // Make sure it's a POST. if (req.method !== 'POST') { console.warn('Request has invalid method.', req.method); return false; } // Check that the Content-Type is JSON. let contentType = (req.header('Content-Type') || '').toLowerCase(); // If it has a charset, just ignore it for now. const semiColon = contentType.indexOf(';'); if (semiColon >= 0) { contentType = contentType.substr(0, semiColon).trim(); } if (contentType !== 'application/json') { console.warn('Request has incorrect Content-Type.', contentType); return false; } // The body must have data. if (_.isUndefined(req.body.data)) { console.warn('Request body is missing data.', req.body); return false; } // TODO(klimt): Allow only whitelisted http headers. // Verify that the body does not have any extra fields. const extras = _.omit(req.body, 'data'); if (!_.isEmpty(extras)) { console.warn('Request body has extra fields.', extras); return false; } return true; } const LONG_TYPE = 'type.googleapis.com/google.protobuf.Int64Value'; const UNSIGNED_LONG_TYPE = 'type.googleapis.com/google.protobuf.UInt64Value'; /** * Encodes arbitrary data in our special format for JSON. * This is exposed only for testing. */ /** @internal */ function encode(data) { if (_.isNull(data) || _.isUndefined(data)) { return null; } // Oddly, _.isFinite(new Number(x)) always returns false, so unwrap Numbers. if (data instanceof Number) { data = data.valueOf(); } if (_.isFinite(data)) { // Any number in JS is safe to put directly in JSON and parse as a double // without any loss of precision. return data; } if (_.isBoolean(data)) { return data; } if (_.isString(data)) { return data; } if (_.isArray(data)) { return _.map(data, encode); } if (_.isObject(data)) { // It's not safe to use _.forEach, because the object might be 'array-like' // if it has a key called 'length'. Note that this intentionally overrides // any toJSON method that an object may have. return _.mapValues(data, encode); } // If we got this far, the data is not encodable. console.error('Data cannot be encoded in JSON.', data); throw new Error('Data cannot be encoded in JSON: ' + data); } exports.encode = encode; /** * Decodes our special format for JSON into native types. * This is exposed only for testing. */ /** @internal */ function decode(data) { if (data === null) { return data; } if (data['@type']) { switch (data['@type']) { case LONG_TYPE: // Fall through and handle this the same as unsigned. case UNSIGNED_LONG_TYPE: { // Technically, this could work return a valid number for malformed // data if there was a number followed by garbage. But it's just not // worth all the extra code to detect that case. const value = parseFloat(data.value); if (_.isNaN(value)) { console.error('Data cannot be decoded from JSON.', data); throw new Error('Data cannot be decoded from JSON: ' + data); } return value; } default: { console.error('Data cannot be decoded from JSON.', data); throw new Error('Data cannot be decoded from JSON: ' + data); } } } if (_.isArray(data)) { return _.map(data, decode); } if (_.isObject(data)) { // It's not safe to use _.forEach, because the object might be 'array-like' // if it has a key called 'length'. return _.mapValues(data, decode); } // Anything else is safe to return. return data; } exports.decode = decode; const corsHandler = cors({ origin: true, methods: 'POST' }); /** @internal */ function _onCallWithOpts(handler, opts) { const func = (req, res) => __awaiter(this, void 0, void 0, function* () { try { if (!isValidRequest(req)) { console.error('Invalid request', req); throw new HttpsError('invalid-argument', 'Bad Request'); } const context = { rawRequest: req }; const authorization = req.header('Authorization'); if (authorization) { const match = authorization.match(/^Bearer (.*)$/); if (!match) { throw new HttpsError('unauthenticated', 'Unauthenticated'); } const idToken = match[1]; try { const authToken = yield apps_1.apps() .admin.auth() .verifyIdToken(idToken); context.auth = { uid: authToken.uid, token: authToken, }; } catch (e) { throw new HttpsError('unauthenticated', 'Unauthenticated'); } } const instanceId = req.header('Firebase-Instance-ID-Token'); if (instanceId) { // Validating the token requires an http request, so we don't do it. // If the user wants to use it for something, it will be validated then. // Currently, the only real use case for this token is for sending // pushes with FCM. In that case, the FCM APIs will validate the token. context.instanceIdToken = req.header('Firebase-Instance-ID-Token'); } const data = decode(req.body.data); let result = yield handler(data, context); // Encode the result as JSON to preserve types like Dates. result = encode(result); // If there was some result, encode it in the body. const responseBody = { result }; res.status(200).send(responseBody); } catch (error) { if (!(error instanceof HttpsError)) { // This doesn't count as an 'explicit' error. console.error('Unhandled error', error); error = new HttpsError('internal', 'INTERNAL'); } const status = error.httpStatus; const body = { error: error.toJSON() }; res.status(status).send(body); } }); // Wrap the function with a cors handler. const corsFunc = (req, res) => { return corsHandler(req, res, () => func(req, res)); }; corsFunc.__trigger = _.assign(cloud_functions_1.optsToTrigger(opts), { httpsTrigger: {}, labels: { 'deployment-callable': 'true' }, }); corsFunc.run = handler; return corsFunc; } exports._onCallWithOpts = _onCallWithOpts;