gristlabs_grist-core/app/client/widgets/FieldEditor.ts
Paul Fitzpatrick f91f45b26d (core) support granular read access for attachments
Summary:
When a user requests to read the contents of an attachment, only allow the request if there exists a cell in an attachment column that contains the attachment and which they have read access to.

This does not cover:
 * Granular write access for attachments. In particular, a user who can write to any attachment column should be considered to have full read access to all attachment columns, currently.
 * Access control of attachment metadata such as name and format.

The implementation uses a sql query that requires a scan, and some notes on how this could be optimized in future. The web client was updated to specify the cell to check for access, and performance seemed fine in casual testing on a doc with 1000s of attachments. I'm not sure how performance would hold up as the set of access rules grows as well.

Test Plan: added tests

Reviewers: alexmojaki

Reviewed By: alexmojaki

Differential Revision: https://phab.getgrist.com/D3490
2022-07-07 07:22:02 -04:00

414 lines
16 KiB
TypeScript

import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {showTooltipToCreateFormula} from 'app/client/widgets/EditorTooltip';
import {FormulaEditor, getFormulaError} from 'app/client/widgets/FormulaEditor';
import {IEditorCommandGroup, NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {asyncOnce} from "app/common/AsyncCreate";
import {CellValue} from "app/common/DocActions";
import * as gutil from 'app/common/gutil';
import {Disposable, Emitter, Holder, MultiHolder} from 'grainjs';
import isEqual = require('lodash/isEqual');
import {CellPosition} from "app/client/components/CellPosition";
type IEditorConstructor = typeof NewBaseEditor;
/**
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
* saves and returns true. E.g. on typing space, CheckBoxEditor toggles the cell without opening.
*/
export function saveWithoutEditor(
editorCtor: IEditorConstructor, editRow: DataRowModel, field: ViewFieldRec, typedVal: string|undefined
): boolean {
// Never skip the editor if editing a formula. Also, check that skipEditor static function
// exists (we don't bother adding it on old-style JS editors that don't need it).
if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {
const origVal = editRow.cells[field.colId()].peek();
const skipEditorValue = editorCtor.skipEditor(typedVal, origVal);
if (skipEditorValue !== undefined) {
setAndSave(editRow, field, skipEditorValue).catch(reportError);
return true;
}
}
return false;
}
// Set the given field of editRow to value, only if different from the current value of the cell.
export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {
const obs = editRow.cells[field.colId()];
if (!isEqual(value, obs.peek())) {
return obs.setAndSave(value);
}
}
/**
* Event that is fired when editor stat has changed
*/
export interface FieldEditorStateEvent {
position: CellPosition,
wasModified: boolean,
currentState: any,
type: string
}
export class FieldEditor extends Disposable {
public readonly saveEmitter = this.autoDispose(new Emitter());
public readonly cancelEmitter = this.autoDispose(new Emitter());
public readonly changeEmitter = this.autoDispose(new Emitter());
private _gristDoc: GristDoc;
private _field: ViewFieldRec;
private _cursor: Cursor;
private _editRow: DataRowModel;
private _cellElem: Element;
private _editCommands: IEditorCommandGroup;
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEdit = asyncOnce(() => this._doSaveEdit());
private _editorHasChanged = false;
private _isFormula = false;
private _readonly = false;
constructor(options: {
gristDoc: GristDoc,
field: ViewFieldRec,
cursor: Cursor,
editRow: DataRowModel,
cellElem: Element,
editorCtor: IEditorConstructor,
startVal?: string,
state?: any,
readonly: boolean
}) {
super();
this._gristDoc = options.gristDoc;
this._field = options.field;
this._cursor = options.cursor;
this._editRow = options.editRow;
this._editorCtor = options.editorCtor;
this._cellElem = options.cellElem;
this._readonly = options.readonly;
const startVal = options.startVal;
let offerToMakeFormula = false;
const column = this._field.column();
this._isFormula = column.isRealFormula.peek();
let editValue: string|undefined = startVal;
if (!options.readonly && startVal && gutil.startsWith(startVal, '=')) {
if (this._isFormula || this._field.column().isEmpty()) {
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
// start editing ignoring the initial '='.
this._isFormula = true;
editValue = gutil.removePrefix(startVal, '=') as string;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
offerToMakeFormula = true;
}
}
// These are the commands for while the editor is active.
this._editCommands = {
// _saveEdit disables this command group, so when we run fieldEditSave again, it triggers
// another registered group, if any. E.g. GridView listens to it to move the cursor down.
fieldEditSave: () => {
this._saveEdit().then((jumped: boolean) => {
// To avoid confusing cursor movement, do not increment the rowIndex if the row
// was re-sorted after editing.
if (!jumped) { commands.allCommands.fieldEditSave.run(); }
})
.catch(reportError);
},
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
fieldEditCancel: () => { this._cancelEdit(); },
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
makeFormula: () => this._makeFormula(),
unmakeFormula: () => this._unmakeFormula(),
};
// for readonly editor rewire commands, most of this also could be
// done by just overriding the saveEdit method, but this is more clearer
if (options.readonly) {
this._editCommands.fieldEditSave = () => {
// those two lines are tightly coupled - without disposing first
// it will run itself in a loop. But this is needed for a GridView
// which navigates to the next row on save.
this._editCommands.fieldEditCancel();
commands.allCommands.fieldEditSave.run();
};
this._editCommands.fieldEditSaveHere = this._editCommands.fieldEditCancel;
this._editCommands.prevField = () => { this._cancelEdit(); commands.allCommands.prevField.run(); };
this._editCommands.nextField = () => { this._cancelEdit(); commands.allCommands.nextField.run(); };
this._editCommands.makeFormula = () => true; /* don't stop propagation */
this._editCommands.unmakeFormula = () => true;
}
this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state);
if (offerToMakeFormula) {
this._offerToMakeFormula();
}
// connect this editor to editor monitor, it will restore this editor
// when user or server refreshes the browser
this._gristDoc.editorMonitor.monitorEditor(this);
// for readonly field we don't need to do anything special
if (!options.readonly) {
setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit);
} else {
setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit());
}
}
// cursorPos refers to the position of the caret within the editor.
public rebuildEditor(editValue: string|undefined, cursorPos: number, state?: any) {
const editorCtor: IEditorConstructor = this._isFormula ? FormulaEditor : this._editorCtor;
const column = this._field.column();
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
let cellValue: CellValue;
if (column.isFormula()) {
cellValue = column.formula();
} else if (Array.isArray(cellCurrentValue) && cellCurrentValue[0] === 'C') {
// This cell value is censored by access control rules
// Really the rules should also block editing, but in case they don't, show a blank value
// rather than a 'C'. However if the user tries to edit the cell and then clicks away
// without typing anything the empty string is saved, deleting what was there.
// We should probably just automatically block updates where reading is not allowed.
cellValue = '';
} else {
cellValue = cellCurrentValue;
}
const error = getFormulaError(this._gristDoc, this._editRow, column);
// For readonly mode use the default behavior of Formula Editor
// TODO: cleanup this flag - it gets modified in too many places
if (!this._readonly){
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
// editor by typing into it (and overriding previous formula). In other cases (e.g. double-click),
// we defer this mode until the user types something.
this._field.editingFormula(this._isFormula && editValue !== undefined);
}
this._editorHasChanged = false;
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc,
field: this._field,
cellValue,
rowId: this._editRow.id(),
formulaError: error,
editValue,
cursorPos,
state,
commands: this._editCommands,
readonly : this._readonly
}));
// if editor supports live changes, connect it to the change emitter
if (editor.editorState) {
editor.autoDispose(editor.editorState.addListener((currentState) => {
this._editorHasChanged = true;
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState,
type: this._field.column.peek().pureType.peek()
};
this.changeEmitter.emit(event);
}));
}
editor.attach(this._cellElem);
}
public getDom() {
return this._editorHolder.get()?.getDom();
}
// calculate current cell's absolute position
public cellPosition() {
const rowId = this._editRow.getRowId();
const colRef = this._field.colRef.peek();
const sectionId = this._field.viewSection.peek().id.peek();
const position = {
rowId,
colRef,
sectionId
};
return position;
}
private _makeFormula() {
const editor = this._editorHolder.get();
// On keyPress of "=" on textInput, consider turning the column into a formula.
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
if (this._field.column().isEmpty()) {
this._isFormula = true;
// If we typed '=' an empty column, convert it to a formula.
this.rebuildEditor(editor.getTextValue(), 0);
return false;
} else {
// If we typed '=' on a non-empty column, only suggest to convert it to a formula.
this._offerToMakeFormula();
}
}
return true; // don't stop propagation.
}
private _unmakeFormula() {
const editor = this._editorHolder.get();
// Only convert to data if we are undoing a to-formula conversion. To convert formula to
// data, use column menu option, or delete the formula first (which makes the column "empty").
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
!this._field.column().isRealFormula()) {
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
// second backspace will delete it.
this._isFormula = false;
this.rebuildEditor('=' + editor.getTextValue(), 1);
return false;
}
return true; // don't stop propagation.
}
private _offerToMakeFormula() {
const editorDom = this._editorHolder.get()?.getDom();
if (!editorDom) { return; }
showTooltipToCreateFormula(editorDom, () => this._convertEditorToFormula());
}
private _convertEditorToFormula() {
const editor = this._editorHolder.get();
if (editor) {
const editValue = editor.getTextValue();
const formulaValue = editValue.startsWith('=') ? editValue.slice(1) : editValue;
this._isFormula = true;
this.rebuildEditor(formulaValue, 0);
}
}
// Cancels the edit
private _cancelEdit() {
if (this.isDisposed()) { return; }
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.cancelEmitter.emit(event);
this.dispose();
}
// Returns true if Enter/Shift+Enter should NOT move the cursor, for instance if the current
// record got reordered (i.e. the cursor jumped), or when editing a formula.
private async _doSaveEdit(): Promise<boolean> {
const editor = this._editorHolder.get();
if (!editor) { return false; }
// Make sure the editor is save ready
const saveIndex = this._cursor.rowIndex();
await editor.prepForSave();
if (this.isDisposed()) {
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
console.warn("Unable to finish saving edited cell"); // tslint:disable-line:no-console
return false;
}
// Then save the value the appropriate way
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
// editingFormula() is used for toggling column headers, and this is deferred to start of
// typing (a double-click or Enter) does not immediately set it. (This can cause a
// console.warn below, although harmless.)
const isFormula = this._field.editingFormula();
const col = this._field.column();
let waitPromise: Promise<unknown>|null = null;
if (isFormula) {
const formula = editor.getCellValue();
// Bundle multiple changes so that we can undo them in one step.
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
col.updateColValues({isFormula, formula}),
// If we're saving a non-empty formula, then also add an empty record to the table
// so that the formula calculation is visible to the user.
(this._editRow._isAddRow.peek() && formula !== "" ?
this._editRow.updateColValues({}) : undefined),
]));
}
} else {
const value = editor.getCellValue();
if (col.isRealFormula()) {
// tslint:disable-next-line:no-console
console.warn("It should be impossible to save a plain data value into a formula column");
} else {
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
// need to toggle isFormula in that case, since the data engine takes care of that.
waitPromise = setAndSave(this._editRow, this._field, value);
}
}
const event: FieldEditorStateEvent = {
position : this.cellPosition(),
wasModified : this._editorHasChanged,
currentState : this._editorHolder.get()?.editorState?.get(),
type : this._field.column.peek().pureType.peek()
};
this.saveEmitter.emit(event);
const cursor = this._cursor;
// Deactivate the editor. We are careful to avoid using `this` afterwards.
this.dispose();
await waitPromise;
return isFormula || (saveIndex !== cursor.rowIndex());
}
}
/**
* For an readonly editor, set up its cleanup:
* - canceling on click-away (when focus returns to Grist "clipboard" element)
*/
function setupReadonlyEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, cancelEdit: () => any
) {
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
gristDoc.app.on('clipboard_focus', cancelEdit);
owner.onDispose(() => {
field.editingFormula(false);
gristDoc.app.off('clipboard_focus', cancelEdit);
});
}
/**
* For an active editor, set up its cleanup:
* - saving on click-away (when focus returns to Grist "clipboard" element)
* - unset field.editingFormula mode
* - Arrange for UnsavedChange protection against leaving the page with unsaved changes.
*/
export function setupEditorCleanup(
owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, _saveEdit: () => Promise<unknown>
) {
const saveEdit = () => _saveEdit().catch(reportError);
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
gristDoc.app.on('clipboard_focus', saveEdit);
// TODO: This should ideally include a callback that returns true only when the editor value
// has changed. Currently an open editor is considered unsaved even when unchanged.
UnsavedChange.create(owner, async () => { await saveEdit(); });
owner.onDispose(() => {
gristDoc.app.off('clipboard_focus', saveEdit);
// Unset field.editingFormula flag when the editor closes.
field.editingFormula(false);
});
}