import {ApiError} from 'app/common/ApiError'; import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain} from 'app/common/gristUrls'; import * as gutil from 'app/common/gutil'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithOrg} from 'app/server/lib/extractOrg'; import * as log from 'app/server/lib/log'; import {Permit} from 'app/server/lib/Permit'; import {Request, Response} from 'express'; import {URL} from 'url'; // log api details outside of dev environment (when GRIST_HOSTED_VERSION is set) const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION); // Offset to https ports in dev/testing environment. export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ? parseInt(process.env.GRIST_TEST_HTTPS_OFFSET, 10) : undefined; // Database fields that we permit in entities but don't want to cross the api. const INTERNAL_FIELDS = new Set(['apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'stripeCustomerId', 'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser']); /** * Adapt a home-server or doc-worker URL to match the hostname in the request URL. For custom * domains and when GRIST_SERVE_SAME_ORIGIN is set, we replace the full hostname; otherwise just * the base of the hostname. The changes to url are made in-place. * * For dev purposes, port is kept but possibly adjusted for TEST_HTTPS_OFFSET. Note that if port * is different from req's port, it is not considered same-origin for CORS purposes, but would * still receive cookies. */ export function adaptServerUrl(url: URL, req: RequestWithOrg): void { const reqBaseDomain = parseSubdomain(req.hostname).base; if (process.env.GRIST_SERVE_SAME_ORIGIN === 'true' || req.isCustomHost) { url.hostname = req.hostname; } else if (reqBaseDomain) { const subdomain: string|undefined = parseSubdomain(url.hostname).org || DEFAULT_HOME_SUBDOMAIN; url.hostname = `${subdomain}${reqBaseDomain}`; } // In dev/test environment we can turn on a flag to adjust URLs to use https. if (TEST_HTTPS_OFFSET && url.port && url.protocol === 'http:') { url.port = String(parseInt(url.port, 10) + TEST_HTTPS_OFFSET); url.protocol = 'https:'; } } /** * If org is not encoded in domain, prefix it to path - otherwise leave path unchanged. * The domain is extracted from the request, so this method is only useful for constructing * urls that stay within that domain. */ export function addOrgToPathIfNeeded(req: RequestWithOrg, path: string): string { return (isOrgInPathOnly(req.hostname) && req.org) ? `/o/${req.org}${path}` : path; } /** * If org is known, prefix it to path unconditionally. */ export function addOrgToPath(req: RequestWithOrg, path: string): string { return req.org ? `/o/${req.org}${path}` : path; } /** * Returns true for requests from permitted origins. For such requests, an * "Access-Control-Allow-Origin" header is added to the response. Vary: Origin * is also set to reflect the fact that the headers are a function of the origin, * to prevent inappropriate caching on the browser's side. */ export function trustOrigin(req: Request, resp: Response): boolean { // TODO: We may want to consider changing allowed origin values in the future. // Note that the request origin is undefined for non-CORS requests. const origin = req.get('origin'); if (!origin) { return true; } // Not a CORS request. if (!allowHost(req, new URL(origin))) { return false; } // For a request to a custom domain, the full hostname must match. resp.header("Access-Control-Allow-Origin", origin); resp.header("Vary", "Origin"); return true; } // Returns whether req satisfies the given allowedHost. Unless req is to a custom domain, it is // enough if only the base domains match. Differing ports are allowed, which helps in dev/testing. export function allowHost(req: Request, allowedHost: string|URL) { const mreq = req as RequestWithOrg; const proto = req.protocol; const actualUrl = new URL(`${proto}://${req.get('host')}`); const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost; if (mreq.isCustomHost) { // For a request to a custom domain, the full hostname must match. return actualUrl.hostname === allowedUrl.hostname; } else { // For requests to a native subdomains, only the base domain needs to match. const allowedDomain = parseSubdomain(allowedUrl.hostname); const actualDomain = parseSubdomain(actualUrl.hostname); return (actualDomain.base === allowedDomain.base); } } export function isParameterOn(parameter: any): boolean { return gutil.isAffirmative(parameter); } /** * Get Scope from request, and make sure it has everything needed for a document. */ export function getDocScope(req: Request): DocScope { const scope = getScope(req); if (!scope.urlId) { throw new Error('document required'); } return scope as DocScope; } /** * Extract information included in the request that may restrict the scope of * that request. Not all requests will support all restrictions. * * - userId - Mandatory. Produced by authentication middleware. * Information returned and actions taken will be limited by what * that user has access to. * * - org - Optional. Extracted by middleware. Limits * information/action to the given org. Not every endpoint * respects this limit. Possible exceptions include endpoints for * listing orgs a user has access to, and endpoints with an org id * encoded in them. * * - urlId - Optional. Embedded as "did" (or "docId") path parameter in endpoints related * to documents. Specifies which document the request pertains to. Can * be a urlId or a docId. * * - includeSupport - Optional. Embedded as "includeSupport" query parameter. * Just a few endpoints support this, it is a very specific "hack" for including * an example workspace in org listings. * * - showRemoved - Optional. Embedded as "showRemoved" query parameter. * Supported by many endpoints. When absent, request is limited * to docs/workspaces that have not been removed. When present, request * is limited to docs/workspaces that have been removed. */ export function getScope(req: Request): Scope { const urlId = req.params.did || req.params.docId; const userId = getUserId(req); const org = (req as RequestWithOrg).org; const {specialPermit} = (req as RequestWithLogin); const includeSupport = isParameterOn(req.query.includeSupport); const showRemoved = isParameterOn(req.query.showRemoved); return {urlId, userId, org, includeSupport, showRemoved, specialPermit}; } /** * If scope is for the given userId, return a new Scope with the special permit added. */ export function addPermit(scope: Scope, userId: number, specialPermit: Permit): Scope { return {...scope, ...(scope.userId === userId ? {specialPermit} : {})}; } // Return a JSON response reflecting the output of a query. // Filter out keys we don't want crossing the api. // Set req to null to not log any information about request. export async function sendReply(req: Request|null, res: Response, result: QueryResult) { const data = pruneAPIResult(result.data || null); if (shouldLogApiDetails && req) { const mreq = req as RequestWithLogin; log.rawDebug('api call', { url: req.url, userId: mreq.userId, email: mreq.user && mreq.user.loginEmail, org: mreq.org, params: req.params, body: req.body, result: data, }); } if (result.status === 200) { return res.json(data); } else { return res.status(result.status).json({error: result.errMessage}); } } export async function sendOkReply(req: Request|null, res: Response, result?: T) { return sendReply(req, res, {status: 200, data: result}); } export function pruneAPIResult(data: T): T { // TODO: This can be optimized by pruning data recursively without serializing in between. But // it's fairly fast even with serializing (on the order of 15usec/kb). const output = JSON.stringify(data, (key: string, value: any) => { // Do not include removedAt field if it is not set. It is not relevant to regular // situations where the user is working with non-deleted resources. if (key === 'removedAt' && value === null) { return undefined; } // Don't bother sending option fields if there are no options set. if (key === 'options' && value === null) { return undefined; } return INTERNAL_FIELDS.has(key) ? undefined : value; }); return JSON.parse(output); } /** * Access the canonical docId associated with the request. Must have already authorized. */ export function getDocId(req: Request) { const mreq = req as RequestWithLogin; // We should always have authorized by now. if (!mreq.docAuth || !mreq.docAuth.docId) { throw new ApiError(`unknown document`, 500); } return mreq.docAuth.docId; } export function optStringParam(p: any): string|undefined { if (typeof p === 'string') { return p; } return undefined; } export function stringParam(p: any, allowed?: string[]): string { if (typeof p !== 'string') { throw new Error(`parameter should be a string: ${p}`); } if (allowed && !allowed.includes(p)) { throw new Error(`parameter ${p} should be one of ${allowed}`); } return p; } export function integerParam(p: any): number { if (typeof p === 'number') { return Math.floor(p); } if (typeof p === 'string') { return parseInt(p, 10); } throw new Error(`parameter should be an integer: ${p}`); } export function optIntegerParam(p: any): number|undefined { if (typeof p === 'number') { return Math.floor(p); } if (typeof p === 'string') { return parseInt(p, 10); } return undefined; } export interface RequestWithGristInfo extends Request { gristInfo?: string; }