mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) implement a safe mode for opening documents with rule problems
Summary: Adds an "enter safe mode" option and explanation in modal that appears when a document fails to load, if user is owner. If "enter safe mode" is selected, document is reloaded on server in a special mode. Currently, the only difference is that if the acl rules fail to load, they are replaced with a fallback that grants full access to owners and no access to anyone else. An extra tag is shown to mark the document as safe mode, with an "x" for cancelling safe mode. There are other ways a document could fail to load than just acl rules, so this is just a start. Test Plan: added test Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2686
This commit is contained in:
parent
02ed4c59a0
commit
3b3ae87ade
@ -11,6 +11,7 @@ import {buildPagesDom} from 'app/client/ui/Pages';
|
|||||||
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
import {openPageWidgetPicker} from 'app/client/ui/PageWidgetPicker';
|
||||||
import {tools} from 'app/client/ui/Tools';
|
import {tools} from 'app/client/ui/Tools';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
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 {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
||||||
@ -30,6 +31,7 @@ export interface DocInfo extends Document {
|
|||||||
isSample: boolean;
|
isSample: boolean;
|
||||||
isPreFork: boolean;
|
isPreFork: boolean;
|
||||||
isFork: boolean;
|
isFork: boolean;
|
||||||
|
isRecoveryMode: boolean;
|
||||||
isBareFork: boolean; // a document created without logging in, which is treated as a
|
isBareFork: boolean; // a document created without logging in, which is treated as a
|
||||||
// fork without an original.
|
// fork without an original.
|
||||||
idParts: UrlIdParts;
|
idParts: UrlIdParts;
|
||||||
@ -53,6 +55,7 @@ export interface DocPageModel {
|
|||||||
isReadonly: Observable<boolean>;
|
isReadonly: Observable<boolean>;
|
||||||
isPrefork: Observable<boolean>;
|
isPrefork: Observable<boolean>;
|
||||||
isFork: Observable<boolean>;
|
isFork: Observable<boolean>;
|
||||||
|
isRecoveryMode: Observable<boolean>;
|
||||||
isBareFork: Observable<boolean>;
|
isBareFork: Observable<boolean>;
|
||||||
isSample: Observable<boolean>;
|
isSample: Observable<boolean>;
|
||||||
|
|
||||||
@ -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 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 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 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 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);
|
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,
|
// 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.
|
// show a modal, and include a toast for the sake of the "Report error" link.
|
||||||
reportError(err);
|
reportError(err);
|
||||||
|
const isOwner = this.currentDoc.get()?.access === 'owners';
|
||||||
confirmModal(
|
confirmModal(
|
||||||
"Error opening document",
|
"Error opening document",
|
||||||
"Reload",
|
"Reload",
|
||||||
async () => window.location.reload(true),
|
async () => window.location.reload(true),
|
||||||
err.message,
|
isOwner ? `You can try reloading the document, or using recovery mode. ` +
|
||||||
{hideCancel: true},
|
`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));
|
flow.onDispose(() => comm.releaseDocConnection(doc.id));
|
||||||
|
|
||||||
const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters);
|
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 gdModule = await gristDocModulePromise;
|
||||||
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
|
const docComm = gdModule.DocComm.create(flow, comm, openDocResponse, doc.id, this.appModel.notifier);
|
||||||
flow.checkIfCancelled();
|
flow.checkIfCancelled();
|
||||||
@ -311,6 +328,7 @@ function buildDocInfo(doc: Document, mode: OpenDocMode): DocInfo {
|
|||||||
return {
|
return {
|
||||||
...doc,
|
...doc,
|
||||||
isFork,
|
isFork,
|
||||||
|
isRecoveryMode: false, // we don't know yet, will learn when doc is opened.
|
||||||
isSample,
|
isSample,
|
||||||
isPreFork,
|
isPreFork,
|
||||||
isBareFork,
|
isBareFork,
|
||||||
|
@ -43,9 +43,11 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
|||||||
docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {
|
docBreadcrumbs(displayNameWs, pageModel.currentDocTitle, gristDoc.currentPageName, {
|
||||||
docNameSave: renameDoc,
|
docNameSave: renameDoc,
|
||||||
pageNameSave: getRenamePageFn(gristDoc),
|
pageNameSave: getRenamePageFn(gristDoc),
|
||||||
|
cancelRecoveryMode: getCancelRecoveryModeFn(gristDoc),
|
||||||
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
|
isPageNameReadOnly: (use) => use(gristDoc.isReadonly) || typeof use(gristDoc.activeViewId) !== 'number',
|
||||||
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
|
isDocNameReadOnly: (use) => use(gristDoc.isReadonly) || use(pageModel.isFork),
|
||||||
isFork: pageModel.isFork,
|
isFork: pageModel.isFork,
|
||||||
|
isRecoveryMode: pageModel.isRecoveryMode,
|
||||||
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)),
|
isFiddle: Computed.create(owner, (use) => use(pageModel.isPrefork) && !use(pageModel.isSample)),
|
||||||
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
|
isSnapshot: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.idParts.snapshotId)),
|
||||||
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
|
isPublic: Computed.create(owner, doc, (use, _doc) => Boolean(_doc && _doc.public)),
|
||||||
@ -91,6 +93,13 @@ function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCancelRecoveryModeFn(gristDoc: GristDoc): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
await gristDoc.app.topAppModel.api.getDocAPI(gristDoc.docPageModel.currentDocId.get()!)
|
||||||
|
.recover(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
|
function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element {
|
||||||
return cssHoverCircle(
|
return cssHoverCircle(
|
||||||
cssTopBarUndoBtn(iconName),
|
cssTopBarUndoBtn(iconName),
|
||||||
|
@ -59,6 +59,14 @@ const cssTag = styled('span', `
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const cssAlertTag = styled(cssTag, `
|
||||||
|
background-color: ${colors.error};
|
||||||
|
--icon-color: white;
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
interface PartialWorkspace {
|
interface PartialWorkspace {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -76,10 +84,12 @@ export function docBreadcrumbs(
|
|||||||
options: {
|
options: {
|
||||||
docNameSave: (val: string) => Promise<void>,
|
docNameSave: (val: string) => Promise<void>,
|
||||||
pageNameSave: (val: string) => Promise<void>,
|
pageNameSave: (val: string) => Promise<void>,
|
||||||
|
cancelRecoveryMode: () => Promise<void>,
|
||||||
isDocNameReadOnly?: BindableValue<boolean>,
|
isDocNameReadOnly?: BindableValue<boolean>,
|
||||||
isPageNameReadOnly?: BindableValue<boolean>,
|
isPageNameReadOnly?: BindableValue<boolean>,
|
||||||
isFork: Observable<boolean>,
|
isFork: Observable<boolean>,
|
||||||
isFiddle: Observable<boolean>,
|
isFiddle: Observable<boolean>,
|
||||||
|
isRecoveryMode: Observable<boolean>,
|
||||||
isSnapshot?: Observable<boolean>,
|
isSnapshot?: Observable<boolean>,
|
||||||
isPublic?: Observable<boolean>,
|
isPublic?: Observable<boolean>,
|
||||||
}
|
}
|
||||||
@ -106,6 +116,13 @@ export function docBreadcrumbs(
|
|||||||
if (use(options.isFork)) {
|
if (use(options.isFork)) {
|
||||||
return cssTag('unsaved', testId('unsaved-tag'));
|
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)) {
|
if (use(options.isFiddle)) {
|
||||||
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
return cssTag('fiddle', tooltip({title: fiddleExplanation}), testId('fiddle-tag'));
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import {reportError} from 'app/client/models/errors';
|
|||||||
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
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 {
|
export interface IModalControl {
|
||||||
close(): void;
|
close(): void;
|
||||||
@ -90,6 +90,7 @@ export interface ISaveModalOptions {
|
|||||||
hideCancel?: boolean; // If set, hide the Cancel button
|
hideCancel?: boolean; // If set, hide the Cancel button
|
||||||
width?: ModalWidth; // Set a width style for the dialog.
|
width?: ModalWidth; // Set a width style for the dialog.
|
||||||
modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element.
|
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),
|
dom.on('click', save),
|
||||||
testId('modal-confirm'),
|
testId('modal-confirm'),
|
||||||
),
|
),
|
||||||
|
options.extraButtons,
|
||||||
options.hideCancel ? null : bigBasicButton('Cancel',
|
options.hideCancel ? null : bigBasicButton('Cancel',
|
||||||
dom.on('click', () => ctl.close()),
|
dom.on('click', () => ctl.close()),
|
||||||
testId('modal-cancel'),
|
testId('modal-cancel'),
|
||||||
@ -182,7 +184,7 @@ export function confirmModal(
|
|||||||
btnText: string,
|
btnText: string,
|
||||||
onConfirm: () => Promise<void>,
|
onConfirm: () => Promise<void>,
|
||||||
explanation?: Element|string,
|
explanation?: Element|string,
|
||||||
{hideCancel}: {hideCancel?: boolean} = {},
|
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
|
||||||
): void {
|
): void {
|
||||||
return saveModal((ctl, owner): ISaveModalOptions => ({
|
return saveModal((ctl, owner): ISaveModalOptions => ({
|
||||||
title,
|
title,
|
||||||
@ -191,6 +193,7 @@ export function confirmModal(
|
|||||||
saveFunc: onConfirm,
|
saveFunc: onConfirm,
|
||||||
hideCancel,
|
hideCancel,
|
||||||
width: 'normal',
|
width: 'normal',
|
||||||
|
extraButtons,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
export class ACLRuleCollection {
|
||||||
// In the absence of rules, some checks are skipped. For now this is important to maintain all
|
// 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
|
// 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.
|
// Maps name to the corresponding UserAttributeRule.
|
||||||
private _userAttributeRules = new Map<string, UserAttributeRule>();
|
private _userAttributeRules = new Map<string, UserAttributeRule>();
|
||||||
|
|
||||||
|
// 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.
|
// Whether there are ANY user-defined rules.
|
||||||
public haveRules(): boolean {
|
public haveRules(): boolean {
|
||||||
return this._haveRules;
|
return this._haveRules;
|
||||||
@ -93,7 +115,7 @@ export class ACLRuleCollection {
|
|||||||
* Update granular access from DocData.
|
* Update granular access from DocData.
|
||||||
*/
|
*/
|
||||||
public async update(docData: DocData, options: ReadAclOptions) {
|
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.
|
// Build a map of user characteristics rules.
|
||||||
const userAttributeMap = new Map<string, UserAttributeRule>();
|
const userAttributeMap = new Map<string, UserAttributeRule>();
|
||||||
@ -143,6 +165,16 @@ export class ACLRuleCollection {
|
|||||||
this._tableIds = [...tableIds];
|
this._tableIds = [...tableIds];
|
||||||
this._userAttributeRules = userAttributeMap;
|
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 {
|
export interface ReadAclOptions {
|
||||||
|
@ -42,6 +42,7 @@ export interface OpenLocalDocResult {
|
|||||||
doc: {[tableId: string]: TableDataAction};
|
doc: {[tableId: string]: TableDataAction};
|
||||||
log: ActionGroup[];
|
log: ActionGroup[];
|
||||||
plugins: LocalPlugin[];
|
plugins: LocalPlugin[];
|
||||||
|
recoveryMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocListAPI {
|
export interface DocListAPI {
|
||||||
|
@ -308,6 +308,7 @@ export interface DocAPI {
|
|||||||
replace(source: DocReplacementOptions): Promise<void>;
|
replace(source: DocReplacementOptions): Promise<void>;
|
||||||
getSnapshots(): Promise<DocSnapshots>;
|
getSnapshots(): Promise<DocSnapshots>;
|
||||||
forceReload(): Promise<void>;
|
forceReload(): Promise<void>;
|
||||||
|
recover(recoveryMode: boolean): Promise<void>;
|
||||||
// Compare two documents, optionally including details of the changes.
|
// Compare two documents, optionally including details of the changes.
|
||||||
compareDoc(remoteDocId: string, options?: { detail: boolean }): Promise<DocStateComparison>;
|
compareDoc(remoteDocId: string, options?: { detail: boolean }): Promise<DocStateComparison>;
|
||||||
// Compare two versions within a document, including details of the changes.
|
// 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<void> {
|
||||||
|
await this.request(`${this._url}/recover`, {
|
||||||
|
body: JSON.stringify({recoveryMode}),
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async compareDoc(remoteDocId: string, options: {
|
public async compareDoc(remoteDocId: string, options: {
|
||||||
detail?: boolean
|
detail?: boolean
|
||||||
} = {}): Promise<DocStateComparison> {
|
} = {}): Promise<DocStateComparison> {
|
||||||
|
@ -38,6 +38,7 @@ export class DocApiForwarder {
|
|||||||
const withDocWithoutAuth = expressWrap(this._forwardToDocWorker.bind(this, true, null));
|
const withDocWithoutAuth = expressWrap(this._forwardToDocWorker.bind(this, true, null));
|
||||||
app.use('/api/docs/:docId/tables', withDoc);
|
app.use('/api/docs/:docId/tables', withDoc);
|
||||||
app.use('/api/docs/:docId/force-reload', withDoc);
|
app.use('/api/docs/:docId/force-reload', withDoc);
|
||||||
|
app.use('/api/docs/:docId/recover', withDoc);
|
||||||
app.use('/api/docs/:docId/remove', withDoc);
|
app.use('/api/docs/:docId/remove', withDoc);
|
||||||
app.delete('/api/docs/:docId', withDoc);
|
app.delete('/api/docs/:docId', withDoc);
|
||||||
app.use('/api/docs/:docId/download', withDoc);
|
app.use('/api/docs/:docId/download', withDoc);
|
||||||
|
@ -123,9 +123,11 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
|
|
||||||
// Timer for shutting down the ActiveDoc a bit after all clients are gone.
|
// Timer for shutting down the ActiveDoc a bit after all clients are gone.
|
||||||
private _inactivityTimer = new InactivityTimer(() => this.shutdown(), Deps.ACTIVEDOC_TIMEOUT * 1000);
|
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();
|
super();
|
||||||
|
if (wantRecoveryMode) { this._recoveryMode = true; }
|
||||||
this._docManager = docManager;
|
this._docManager = docManager;
|
||||||
this._docName = docName;
|
this._docName = docName;
|
||||||
this.docStorage = new DocStorage(docManager.storageManager, 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 docName(): string { return this._docName; }
|
||||||
|
|
||||||
|
public get recoveryMode(): boolean { return this._recoveryMode; }
|
||||||
|
|
||||||
// Helpers to log a message along with metadata about the request.
|
// 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 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); }
|
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();
|
await this._actionHistory.initialize();
|
||||||
this._granularAccess = new GranularAccess(this.docData, (query) => {
|
this._granularAccess = new GranularAccess(this.docData, (query) => {
|
||||||
return this._fetchQueryFromDB(query, false);
|
return this._fetchQueryFromDB(query, false);
|
||||||
});
|
}, this.recoveryMode);
|
||||||
await this._granularAccess.update();
|
await this._granularAccess.update();
|
||||||
this._sharing = new Sharing(this, this._actionHistory, this._modificationLock);
|
this._sharing = new Sharing(this, this._actionHistory, this._modificationLock);
|
||||||
|
|
||||||
@ -871,6 +875,10 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
return this.shutdown();
|
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
|
* 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.
|
* ID for the fork. TODO: reconcile the two ways there are now of preparing a fork.
|
||||||
|
@ -222,11 +222,22 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
// Reload a document forcibly (in fact this closes the doc, it will be automatically
|
// Reload a document forcibly (in fact this closes the doc, it will be automatically
|
||||||
// reopened on use).
|
// 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();
|
await activeDoc.reloadDoc();
|
||||||
res.json(null);
|
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 /api/docs/:docId
|
||||||
// Delete the specified doc.
|
// Delete the specified doc.
|
||||||
this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => {
|
this._app.delete('/api/docs/:docId', canEditMaybeRemoved, throttled(async (req, res) => {
|
||||||
|
@ -296,7 +296,8 @@ export class DocManager extends EventEmitter {
|
|||||||
clientId: docSession.client.clientId,
|
clientId: docSession.client.clientId,
|
||||||
doc: metaTables,
|
doc: metaTables,
|
||||||
log: recentActions,
|
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.
|
* Fetches an ActiveDoc object. Used by openDoc.
|
||||||
*/
|
*/
|
||||||
public async fetchDoc(docSession: OptDocSession, docName: string): Promise<ActiveDoc> {
|
public async fetchDoc(docSession: OptDocSession, docName: string,
|
||||||
|
wantRecoveryMode?: boolean): Promise<ActiveDoc> {
|
||||||
log.debug('DocManager.fetchDoc', docName);
|
log.debug('DocManager.fetchDoc', docName);
|
||||||
// Repeat until we acquire an ActiveDoc that is not muted (shutting down).
|
// Repeat until we acquire an ActiveDoc that is not muted (shutting down).
|
||||||
for (;;) {
|
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)) {
|
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)
|
// Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)
|
||||||
newDoc.on('backupMade', (bakPath: string) => {
|
newDoc.on('backupMade', (bakPath: string) => {
|
||||||
this.emit('backupMade', bakPath);
|
this.emit('backupMade', bakPath);
|
||||||
|
@ -109,7 +109,7 @@ export class GranularAccess {
|
|||||||
// Flag tracking whether a set of actions have been applied to the database or not.
|
// Flag tracking whether a set of actions have been applied to the database or not.
|
||||||
private _applied: boolean = false;
|
private _applied: boolean = false;
|
||||||
|
|
||||||
public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>) {
|
public constructor(private _docData: DocData, private _fetchQueryFromDB: (query: Query) => Promise<TableDataAction>, 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 {
|
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);
|
const access = getDocSessionAccess(docSession);
|
||||||
return access === 'owners';
|
return access === 'owners';
|
||||||
}
|
}
|
||||||
@ -765,6 +775,12 @@ export class GranularAccess {
|
|||||||
// TODO: could also get this for websocket access, just via a different route.
|
// TODO: could also get this for websocket access, just via a different route.
|
||||||
user.Origin = docSession.req?.get('origin') || null;
|
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()) {
|
for (const clause of this._ruleCollection.getUserAttributeRules().values()) {
|
||||||
if (clause.name in user) {
|
if (clause.name in user) {
|
||||||
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
|
log.warn(`User attribute ${clause.name} ignored; conflicts with an existing one`);
|
||||||
|
@ -29,7 +29,7 @@ export interface ICreate {
|
|||||||
// should not interfere with each other.
|
// should not interfere with each other.
|
||||||
ExternalStorage(purpose: 'doc' | 'meta', testExtraPrefix: string): ExternalStorage|undefined;
|
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,
|
DocManager(storageManager: IDocStorageManager, pluginManager: PluginManager,
|
||||||
homeDbManager: HomeDBManager|null, gristServer: GristServer): DocManager;
|
homeDbManager: HomeDBManager|null, gristServer: GristServer): DocManager;
|
||||||
NSandbox(options: ISandboxCreationOptions): ISandbox;
|
NSandbox(options: ISandboxCreationOptions): ISandbox;
|
||||||
|
@ -31,7 +31,7 @@ export const create: ICreate = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
ExternalStorage() { return undefined; },
|
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) {
|
DocManager(storageManager, pluginManager, homeDBManager, gristServer) {
|
||||||
return new DocManager(storageManager, pluginManager, homeDBManager, gristServer);
|
return new DocManager(storageManager, pluginManager, homeDBManager, gristServer);
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user