diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index b3193c65..ce5bedeb 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -11,6 +11,7 @@ import {buildPagesDom} from 'app/client/ui/Pages'; import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker'; import {tools} from 'app/client/ui/Tools'; import {testId} from 'app/client/ui2018/cssVars'; +import {bigBasicButton} from 'app/client/ui2018/buttons'; import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; @@ -30,6 +31,7 @@ export interface DocInfo extends Document { isSample: boolean; isPreFork: boolean; isFork: boolean; + isRecoveryMode: boolean; isBareFork: boolean; // a document created without logging in, which is treated as a // fork without an original. idParts: UrlIdParts; @@ -53,6 +55,7 @@ export interface DocPageModel { isReadonly: Observable; isPrefork: Observable; isFork: Observable; + isRecoveryMode: Observable; isBareFork: Observable; isSample: Observable; @@ -89,6 +92,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { public readonly isReadonly = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isReadonly : false); public readonly isPrefork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isPreFork : false); public readonly isFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isFork : false); + public readonly isRecoveryMode = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isRecoveryMode : false); public readonly isBareFork = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isBareFork : false); public readonly isSample = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.isSample : false); @@ -198,12 +202,21 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { // Expected errors (e.g. Access Denied) produce a separate error page. For unexpected errors, // show a modal, and include a toast for the sake of the "Report error" link. reportError(err); + const isOwner = this.currentDoc.get()?.access === 'owners'; confirmModal( "Error opening document", "Reload", async () => window.location.reload(true), - err.message, - {hideCancel: true}, + isOwner ? `You can try reloading the document, or using recovery mode. ` + + `Recovery mode opens the document to be fully accessible to owners, and ` + + `inaccessible to others. ` + + `[${err.message}]` : err.message, + { hideCancel: true, + extraButtons: isOwner ? bigBasicButton('Enter recovery mode', dom.on('click', async () => { + await this._api.getDocAPI(this.currentDocId.get()!).recover(true); + window.location.reload(true); + }), testId('modal-recovery-mode')) : null, + }, ); } @@ -232,6 +245,10 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { flow.onDispose(() => comm.releaseDocConnection(doc.id)); const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters); + if (openDocResponse.recoveryMode) { + doc.isRecoveryMode = true; + this.currentDoc.set({...doc}); + } const gdModule = await gristDocModulePromise; const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier); flow.checkIfCancelled(); @@ -311,6 +328,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo { return { ...doc, isFork, + isRecoveryMode: false, // we don't know yet, will learn when doc is opened. isSample, isPreFork, isBareFork, diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 57c6abef..9d915f75 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -43,9 +43,11 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, { docNameSave: renameDoc, pageNameSave: getRenamePageFn(gristDoc), + cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc), isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number', isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork), isFork: pageModel.isFork, + isRecoveryMode: pageModel.isRecoveryMode, isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)), isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)), isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)), @@ -91,6 +93,13 @@ function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise { }; } +function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise { + return async () => { + await gristDoc.app.topAppModel.api.getDocAPI(gristDoc.docPageModel.currentDocId.get()!) + .recover(false); + }; +} + function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element { return cssHoverCircle( cssTopBarUndoBtn(iconName), diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index e58b0669..c4ad9a61 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -59,6 +59,14 @@ const cssTag = styled('span', ` margin-left: 4px; `); +const cssAlertTag = styled(cssTag, ` + background-color: ${colors.error}; + --icon-color: white; + a { + cursor: pointer; + } +`); + interface PartialWorkspace { id: number; name: string; @@ -76,10 +84,12 @@ export function docBreadcrumbs( options: { docNameSave: (val: string) => Promise, pageNameSave: (val: string) => Promise, + cancelRecoveryMode: () => Promise, isDocNameReadOnly?: BindableValue, isPageNameReadOnly?: BindableValue, isFork: Observable, isFiddle: Observable, + isRecoveryMode: Observable, isSnapshot?: Observable, isPublic?: Observable, } @@ -106,6 +116,13 @@ export function docBreadcrumbs( if (use(options.isFork)) { return cssTag('unsaved', testId('unsaved-tag')); } + if (use(options.isRecoveryMode)) { + return cssAlertTag('recovery mode', + dom('a', dom.on('click', async () => { + await options.cancelRecoveryMode() + }), icon('CrossSmall')), + testId('recovery-mode-tag')); + } if (use(options.isFiddle)) { return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag')); } diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 841d52fa..ecee665b 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -4,7 +4,7 @@ import {reportError} from 'app/client/models/errors'; import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {Computed, dom, DomElementArg, MultiHolder, Observable, styled} from 'grainjs'; +import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs'; export interface IModalControl { close(): void; @@ -90,6 +90,7 @@ export interface ISaveModalOptions { hideCancel?: boolean; // If set, hide the Cancel button width?: ModalWidth; // Set a width style for the dialog. modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element. + extraButtons?: DomContents; // More buttons! } /** @@ -160,6 +161,7 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) = dom.on('click', save), testId('modal-confirm'), ), + options.extraButtons, options.hideCancel ? null : bigBasicButton('Cancel', dom.on('click', () => ctl.close()), testId('modal-cancel'), @@ -182,7 +184,7 @@ export function confirmModal( btnText: string, onConfirm: () => Promise, explanation?: Element|string, - {hideCancel}: {hideCancel?: boolean} = {}, + {hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {}, ): void { return saveModal((ctl, owner): ISaveModalOptions => ({ title, @@ -191,6 +193,7 @@ export function confirmModal( saveFunc: onConfirm, hideCancel, width: 'normal', + extraButtons, })); } diff --git a/app/common/ACLRuleCollection.ts b/app/common/ACLRuleCollection.ts index 66cc836e..1255ebce 100644 --- a/app/common/ACLRuleCollection.ts +++ b/app/common/ACLRuleCollection.ts @@ -30,6 +30,24 @@ const DEFAULT_RULE_SET: RuleSet = { }], }; +// If the user-created rules become dysfunctional, we can swap in this emergency set. +// It grants full access to owners, and no access to anyone else. +const EMERGENCY_RULE_SET: RuleSet = { + tableId: '*', + colIds: '*', + body: [{ + aclFormula: "user.Access in ['owners']", + matchFunc: (input) => ['owners'].includes(String(input.user.Access)), + permissions: parsePermissions('all'), + permissionsText: 'all', + }, { + aclFormula: "", + matchFunc: defaultMatchFunc, + permissions: parsePermissions('none'), + permissionsText: 'none', + }], +}; + export class ACLRuleCollection { // In the absence of rules, some checks are skipped. For now this is important to maintain all // existing behavior. TODO should make sure checking access against default rules is equivalent @@ -54,6 +72,10 @@ export class ACLRuleCollection { // Maps name to the corresponding UserAttributeRule. private _userAttributeRules = new Map(); + // Store error if one occurs while reading rules. Rules are replaced with emergency rules + // in this case. + public ruleError: Error|undefined; + // Whether there are ANY user-defined rules. public haveRules(): boolean { return this._haveRules; @@ -93,7 +115,7 @@ export class ACLRuleCollection { * Update granular access from DocData. */ public async update(docData: DocData, options: ReadAclOptions) { - const {ruleSets, userAttributes} = readAclRules(docData, options); + const {ruleSets, userAttributes} = this._safeReadAclRules(docData, options); // Build a map of user characteristics rules. const userAttributeMap = new Map(); @@ -143,6 +165,16 @@ export class ACLRuleCollection { this._tableIds = [...tableIds]; this._userAttributeRules = userAttributeMap; } + + private _safeReadAclRules(docData: DocData, options: ReadAclOptions): ReadAclResults { + try { + this.ruleError = undefined; + return readAclRules(docData, options); + } catch(e) { + this.ruleError = e; // Report the error indirectly. + return {ruleSets: [EMERGENCY_RULE_SET], userAttributes: []}; + } + } } export interface ReadAclOptions { diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 7edf483d..493ce5a9 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -42,6 +42,7 @@ export interface OpenLocalDocResult { doc: {[tableId: string]: TableDataAction}; log: ActionGroup[]; plugins: LocalPlugin[]; + recoveryMode?: boolean; } export interface DocListAPI { diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 6d5f4d36..90e3b3ed 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -308,6 +308,7 @@ export interface DocAPI { replace(source: DocReplacementOptions): Promise; getSnapshots(): Promise; forceReload(): Promise; + recover(recoveryMode: boolean): Promise; // Compare two documents, optionally including details of the changes. compareDoc(remoteDocId: string, options?: { detail: boolean }): Promise; // Compare two versions within a document, including details of the changes. @@ -715,6 +716,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { }); } + public async recover(recoveryMode: boolean): Promise { + await this.request(`${this._url}/recover`, { + body: JSON.stringify({recoveryMode}), + method: 'POST' + }); + } + public async compareDoc(remoteDocId: string, options: { detail?: boolean } = {}): Promise { diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 1cf9978c..92ab61a4 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -38,6 +38,7 @@ export class DocApiForwarder { const withDocWithoutAuth = expressWrap(this._forwardToDocWorker.bind(this, true, null)); app.use('/api/docs/:docId/tables', withDoc); app.use('/api/docs/:docId/force-reload', withDoc); + app.use('/api/docs/:docId/recover', withDoc); app.use('/api/docs/:docId/remove', withDoc); app.delete('/api/docs/:docId', withDoc); app.use('/api/docs/:docId/download', withDoc); diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index e78ae5e3..1771ba86 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -123,9 +123,11 @@ export class ActiveDoc extends EventEmitter { // Timer for shutting down the ActiveDoc a bit after all clients are gone. private _inactivityTimer = new InactivityTimer(() => this.shutdown(), Deps.ACTIVEDOC_TIMEOUT * 1000); + private _recoveryMode: boolean = false; - constructor(docManager: DocManager, docName: string) { + constructor(docManager: DocManager, docName: string, wantRecoveryMode?: boolean) { super(); + if (wantRecoveryMode) { this._recoveryMode = true; } this._docManager = docManager; this._docName = docName; this.docStorage = new DocStorage(docManager.storageManager, docName); @@ -154,6 +156,8 @@ export class ActiveDoc extends EventEmitter { public get docName(): string { return this._docName; } + public get recoveryMode(): boolean { return this._recoveryMode; } + // Helpers to log a message along with metadata about the request. public logDebug(s: OptDocSession, msg: string, ...args: any[]) { this._log('debug', s, msg, ...args); } public logInfo(s: OptDocSession, msg: string, ...args: any[]) { this._log('info', s, msg, ...args); } @@ -411,7 +415,7 @@ export class ActiveDoc extends EventEmitter { await this._actionHistory.initialize(); this._granularAccess = new GranularAccess(this.docData, (query) => { return this._fetchQueryFromDB(query, false); - }); + }, this.recoveryMode); await this._granularAccess.update(); this._sharing = new Sharing(this, this._actionHistory, this._modificationLock); @@ -871,6 +875,10 @@ export class ActiveDoc extends EventEmitter { return this.shutdown(); } + public isOwner(docSession: OptDocSession): boolean { + return this._granularAccess.isOwner(docSession); + } + /** * Fork the current document. In fact, all that requires is calculating a good * ID for the fork. TODO: reconcile the two ways there are now of preparing a fork. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 24a68b77..910bf5c6 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -222,11 +222,22 @@ export class DocWorkerApi { // Reload a document forcibly (in fact this closes the doc, it will be automatically // reopened on use). - this._app.post('/api/docs/:docId/force-reload', canEdit, withDoc(async (activeDoc, req, res) => { + this._app.post('/api/docs/:docId/force-reload', canEdit, throttled(async (req, res) => { + const activeDoc = await this._getActiveDoc(req); await activeDoc.reloadDoc(); res.json(null); })); + this._app.post('/api/docs/:docId/recover', canEdit, throttled(async (req, res) => { + const recoveryModeRaw = req.body.recoveryMode; + const recoveryMode = (typeof recoveryModeRaw === 'boolean') ? recoveryModeRaw : undefined; + if (!this._isOwner(req)) { throw new Error('Only owners can control recovery mode'); } + const activeDoc = await this._docManager.fetchDoc(docSessionFromRequest(req), getDocId(req), recoveryMode); + res.json({ + recoveryMode: activeDoc.recoveryMode + }); + })); + // DELETE /api/docs/:docId // Delete the specified doc. this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => { diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index ca9901ae..bcdedef2 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -296,7 +296,8 @@ export class DocManager extends EventEmitter { clientId: docSession.client.clientId, doc: metaTables, log: recentActions, - plugins: activeDoc.docPluginManager.getPlugins() + plugins: activeDoc.docPluginManager.getPlugins(), + recoveryMode: activeDoc.recoveryMode, }; } @@ -364,12 +365,21 @@ export class DocManager extends EventEmitter { /** * Fetches an ActiveDoc object. Used by openDoc. */ - public async fetchDoc(docSession: OptDocSession, docName: string): Promise { + public async fetchDoc(docSession: OptDocSession, docName: string, + wantRecoveryMode?: boolean): Promise { log.debug('DocManager.fetchDoc', docName); // Repeat until we acquire an ActiveDoc that is not muted (shutting down). for (;;) { + if (this._activeDocs.has(docName) && wantRecoveryMode !== undefined) { + const activeDoc = await this._activeDocs.get(docName); + if (activeDoc && activeDoc.recoveryMode !== wantRecoveryMode && activeDoc.isOwner(docSession)) { + // shutting doc down to have a chance to re-open in the correct mode. + // TODO: there could be a battle with other users opening it in a different mode. + await activeDoc.shutdown(); + } + } if (!this._activeDocs.has(docName)) { - const newDoc = this.gristServer.create.ActiveDoc(this, docName); + const newDoc = this.gristServer.create.ActiveDoc(this, docName, wantRecoveryMode); // Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan) newDoc.on('backupMade', (bakPath: string) => { this.emit('backupMade', bakPath); diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index f19cc4b3..33826848 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -109,7 +109,7 @@ export class GranularAccess { // Flag tracking whether a set of actions have been applied to the database or not. private _applied: boolean = false; - public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise) { + public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise, private _recoveryMode: boolean) { } /** @@ -292,9 +292,19 @@ export class GranularAccess { } /** - * Check whether user has owner-level access to the document. + * Check whether user has full access to the document. Currently that is interpreted + * as equivalent owner-level access to the document. + * TODO: uses of this method should be checked to see if they can be fleshed out + * now we have more of the ACL implementation done. */ public hasFullAccess(docSession: OptDocSession): boolean { + return this.isOwner(docSession); + } + + /** + * Check whether user has owner-level access to the document. + */ + public isOwner(docSession: OptDocSession): boolean { const access = getDocSessionAccess(docSession); return access === 'owners'; } @@ -765,6 +775,12 @@ export class GranularAccess { // TODO: could also get this for websocket access, just via a different route. user.Origin = docSession.req?.get('origin') || null; + if (this._ruleCollection.ruleError && !this._recoveryMode) { + // It is important to signal that the doc is in an unexpected state, + // and prevent it opening. + throw this._ruleCollection.ruleError; + } + for (const clause of this._ruleCollection.getUserAttributeRules().values()) { if (clause.name in user) { log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`); diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index 92166123..f67ac728 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -29,7 +29,7 @@ export interface ICreate { // should not interfere with each other. ExternalStorage(purpose: 'doc' | 'meta', testExtraPrefix: string): ExternalStorage|undefined; - ActiveDoc(docManager: DocManager, docName: string): ActiveDoc; + ActiveDoc(docManager: DocManager, docName: string, safeMode?: boolean): ActiveDoc; DocManager(storageManager: IDocStorageManager, pluginManager: PluginManager, homeDbManager: HomeDBManager|null, gristServer: GristServer): DocManager; NSandbox(options: ISandboxCreationOptions): ISandbox; diff --git a/stubs/app/server/lib/create.ts b/stubs/app/server/lib/create.ts index a624e01a..dd6becd3 100644 --- a/stubs/app/server/lib/create.ts +++ b/stubs/app/server/lib/create.ts @@ -31,7 +31,7 @@ export const create: ICreate = { }; }, ExternalStorage() { return undefined; }, - ActiveDoc(docManager, docName) { return new ActiveDoc(docManager, docName); }, + ActiveDoc(docManager, docName, wantSafeMode) { return new ActiveDoc(docManager, docName, wantSafeMode); }, DocManager(storageManager, pluginManager, homeDBManager, gristServer) { return new DocManager(storageManager, pluginManager, homeDBManager, gristServer); },