(core) make Grist easier to run with a single server

Summary:
This makes many small changes so that Grist is less fussy to run as a single instance behind a reverse proxy. Some users had difficulty with the self-connections Grist would make, due to internal network setup, and since these are unnecessary in any case in this scenario, they are now optimized away. Likewise some users had difficulties related to doc worker urls, which are now also optimized away. With these changes, users should be able to get a lot further on first try, at least far enough to open and edit documents.

The `GRIST_SINGLE_ORG` setting was proving a bit confusing, since it appeared to only work when set to `docs`. This diff
adds a check for whether the specified org exists, and if not, it creates it. This still depends on having a user email to make as the owner of the team, so there could be remaining difficulties there.

Test Plan: tested manually with nginx

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3299
This commit is contained in:
Paul Fitzpatrick 2022-03-02 14:07:26 -05:00
parent 0da397ab90
commit 2563fb745a
13 changed files with 228 additions and 68 deletions

View File

@ -14,7 +14,7 @@ import * as roles from 'app/common/roles';
import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
import {Disposable, dom, DomElementArg, styled} from 'grainjs'; import {Disposable, dom, DomElementArg, styled} from 'grainjs';
import {cssMenuItem} from 'popweasel'; import {cssMenuItem} from 'popweasel';
import {buildSiteSwitcher} from 'app/client/ui/SiteSwitcher'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
/** /**
* Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in * Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in
@ -91,7 +91,6 @@ export class AccountWidget extends Disposable {
} }
const users = this._appModel.topAppModel.users; const users = this._appModel.topAppModel.users;
const orgs = this._appModel.topAppModel.orgs;
return [ return [
cssUserInfo( cssUserInfo(
@ -143,10 +142,7 @@ export class AccountWidget extends Disposable {
menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')), menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')),
dom.maybe((use) => use(orgs).length > 0, () => [ maybeAddSiteSwitcherSection(this._appModel),
menuDivider(),
buildSiteSwitcher(this._appModel),
]),
]; ];
} }

View File

@ -4,14 +4,14 @@ import {cssLeftPane} from 'app/client/ui/PagePanels';
import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import * as version from 'app/common/version'; import * as version from 'app/common/version';
import {BindableValue, Disposable, dom, styled} from "grainjs"; import {BindableValue, Disposable, dom, styled} from "grainjs";
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {DocPageModel} from 'app/client/models/DocPageModel'; import {DocPageModel} from 'app/client/models/DocPageModel';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {loadUserManager} from 'app/client/lib/imports'; import {loadUserManager} from 'app/client/lib/imports';
import {buildSiteSwitcher} from 'app/client/ui/SiteSwitcher'; import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher';
export class AppHeader extends Disposable { export class AppHeader extends Disposable {
@ -24,7 +24,6 @@ export class AppHeader extends Disposable {
const theme = getTheme(this._appModel.topAppModel.productFlavor); const theme = getTheme(this._appModel.topAppModel.productFlavor);
const user = this._appModel.currentValidUser; const user = this._appModel.currentValidUser;
const orgs = this._appModel.topAppModel.orgs;
const currentOrg = this._appModel.currentOrg; const currentOrg = this._appModel.currentOrg;
const isTeamSite = Boolean(currentOrg && !currentOrg.owner); const isTeamSite = Boolean(currentOrg && !currentOrg.owner);
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
@ -74,10 +73,7 @@ export class AppHeader extends Disposable {
) : ) :
null, null,
dom.maybe((use) => use(orgs).length > 0, () => [ maybeAddSiteSwitcherSection(this._appModel),
menuDivider(),
buildSiteSwitcher(this._appModel),
]),
], { placement: 'bottom-start' }), ], { placement: 'bottom-start' }),
testId('dm-org'), testId('dm-org'),
), ),

View File

@ -1,14 +1,25 @@
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls, getSingleOrg} from 'app/common/gristUrls';
import {getOrgName} from 'app/common/UserAPI'; import {getOrgName} from 'app/common/UserAPI';
import {dom, makeTestId, styled} from 'grainjs'; import {dom, makeTestId, styled} from 'grainjs';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {menuIcon, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {menuDivider, menuIcon, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {colors} from 'app/client/ui2018/cssVars'; import {colors} from 'app/client/ui2018/cssVars';
const testId = makeTestId('test-site-switcher-'); const testId = makeTestId('test-site-switcher-');
/**
* Adds a menu divider and a site switcher, if there is need for one.
*/
export function maybeAddSiteSwitcherSection(appModel: AppModel) {
const orgs = appModel.topAppModel.orgs;
return dom.maybe((use) => use(orgs).length > 0 && !getSingleOrg(), () => [
menuDivider(),
buildSiteSwitcher(appModel),
]);
}
/** /**
* Builds a menu sub-section that displays a list of orgs/sites that the current * Builds a menu sub-section that displays a list of orgs/sites that the current
* valid user has access to, with buttons to navigate to them. * valid user has access to, with buttons to navigate to them.

View File

@ -672,7 +672,7 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
method: 'GET', method: 'GET',
credentials: 'include' credentials: 'include'
}); });
return json.docWorkerUrl; return getDocWorkerUrl(this._homeUrl, json);
} }
public async getWorkerAPI(key: string): Promise<DocWorkerAPI> { public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {
@ -968,3 +968,28 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this.requestJson(url.href); return this.requestJson(url.href);
} }
} }
/**
* Get a docWorkerUrl from information returned from backend. When the backend
* is fully configured, and there is a pool of workers, this is straightforward,
* just return the docWorkerUrl reported by the backend. For single-instance
* installs, the backend returns a null docWorkerUrl, and a client can simply
* use the homeUrl of the backend, with extra path prefix information
* given by selfPrefix. At the time of writing, the selfPrefix contains a
* doc-worker id, and a tag for the codebase (used in consistency checks).
*/
export function getDocWorkerUrl(homeUrl: string, docWorkerInfo: {
docWorkerUrl: string|null,
selfPrefix?: string,
}): string {
if (!docWorkerInfo.docWorkerUrl) {
if (!docWorkerInfo.selfPrefix) {
// This should never happen.
throw new Error('missing selfPrefix for docWorkerUrl');
}
const url = new URL(homeUrl);
url.pathname = docWorkerInfo.selfPrefix + url.pathname;
return url.href;
}
return docWorkerInfo.docWorkerUrl;
}

