(core) clean up a collection of small problems affecting grist-core

Summary:
 * Remove adjustSession hack, interfering with loading docs under saml.
 * Allow the anonymous user to receive an empty list of workspaces for
   the merged org.
 * Behave better on first page load when org is in path - this used to
   fail because of lack of cookie.  This is very visible in grist-core,
   as a failure to load localhost:8484 on first visit.
 * Mark cookie explicitly as SameSite=Lax to remove a warning in firefox.
 * Make errorPages available in grist-core.

This changes the default behavior of grist-core to now start off in
anonymous mode, with an explicit sign-in step available.  If SAML is not configured,
the sign-in operation will unconditionally sign the user in as a default
user, without any password check or other security.  The user email is
taken from GRIST_DEFAULT_EMAIL if set.  This is a significant change, but
makes anonymous mode available in grist-core (which is convenient
for testing) and makes behavior with and without SAML much more consistent.

Test Plan: updated test; manual (time to start adding grist-core tests though!)

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2980
This commit is contained in:
Paul Fitzpatrick 2021-08-17 11:22:30 -04:00
parent e6e792655b
commit f9630b3aa4
17 changed files with 193 additions and 132 deletions

17
app/client/errorMain.ts Normal file
View File

@ -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),
]));

View File

@ -774,7 +774,10 @@ export class HomeDBManager extends EventEmitter {
public async getOrgWorkspaces(scope: Scope, orgKey: string|number, public async getOrgWorkspaces(scope: Scope, orgKey: string|number,
options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> { options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {
const query = this._orgWorkspaces(scope, orgKey, options); 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). // Return the workspaces, not the org(s).
if (result.status === 200) { if (result.status === 200) {
// Place ownership information in workspaces, available for the merged org. // Place ownership information in workspaces, available for the merged org.

View File

@ -94,7 +94,6 @@ export function isSingleUserMode(): boolean {
* - req.users: set for org-and-session-based logins, with list of profiles in session * - req.users: set for org-and-session-based logins, with list of profiles in session
*/ */
export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore, export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore,
fallbackEmail: string|null,
req: Request, res: Response, next: NextFunction) { req: Request, res: Response, next: NextFunction) {
const mreq = req as RequestWithLogin; const mreq = req as RequestWithLogin;
let profile: UserProfile|undefined; 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 no userId has been found yet, fall back on anonymous.
if (!mreq.userId) { if (!mreq.userId) {
const anon = dbManager.getAnonymousUser(); const anon = dbManager.getAnonymousUser();
@ -309,11 +296,16 @@ export function redirectToLogin(
if (mreq.userIsAuthorized) { return next(); } if (mreq.userIsAuthorized) { return next(); }
try { try {
// Otherwise it's an anonymous user. Proceed normally only if the org allows anon access. // Otherwise it's an anonymous user. Proceed normally only if the org allows anon access,
if (mreq.userId && mreq.org && allowExceptions) { // 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. // Anonymous user has qualified access to merged org.
if (dbManager.isMergedOrg(mreq.org)) { return next(); } // If no org is set, leave it to other middleware. One common case where the
const result = await dbManager.getOrg({userId: mreq.userId}, mreq.org || null); // 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(); } if (result.status === 200) { return next(); }
} }

View File

@ -96,17 +96,17 @@ export class FlexServer implements GristServer {
public host: string; public host: string;
public tag: string; public tag: string;
public info = new Array<[string, any]>(); public info = new Array<[string, any]>();
public dbManager: HomeDBManager;
public notifier: INotifier; public notifier: INotifier;
public usage: Usage; public usage: Usage;
public housekeeper: Housekeeper; public housekeeper: Housekeeper;
public server: http.Server; public server: http.Server;
public httpsServer?: https.Server; public httpsServer?: https.Server;
public comm: Comm;
public settings: any; public settings: any;
public worker: DocWorkerInfo; public worker: DocWorkerInfo;
public electronServerMethods: ElectronServerMethods; public electronServerMethods: ElectronServerMethods;
public readonly docsRoot: string; public readonly docsRoot: string;
private _comm: Comm;
private _dbManager: HomeDBManager;
private _defaultBaseDomain: string|undefined; private _defaultBaseDomain: string|undefined;
private _pluginUrl: string|undefined; private _pluginUrl: string|undefined;
private _billing: IBilling; private _billing: IBilling;
@ -249,6 +249,21 @@ export class FlexServer implements GristServer {
return this._sessions; 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() { public addLogging() {
if (this._check('logging')) { return; } if (this._check('logging')) { return; }
if (process.env.GRIST_LOG_SKIP_HTTP) { 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. // Prepare cache for managing org-to-host relationship.
public addHosts() { public addHosts() {
if (this._check('hosts', 'homedb')) { return; } 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() { public async initHomeDBManager() {
if (this._check('homedb')) { return; } if (this._check('homedb')) { return; }
this.dbManager = new HomeDBManager(); this._dbManager = new HomeDBManager();
this.dbManager.setPrefix(process.env.GRIST_ID_PREFIX || ""); this._dbManager.setPrefix(process.env.GRIST_ID_PREFIX || "");
await this.dbManager.connect(); await this._dbManager.connect();
await this.dbManager.initializeSpecialIds(); 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();
}
}
// Report which database we are using, without sensitive credentials. // 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() { public addDocWorkerMap() {
@ -448,11 +450,7 @@ export class FlexServer implements GristServer {
// Middleware to redirect landing pages to preferred host // Middleware to redirect landing pages to preferred host
this._redirectToHostMiddleware = this._hosts.redirectHost; this._redirectToHostMiddleware = this._hosts.redirectHost;
// Middleware to add the userId to the express request object. // Middleware to add the userId to the express request object.
// If GRIST_DEFAULT_EMAIL is set, login as that user when no other credentials this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this._dbManager, this._internalPermitStore));
// presented.
const fallbackEmail = process.env.GRIST_DEFAULT_EMAIL || null;
this._userIdMiddleware = expressWrap(addRequestUser.bind(null, this.dbManager, this._internalPermitStore,
fallbackEmail));
this._trustOriginsMiddleware = expressWrap(trustOriginHandler); this._trustOriginsMiddleware = expressWrap(trustOriginHandler);
// middleware to authorize doc access to the app. Note that this requires the userId // middleware to authorize doc access to the app. Note that this requires the userId
// to be set on the request by _userIdMiddleware. // to be set on the request by _userIdMiddleware.
@ -460,11 +458,11 @@ export class FlexServer implements GristServer {
this._redirectToLoginWithExceptionsMiddleware = redirectToLogin(true, this._redirectToLoginWithExceptionsMiddleware = redirectToLogin(true,
this._getLoginRedirectUrl, this._getLoginRedirectUrl,
this._getSignUpRedirectUrl, this._getSignUpRedirectUrl,
this.dbManager); this._dbManager);
this._redirectToLoginWithoutExceptionsMiddleware = redirectToLogin(false, this._redirectToLoginWithoutExceptionsMiddleware = redirectToLogin(false,
this._getLoginRedirectUrl, this._getLoginRedirectUrl,
this._getSignUpRedirectUrl, this._getSignUpRedirectUrl,
this.dbManager); this._dbManager);
this._redirectToLoginUnconditionally = redirectToLoginUnconditionally(this._getLoginRedirectUrl, this._redirectToLoginUnconditionally = redirectToLoginUnconditionally(this._getLoginRedirectUrl,
this._getSignUpRedirectUrl); this._getSignUpRedirectUrl);
this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this); this._redirectToOrgMiddleware = tbind(this._redirectToOrg, this);
@ -519,7 +517,7 @@ export class FlexServer implements GristServer {
// ApiServer's constructor adds endpoints to the app. // ApiServer's constructor adds endpoints to the app.
// tslint:disable-next-line:no-unused-expression // tslint:disable-next-line:no-unused-expression
new ApiServer(this.app, this.dbManager); new ApiServer(this.app, this._dbManager);
} }
public addBillingApi() { public addBillingApi() {
@ -553,9 +551,9 @@ export class FlexServer implements GristServer {
public async close() { public async close() {
if (this.usage) { await this.usage.close(); } if (this.usage) { await this.usage.close(); }
if (this._hosts) { this._hosts.close(); } if (this._hosts) { this._hosts.close(); }
if (this.dbManager) { if (this._dbManager) {
this.dbManager.removeAllListeners(); this._dbManager.removeAllListeners();
this.dbManager.flushDocAuthCache(); this._dbManager.flushDocAuthCache();
} }
if (this.server) { this.server.close(); } if (this.server) { this.server.close(); }
if (this.httpsServer) { this.httpsServer.close(); } if (this.httpsServer) { this.httpsServer.close(); }
@ -568,7 +566,7 @@ export class FlexServer implements GristServer {
public addDocApiForwarder() { public addDocApiForwarder() {
if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; } 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); docApiForwarder.addEndpoints(this.app);
} }
@ -605,9 +603,9 @@ export class FlexServer implements GristServer {
this._disabled = true; this._disabled = true;
} else { } else {
this._disabled = true; this._disabled = true;
if (this.comm) { if (this._comm) {
this.comm.setServerActivation(false); this._comm.setServerActivation(false);
this.comm.destroyAllClients(); this._comm.destroyAllClients();
} }
} }
this.server.close(); this.server.close();
@ -649,7 +647,7 @@ export class FlexServer implements GristServer {
if (this._storageManager) { if (this._storageManager) {
this._storageManager.testReopenStorage(); this._storageManager.testReopenStorage();
} }
this.comm.setServerActivation(true); this._comm.setServerActivation(true);
if (this.worker) { if (this.worker) {
await this._startServers(this.server, this.httpsServer, this.name, this.port, false); await this._startServers(this.server, this.httpsServer, this.name, this.port, false);
await this._addSelfAsWorker(this._docWorkerMap); 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 // If "welcomeNewUser" is ever added to billing pages, we'd need
// to avoid a redirect loop. // 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) { if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) {
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
@ -722,7 +720,7 @@ export class FlexServer implements GristServer {
forceLogin: this._redirectToLoginUnconditionally, forceLogin: this._redirectToLoginUnconditionally,
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()
}); });
} }
@ -755,7 +753,7 @@ export class FlexServer implements GristServer {
public addComm() { public addComm() {
if (this._check('comm', 'start')) { return; } if (this._check('comm', 'start')) { return; }
this.comm = new Comm(this.server, { this._comm = new Comm(this.server, {
settings: this.settings, settings: this.settings,
sessions: this._sessions, sessions: this._sessions,
hosts: this._hosts, hosts: this._hosts,
@ -784,8 +782,8 @@ export class FlexServer implements GristServer {
})); }));
} }
public addLoginRoutes() { public async addLoginRoutes() {
if (this._check('login', 'org', 'sessions')) { return; } 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 // TODO: We do NOT want Comm here at all, it's only being used for handling sessions, which
// should be factored out of it. // should be factored out of it.
this.addComm(); this.addComm();
@ -869,13 +867,13 @@ export class FlexServer implements GristServer {
this.app.get('/verified', expressWrap((req, resp) => this.app.get('/verified', expressWrap((req, resp) =>
this._sendAppPage(req, resp, {path: 'error.html', status: 200, config: {errPage: 'verified'}}))); 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]); this.info.push(['loginMiddlewareComment', comment]);
} }
public async addTestingHooks(workerServers?: FlexServer[]) { public async addTestingHooks(workerServers?: FlexServer[]) {
if (process.env.GRIST_TESTING_SOCKET) { 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 || []); workerServers || []);
this._hasTestingHooks = true; this._hasTestingHooks = true;
} }
@ -914,30 +912,30 @@ export class FlexServer implements GristServer {
const docWorkerId = await this._addSelfAsWorker(workers); const docWorkerId = await this._addSelfAsWorker(workers);
const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableS3, '', workers, const storageManager = new HostedStorageManager(this.docsRoot, docWorkerId, this._disableS3, '', workers,
this.dbManager, this.create); this._dbManager, this.create);
this._storageManager = storageManager; this._storageManager = storageManager;
} else { } else {
const samples = getAppPathTo(this.appRoot, 'public_samples'); 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; this._storageManager = storageManager;
} }
const pluginManager = await this._addPluginManager(); const pluginManager = await this._addPluginManager();
this._docManager = this._docManager || new DocManager(this._storageManager, pluginManager, this._docManager = this._docManager || new DocManager(this._storageManager, pluginManager,
this.dbManager, this); this._dbManager, this);
const docManager = this._docManager; const docManager = this._docManager;
shutdown.addCleanupHandler(null, this._shutdown.bind(this), 25000, 'FlexServer._shutdown'); shutdown.addCleanupHandler(null, this._shutdown.bind(this), 25000, 'FlexServer._shutdown');
if (!isSingleUserMode()) { if (!isSingleUserMode()) {
this.comm.registerMethods({ this._comm.registerMethods({
openDoc: docManager.openDoc.bind(docManager), openDoc: docManager.openDoc.bind(docManager),
}); });
this._serveDocPage(); this._serveDocPage();
} }
// Attach docWorker endpoints and Comm methods. // 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; this._docWorker = docWorker;
// Register the websocket comm functions associated with the docworker. // Register the websocket comm functions associated with the docworker.
@ -954,7 +952,7 @@ export class FlexServer implements GristServer {
this._addSupportPaths(docAccessMiddleware); this._addSupportPaths(docAccessMiddleware);
if (!isSingleUserMode()) { 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'}}); return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}});
} }
// Allow the support user access to billing pages. // Allow the support user access to billing pages.
const scope = addPermit(getScope(mreq), this.dbManager.getSupportUserId(), {org: orgDomain}); const scope = addPermit(getScope(mreq), this._dbManager.getSupportUserId(), {org: orgDomain});
const query = await this.dbManager.getOrg(scope, orgDomain); const query = await this._dbManager.getOrg(scope, orgDomain);
const org = this.dbManager.unwrapQueryResult(query); const org = this._dbManager.unwrapQueryResult(query);
// This page isn't availabe for personal site. // This page isn't availabe for personal site.
if (org.owner) { if (org.owner) {
return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}}); 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') { if (req.params.page === 'user') {
const name: string|undefined = req.body && req.body.username || undefined; 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'; redirectPath = '/welcome/info';
} else if (req.params.page === '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 // 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. // form to this doc, and do it from the client directly.
const previewerUserId = this.dbManager.getPreviewerUserId(); const previewerUserId = this._dbManager.getPreviewerUserId();
const docAuth = await this.dbManager.getDocAuthCached({urlId, userId: previewerUserId}); const docAuth = await this._dbManager.getDocAuthCached({urlId, userId: previewerUserId});
const docId = docAuth.docId; const docId = docAuth.docId;
if (!docId) { if (!docId) {
throw new Error(`Can't resolve ${urlId}: ${docAuth.error}`); 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 // redirect to teams page if users has access to more than one org. Otherwise redirect to
// personal org. // 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; const orgs = (result.status === 200) ? result.data : null;
if (orgs && orgs.length > 1) { if (orgs && orgs.length > 1) {
redirectPath = '/welcome/teams'; redirectPath = '/welcome/teams';
} }
} }
const mergedOrgDomain = this.dbManager.mergedOrgDomain(); const mergedOrgDomain = this._dbManager.mergedOrgDomain();
const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, redirectPath); const redirectUrl = this._getOrgRedirectUrl(mreq, mergedOrgDomain, redirectPath);
resp.json({redirectUrl}); 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 // 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 // case of notification(s) from stripe. May need to associate a preferred base domain
// with org/user and persist that? // 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 { public getGristConfig(): GristLoadConfig {
@ -1172,9 +1170,9 @@ export class FlexServer implements GristServer {
* the db for document details without including organization disambiguation. * the db for document details without including organization disambiguation.
*/ */
public async getDocUrl(docId: string): Promise<string> { public async getDocUrl(docId: string): Promise<string> {
if (!this.dbManager) { throw new Error('database missing'); } if (!this._dbManager) { throw new Error('database missing'); }
const doc = await this.dbManager.getDoc({ const doc = await this._dbManager.getDoc({
userId: this.dbManager.getPreviewerUserId(), userId: this._dbManager.getPreviewerUserId(),
urlId: docId, urlId: docId,
showAll: true showAll: true
}); });
@ -1185,19 +1183,19 @@ export class FlexServer implements GristServer {
* Get a url for a team site. * Get a url for a team site.
*/ */
public async getOrgUrl(orgKey: string|number): Promise<string> { public async getOrgUrl(orgKey: string|number): Promise<string> {
if (!this.dbManager) { throw new Error('database missing'); } if (!this._dbManager) { throw new Error('database missing'); }
const org = await this.dbManager.getOrg({ const org = await this._dbManager.getOrg({
userId: this.dbManager.getPreviewerUserId(), userId: this._dbManager.getPreviewerUserId(),
showAll: true showAll: true
}, orgKey); }, orgKey);
return this.getResourceUrl(this.dbManager.unwrapQueryResult(org)); return this.getResourceUrl(this._dbManager.unwrapQueryResult(org));
} }
/** /**
* Get a url for an organization, workspace, or document. * Get a url for an organization, workspace, or document.
*/ */
public async getResourceUrl(resource: Organization|Workspace|Document): Promise<string> { public async getResourceUrl(resource: Organization|Workspace|Document): Promise<string> {
if (!this.dbManager) { throw new Error('database missing'); } if (!this._dbManager) { throw new Error('database missing'); }
const gristConfig = this.getGristConfig(); const gristConfig = this.getGristConfig();
const state: IGristUrlState = {}; const state: IGristUrlState = {};
let org: Organization; let org: Organization;
@ -1211,20 +1209,20 @@ export class FlexServer implements GristServer {
state.doc = resource.urlId || resource.id; state.doc = resource.urlId || resource.id;
state.slug = getSlugIfNeeded(resource); 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'); } if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); }
return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl)); return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl));
} }
public addUsage() { public addUsage() {
if (this._check('usage', 'start', 'homedb')) { return; } if (this._check('usage', 'start', 'homedb')) { return; }
this.usage = new Usage(this.dbManager); this.usage = new Usage(this._dbManager);
} }
public async addHousekeeper() { public async addHousekeeper() {
if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; } if (this._check('housekeeper', 'start', 'homedb', 'map', 'json', 'api-mw')) { return; }
const store = this._docWorkerMap; 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); this.housekeeper.addEndpoints(this.app);
await this.housekeeper.start(); await this.housekeeper.start();
} }
@ -1427,8 +1425,8 @@ export class FlexServer implements GristServer {
} catch (err) { } catch (err) {
log.error("FlexServer shutdown problem", err); log.error("FlexServer shutdown problem", err);
} }
if (this.comm) { if (this._comm) {
this.comm.destroyAllClients(); this._comm.destroyAllClients();
} }
log.info("FlexServer shutdown is complete"); 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) { private async _redirectToOrg(req: express.Request, resp: express.Response, next: express.NextFunction) {
const mreq = req as RequestWithLogin; 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 // 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 // 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. // 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 orgs = (result.status === 200) ? result.data : null;
const subdomain = orgs && orgs.length > 0 ? orgs[0].domain : null; const subdomain = orgs && orgs.length > 0 ? orgs[0].domain : null;
const redirectUrl = subdomain && this._getOrgRedirectUrl(mreq, subdomain); const redirectUrl = subdomain && this._getOrgRedirectUrl(mreq, subdomain);
@ -1512,8 +1517,8 @@ export class FlexServer implements GristServer {
private _getBilling(): IBilling { private _getBilling(): IBilling {
if (!this._billing) { if (!this._billing) {
if (!this.dbManager) { throw new Error("need dbManager"); } if (!this._dbManager) { throw new Error("need dbManager"); }
this._billing = this.create.Billing(this.dbManager, this); this._billing = this.create.Billing(this._dbManager, this);
} }
return this._billing; return this._billing;
} }

View File

@ -2,6 +2,7 @@ import { GristLoadConfig } from 'app/common/gristUrls';
import { Document } from 'app/gen-server/entity/Document'; import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization'; import { Organization } from 'app/gen-server/entity/Organization';
import { Workspace } from 'app/gen-server/entity/Workspace'; 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 * as Comm from 'app/server/lib/Comm';
import { Hosts } from 'app/server/lib/extractOrg'; import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate'; import { ICreate } from 'app/server/lib/ICreate';
@ -25,6 +26,9 @@ export interface GristServer {
getPermitStore(): IPermitStore; getPermitStore(): IPermitStore;
getExternalPermitStore(): IPermitStore; getExternalPermitStore(): IPermitStore;
getSessions(): Sessions; getSessions(): Sessions;
getComm(): Comm;
getHosts(): Hosts;
getHomeDBManager(): HomeDBManager;
} }
export interface GristLoginMiddleware { export interface GristLoginMiddleware {
@ -33,5 +37,5 @@ export interface GristLoginMiddleware {
getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>; getLogoutRedirectUrl(req: express.Request, nextUrl: URL): Promise<string>;
// Returns arbitrary string for log. // Returns arbitrary string for log.
addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts): string; addEndpoints(app: express.Express): Promise<string>;
} }

View File

@ -1,6 +1,5 @@
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {ScopedSession} from 'app/server/lib/BrowserSession';
import {DocManager} from 'app/server/lib/DocManager'; import {DocManager} from 'app/server/lib/DocManager';
import {ExternalStorage} from 'app/server/lib/ExternalStorage'; import {ExternalStorage} from 'app/server/lib/ExternalStorage';
import {GristServer} from 'app/server/lib/GristServer'; 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'; import {IShell} from 'app/server/lib/IShell';
export interface ICreate { 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; Billing(dbManager: HomeDBManager, gristConfig: GristServer): IBilling;
Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier; Notifier(dbManager: HomeDBManager, gristConfig: GristServer): INotifier;

View File

@ -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<GristLoginMiddleware> {
// 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',
};
}

View File

@ -57,9 +57,7 @@ import * as express from 'express';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
import * as saml2 from 'saml2-js'; import * as saml2 from 'saml2-js';
import * as Comm from 'app/server/lib/Comm';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';
import {Hosts} from 'app/server/lib/extractOrg';
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer'; import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer';
import * as log from 'app/server/lib/log'; import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit'; 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? // TODO: is there a better link to give here?
getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig), getSignUpRedirectUrl: samlConfig.getLoginRedirectUrl.bind(samlConfig),
getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig), getLogoutRedirectUrl: samlConfig.getLogoutRedirectUrl.bind(samlConfig),
addEndpoints(app: express.Express, comm: Comm, sessions: Sessions, hosts: Hosts) { async addEndpoints(app: express.Express) {
samlConfig.addSamlEndpoints(app, sessions); samlConfig.addSamlEndpoints(app, gristServer.getSessions());
return 'saml'; return 'saml';
} }
}; };

View File

@ -1,5 +1,4 @@
import {ScopedSession} from 'app/server/lib/BrowserSession'; import {ScopedSession} from 'app/server/lib/BrowserSession';
import {GristServer} from 'app/server/lib/GristServer';
import {cookieName, SessionStore} from 'app/server/lib/gristSessions'; import {cookieName, SessionStore} from 'app/server/lib/gristSessions';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import * as cookieParser from 'cookie-parser'; import * as cookieParser from 'cookie-parser';
@ -26,7 +25,7 @@ import {Request} from 'express';
export class Sessions { export class Sessions {
private _sessions = new Map<string, ScopedSession>(); private _sessions = new Map<string, ScopedSession>();
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); const key = this._getSessionOrgKey(sid, domain, userSelector);
if (!this._sessions.has(key)) { if (!this._sessions.has(key)) {
const scopedSession = new ScopedSession(sid, this._sessionStore, domain, userSelector); const scopedSession = new ScopedSession(sid, this._sessionStore, domain, userSelector);
this._server.create.adjustSession(scopedSession);
this._sessions.set(key, scopedSession); this._sessions.set(key, scopedSession);
} }
return this._sessions.get(key)!; return this._sessions.get(key)!;

View File

@ -81,7 +81,7 @@ export class TestingHooks implements ITestingHooks {
log.info("TestingHooks.setServerVersion called with", version); log.info("TestingHooks.setServerVersion called with", version);
this._comm.setServerVersion(version); this._comm.setServerVersion(version);
for (const server of this._workerServers) { 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"); log.info("TestingHooks.disconnectClients called");
this._comm.destroyAllClients(); this._comm.destroyAllClients();
for (const server of this._workerServers) { 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"); log.info("TestingHooks.commShutdown called");
await this._comm.testServerShutdown(); await this._comm.testServerShutdown();
for (const server of this._workerServers) { 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"); log.info("TestingHooks.commRestart called");
await this._comm.testServerRestart(); await this._comm.testServerRestart();
for (const server of this._workerServers) { 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); log.info("TestingHooks.setClientPersistence called with", ttlMs);
this._comm.testSetClientPersistence(ttlMs); this._comm.testSetClientPersistence(ttlMs);
for (const server of this._workerServers) { 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<void> { public async flushAuthorizerCache(): Promise<void> {
log.info("TestingHooks.flushAuthorizerCache called"); log.info("TestingHooks.flushAuthorizerCache called");
this._server.dbManager.flushDocAuthCache(); this._server.getHomeDBManager().flushDocAuthCache();
for (const server of this._workerServers) { for (const server of this._workerServers) {
server.dbManager.flushDocAuthCache(); server.getHomeDBManager().flushDocAuthCache();
} }
} }

View File

@ -132,6 +132,8 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
requestDomain: getCookieDomain, requestDomain: getCookieDomain,
genid: generateId, genid: generateId,
cookie: { cookie: {
sameSite: 'lax',
// We do not initially set max-age, leaving the cookie as a // We do not initially set max-age, leaving the cookie as a
// session cookie until there's a successful login. On the // session cookie until there's a successful login. On the
// redis back-end, the session associated with the cookie will // redis back-end, the session associated with the cookie will
@ -144,7 +146,7 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
store: sessionStore store: sessionStore
}); });
const sessions = new Sessions(sessionSecret, sessionStore, server); const sessions = new Sessions(sessionSecret, sessionStore);
return {sessions, sessionSecret, sessionStore, sessionMiddleware, sessionStoreCreator}; return {sessions, sessionSecret, sessionStore, sessionMiddleware, sessionStoreCreator};
} }

View File

@ -117,7 +117,7 @@ export async function main(port: number, serverTypes: ServerType[],
server.addNotifier(); server.addNotifier();
await server.addHousekeeper(); await server.addHousekeeper();
} }
server.addLoginRoutes(); await server.addLoginRoutes();
server.addBillingPages(); server.addBillingPages();
server.addWelcomePaths(); server.addWelcomePaths();
server.addLogEndpoint(); server.addLogEndpoint();

View File

@ -6,6 +6,7 @@ module.exports = {
target: 'web', target: 'web',
entry: { entry: {
main: "app/client/app.js", main: "app/client/app.js",
errorPages: "app/client/errorMain.js",
}, },
output: { output: {
filename: "[name].bundle.js", filename: "[name].bundle.js",

View File

@ -1,17 +1,11 @@
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {ICreate} from 'app/server/lib/ICreate'; import {ICreate} from 'app/server/lib/ICreate';
import {ScopedSession} from 'app/server/lib/BrowserSession';
import {NSandboxCreator} from 'app/server/lib/NSandbox'; import {NSandboxCreator} from 'app/server/lib/NSandbox';
// Use raw python - update when pynbox or other solution is set up for core. // Use raw python - update when pynbox or other solution is set up for core.
const sandboxCreator = new NSandboxCreator({defaultFlavor: 'unsandboxed'}); const sandboxCreator = new NSandboxCreator({defaultFlavor: 'unsandboxed'});
export const create: ICreate = { 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() { Billing() {
return { return {
addEndpoints() { /* do nothing */ }, addEndpoints() { /* do nothing */ },

View File

@ -1,13 +1,9 @@
import {GristLoginMiddleware, GristServer} from 'app/server/lib/GristServer'; import { GristLoginMiddleware, GristServer } from 'app/server/lib/GristServer';
import {getSamlLoginMiddleware} from 'app/server/lib/SamlConfig'; import { getMinimalLoginMiddleware } from 'app/server/lib/MinimalLogin';
import { getSamlLoginMiddleware } from 'app/server/lib/SamlConfig';
export async function getLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware> { export async function getLoginMiddleware(gristServer: GristServer): Promise<GristLoginMiddleware> {
const saml = await getSamlLoginMiddleware(gristServer); const saml = await getSamlLoginMiddleware(gristServer);
if (saml) { return saml; } if (saml) { return saml; }
return { return getMinimalLoginMiddleware(gristServer);
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"; }
};
} }

View File

@ -15,8 +15,8 @@ if (!debugging) {
setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true'); setDefaultEnv('GRIST_LOG_SKIP_HTTP', 'true');
} }
// Use a distinct cookie. // Use a distinct cookie. Bump version to 2.
setDefaultEnv('GRIST_SESSION_COOKIE', 'grist_core'); setDefaultEnv('GRIST_SESSION_COOKIE', 'grist_core2');
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';
@ -41,7 +41,7 @@ export async function main() {
console.log('For full logs, re-run with DEBUG=1'); 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) { if (!process.env.GRIST_SAML_SP_HOST) {
setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com'); setDefaultEnv('GRIST_DEFAULT_EMAIL', 'you@example.com');
} }

View File

@ -1617,6 +1617,8 @@ export async function openUserProfile() {
// Since the AccountWidget loads orgs and the user data asynchronously, the menu // Since the AccountWidget loads orgs and the user data asynchronously, the menu
// can expand itself causing the click to land on a wrong button. // can expand itself causing the click to land on a wrong button.
await waitForServer(); 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.findContent('.grist-floating-menu li', 'Profile Settings').click();
await driver.findWait('.test-login-method', 5000); await driver.findWait('.test-login-method', 5000);
} }