diff --git a/app/client/errorMain.ts b/app/client/errorMain.ts new file mode 100644 index 00000000..f4f44f97 --- /dev/null +++ b/app/client/errorMain.ts @@ -0,0 +1,17 @@ +import {TopAppModelImpl} from 'app/client/models/AppModel'; +import {setUpErrorHandling} from 'app/client/models/errors'; +import {createErrPage} from 'app/client/ui/errorPages'; +import {buildSnackbarDom} from 'app/client/ui/NotifyUI'; +import {addViewportTag} from 'app/client/ui/viewport'; +import {attachCssRootVars} from 'app/client/ui2018/cssVars'; +import {dom} from 'grainjs'; + +// Set up the global styles for variables, and root/body styles. +setUpErrorHandling(); +const topAppModel = TopAppModelImpl.create(null, {}); +attachCssRootVars(topAppModel.productFlavor); +addViewportTag(); +dom.update(document.body, dom.maybe(topAppModel.appObs, (appModel) => [ + createErrPage(appModel), + buildSnackbarDom(appModel.notifier, appModel), +])); diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 77fd8688..2bf94066 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -774,7 +774,10 @@ export class HomeDBManager extends EventEmitter { public async getOrgWorkspaces(scope: Scope, orgKey: string|number, options: QueryOptions = {}): Promise> { const query = this._orgWorkspaces(scope, orgKey, options); - const result = await this._verifyAclPermissions(query, { scope }); + // Allow an empty result for the merged org for the anonymous user. The anonymous user + // has no home org or workspace. For all other sitations, expect at least one workspace. + const emptyAllowed = this.isMergedOrg(orgKey) && scope.userId === this.getAnonymousUserId(); + const result = await this._verifyAclPermissions(query, { scope, emptyAllowed }); // Return the workspaces, not the org(s). if (result.status === 200) { // Place ownership information in workspaces, available for the merged org. diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 91789683..edb6ff3a 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -94,7 +94,6 @@ export function isSingleUserMode(): boolean { * - req.users: set for org-and-session-based logins, with list of profiles in session */ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore, - fallbackEmail: string|null, req: Request, res: Response, next: NextFunction) { const mreq = req as RequestWithLogin; let profile: UserProfile|undefined; @@ -234,18 +233,6 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer } } - if (!mreq.userId && fallbackEmail) { - const user = await dbManager.getUserByLogin(fallbackEmail); - if (user) { - mreq.user = user; - mreq.userId = user.id; - mreq.userIsAuthorized = true; - const fullUser = dbManager.makeFullUser(user); - mreq.users = [fullUser]; - profile = fullUser; - } - } - // If no userId has been found yet, fall back on anonymous. if (!mreq.userId) { const anon = dbManager.getAnonymousUser(); @@ -309,11 +296,16 @@ export function redirectToLogin( if (mreq.userIsAuthorized) { return next(); } try { - // Otherwise it's an anonymous user. Proceed normally only if the org allows anon access. - if (mreq.userId && mreq.org && allowExceptions) { + // Otherwise it's an anonymous user. Proceed normally only if the org allows anon access, + // or if the org is not set (FlexServer._redirectToOrg will deal with that case). + if (mreq.userId && allowExceptions) { // Anonymous user has qualified access to merged org. - if (dbManager.isMergedOrg(mreq.org)) { return next(); } - const result = await dbManager.getOrg({userId: mreq.userId}, mreq.org || null); + // If no org is set, leave it to other middleware. One common case where the + // org is not set is when it is embedded in the url, and the user visits '/'. + // If we immediately require a login, it could fail if no cookie exists yet. + // Also, '/o/docs' allows anonymous access. + if (!mreq.org || dbManager.isMergedOrg(mreq.org)) { return next(); } + const result = await dbManager.getOrg({userId: mreq.userId}, mreq.org); if (result.status === 200) { return next(); } } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index bd85b2f6..d517cb58 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -96,17 +96,17 @@ export class FlexServer implements GristServer { public host: string; public tag: string; public info = new Array<[string, any]>(); - public dbManager: HomeDBManager; public notifier: INotifier; public usage: Usage; public housekeeper: Housekeeper; public server: http.Server; public httpsServer?: https.Server; - public comm: Comm; public settings: any; public worker: DocWorkerInfo; public electronServerMethods: ElectronServerMethods; public readonly docsRoot: string; + private _comm: Comm; + private _dbManager: HomeDBManager; private _defaultBaseDomain: string|undefined; private _pluginUrl: string|undefined; private _billing: IBilling; @@ -249,6 +249,21 @@ export class FlexServer implements GristServer { return this._sessions; } + public getComm(): Comm { + if (!this._comm) { throw new Error('no Comm available'); } + return this._comm; + } + + public getHosts(): Hosts { + if (!this._hosts) { throw new Error('no hosts available'); } + return this._hosts; + } + + public getHomeDBManager(): HomeDBManager { + if (!this._dbManager) { throw new Error('no home db available'); } + return this._dbManager; + } + public addLogging() { if (this._check('logging')) { return; } if (process.env.GRIST_LOG_SKIP_HTTP) { return; } @@ -406,30 +421,17 @@ export class FlexServer implements GristServer { // Prepare cache for managing org-to-host relationship. public addHosts() { if (this._check('hosts', 'homedb')) { return; } - this._hosts = new Hosts(this._defaultBaseDomain, this.dbManager, this._pluginUrl); + this._hosts = new Hosts(this._defaultBaseDomain, this._dbManager, this._pluginUrl); } public async initHomeDBManager() { if (this._check('homedb')) { return; } - this.dbManager = new HomeDBManager(); - this.dbManager.setPrefix(process.env.GRIST_ID_PREFIX || ""); - await this.dbManager.connect(); - await this.dbManager.initializeSpecialIds(); - // If working without a login system, make sure default user exists. - if (process.env.GRIST_DEFAULT_EMAIL) { - const profile: UserProfile = { - name: 'You', - email: process.env.GRIST_DEFAULT_EMAIL, - }; - const user = await this.dbManager.getUserByLoginWithRetry(profile.email, profile); - if (user) { - // No need to survey this user! - user.isFirstTimeUser = false; - await user.save(); - } - } + this._dbManager = new HomeDBManager(); + this._dbManager.setPrefix(process.env.GRIST_ID_PREFIX || ""); + await this._dbManager.connect(); + await this._dbManager.initializeSpecialIds(); // Report which database we are using, without sensitive credentials. - this.info.push(['database', getDatabaseUrl(this.dbManager.connection.options, false)]); + this.info.push(['database', getDatabaseUrl(this._dbManager.connection.options, false)]); } public addDocWorkerMap() { @@ -448,11 +450,7 @@ export class FlexServer implements GristServer { // Middleware to redirect landing pages to preferred host this._redirectToHostMiddleware = this._hosts.redirectHost; // Middleware to add the userId to the express request object. - // If GRIST_DEFAULT_EMAIL is set, login as that user when no other credentials - // presented. - const fallbackEmail = process.env.GRIST_DEFAULT_EMAIL || null; - this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this.dbManager, this._internalPermitStore, - fallbackEmail)); + this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this._dbManager, this._internalPermitStore)); this._trustOriginsMiddleware = expressWrap(trustOriginHandler); // middleware to authorize doc access to the app. Note that this requires the userId // to be set on the request by _userIdMiddleware. @@ -460,11 +458,11 @@ export class FlexServer implements GristServer { this._redirectToLoginWithExceptionsMiddleware = redirectToLogin(true, this._getLoginRedirectUrl, this._getSignUpRedirectUrl, - this.dbManager); + this._dbManager); this._redirectToLoginWithoutExceptionsMiddleware = redirectToLogin(false, this._getLoginRedirectUrl, this._getSignUpRedirectUrl, - this.dbManager); + this._dbManager); this._redirectToLoginUnconditionally = redirectToLoginUnconditionally(this._getLoginRedirectUrl, this._getSignUpRedirectUrl); this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this); @@ -519,7 +517,7 @@ export class FlexServer implements GristServer { // ApiServer's constructor adds endpoints to the app. // tslint:disable-next-line:no-unused-expression - new ApiServer(this.app, this.dbManager); + new ApiServer(this.app, this._dbManager); } public addBillingApi() { @@ -553,9 +551,9 @@ export class FlexServer implements GristServer { public async close() { if (this.usage) { await this.usage.close(); } if (this._hosts) { this._hosts.close(); } - if (this.dbManager) { - this.dbManager.removeAllListeners(); - this.dbManager.flushDocAuthCache(); + if (this._dbManager) { + this._dbManager.removeAllListeners(); + this._dbManager.flushDocAuthCache(); } if (this.server) { this.server.close(); } if (this.httpsServer) { this.httpsServer.close(); } @@ -568,7 +566,7 @@ export class FlexServer implements GristServer { public addDocApiForwarder() { if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; } - const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this.dbManager); + const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager); docApiForwarder.addEndpoints(this.app); } @@ -605,9 +603,9 @@ export class FlexServer implements GristServer { this._disabled = true; } else { this._disabled = true; - if (this.comm) { - this.comm.setServerActivation(false); - this.comm.destroyAllClients(); + if (this._comm) { + this._comm.setServerActivation(false); + this._comm.destroyAllClients(); } } this.server.close(); @@ -649,7 +647,7 @@ export class FlexServer implements GristServer { if (this._storageManager) { this._storageManager.testReopenStorage(); } - this.comm.setServerActivation(true); + this._comm.setServerActivation(true); if (this.worker) { await this._startServers(this.server, this.httpsServer, this.name, this.port, false); await this._addSelfAsWorker(this._docWorkerMap); @@ -694,7 +692,7 @@ export class FlexServer implements GristServer { // If "welcomeNewUser" is ever added to billing pages, we'd need // to avoid a redirect loop. - const orgInfo = this.dbManager.unwrapQueryResult(await this.dbManager.getOrg({userId: user.id}, mreq.org)); + const orgInfo = this._dbManager.unwrapQueryResult(await this._dbManager.getOrg({userId: user.id}, mreq.org)); if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) { const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); @@ -722,7 +720,7 @@ export class FlexServer implements GristServer { forceLogin: this._redirectToLoginUnconditionally, docWorkerMap: isSingleUserMode() ? null : this._docWorkerMap, sendAppPage: this._sendAppPage, - dbManager: this.dbManager, + dbManager: this._dbManager, plugins : (await this._addPluginManager()).getPlugins() }); } @@ -755,7 +753,7 @@ export class FlexServer implements GristServer { public addComm() { if (this._check('comm', 'start')) { return; } - this.comm = new Comm(this.server, { + this._comm = new Comm(this.server, { settings: this.settings, sessions: this._sessions, hosts: this._hosts, @@ -784,8 +782,8 @@ export class FlexServer implements GristServer { })); } - public addLoginRoutes() { - if (this._check('login', 'org', 'sessions')) { return; } + public async addLoginRoutes() { + if (this._check('login', 'org', 'sessions', 'homedb')) { return; } // TODO: We do NOT want Comm here at all, it's only being used for handling sessions, which // should be factored out of it. this.addComm(); @@ -869,13 +867,13 @@ export class FlexServer implements GristServer { this.app.get('/verified', expressWrap((req, resp) => this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'verified'}}))); - const comment = this._loginMiddleware.addEndpoints(this.app, this.comm, this._sessions, this._hosts); + const comment = await this._loginMiddleware.addEndpoints(this.app); this.info.push(['loginMiddlewareComment', comment]); } public async addTestingHooks(workerServers?: FlexServer[]) { if (process.env.GRIST_TESTING_SOCKET) { - await startTestingHooks(process.env.GRIST_TESTING_SOCKET, this.port, this.comm, this, + await startTestingHooks(process.env.GRIST_TESTING_SOCKET, this.port, this._comm, this, workerServers || []); this._hasTestingHooks = true; } @@ -914,30 +912,30 @@ export class FlexServer implements GristServer { const docWorkerId = await this._addSelfAsWorker(workers); const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableS3, '', workers, - this.dbManager, this.create); + this._dbManager, this.create); this._storageManager = storageManager; } else { const samples = getAppPathTo(this.appRoot, 'public_samples'); - const storageManager = new DocStorageManager(this.docsRoot, samples, this.comm, this); + const storageManager = new DocStorageManager(this.docsRoot, samples, this._comm, this); this._storageManager = storageManager; } const pluginManager = await this._addPluginManager(); this._docManager = this._docManager || new DocManager(this._storageManager, pluginManager, - this.dbManager, this); + this._dbManager, this); const docManager = this._docManager; shutdown.addCleanupHandler(null, this._shutdown.bind(this), 25000, 'FlexServer._shutdown'); if (!isSingleUserMode()) { - this.comm.registerMethods({ + this._comm.registerMethods({ openDoc: docManager.openDoc.bind(docManager), }); this._serveDocPage(); } // Attach docWorker endpoints and Comm methods. - const docWorker = new DocWorker(this.dbManager, {comm: this.comm}); + const docWorker = new DocWorker(this._dbManager, {comm: this._comm}); this._docWorker = docWorker; // Register the websocket comm functions associated with the docworker. @@ -954,7 +952,7 @@ export class FlexServer implements GristServer { this._addSupportPaths(docAccessMiddleware); if (!isSingleUserMode()) { - addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this.dbManager, this); + addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this); } } @@ -979,9 +977,9 @@ export class FlexServer implements GristServer { return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); } // Allow the support user access to billing pages. - const scope = addPermit(getScope(mreq), this.dbManager.getSupportUserId(), {org: orgDomain}); - const query = await this.dbManager.getOrg(scope, orgDomain); - const org = this.dbManager.unwrapQueryResult(query); + const scope = addPermit(getScope(mreq), this._dbManager.getSupportUserId(), {org: orgDomain}); + const query = await this._dbManager.getOrg(scope, orgDomain); + const org = this._dbManager.unwrapQueryResult(query); // This page isn't availabe for personal site. if (org.owner) { return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); @@ -1044,7 +1042,7 @@ export class FlexServer implements GristServer { if (req.params.page === 'user') { const name: string|undefined = req.body && req.body.username || undefined; - await this.dbManager.updateUser(userId, {name, isFirstTimeUser: false}); + await this._dbManager.updateUser(userId, {name, isFirstTimeUser: false}); redirectPath = '/welcome/info'; } else if (req.params.page === 'info') { @@ -1062,8 +1060,8 @@ export class FlexServer implements GristServer { // // TODO With proper forms support, we could give an origin-based permission to submit a // form to this doc, and do it from the client directly. - const previewerUserId = this.dbManager.getPreviewerUserId(); - const docAuth = await this.dbManager.getDocAuthCached({urlId, userId: previewerUserId}); + const previewerUserId = this._dbManager.getPreviewerUserId(); + const docAuth = await this._dbManager.getDocAuthCached({urlId, userId: previewerUserId}); const docId = docAuth.docId; if (!docId) { throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`); @@ -1089,14 +1087,14 @@ export class FlexServer implements GristServer { // redirect to teams page if users has access to more than one org. Otherwise redirect to // personal org. - const result = await this.dbManager.getMergedOrgs(userId, userId, domain || null); + const result = await this._dbManager.getMergedOrgs(userId, userId, domain || null); const orgs = (result.status === 200) ? result.data : null; if (orgs && orgs.length > 1) { redirectPath = '/welcome/teams'; } } - const mergedOrgDomain = this.dbManager.mergedOrgDomain(); + const mergedOrgDomain = this._dbManager.mergedOrgDomain(); const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, redirectPath); resp.json({redirectUrl}); }), @@ -1160,7 +1158,7 @@ export class FlexServer implements GristServer { // and all that is needed is a refactor to pass that info along. But there is also the // case of notification(s) from stripe. May need to associate a preferred base domain // with org/user and persist that? - this.notifier = this.create.Notifier(this.dbManager, this); + this.notifier = this.create.Notifier(this._dbManager, this); } public getGristConfig(): GristLoadConfig { @@ -1172,9 +1170,9 @@ export class FlexServer implements GristServer { * the db for document details without including organization disambiguation. */ public async getDocUrl(docId: string): Promise { - if (!this.dbManager) { throw new Error('database missing'); } - const doc = await this.dbManager.getDoc({ - userId: this.dbManager.getPreviewerUserId(), + if (!this._dbManager) { throw new Error('database missing'); } + const doc = await this._dbManager.getDoc({ + userId: this._dbManager.getPreviewerUserId(), urlId: docId, showAll: true }); @@ -1185,19 +1183,19 @@ export class FlexServer implements GristServer { * Get a url for a team site. */ public async getOrgUrl(orgKey: string|number): Promise { - if (!this.dbManager) { throw new Error('database missing'); } - const org = await this.dbManager.getOrg({ - userId: this.dbManager.getPreviewerUserId(), + if (!this._dbManager) { throw new Error('database missing'); } + const org = await this._dbManager.getOrg({ + userId: this._dbManager.getPreviewerUserId(), showAll: true }, orgKey); - return this.getResourceUrl(this.dbManager.unwrapQueryResult(org)); + return this.getResourceUrl(this._dbManager.unwrapQueryResult(org)); } /** * Get a url for an organization, workspace, or document. */ public async getResourceUrl(resource: Organization|Workspace|Document): Promise { - if (!this.dbManager) { throw new Error('database missing'); } + if (!this._dbManager) { throw new Error('database missing'); } const gristConfig = this.getGristConfig(); const state: IGristUrlState = {}; let org: Organization; @@ -1211,20 +1209,20 @@ export class FlexServer implements GristServer { state.doc = resource.urlId || resource.id; state.slug = getSlugIfNeeded(resource); } - state.org = this.dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId); + state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId); if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); } return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl)); } public addUsage() { if (this._check('usage', 'start', 'homedb')) { return; } - this.usage = new Usage(this.dbManager); + this.usage = new Usage(this._dbManager); } public async addHousekeeper() { if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; } const store = this._docWorkerMap; - this.housekeeper = new Housekeeper(this.dbManager, this, this._internalPermitStore, store); + this.housekeeper = new Housekeeper(this._dbManager, this, this._internalPermitStore, store); this.housekeeper.addEndpoints(this.app); await this.housekeeper.start(); } @@ -1427,8 +1425,8 @@ export class FlexServer implements GristServer { } catch (err) { log.error("FlexServer shutdown problem", err); } - if (this.comm) { - this.comm.destroyAllClients(); + if (this._comm) { + this._comm.destroyAllClients(); } log.info("FlexServer shutdown is complete"); } @@ -1439,12 +1437,19 @@ export class FlexServer implements GristServer { */ private async _redirectToOrg(req: express.Request, resp: express.Response, next: express.NextFunction) { const mreq = req as RequestWithLogin; - if (mreq.org || !mreq.userId || !mreq.userIsAuthorized) { return next(); } + if (mreq.org || !mreq.userId) { return next(); } + + // Redirect anonymous users to the merged org. + if (!mreq.userIsAuthorized) { + const redirectUrl = this._getOrgRedirectUrl(mreq, this._dbManager.mergedOrgDomain()); + log.debug(`Redirecting anonymous user to: ${redirectUrl}`); + return resp.redirect(redirectUrl); + } // We have a userId, but the request is for an unknown org. Redirect to an org that's // available to the user. This matters in dev, and in prod when visiting a generic URL, which // will here redirect to e.g. the user's personal org. - const result = await this.dbManager.getMergedOrgs(mreq.userId, mreq.userId, null); + const result = await this._dbManager.getMergedOrgs(mreq.userId, mreq.userId, null); const orgs = (result.status === 200) ? result.data : null; const subdomain = orgs && orgs.length > 0 ? orgs[0].domain : null; const redirectUrl = subdomain && this._getOrgRedirectUrl(mreq, subdomain); @@ -1512,8 +1517,8 @@ export class FlexServer implements GristServer { private _getBilling(): IBilling { if (!this._billing) { - if (!this.dbManager) { throw new Error("need dbManager"); } - this._billing = this.create.Billing(this.dbManager, this); + if (!this._dbManager) { throw new Error("need dbManager"); } + this._billing = this.create.Billing(this._dbManager, this); } return this._billing; } diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 8503b730..ea17bd73 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -2,6 +2,7 @@ import { GristLoadConfig } from 'app/common/gristUrls'; import { Document } from 'app/gen-server/entity/Document'; import { Organization } from 'app/gen-server/entity/Organization'; import { Workspace } from 'app/gen-server/entity/Workspace'; +import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import * as Comm from 'app/server/lib/Comm'; import { Hosts } from 'app/server/lib/extractOrg'; import { ICreate } from 'app/server/lib/ICreate'; @@ -25,6 +26,9 @@ export interface GristServer { getPermitStore(): IPermitStore; getExternalPermitStore(): IPermitStore; getSessions(): Sessions; + getComm(): Comm; + getHosts(): Hosts; + getHomeDBManager(): HomeDBManager; } export interface GristLoginMiddleware { @@ -33,5 +37,5 @@ export interface GristLoginMiddleware { getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise; // Returns arbitrary string for log. - addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts): string; + addEndpoints(app: express.Express): Promise; } diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 2132ba57..514048fa 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -1,6 +1,5 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {ActiveDoc} from 'app/server/lib/ActiveDoc'; -import {ScopedSession} from 'app/server/lib/BrowserSession'; import {DocManager} from 'app/server/lib/DocManager'; import {ExternalStorage} from 'app/server/lib/ExternalStorage'; import {GristServer} from 'app/server/lib/GristServer'; @@ -10,9 +9,6 @@ import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox'; import {IShell} from 'app/server/lib/IShell'; export interface ICreate { - // A ScopedSession knows which user is logged in to an org. This method may be used to replace - // its behavior with stubs when logins aren't available. - adjustSession(scopedSession: ScopedSession): void; Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling; Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier; diff --git a/app/server/lib/MinimalLogin.ts b/app/server/lib/MinimalLogin.ts new file mode 100644 index 00000000..4e24e593 --- /dev/null +++ b/app/server/lib/MinimalLogin.ts @@ -0,0 +1,53 @@ +import { UserProfile } from 'app/common/UserAPI'; +import { GristLoginMiddleware, GristServer } from 'app/server/lib/GristServer'; +import { Request } from 'express'; + +/** + * Return a login system that supports a single hard-coded user. + */ +export async function getMinimalLoginMiddleware(gristServer: GristServer): Promise { + // Login and logout, redirecting immediately back. Signup is treated as login, + // no nuance here. + return { + async getLoginRedirectUrl(req: Request, url: URL) { + await setSingleUser(req, gristServer); + return url.href; + }, + async getLogoutRedirectUrl(req: Request, url: URL) { + return url.href; + }, + async getSignUpRedirectUrl(req: Request, url: URL) { + await setSingleUser(req, gristServer); + return url.href; + }, + async addEndpoints() { + // If working without a login system, make sure default user exists. + const dbManager = gristServer.getHomeDBManager(); + const profile = getDefaultProfile(); + const user = await dbManager.getUserByLoginWithRetry(profile.email, profile); + if (user) { + // No need to survey this user! + user.isFirstTimeUser = false; + await user.save(); + } + return "no-logins"; + } + }; +} + +/** + * Set the user in the current session to the single hard-coded user. + */ +async function setSingleUser(req: Request, gristServer: GristServer) { + const scopedSession = gristServer.getSessions().getOrCreateSessionFromRequest(req); + await scopedSession.operateOnScopedSession(async (user) => Object.assign(user, { + profile: getDefaultProfile() + })); +} + +function getDefaultProfile(): UserProfile { + return { + email: process.env.GRIST_DEFAULT_EMAIL || 'you@example.com', + name: 'You', + }; +} diff --git a/app/server/lib/SamlConfig.ts b/app/server/lib/SamlConfig.ts index dc4e5522..66631536 100644 --- a/app/server/lib/SamlConfig.ts +++ b/app/server/lib/SamlConfig.ts @@ -57,9 +57,7 @@ import * as express from 'express'; import * as fse from 'fs-extra'; import * as saml2 from 'saml2-js'; -import * as Comm from 'app/server/lib/Comm'; import {expressWrap} from 'app/server/lib/expressWrap'; -import {Hosts} from 'app/server/lib/extractOrg'; import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer'; import * as log from 'app/server/lib/log'; import {Permit} from 'app/server/lib/Permit'; @@ -254,8 +252,8 @@ export async function getSamlLoginMiddleware(gristServer: GristServer): Promise< // TODO: is there a better link to give here? getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig), getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig), - addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts) { - samlConfig.addSamlEndpoints(app, sessions); + async addEndpoints(app: express.Express) { + samlConfig.addSamlEndpoints(app, gristServer.getSessions()); return 'saml'; } }; diff --git a/app/server/lib/Sessions.ts b/app/server/lib/Sessions.ts index 2ccda7f9..89278f7f 100644 --- a/app/server/lib/Sessions.ts +++ b/app/server/lib/Sessions.ts @@ -1,5 +1,4 @@ import {ScopedSession} from 'app/server/lib/BrowserSession'; -import {GristServer} from 'app/server/lib/GristServer'; import {cookieName, SessionStore} from 'app/server/lib/gristSessions'; import * as cookie from 'cookie'; import * as cookieParser from 'cookie-parser'; @@ -26,7 +25,7 @@ import {Request} from 'express'; export class Sessions { private _sessions = new Map(); - constructor(private _sessionSecret: string, private _sessionStore: SessionStore, private _server: GristServer) { + constructor(private _sessionSecret: string, private _sessionStore: SessionStore) { } /** @@ -47,7 +46,6 @@ export class Sessions { const key = this._getSessionOrgKey(sid, domain, userSelector); if (!this._sessions.has(key)) { const scopedSession = new ScopedSession(sid, this._sessionStore, domain, userSelector); - this._server.create.adjustSession(scopedSession); this._sessions.set(key, scopedSession); } return this._sessions.get(key)!; diff --git a/app/server/lib/TestingHooks.ts b/app/server/lib/TestingHooks.ts index 987dc0f5..b6831551 100644 --- a/app/server/lib/TestingHooks.ts +++ b/app/server/lib/TestingHooks.ts @@ -81,7 +81,7 @@ export class TestingHooks implements ITestingHooks { log.info("TestingHooks.setServerVersion called with", version); this._comm.setServerVersion(version); for (const server of this._workerServers) { - server.comm.setServerVersion(version); + server.getComm().setServerVersion(version); } } @@ -89,7 +89,7 @@ export class TestingHooks implements ITestingHooks { log.info("TestingHooks.disconnectClients called"); this._comm.destroyAllClients(); for (const server of this._workerServers) { - server.comm.destroyAllClients(); + server.getComm().destroyAllClients(); } } @@ -97,7 +97,7 @@ export class TestingHooks implements ITestingHooks { log.info("TestingHooks.commShutdown called"); await this._comm.testServerShutdown(); for (const server of this._workerServers) { - await server.comm.testServerShutdown(); + await server.getComm().testServerShutdown(); } } @@ -105,7 +105,7 @@ export class TestingHooks implements ITestingHooks { log.info("TestingHooks.commRestart called"); await this._comm.testServerRestart(); for (const server of this._workerServers) { - await server.comm.testServerRestart(); + await server.getComm().testServerRestart(); } } @@ -115,7 +115,7 @@ export class TestingHooks implements ITestingHooks { log.info("TestingHooks.setClientPersistence called with", ttlMs); this._comm.testSetClientPersistence(ttlMs); for (const server of this._workerServers) { - server.comm.testSetClientPersistence(ttlMs); + server.getComm().testSetClientPersistence(ttlMs); } } @@ -153,9 +153,9 @@ export class TestingHooks implements ITestingHooks { public async flushAuthorizerCache(): Promise { log.info("TestingHooks.flushAuthorizerCache called"); - this._server.dbManager.flushDocAuthCache(); + this._server.getHomeDBManager().flushDocAuthCache(); for (const server of this._workerServers) { - server.dbManager.flushDocAuthCache(); + server.getHomeDBManager().flushDocAuthCache(); } } diff --git a/app/server/lib/gristSessions.ts b/app/server/lib/gristSessions.ts index 7fa11541..8a26c5db 100644 --- a/app/server/lib/gristSessions.ts +++ b/app/server/lib/gristSessions.ts @@ -132,6 +132,8 @@ export function initGristSessions(instanceRoot: string, server: GristServer) { 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 @@ -144,7 +146,7 @@ export function initGristSessions(instanceRoot: string, server: GristServer) { store: sessionStore }); - const sessions = new Sessions(sessionSecret, sessionStore, server); + const sessions = new Sessions(sessionSecret, sessionStore); return {sessions, sessionSecret, sessionStore, sessionMiddleware, sessionStoreCreator}; } diff --git a/app/server/mergedServerMain.ts b/app/server/mergedServerMain.ts index a56a4a2d..84e89fc6 100644 --- a/app/server/mergedServerMain.ts +++ b/app/server/mergedServerMain.ts @@ -117,7 +117,7 @@ export async function main(port: number, serverTypes: ServerType[], server.addNotifier(); await server.addHousekeeper(); } - server.addLoginRoutes(); + await server.addLoginRoutes(); server.addBillingPages(); server.addWelcomePaths(); server.addLogEndpoint(); diff --git a/buildtools/webpack.config.js b/buildtools/webpack.config.js index 56a73c7d..187743bf 100644 --- a/buildtools/webpack.config.js +++ b/buildtools/webpack.config.js @@ -6,6 +6,7 @@ module.exports = { target: 'web', entry: { main: "app/client/app.js", + errorPages: "app/client/errorMain.js", }, output: { filename: "[name].bundle.js", diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts index ec925076..ba52e539 100644 --- a/stubs/app/server/lib/create.ts +++ b/stubs/app/server/lib/create.ts @@ -1,17 +1,11 @@ import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ICreate} from 'app/server/lib/ICreate'; -import {ScopedSession} from 'app/server/lib/BrowserSession'; import {NSandboxCreator} from 'app/server/lib/NSandbox'; // Use raw python - update when pynbox or other solution is set up for core. const sandboxCreator = new NSandboxCreator({defaultFlavor: 'unsandboxed'}); export const create: ICreate = { - adjustSession(scopedSession: ScopedSession): void { - const email = process.env.GRIST_DEFAULT_EMAIL || 'anon@getgrist.com'; - const profile = {email, name: email}; - scopedSession.getSessionProfile = async () => profile; - }, Billing() { return { addEndpoints() { /* do nothing */ }, diff --git a/stubs/app/server/lib/logins.ts b/stubs/app/server/lib/logins.ts index 16996300..790b0bab 100644 --- a/stubs/app/server/lib/logins.ts +++ b/stubs/app/server/lib/logins.ts @@ -1,13 +1,9 @@ -import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer'; -import {getSamlLoginMiddleware} from 'app/server/lib/SamlConfig'; +import { GristLoginMiddleware, GristServer } from 'app/server/lib/GristServer'; +import { getMinimalLoginMiddleware } from 'app/server/lib/MinimalLogin'; +import { getSamlLoginMiddleware } from 'app/server/lib/SamlConfig'; export async function getLoginMiddleware(gristServer: GristServer): Promise { const saml = await getSamlLoginMiddleware(gristServer); if (saml) { return saml; } - return { - async getLoginRedirectUrl() { throw new Error('logins not implemented'); }, - async getLogoutRedirectUrl() { throw new Error('logins not implemented'); }, - async getSignUpRedirectUrl() { throw new Error('logins not implemented'); }, - addEndpoints() { return "no-logins"; } - }; + return getMinimalLoginMiddleware(gristServer); } diff --git a/stubs/app/server/server.ts b/stubs/app/server/server.ts index 6e096de1..2f6dc92d 100644 --- a/stubs/app/server/server.ts +++ b/stubs/app/server/server.ts @@ -15,8 +15,8 @@ if (!debugging) { setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true'); } -// Use a distinct cookie. -setDefaultEnv('GRIST_SESSION_COOKIE', 'grist_core'); +// Use a distinct cookie. Bump version to 2. +setDefaultEnv('GRIST_SESSION_COOKIE', 'grist_core2'); import {updateDb} from 'app/server/lib/dbUtils'; import {main as mergedServerMain} from 'app/server/mergedServerMain'; @@ -41,7 +41,7 @@ export async function main() { console.log('For full logs, re-run with DEBUG=1'); } - // If SAML is not configured, there's no login system, so force 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) { setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com'); } diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index acdf5c29..5f69e612 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1617,6 +1617,8 @@ export async function openUserProfile() { // Since the AccountWidget loads orgs and the user data asynchronously, the menu // can expand itself causing the click to land on a wrong button. await waitForServer(); + await driver.findWait('.test-usermenu-org', 1000); + await driver.sleep(250); // There's still some jitter (scroll-bar? other user accounts?) await driver.findContent('.grist-floating-menu li', 'Profile Settings').click(); await driver.findWait('.test-login-method', 5000); }