View File

@ -472,7 +472,7 @@ export interface GristLoadConfig {
getDoc?: {[id: string]: Document}; getDoc?: {[id: string]: Document};
// Pre-fetched call to getWorker for the doc being loaded. // Pre-fetched call to getWorker for the doc being loaded.
getWorker?: {[id: string]: string}; getWorker?: {[id: string]: string|null};
// The timestamp when this gristConfig was generated. // The timestamp when this gristConfig was generated.
timestampMs: number; timestampMs: number;
@ -528,6 +528,21 @@ export function getKnownOrg(): string|null {
} }
} }
/**
* Like getKnownOrg, but respects singleOrg/GRIST_SINGLE_ORG strictly.
* The main difference in behavior would be for orgs with custom domains
* served from a shared pool of servers, for which gristConfig.org would
* be set, but not gristConfig.singleOrg.
*/
export function getSingleOrg(): string|null {
if (isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return (gristConfig && gristConfig.singleOrg) || null;
} else {
return process.env.GRIST_SINGLE_ORG || null;
}
}
/** /**
* Returns true if org must be encoded in path, not in domain. Determined from * Returns true if org must be encoded in path, not in domain. Determined from
* gristConfig on the client. On on the server returns true if the host is * gristConfig on the client. On on the server returns true if the host is

View File

@ -299,7 +299,9 @@ export class HomeDBManager extends EventEmitter {
} }
// make sure special users and workspaces are available // make sure special users and workspaces are available
public async initializeSpecialIds(): Promise<void> { public async initializeSpecialIds(options?: {
skipWorkspaces?: boolean // if set, skip setting example workspace.
}): Promise<void> {
await this._getSpecialUserId({ await this._getSpecialUserId({
email: ANONYMOUS_USER_EMAIL, email: ANONYMOUS_USER_EMAIL,
name: "Anonymous" name: "Anonymous"
@ -317,9 +319,13 @@ export class HomeDBManager extends EventEmitter {
name: "Support" name: "Support"
}); });
if (!options?.skipWorkspaces) {
// Find the example workspace. If there isn't one named just right, take the first workspace // Find the example workspace. If there isn't one named just right, take the first workspace
// belonging to the support user. This shouldn't happen in deployments but could happen // belonging to the support user. This shouldn't happen in deployments but could happen
// in tests. // in tests.
// TODO: it should now be possible to remove all this; the only remaining
// issue is what workspace to associate with documents created by
// anonymous users.
const supportWorkspaces = await this._workspaces() const supportWorkspaces = await this._workspaces()
.leftJoinAndSelect('workspaces.org', 'orgs') .leftJoinAndSelect('workspaces.org', 'orgs')
.where('orgs.owner_id = :userId', { userId: this.getSupportUserId() }) .where('orgs.owner_id = :userId', { userId: this.getSupportUserId() })
@ -333,6 +339,7 @@ export class HomeDBManager extends EventEmitter {
this._exampleWorkspaceId = exampleWorkspace.id; this._exampleWorkspaceId = exampleWorkspace.id;
this._exampleOrgId = exampleWorkspace.org.id; this._exampleOrgId = exampleWorkspace.org.id;
} }
}
public get connection() { public get connection() {
return this._connection; return this._connection;

View File

@ -17,6 +17,7 @@ import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
RequestWithLogin} from 'app/server/lib/Authorizer'; RequestWithLogin} from 'app/server/lib/Authorizer';
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';
import {DocTemplate, GristServer} from 'app/server/lib/GristServer';
import {getAssignmentId} from 'app/server/lib/idUtils'; import {getAssignmentId} from 'app/server/lib/idUtils';
import * as log from 'app/server/lib/log'; import * as log from 'app/server/lib/log';
import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils'; import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
@ -31,6 +32,7 @@ export interface AttachOptions {
sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>; sendAppPage: (req: express.Request, resp: express.Response, options: ISendAppPageOptions) => Promise<void>;
dbManager: HomeDBManager; dbManager: HomeDBManager;
plugins: LocalPlugin[]; plugins: LocalPlugin[];
gristServer: GristServer;
} }
/** /**
@ -61,7 +63,13 @@ export interface AttachOptions {
* TODO: doc worker registration could be redesigned to remove the assumption * TODO: doc worker registration could be redesigned to remove the assumption
* of a fixed base domain. * of a fixed base domain.
*/ */
function customizeDocWorkerUrl(docWorkerUrlSeed: string, req: express.Request) { function customizeDocWorkerUrl(docWorkerUrlSeed: string|undefined, req: express.Request): string|null {
if (!docWorkerUrlSeed) {
// When no doc worker seed, we're in single server mode.
// Return null, to signify that the URL prefix serving the
// current endpoint is the only one available.
return null;
}
const docWorkerUrl = new URL(docWorkerUrlSeed); const docWorkerUrl = new URL(docWorkerUrlSeed);
const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org; const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;
adaptServerUrl(docWorkerUrl, req); adaptServerUrl(docWorkerUrl, req);
@ -105,6 +113,14 @@ function customizeDocWorkerUrl(docWorkerUrlSeed: string, req: express.Request) {
*/ */
async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string, async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string,
urlPath: string, config: RequestInit = {}) { urlPath: string, config: RequestInit = {}) {
if (!useWorkerPool()) {
// This should never happen. We are careful to not use getWorker
// when everything is on a single server, since it is burdensome
// for self-hosted users to figure out the correct settings for
// the server to be able to contact itself, and there are cases
// of the defaults not working.
throw new Error("AppEndpoint.getWorker was called unnecessarily");
}
let docStatus: DocStatus|undefined; let docStatus: DocStatus|undefined;
const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS); const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS);
for (;;) { for (;;) {
@ -151,13 +167,27 @@ async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string,
} }
export function attachAppEndpoint(options: AttachOptions): void { export function attachAppEndpoint(options: AttachOptions): void {
const {app, middleware, docMiddleware, docWorkerMap, forceLogin, sendAppPage, dbManager, plugins} = options; const {app, middleware, docMiddleware, docWorkerMap, forceLogin,
sendAppPage, dbManager, plugins, gristServer} = options;
// Per-workspace URLs open the same old Home page, and it's up to the client to notice and // Per-workspace URLs open the same old Home page, and it's up to the client to notice and
// render the right workspace. // render the right workspace.
app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) => app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) =>
sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'}))); sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'})));
app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => { app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => {
if (!useWorkerPool()) {
// Let the client know there is not a separate pool of workers,
// so they should continue to use the same base URL for accessing
// documents. For consistency, return a prefix to add into that
// URL, as there would be for a pool of workers. It would be nice
// to go ahead and provide the full URL, but that requires making
// more assumptions about how Grist is configured.
// Alternatives could be: have the client to send their base URL
// in the request; or use headers commonly added by reverse proxies.
const selfPrefix = "/dw/self/v/" + gristServer.getTag();
res.json({docWorkerUrl: null, selfPrefix});
return;
}
if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); } if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); }
res.header("Access-Control-Allow-Credentials", "true"); res.header("Access-Control-Allow-Credentials", "true");
@ -237,25 +267,31 @@ export function attachAppEndpoint(options: AttachOptions): void {
throw err; throw err;
} }
let body: DocTemplate;
let docStatus: DocStatus|undefined;
const docId = doc.id;
if (!useWorkerPool()) {
body = await gristServer.getDocTemplate();
} else {
// The reason to pass through app.html fetched from docWorker is in case it is a different // The reason to pass through app.html fetched from docWorker is in case it is a different
// version of Grist (could be newer or older). // version of Grist (could be newer or older).
// TODO: More must be done for correct version tagging of URLs: <base href> assumes all // TODO: More must be done for correct version tagging of URLs: <base href> assumes all
// links and static resources come from the same host, but we'll have Home API, DocWorker, // links and static resources come from the same host, but we'll have Home API, DocWorker,
// and static resources all at hostnames different from where this page is served. // and static resources all at hostnames different from where this page is served.
// TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set. // TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set.
const docId = doc.id;
const headers = { const headers = {
Accept: 'application/json', Accept: 'application/json',
...getTransitiveHeaders(req), ...getTransitiveHeaders(req),
}; };
const {docStatus, resp} = await getWorker(docWorkerMap, docId, const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, {headers});
`/${docId}/app.html`, {headers}); docStatus = workerInfo.docStatus;
const body = await resp.json(); body = await workerInfo.resp.json();
}
await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200, await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200,
googleTagManager: 'anon', config: { googleTagManager: 'anon', config: {
assignmentId: docId, assignmentId: docId,
getWorker: {[docId]: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)}, getWorker: {[docId]: customizeDocWorkerUrl(docStatus?.docWorker?.publicUrl, req)},
getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)}, getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)},
plugins plugins
}}); }});
@ -266,3 +302,8 @@ export function attachAppEndpoint(options: AttachOptions): void {
app.get('/:urlId([^/]{12,})/:slug([^/]+):remainder(*)', app.get('/:urlId([^/]{12,})/:slug([^/]+):remainder(*)',
...docMiddleware, docHandler); ...docMiddleware, docHandler);
} }
// Return true if document related endpoints are served by separate workers.
function useWorkerPool() {
return process.env.GRIST_SINGLE_PORT !== 'true';
}

