gristlabs_grist-core/app/server/lib/gristSessions.ts
Dmitry S 51ff72c15e (core) Faster builds all around.
Summary:
Building:
- Builds no longer wait for tsc for either client, server, or test targets. All use esbuild which is very fast.
- Build still runs tsc, but only to report errors. This may be turned off with `SKIP_TSC=1` env var.
- Grist-core continues to build using tsc.
- Esbuild requires ES6 module semantics. Typescript's esModuleInterop is turned
  on, so that tsc accepts and enforces correct usage.
- Client-side code is watched and bundled by webpack as before (using esbuild-loader)

Code changes:
- Imports must now follow ES6 semantics: `import * as X from ...` produces a
  module object; to import functions or class instances, use `import X from ...`.
- Everything is now built with isolatedModules flag. Some exports were updated for it.

Packages:
- Upgraded browserify dependency, and related packages (used for the distribution-building step).
- Building the distribution now uses esbuild's minification. babel-minify is no longer used.

Test Plan: Should have no behavior changes, existing tests should pass, and docker image should build too.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: alexmojaki

Differential Revision: https://phab.getgrist.com/D3506
2022-07-04 10:42:40 -04:00

167 lines
6.2 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 {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() {
// Doesn't actually close, just unrefs stream so node becomes close-able.
store.client.unref();
}});
};
} 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;
}