gristlabs_grist-core/app/server/lib/extractOrg.ts
Paul Fitzpatrick d7b3fb972c (core) upgrade typeorm so we can support newer postgres
Summary:
upgrade typeorm version, so Grist can run against newer versions of postgres.

Dusted off some old benchmarking code to verify that important queries don't get slower. They don't appear to, unlike for some intermediate versions of typeorm I tried in the past.

Most of the changes are because `findOne` changed how it interprets its arguments, and the value it returns when nothing is found. For the return value, I stuck with limiting its impact by emulating old behavior (returning undefined rather than null) rather than propagating the change out to parts of the code unrelated to the database.

Test Plan: existing tests pass; manual testing with postgres 10 and 14

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3613
2022-09-02 15:34:21 -04:00

171 lines
7.1 KiB
TypeScript

import { ApiError } from 'app/common/ApiError';
import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls';
import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { getOriginUrl } from 'app/server/lib/requestUtils';
import { NextFunction, Request, RequestHandler, Response } from 'express';
import { IncomingMessage } from 'http';
// How long we cache information about the relationship between
// orgs and custom hosts. The higher this is, the fewer requests
// to the DB needed, but the longer it will take for changes
// to custom host setting to take effect. Also, since the caching
// is done on individual servers/workers, it could be inconsistent
// between servers/workers for some time. During this period,
// redirect cycles are possible.
// Units are milliseconds.
const ORG_HOST_CACHE_TTL = 60 * 1000;
export interface RequestOrgInfo {
org: string;
isCustomHost: boolean; // when set, the request's domain is a recognized custom host linked
// with the specified org.
// path remainder after stripping /o/{org} if any.
url: string;
}
export type RequestWithOrg = Request & Partial<RequestOrgInfo>;
/**
* Manage the relationship between orgs and custom hosts in the url.
*/
export class Hosts {
// Cache of orgs (e.g. "fancy" of "fancy.getgrist.com") associated with custom hosts
// (e.g. "www.fancypants.com")
private _host2org = new MapWithTTL<string, Promise<string|undefined>>(ORG_HOST_CACHE_TTL);
// Cache of custom hosts associated with orgs.
private _org2host = new MapWithTTL<string, Promise<string|undefined>>(ORG_HOST_CACHE_TTL);
// baseDomain should start with ".". It may be undefined for localhost or single-org mode.
constructor(private _baseDomain: string|undefined, private _dbManager: HomeDBManager,
private _pluginUrl: string|undefined) {
}
/**
* Use app.use(hosts.extractOrg) to set req.org, req.isCustomHost, and to strip
* /o/ORG/ from urls (when present).
*
* If Host header has a getgrist.com subdomain, then it must match the value in /o/ORG (when
* present), and req.org will be set to the subdomain. On mismatch, a 400 response is returned.
*
* If Host header is a localhost domain, then req.org is set to the value in /o/ORG when
* present, and to "" otherwise.
*
* If Host header is something else, we query the db for an org whose host value matches.
* If found, req.org is set appropriately, and req.isCustomHost is set to true.
* If not found, a 'Domain not recognized' error is thrown, showing an error page.
*/
public get extractOrg(): RequestHandler {
return this._extractOrg.bind(this);
}
// Extract org info in a request. This applies to the low-level IncomingMessage type (rather
// than express.Request that derives from it) to be usable with websocket requests too.
public async getOrgInfo(req: IncomingMessage): Promise<RequestOrgInfo> {
const host = req.headers.host!;
const info = await this.getOrgInfoFromParts(host, req.url!);
// "Organization" header is used in proxying to doc worker, so respect it if
// no org info found in url.
if (!info.org && req.headers.organization) {
info.org = req.headers.organization as string;
}
return info;
}
// Extract org, isCustomHost, and the URL with /o/ORG stripped away. Throws ApiError for
// mismatching org or invalid custom domain. Hostname should not include port.
public async getOrgInfoFromParts(host: string, urlPath: string): Promise<RequestOrgInfo> {
const hostname = host.split(':')[0]; // Strip out port (ignores IPv6 but is OK for us).
// Extract the org from the host and URL path.
const parts = extractOrgParts(hostname, urlPath);
// If the server is configured to serve a single hard-wired org, respect that.
const singleOrg = getKnownOrg();
if (singleOrg) {
return {org: singleOrg, url: parts.pathRemainder, isCustomHost: false};
}
const hostType = this._getHostType(host);
if (hostType === 'native') {
if (parts.mismatch) {
throw new ApiError(`Wrong org for this domain: ` +
`'${parts.orgFromPath}' does not match '${parts.orgFromHost}'`, 400);
}
return {org: parts.subdomain || '', url: parts.pathRemainder, isCustomHost: false};
} else if (hostType === 'plugin') {
return {org: '', url: parts.pathRemainder, isCustomHost: false};
} else {
// Otherwise check for a custom host.
const org = await mapGetOrSet(this._host2org, hostname, async () => {
const o = await this._dbManager.connection.manager.findOne(Organization, {where: {host: hostname}});
return o && o.domain || undefined;
});
if (!org) { throw new ApiError(`Domain not recognized: ${hostname}`, 404); }
// Strip any stray /o/.... that has been added to a url with a custom host.
// TODO: it would eventually be cleaner to make sure we don't make those
// additions in the first place.
// To check for mismatch, compare to org, since orgFromHost is not expected to match.
if (parts.orgFromPath && parts.orgFromPath !== org) {
throw new ApiError(`Wrong org for this domain: ` +
`'${parts.orgFromPath}' does not match '${org}'`, 400);
}
return {org, isCustomHost: true, url: parts.pathRemainder};
}
}
public async addOrgInfo<T extends IncomingMessage>(req: T): Promise<T & RequestOrgInfo> {
return Object.assign(req, await this.getOrgInfo(req));
}
/**
* Use app.use(hosts.redirectHost) to ensure (by redirecting if necessary)
* that the domain in the url matches the preferred domain for the current org.
* Expects that the extractOrg has been used first.
*/
public get redirectHost(): RequestHandler {
return this._redirectHost.bind(this);
}
public close() {
this._host2org.clear();
this._org2host.clear();
}
private async _extractOrg(req: Request, resp: Response, next: NextFunction) {
try {
await this.addOrgInfo(req);
return next();
} catch (err) {
return resp.status(err.status || 500).send({error: err.message});
}
}
private async _redirectHost(req: Request, resp: Response, next: NextFunction) {
const {org} = req as RequestWithOrg;
if (org && this._getHostType(req.headers.host!) === 'native' && !this._dbManager.isMergedOrg(org)) {
// Check if the org has a preferred host.
const orgHost = await mapGetOrSet(this._org2host, org, async () => {
const o = await this._dbManager.connection.manager.findOne(Organization, {where: {domain: org}});
return o && o.host || undefined;
});
if (orgHost && orgHost !== req.hostname) {
const url = new URL(getOriginUrl(req) + req.path);
url.hostname = orgHost; // assigning hostname rather than host preserves port.
return resp.redirect(url.href);
}
}
return next();
}
private _getHostType(host: string) {
return getHostType(host, {baseDomain: this._baseDomain, pluginUrl: this._pluginUrl});
}
}