mirror of
https://github.com/titanscouting/tra-analysis.git
synced 2024-11-15 07:36:18 +00:00
360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
|
"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;
|