mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) move home server into core
Summary: This moves enough server material into core to run a home server. The data engine is not yet incorporated (though in manual testing it works when ported). Test Plan: existing tests pass Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2552
This commit is contained in:
166
app/server/lib/extractOrg.ts
Normal file
166
app/server/lib/extractOrg.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
|
||||
import { extractOrgParts, getKnownOrg, isCustomHost } from 'app/common/gristUrls';
|
||||
import { Organization } from 'app/gen-server/entity/Organization';
|
||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
||||
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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 hostname = host.split(':')[0]; // Strip out port (ignores IPv6 but is OK for us).
|
||||
const info = await this.getOrgInfoFromParts(hostname, 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(hostname: string, urlPath: string): Promise<RequestOrgInfo> {
|
||||
// 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};
|
||||
}
|
||||
|
||||
// Fake the protocol; it doesn't matter for parsing out the hostname.
|
||||
if (this._isNativeDomain(hostname)) {
|
||||
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 {
|
||||
// Otherwise check for a custom host.
|
||||
const org = await mapGetOrSet(this._host2org, hostname, async () => {
|
||||
const o = await this._dbManager.connection.manager.findOne(Organization, {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(req: Request): Promise<RequestWithOrg> {
|
||||
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._isNativeDomain(req.hostname) && !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, {domain: org});
|
||||
return o && o.host || undefined;
|
||||
});
|
||||
if (orgHost && orgHost !== req.hostname) {
|
||||
const url = new URL(`${req.protocol}://${req.get('host')}${req.path}`);
|
||||
url.hostname = orgHost; // assigning hostname rather than host preserves port.
|
||||
return resp.redirect(url.href);
|
||||
}
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
private _isNativeDomain(hostname: string) {
|
||||
return !this._baseDomain || !isCustomHost(hostname, this._baseDomain);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user