diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 8d9b417f..9ec42ab5 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -14,7 +14,7 @@ import * as roles from 'app/common/roles'; import {Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; import {Disposable, dom, DomElementArg, styled} from 'grainjs'; 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 @@ -91,7 +91,6 @@ export class AccountWidget extends Disposable { } const users = this._appModel.topAppModel.users; - const orgs = this._appModel.topAppModel.orgs; return [ cssUserInfo( @@ -143,10 +142,7 @@ export class AccountWidget extends Disposable { menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')), - dom.maybe((use) => use(orgs).length > 0, () => [ - menuDivider(), - buildSiteSwitcher(this._appModel), - ]), + maybeAddSiteSwitcherSection(this._appModel), ]; } diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 13c897bc..3f577c47 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -4,14 +4,14 @@ import {cssLeftPane} from 'app/client/ui/PagePanels'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import * as version from 'app/common/version'; 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 {AppModel} from 'app/client/models/AppModel'; import {icon} from 'app/client/ui2018/icons'; import {DocPageModel} from 'app/client/models/DocPageModel'; import * as roles from 'app/common/roles'; 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 { @@ -24,7 +24,6 @@ export class AppHeader extends Disposable { const theme = getTheme(this._appModel.topAppModel.productFlavor); const user = this._appModel.currentValidUser; - const orgs = this._appModel.topAppModel.orgs; const currentOrg = this._appModel.currentOrg; const isTeamSite = Boolean(currentOrg && !currentOrg.owner); const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount && @@ -74,10 +73,7 @@ export class AppHeader extends Disposable { ) : null, - dom.maybe((use) => use(orgs).length > 0, () => [ - menuDivider(), - buildSiteSwitcher(this._appModel), - ]), + maybeAddSiteSwitcherSection(this._appModel), ], { placement: 'bottom-start' }), testId('dm-org'), ), diff --git a/app/client/ui/SiteSwitcher.ts b/app/client/ui/SiteSwitcher.ts index 634f5471..62454f61 100644 --- a/app/client/ui/SiteSwitcher.ts +++ b/app/client/ui/SiteSwitcher.ts @@ -1,14 +1,25 @@ -import {commonUrls} from 'app/common/gristUrls'; +import {commonUrls, getSingleOrg} from 'app/common/gristUrls'; import {getOrgName} from 'app/common/UserAPI'; import {dom, makeTestId, styled} from 'grainjs'; import {AppModel} from 'app/client/models/AppModel'; 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 {colors} from 'app/client/ui2018/cssVars'; 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 * valid user has access to, with buttons to navigate to them. diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 82b2302c..90d9f509 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -672,7 +672,7 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { method: 'GET', credentials: 'include' }); - return json.docWorkerUrl; + return getDocWorkerUrl(this._homeUrl, json); } public async getWorkerAPI(key: string): Promise { @@ -968,3 +968,28 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { 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; +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 2623df63..5515b5f5 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -472,7 +472,7 @@ export interface GristLoadConfig { getDoc?: {[id: string]: Document}; // 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. 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 * gristConfig on the client. On on the server returns true if the host is diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 800e75db..f3f7cf64 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -299,7 +299,9 @@ export class HomeDBManager extends EventEmitter { } // make sure special users and workspaces are available - public async initializeSpecialIds(): Promise { + public async initializeSpecialIds(options?: { + skipWorkspaces?: boolean // if set, skip setting example workspace. + }): Promise { await this._getSpecialUserId({ email: ANONYMOUS_USER_EMAIL, name: "Anonymous" @@ -317,21 +319,26 @@ export class HomeDBManager extends EventEmitter { name: "Support" }); - // 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 - // in tests. - const supportWorkspaces = await this._workspaces() - .leftJoinAndSelect('workspaces.org', 'orgs') - .where('orgs.owner_id = :userId', { userId: this.getSupportUserId() }) - .orderBy('workspaces.created_at') - .getMany(); - const exampleWorkspace = supportWorkspaces.find(ws => ws.name === EXAMPLE_WORKSPACE_NAME) || supportWorkspaces[0]; - if (!exampleWorkspace) { throw new Error('No example workspace available'); } - if (exampleWorkspace.name !== EXAMPLE_WORKSPACE_NAME) { - log.warn('did not find an appropriately named example workspace in deployment'); + if (!options?.skipWorkspaces) { + // 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 + // 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() + .leftJoinAndSelect('workspaces.org', 'orgs') + .where('orgs.owner_id = :userId', { userId: this.getSupportUserId() }) + .orderBy('workspaces.created_at') + .getMany(); + const exampleWorkspace = supportWorkspaces.find(ws => ws.name === EXAMPLE_WORKSPACE_NAME) || supportWorkspaces[0]; + if (!exampleWorkspace) { throw new Error('No example workspace available'); } + if (exampleWorkspace.name !== EXAMPLE_WORKSPACE_NAME) { + log.warn('did not find an appropriately named example workspace in deployment'); + } + this._exampleWorkspaceId = exampleWorkspace.id; + this._exampleOrgId = exampleWorkspace.org.id; } - this._exampleWorkspaceId = exampleWorkspace.id; - this._exampleOrgId = exampleWorkspace.org.id; } public get connection() { diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 3998c2a8..5a2cf8ea 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -17,6 +17,7 @@ import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, RequestWithLogin} from 'app/server/lib/Authorizer'; import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {expressWrap} from 'app/server/lib/expressWrap'; +import {DocTemplate, GristServer} from 'app/server/lib/GristServer'; import {getAssignmentId} from 'app/server/lib/idUtils'; import * as log from 'app/server/lib/log'; 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; dbManager: HomeDBManager; plugins: LocalPlugin[]; + gristServer: GristServer; } /** @@ -61,7 +63,13 @@ export interface AttachOptions { * TODO: doc worker registration could be redesigned to remove the assumption * 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 workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org; adaptServerUrl(docWorkerUrl, req); @@ -105,6 +113,14 @@ function customizeDocWorkerUrl(docWorkerUrlSeed: string, req: express.Request) { */ async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string, 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; const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS); for (;;) { @@ -151,13 +167,27 @@ async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string, } 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 // render the right workspace. app.get(['/', '/ws/:wsId', '/p/:page'], ...middleware, expressWrap(async (req, res) => sendAppPage(req, res, {path: 'app.html', status: 200, config: {plugins}, googleTagManager: 'anon'}))); 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'); } res.header("Access-Control-Allow-Credentials", "true"); @@ -237,25 +267,31 @@ export function attachAppEndpoint(options: AttachOptions): void { throw err; } - // 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). - // TODO: More must be done for correct version tagging of URLs: assumes all - // 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. - // TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set. + let body: DocTemplate; + let docStatus: DocStatus|undefined; const docId = doc.id; - const headers = { - Accept: 'application/json', - ...getTransitiveHeaders(req), - }; - const {docStatus, resp} = await getWorker(docWorkerMap, docId, - `/${docId}/app.html`, {headers}); - const body = await resp.json(); + if (!useWorkerPool()) { + body = await gristServer.getDocTemplate(); + } else { + // 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). + // TODO: More must be done for correct version tagging of URLs: assumes all + // 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. + // TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set. + const headers = { + Accept: 'application/json', + ...getTransitiveHeaders(req), + }; + const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, {headers}); + docStatus = workerInfo.docStatus; + body = await workerInfo.resp.json(); + } await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200, googleTagManager: 'anon', config: { assignmentId: docId, - getWorker: {[docId]: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)}, + getWorker: {[docId]: customizeDocWorkerUrl(docStatus?.docWorker?.publicUrl, req)}, getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)}, plugins }}); @@ -266,3 +302,8 @@ export function attachAppEndpoint(options: AttachOptions): void { app.get('/:urlId([^/]{12,})/:slug([^/]+):remainder(*)', ...docMiddleware, docHandler); } + +// Return true if document related endpoints are served by separate workers. +function useWorkerPool() { + return process.env.GRIST_SINGLE_PORT !== 'true'; +} diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index d5c852a1..7a70efc7 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -34,7 +34,7 @@ import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap'; import {Hosts, RequestWithOrg} from 'app/server/lib/extractOrg'; 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 {HostedStorageManager} from 'app/server/lib/HostedStorageManager'; import {IBilling} from 'app/server/lib/IBilling'; @@ -766,7 +766,8 @@ export class FlexServer implements GristServer { docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap, sendAppPage: this._sendAppPage, 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); } + // Get the HTML template sent for document pages. + public async getDocTemplate(): Promise { + 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. private _addSupportPaths(docAccessMiddleware: express.RequestHandler[]) { 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 // the correct version of static files for this app.html. this.app.get('/:docId/app.html', this._userIdMiddleware, expressWrap(async (req, res) => { - const page = await fse.readFile(path.join(getAppPathTo(this.appRoot, 'static'), - 'app.html'), 'utf8'); - res.json({ - page, - tag: this.tag - }); + res.json(await this.getDocTemplate()); })); } diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 54dacb46..ece3f6ea 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -23,6 +23,7 @@ export interface GristServer { getHost(): string; getHomeUrl(req: express.Request, relPath?: string): string; getHomeUrlByDocId(docId: string, relPath?: string): Promise; + getOwnUrl(): string; getDocUrl(docId: string): Promise; getOrgUrl(orgKey: string|number): Promise; getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string; @@ -36,6 +37,8 @@ export interface GristServer { getHomeDBManager(): HomeDBManager; getStorageManager(): IDocStorageManager; getNotifier(): INotifier; + getDocTemplate(): Promise; + getTag(): string; } export interface GristLoginSystem { @@ -55,3 +58,8 @@ export interface GristLoginMiddleware { export interface RequestWithGrist extends express.Request { gristServer?: GristServer; } + +export interface DocTemplate { + page: string, + tag: string, +} diff --git a/app/server/lib/MinimalLogin.ts b/app/server/lib/MinimalLogin.ts index 32270a23..9fff97d6 100644 --- a/app/server/lib/MinimalLogin.ts +++ b/app/server/lib/MinimalLogin.ts @@ -1,5 +1,6 @@ import { UserProfile } from 'app/common/UserAPI'; import { GristLoginSystem, GristServer } from 'app/server/lib/GristServer'; +import { fromCallback } from 'app/server/lib/serverUtils'; import { Request } from 'express'; /** @@ -47,6 +48,12 @@ export async function getMinimalLoginSystem(): Promise { */ async function setSingleUser(req: Request, gristServer: GristServer) { 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, { profile: getDefaultProfile() })); diff --git a/app/server/lib/Sessions.ts b/app/server/lib/Sessions.ts index ce947932..93f8b7fd 100644 --- a/app/server/lib/Sessions.ts +++ b/app/server/lib/Sessions.ts @@ -85,9 +85,9 @@ export class Sessions { if (req.headers.cookie) { const cookies = cookie.parse(req.headers.cookie); 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 } /** diff --git a/app/server/lib/uploads.ts b/app/server/lib/uploads.ts index f750a150..de0f9f81 100644 --- a/app/server/lib/uploads.ts +++ b/app/server/lib/uploads.ts @@ -1,6 +1,7 @@ import {ApiError} from 'app/common/ApiError'; import {InactivityTimer} from 'app/common/InactivityTimer'; import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads'; +import {getDocWorkerUrl} from 'app/common/UserAPI'; import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; 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'); } const accessId = makeAccessId(req, getAuthorizedUserId(req)); 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'); if (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 * 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 { - // Prepare headers that preserve credentials of current user. const headers = getTransitiveHeaders(req); // 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 response: FetchResponse = await Deps.fetch(fetchUrl.href, {headers}); 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. - const url = `${docWorkerUrl}download?doc=${docId}&template=${Number(template)}`; - return _fetchURL(url, accessId, {headers}); + const url = new URL(`download?doc=${docId}&template=${Number(template)}`, + docWorkerUrl.replace(/\/*$/, '/')); + return _fetchURL(url.href, accessId, {headers}); } // Re-issue failures as exceptions. diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 85afe3e7..98e95ea4 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -5,6 +5,7 @@ */ import {isAffirmative} from 'app/common/gutil'; +import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; 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_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 {main as mergedServerMain} from 'app/server/mergedServerMain'; 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 (!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. setDefaultEnv('GRIST_DATA_DIR', 'docs'); await fse.mkdirp(process.env.GRIST_DATA_DIR!); // Make a blank db if needed. 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. const server = await mergedServerMain(G.port, ["home", "docs", "static"]); if (process.env.GRIST_TESTING_SOCKET) {