View File

@ -34,7 +34,7 @@ import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap'; import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap';
import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg'; import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg';
import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth"; import {addGoogleAuthEndpoint} from "app/server/lib/GoogleAuth";
import {GristLoginMiddleware, GristServer, RequestWithGrist} from 'app/server/lib/GristServer'; import {DocTemplate, GristLoginMiddleware, GristServer, RequestWithGrist} from 'app/server/lib/GristServer';
import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions'; import {initGristSessions, SessionStore} from 'app/server/lib/gristSessions';
import {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling'; import {IBilling} from 'app/server/lib/IBilling';
@ -766,7 +766,8 @@ export class FlexServer implements GristServer {
docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap, docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap,
sendAppPage: this._sendAppPage, sendAppPage: this._sendAppPage,
dbManager: this._dbManager, dbManager: this._dbManager,
plugins : (await this._addPluginManager()).getPlugins() plugins : (await this._addPluginManager()).getPlugins(),
gristServer: this,
}); });
} }
@ -1301,6 +1302,20 @@ export class FlexServer implements GristServer {
addGoogleAuthEndpoint(this.app, messagePage); addGoogleAuthEndpoint(this.app, messagePage);
} }
// Get the HTML template sent for document pages.
public async getDocTemplate(): Promise<DocTemplate> {
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'),
'app.html'), 'utf8');
return {
page,
tag: this.tag
};
}
public getTag(): string {
return this.tag;
}
// Adds endpoints that support imports and exports. // Adds endpoints that support imports and exports.
private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) { private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) {
if (!this._docWorker) { throw new Error("need DocWorker"); } if (!this._docWorker) { throw new Error("need DocWorker"); }
@ -1552,12 +1567,7 @@ export class FlexServer implements GristServer {
// TODO: We should be the ones to fill in the base href here to ensure that the browser fetches // TODO: We should be the ones to fill in the base href here to ensure that the browser fetches
// the correct version of static files for this app.html. // the correct version of static files for this app.html.
this.app.get('/:docId/app.html', this._userIdMiddleware, expressWrap(async (req, res) => { this.app.get('/:docId/app.html', this._userIdMiddleware, expressWrap(async (req, res) => {
const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'), res.json(await this.getDocTemplate());
'app.html'), 'utf8');
res.json({
page,
tag: this.tag
});
})); }));
} }

