mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
fbae81648c
Summary: - /status accepts new optional query parameters: db=1, redis=1, and timeout=<ms> (defaults to 10_000). - These verify that the server can make trivial calls to DB/Redis, and that they return within the timeout. - New HealthCheck tests simulates DB and Redis problems. - Added resilience to Redis reconnects (helped by a test case that simulates disconnects) - When closing Redis-based session store, disconnect from Redis (to avoid hanging tests) Some associated test reorg: - Move stripeTools out of test/nbrowser, and remove an unnecessary dependency, to avoid starting up browser for gen-server tests. - Move TcpForwarder to its own file, to use in the new test. Test Plan: Added a new HealthCheck test that simulates DB and Redis problems. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4054
169 lines
6.4 KiB
TypeScript
169 lines
6.4 KiB
TypeScript
import session from '@gristlabs/express-session';
|
|
import {parseSubdomain} from 'app/common/gristUrls';
|
|
import {isNumber} from 'app/common/gutil';
|
|
import {RequestWithOrg} from 'app/server/lib/extractOrg';
|
|
import {GristServer} from 'app/server/lib/GristServer';
|
|
import {fromCallback} from 'app/server/lib/serverUtils';
|
|
import {Sessions} from 'app/server/lib/Sessions';
|
|
import {promisifyAll} from 'bluebird';
|
|
import * as express from 'express';
|
|
import assignIn = require('lodash/assignIn');
|
|
import * as path from 'path';
|
|
import * as shortUUID from "short-uuid";
|
|
|
|
|
|
export const cookieName = process.env.GRIST_SESSION_COOKIE || 'grist_sid';
|
|
|
|
export const COOKIE_MAX_AGE =
|
|
process.env.COOKIE_MAX_AGE === 'none' ? null :
|
|
isNumber(process.env.COOKIE_MAX_AGE || '') ? Number(process.env.COOKIE_MAX_AGE) :
|
|
90 * 24 * 60 * 60 * 1000; // 90 days in milliseconds
|
|
|
|
// RedisStore and SqliteStore are expected to provide a set/get interface for sessions.
|
|
export interface SessionStore {
|
|
getAsync(sid: string): Promise<any>;
|
|
setAsync(sid: string, session: any): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* A V1 session. A session can be associated with a number of users.
|
|
* There may be a preferred association between users and organizations:
|
|
* specifically, if from the url we can tell that we are showing material
|
|
* for a given organization, we should pick a user that has access to that
|
|
* organization.
|
|
*
|
|
* This interface plays no role at all yet! Working on refactoring existing
|
|
* sessions step by step to get closer to this.
|
|
*
|
|
*/
|
|
export interface IGristSession {
|
|
|
|
// V1 Hosted Grist - known available users.
|
|
users: Array<{
|
|
userId?: number;
|
|
}>;
|
|
|
|
// V1 Hosted Grist - known user/org relationships.
|
|
orgs: Array<{
|
|
orgId: number;
|
|
userId: number;
|
|
}>;
|
|
}
|
|
|
|
function createSessionStoreFactory(sessionsDB: string): () => SessionStore {
|
|
if (process.env.REDIS_URL) {
|
|
// Note that ./build excludes this module from the electron build.
|
|
const RedisStore = require('connect-redis')(session);
|
|
promisifyAll(RedisStore.prototype);
|
|
return () => {
|
|
const store = new RedisStore({
|
|
url: process.env.REDIS_URL,
|
|
});
|
|
return assignIn(store, {
|
|
async close() {
|
|
// Quit the client, so that it doesn't attempt to reconnect (which matters for some
|
|
// tests), and so that node becomes close-able.
|
|
await fromCallback(cb => store.client.quit(cb));
|
|
}});
|
|
};
|
|
} else {
|
|
const SQLiteStore = require('@gristlabs/connect-sqlite3')(session);
|
|
promisifyAll(SQLiteStore.prototype);
|
|
return () => {
|
|
const store = new SQLiteStore({
|
|
dir: path.dirname(sessionsDB),
|
|
db: path.basename(sessionsDB), // SQLiteStore no longer appends a .db suffix.
|
|
table: 'sessions',
|
|
});
|
|
// In testing, and monorepo's "yarn start", session is accessed from multiple
|
|
// processes, so could hit lock failures.
|
|
// connect-sqlite3 has a concurrentDb: true flag that can be set, but
|
|
// it puts the database in WAL mode, which would have implications
|
|
// for self-hosters (a second file to think about). Instead we just
|
|
// set a busy timeout.
|
|
store.db.run('PRAGMA busy_timeout = 1000');
|
|
|
|
return assignIn(store, { async close() {}});
|
|
};
|
|
}
|
|
}
|
|
|
|
export function getAllowedOrgForSessionID(sessionID: string): {org: string, host: string}|null {
|
|
if (sessionID.startsWith('c-') && sessionID.includes('@')) {
|
|
const [, org, host] = sessionID.split('@');
|
|
if (!host) { throw new Error('Invalid session ID'); }
|
|
return {org, host};
|
|
}
|
|
// Otherwise sessions start with 'g-', but we also accept older sessions without a prefix.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set up Grist Sessions, either in a sqlite db or via redis.
|
|
* @param instanceRoot: path to storage area in case we need to make a sqlite db.
|
|
*/
|
|
export function initGristSessions(instanceRoot: string, server: GristServer) {
|
|
// TODO: We may need to evaluate the usage of space in the SQLite store grist-sessions.db
|
|
// since entries are created on the first get request.
|
|
const sessionsDB: string = path.join(instanceRoot, 'grist-sessions.db');
|
|
|
|
// The extra step with the creator function is used in server.js to create a new session store
|
|
// after unpausing the server.
|
|
const sessionStoreCreator = createSessionStoreFactory(sessionsDB);
|
|
const sessionStore = sessionStoreCreator();
|
|
|
|
// Use a separate session IDs for custom domains than for native ones. Because a custom domain
|
|
// cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage
|
|
// by only honoring custom-domain cookies for requests to that domain.
|
|
const generateId = (req: RequestWithOrg) => {
|
|
const uid = shortUUID.generate();
|
|
return req.isCustomHost ? `c-${uid}@${req.org}@${req.get('host')}` : `g-${uid}`;
|
|
};
|
|
const sessionSecret = server.create.sessionSecret();
|
|
const sessionMiddleware = session({
|
|
secret: sessionSecret,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
name: cookieName,
|
|
requestDomain: getCookieDomain,
|
|
genid: generateId,
|
|
cookie: {
|
|
sameSite: 'lax',
|
|
|
|
// We do not initially set max-age, leaving the cookie as a
|
|
// session cookie until there's a successful login. On the
|
|
// redis back-end, the session associated with the cookie will
|
|
// persist for 24 hours if there is no successful login. Once
|
|
// there is a successful login, max-age will be set to
|
|
// COOKIE_MAX_AGE, making the cookie a persistent cookie. The
|
|
// session associated with the cookie will receive an updated
|
|
// time-to-live, so that it persists for COOKIE_MAX_AGE.
|
|
},
|
|
store: sessionStore
|
|
});
|
|
|
|
const sessions = new Sessions(sessionSecret, sessionStore);
|
|
|
|
return {sessions, sessionSecret, sessionStore, sessionMiddleware};
|
|
}
|
|
|
|
export function getCookieDomain(req: express.Request) {
|
|
const mreq = req as RequestWithOrg;
|
|
if (mreq.isCustomHost) {
|
|
// For custom hosts, omit the domain to make it a "host-only" cookie, to avoid it being
|
|
// included into subdomain requests (since we would not control all the subdomains).
|
|
return undefined;
|
|
}
|
|
|
|
const adaptDomain = process.env.GRIST_ADAPT_DOMAIN === 'true';
|
|
const fixedDomain = process.env.GRIST_SESSION_DOMAIN || process.env.GRIST_DOMAIN;
|
|
|
|
if (adaptDomain) {
|
|
const reqDomain = parseSubdomain(req.get('host'));
|
|
if (reqDomain.base) { return reqDomain.base.split(':')[0]; }
|
|
}
|
|
return fixedDomain;
|
|
}
|