View File

@ -23,6 +23,7 @@ export interface GristServer {
getHost(): string; getHost(): string;
getHomeUrl(req: express.Request, relPath?: string): string; getHomeUrl(req: express.Request, relPath?: string): string;
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>; getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
getOwnUrl(): string;
getDocUrl(docId: string): Promise<string>; getDocUrl(docId: string): Promise<string>;
getOrgUrl(orgKey: string|number): Promise<string>; getOrgUrl(orgKey: string|number): Promise<string>;
getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string; getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string;
@ -36,6 +37,8 @@ export interface GristServer {
getHomeDBManager(): HomeDBManager; getHomeDBManager(): HomeDBManager;
getStorageManager(): IDocStorageManager; getStorageManager(): IDocStorageManager;
getNotifier(): INotifier; getNotifier(): INotifier;
getDocTemplate(): Promise<DocTemplate>;
getTag(): string;
} }
export interface GristLoginSystem { export interface GristLoginSystem {
@ -55,3 +58,8 @@ export interface GristLoginMiddleware {
export interface RequestWithGrist extends express.Request { export interface RequestWithGrist extends express.Request {
gristServer?: GristServer; gristServer?: GristServer;
} }
export interface DocTemplate {
page: string,
tag: string,
}

View File

@ -1,5 +1,6 @@
import { UserProfile } from 'app/common/UserAPI'; import { UserProfile } from 'app/common/UserAPI';
import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer'; import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer';
import { fromCallback } from 'app/server/lib/serverUtils';
import { Request } from 'express'; import { Request } from 'express';
/** /**
@ -47,6 +48,12 @@ export async function getMinimalLoginSystem(): Promise<GristLoginSystem> {
*/ */
async function setSingleUser(req: Request, gristServer: GristServer) { async function setSingleUser(req: Request, gristServer: GristServer) {
const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req); const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req);
// Make sure session is up to date before operating on it.
// Behavior on a completely fresh session is a little awkward currently.
const reqSession = (req as any).session;
if (reqSession?.save) {
await fromCallback(cb => reqSession.save(cb));
}
await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, { await scopedSession.operateOnScopedSession(req, async (user) => Object.assign(user, {
profile: getDefaultProfile() profile: getDefaultProfile()
})); }));

View File

@ -85,9 +85,9 @@ export class Sessions {
if (req.headers.cookie) { if (req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie); const cookies = cookie.parse(req.headers.cookie);
const sessionId = this.getSessionIdFromCookie(cookies[cookieName]); const sessionId = this.getSessionIdFromCookie(cookies[cookieName]);
return sessionId; if (sessionId) { return sessionId; }
} }
return null; return (req as any).sessionID || null; // sessionID set by express-session
} }
/** /**

View File

@ -1,6 +1,7 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads'; import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
import {getDocWorkerUrl} from 'app/common/UserAPI';
import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode, import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,
RequestWithLogin} from 'app/server/lib/Authorizer'; RequestWithLogin} from 'app/server/lib/Authorizer';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';
@ -67,7 +68,7 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ...
if (!docId) { throw new Error('doc must be specified'); } if (!docId) { throw new Error('doc must be specified'); }
const accessId = makeAccessId(req, getAuthorizedUserId(req)); const accessId = makeAccessId(req, getAuthorizedUserId(req));
try { try {
const uploadResult: UploadResult = await fetchDoc(server.getHomeUrl(req), docId, req, accessId, const uploadResult: UploadResult = await fetchDoc(server, docId, req, accessId,
req.query.template === '1'); req.query.template === '1');
if (name) { if (name) {
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name); globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name);
@ -398,21 +399,22 @@ async function _fetchURL(url: string, accessId: string|null, options?: FetchUrlO
* Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials * Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials
* supplied in the current request. * supplied in the current request.
*/ */
async function fetchDoc(homeUrl: string, docId: string, req: Request, accessId: string|null, async function fetchDoc(server: GristServer, docId: string, req: Request, accessId: string|null,
template: boolean): Promise<UploadResult> { template: boolean): Promise<UploadResult> {
// Prepare headers that preserve credentials of current user. // Prepare headers that preserve credentials of current user.
const headers = getTransitiveHeaders(req); const headers = getTransitiveHeaders(req);
// Find the doc worker responsible for the document we wish to copy. // Find the doc worker responsible for the document we wish to copy.
// The backend needs to be well configured for this to work.
const homeUrl = server.getHomeUrl(req);
const fetchUrl = new URL(`/api/worker/${docId}`, homeUrl); const fetchUrl = new URL(`/api/worker/${docId}`, homeUrl);
const response: FetchResponse = await Deps.fetch(fetchUrl.href, {headers}); const response: FetchResponse = await Deps.fetch(fetchUrl.href, {headers});
await _checkForError(response); await _checkForError(response);
const {docWorkerUrl} = await response.json(); const docWorkerUrl = getDocWorkerUrl(server.getOwnUrl(), await response.json());
// Download the document, in full or as a template. // Download the document, in full or as a template.
const url = `${docWorkerUrl}download?doc=${docId}&template=${Number(template)}`; const url = new URL(`download?doc=${docId}&template=${Number(template)}`,
return _fetchURL(url, accessId, {headers}); docWorkerUrl.replace(/\/*$/, '/'));
return _fetchURL(url.href, accessId, {headers});
} }
// Re-issue failures as exceptions. // Re-issue failures as exceptions.

View File

@ -5,6 +5,7 @@
*/ */
import {isAffirmative} from 'app/common/gutil'; import {isAffirmative} from 'app/common/gutil';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE); const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE);
@ -22,6 +23,12 @@ setDefaultEnv('GRIST_SERVE_SAME_ORIGIN', 'true');
setDefaultEnv('GRIST_SINGLE_PORT', 'true'); setDefaultEnv('GRIST_SINGLE_PORT', 'true');
setDefaultEnv('GRIST_DEFAULT_PRODUCT', 'Free'); setDefaultEnv('GRIST_DEFAULT_PRODUCT', 'Free');
if (!process.env.GRIST_SINGLE_ORG) {
// org identifiers in domains are fiddly to configure right, so by
// default don't do that.
setDefaultEnv('GRIST_ORG_IN_PATH', 'true');
}
import {updateDb} from 'app/server/lib/dbUtils'; import {updateDb} from 'app/server/lib/dbUtils';
import {main as mergedServerMain} from 'app/server/mergedServerMain'; import {main as mergedServerMain} from 'app/server/mergedServerMain';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
@ -46,14 +53,49 @@ export async function main() {
} }
// If SAML is not configured, there's no login system, so provide a default email address. // If SAML is not configured, there's no login system, so provide a default email address.
if (!process.env.GRIST_SAML_SP_HOST && !process.env.GRIST_TEST_LOGIN) {
setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com'); setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
}
// Set directory for uploaded documents. // Set directory for uploaded documents.
setDefaultEnv('GRIST_DATA_DIR', 'docs'); setDefaultEnv('GRIST_DATA_DIR', 'docs');
await fse.mkdirp(process.env.GRIST_DATA_DIR!); await fse.mkdirp(process.env.GRIST_DATA_DIR!);
// Make a blank db if needed. // Make a blank db if needed.
await updateDb(); await updateDb();
// If a team/organization is specified, make sure it exists.
const org = process.env.GRIST_SINGLE_ORG;
if (org && org !== 'docs') {
const db = new HomeDBManager();
await db.connect();
await db.initializeSpecialIds({skipWorkspaces: true});
try {
db.unwrapQueryResult(await db.getOrg({
userId: db.getPreviewerUserId(),
includeSupport: false,
}, org));
} catch(e) {
if (!String(e).match(/organization not found/)) {
throw e;
}
const email = process.env.GRIST_DEFAULT_EMAIL;
if (!email) {
throw new Error('need GRIST_DEFAULT_EMAIL to create site');
}
const user = await db.getUserByLogin(email, {
email,
name: email,
});
if (!user) {
// This should not happen.
throw new Error('failed to create GRIST_DEFAULT_EMAIL user');
}
await db.addOrg(user, {
name: org,
domain: org,
}, {
setUserAsOwner: false,
useNewPlan: true,
planType: 'free'
});
}
}
// Launch single-port, self-contained version of Grist. // Launch single-port, self-contained version of Grist.
const server = await mergedServerMain(G.port, ["home", "docs", "static"]); const server = await mergedServerMain(G.port, ["home", "docs", "static"]);
if (process.env.GRIST_TESTING_SOCKET) { if (process.env.GRIST_TESTING_SOCKET) {