(core) Comments

Summary:
First iteration for comments system for Grist.
- Comments are stored in a generic metatable `_grist_Cells`
- Each comment is connected to a particular cell (hence the generic name of the table)
- Access level works naturally for records stored in this table
-- User can add/read comments for cells he can see
-- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns)
-- Anonymous users can't see comments at all.
- Each comment can have replies (but replies can't have more replies)

Comments are hidden by default, they can be enabled by COMMENTS=true env variable.
Some things for follow-up
- Avatars, currently the user's profile image is not shown or retrieved from the server
- Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments.

Test Plan: New and existing tests

Reviewers: georgegevoian, paulfitz

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3509
This commit is contained in:
Jarosław Sadziński
2022-10-17 11:47:16 +02:00
parent 8be920dd25
commit bfd7243fe2
41 changed files with 2621 additions and 77 deletions

View File

@@ -10,6 +10,11 @@ import {
BulkColValues,
BulkRemoveRecord,
BulkUpdateRecord,
getColValues,
isBulkAddRecord,
isBulkRemoveRecord,
isBulkUpdateRecord,
isUpdateRecord,
} from 'app/common/DocActions';
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions';
@@ -23,7 +28,7 @@ import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessCl
import { UserInfo } from 'app/common/GranularAccessClause';
import * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { SingleCell } from 'app/common/TableData';
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
@@ -40,6 +45,7 @@ import { integerParam } from 'app/server/lib/requestUtils';
import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
import cloneDeep = require('lodash/cloneDeep');
import fromPairs = require('lodash/fromPairs');
import memoize = require('lodash/memoize');
import get = require('lodash/get');
// tslint:disable:no-bitwise
@@ -208,7 +214,7 @@ export interface GranularAccessForBundle {
* will be abandoned.
* - appliedBundle(), called when DocActions have been applied to the DB, but before
* those changes have been sent to clients.
* - sendDocUpdateforBundle() is called once a bundle has been applied, to notify
* - sendDocUpdateForBundle() is called once a bundle has been applied, to notify
* client of changes.
* - finishedBundle(), called when completely done with modification and any needed
* client notifications, whether successful or failed.
@@ -303,21 +309,44 @@ export class GranularAccess implements GranularAccessForBundle {
}
/**
* Get content of a given cell, if user has read access.
* Checks if user has read access to a cell. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
*/
public async hasCellAccess(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<boolean> {
try {
await this.getCellValue(docSession, cell, docData);
return true;
} catch(err) {
if (err instanceof ErrorWithCode) { return false; }
throw err;
}
}
/**
* Get content of a given cell, if user has read access. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
* Throws if not.
*/
public async getCellValue(docSession: OptDocSession, cell: SingleCell): Promise<CellValue> {
public async getCellValue(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<CellValue> {
function fail(): never {
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
}
const pset = await this.getTableAccess(docSession, cell.tableId);
const tableAccess = this.getReadPermission(pset);
if (tableAccess === 'deny') { fail(); }
const rows = await this._fetchQueryFromDB({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
if (!rows || rows[2].length === 0) { fail(); }
if (!await this.hasTableAccess(docSession, cell.tableId)) { fail(); }
let rows: TableDataAction|null = null;
if (docData) {
const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);
if (record) {
rows = ['TableData', cell.tableId, [cell.rowId], getColValues([record])];
}
} else {
rows = await this._fetchQueryFromDB({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
}
if (!rows || rows[2].length === 0) {
return fail();
}
const rec = new RecordView(rows, 0);
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
@@ -361,7 +390,9 @@ export class GranularAccess implements GranularAccessForBundle {
public async canApplyBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession, isDirect} = this._activeBundle;
if (this._activeBundle.hasDeliberateRuleChange && !await this.isOwner(docSession)) {
const currentUser = await this._getUser(docSession);
const userIsOwner = await this.isOwner(docSession);
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
}
// Normally, viewer requests would never reach this point, but they can happen
@@ -383,6 +414,8 @@ export class GranularAccess implements GranularAccessForBundle {
}));
}
await this._canApplyCellActions(currentUser, userIsOwner);
if (this._recoveryMode) {
// Don't do any further checking in recovery mode.
return;
@@ -483,9 +516,12 @@ export class GranularAccess implements GranularAccessForBundle {
const actions = await Promise.all(
docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx})));
return ([] as DocAction[]).concat(...actions);
const result = ([] as DocAction[]).concat(...actions);
return await this._filterOutgoingCellInfo(docSession, docActions, result);
}
/**
* Filter an ActionGroup to be sent to a client.
*/
@@ -762,9 +798,21 @@ export class GranularAccess implements GranularAccessForBundle {
// If we are going to modify metadata, make a copy.
tables = cloneDeep(tables);
// Prepare cell censorship information.
const cells = new CellData(this._docData).convertToCells(tables['_grist_Cells']);
let cellCensor: CellAccessHelper|undefined;
if (cells.length > 0) {
cellCensor = this._createCellAccess(docSession);
await cellCensor.calculate(cells);
}
const permInfo = await this._getAccess(docSession);
const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables,
await this.hasAccessRulesPermission(docSession));
await this.hasAccessRulesPermission(docSession),
cellCensor);
if (cellCensor) {
censor.filter(tables["_grist_Cells"]);
}
for (const tableId of STRUCTURAL_TABLES) {
censor.apply(tables[tableId]);
@@ -899,6 +947,38 @@ export class GranularAccess implements GranularAccessForBundle {
return baseAccess;
}
public async createSnapshotWithCells(docActions?: DocAction[]) {
if (!docActions) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
if (this._activeBundle.applied) {
throw new Error("Can't calculate last state for cell metadata");
}
docActions = this._activeBundle.docActions;
}
const rows = new Map(getRelatedRows(docActions));
const cellData = new CellData(this._docData);
for(const action of docActions) {
for(const cell of cellData.convertToCells(action)) {
if (!rows.has(cell.tableId)) { rows.set(cell.tableId, new Set()); }
rows.get(cell.tableId)?.add(cell.rowId);
}
}
// Don't need to sync _grist_Cells table, since we already have it.
rows.delete('_grist_Cells');
// Populate a minimal in-memory version of the database with these rows.
const docData = new DocData(
(tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), {
_grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(),
// We need some basic table information to translate numeric ids to string ids (refs to ids).
_grist_Tables: this._docData.getMetaTable('_grist_Tables')!.getTableDataAction(),
_grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column')!.getTableDataAction()
},
);
// Load pre-existing rows touched by the bundle.
await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));
return docData;
}
/**
* An optimization to catch obvious access problems for simple data
* actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks
@@ -2067,9 +2147,9 @@ export class GranularAccess implements GranularAccessForBundle {
return dummyAccessCheck;
}
const tableId = getTableId(a);
if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments') {
if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments' && tableId !== '_grist_Cells') {
// Actions on any metadata table currently require the schemaEdit flag.
// Exception: the attachments table, which needs to be reworked to be compatible
// Exception: the attachments table and cell info table, which needs to be reworked to be compatible
// with granular access.
// Another exception: ensure owners always have full access to ACL tables, so they
@@ -2088,6 +2168,105 @@ export class GranularAccess implements GranularAccessForBundle {
return accessChecks[severity].schemaEdit;
}
}
/**
* Filter outgoing actions and include or remove cell information from _grist_Cells.
*/
private async _filterOutgoingCellInfo(docSession: OptDocSession, before: DocAction[], after: DocAction[]) {
// Rewrite bundle, simplifying all actions that are touching cell metadata.
const cellView = new CellData(this._docData);
const patch = cellView.generatePatch(before);
// If there is nothing to do, just return after state.
if (!patch) { return after; }
// Now remove all action that modify cell metadata from after.
// We will use the patch to reconstruct the cell metadata.
const result = after.filter(action => !isCellDataAction(action));
// Prepare checker, we need to use checker from the last step.
const cursor = {
docSession,
action: before[before.length - 1],
actionIdx: before.length - 1
};
const ruler = await this._getRuler(cursor);
const permInfo = await ruler.getAccess(docSession);
const user = await this._getUser(docSession);
// Cache some data, as they are checked.
const readRows = memoize(this._fetchQueryFromDB.bind(this));
const hasAccess = async (cell: SingleCell) => {
// First check table access, maybe table is hidden.
const tableAccess = permInfo.getTableAccess(cell.tableId);
const access = this.getReadPermission(tableAccess);
if (access === 'deny') { return false; }
// Check, if table is fully allowed (no ACL column/rows rules).
if (access === 'allow') { return true; }
// Maybe there are only rules that hides this column completely.
if (access === 'mixedColumns') {
const collAccess = this.getReadPermission(permInfo.getColumnAccess(cell.tableId, cell.colId));
if (collAccess === 'deny') { return false; }
if (collAccess === 'allow') { return true; }
}
// Probably there are rules at the cell level, check them.
const rows = await readRows({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
// Make sure we have row.
if (!rows || rows[2].length === 0) {
if (cell.rowId) {
return false;
}
}
const rec = rows ? new RecordView(rows, 0) : undefined;
const input: AclMatchInput = {user, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return false; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return false; }
}
return true;
};
// Now censor the patch, so it only contains cells content that user has access to.
await cellView.censorCells(patch, (cell) => hasAccess(cell));
// And append it to the result.
result.push(...patch);
return result;
}
/**
* Tests if the user can modify cell's data.
*/
private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession} = this._activeBundle;
const snapShot = await this.createSnapshotWithCells();
const cellView = new CellData(snapShot);
await cellView.applyAndCheck(
docActions,
userIsOwner,
this._ruler.haveRules(),
currentUser.UserRef || '',
(cell, state) => this.hasCellAccess(docSession, cell, state),
);
}
private _createCellAccess(docSession: OptDocSession, docData?: DocData) {
return new CellAccessHelper(this, this._ruler, docSession, this._fetchQueryFromDB, docData);
}
}
/**
@@ -2308,6 +2487,84 @@ const dummyAccessCheck: IAccessCheck = {
throwIfNotFullyAllowed() {}
};
/**
* Helper class to calculate access for a set of cells in bulk. Used for initial
* access check for a whole _grist_Cell table. Each cell can belong to a diffrent
* table and row, so here we will avoid loading rows multiple times and checking
* the table access multiple time.
*/
class CellAccessHelper {
private _tableAccess: Map<string, boolean> = new Map();
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
private _rows: Map<string, TableDataAction> = new Map();
private _user!: UserInfo;
constructor(
private _granular: GranularAccess,
private _ruler: Ruler,
private _docSession: OptDocSession,
private _fetchQueryFromDB?: (query: ServerQuery) => Promise<TableDataAction>,
private _state?: DocData,
) { }
/**
* Resolves access for all cells, and save the results in the cache.
*/
public async calculate(cells: SingleCell[]) {
this._user = await this._granular.getUser(this._docSession);
const tableIds = new Set(cells.map(cell => cell.tableId));
for (const tableId of tableIds) {
this._tableAccess.set(tableId, await this._granular.hasTableAccess(this._docSession, tableId));
if (this._tableAccess.get(tableId)) {
const rowIds = new Set(cells.filter(cell => cell.tableId === tableId).map(cell => cell.rowId));
const rows = await this._getRows(tableId, rowIds);
for(const [idx, rowId] of rows[2].entries()) {
if (rowIds.has(rowId) === false) { continue; }
const rec = new RecordView(rows, idx);
const input: AclMatchInput = {user: this._user, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
if (!this._rowPermInfo.has(tableId)) {
this._rowPermInfo.set(tableId, new Map());
}
this._rowPermInfo.get(tableId)!.set(rows[2][idx], rowPermInfo);
this._rows.set(tableId, rows);
}
}
}
}
/**
* Checks if user has a read access to a particular cell. Needs to be called after calculate().
*/
public hasAccess(cell: SingleCell) {
const rowPermInfo = this._rowPermInfo.get(cell.tableId)?.get(cell.rowId);
if (!rowPermInfo) { return true; }
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return true; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return true; }
}
const colValues = this._rows.get(cell.tableId);
if (!colValues || !(cell.colId in colValues[3])) { return true; }
return false;
}
private async _getRows(tableId: string, rowIds: Set<number>) {
if (this._state) {
const rows = this._state.getTable(tableId)!.getTableDataAction();
return rows;
}
if (this._fetchQueryFromDB) {
return await this._fetchQueryFromDB({
tableId,
filters: { id: [...rowIds] }
});
}
return ['TableData', tableId, [], {}] as TableDataAction;
}
}
/**
* Manage censoring metadata.
@@ -2325,23 +2582,27 @@ export class CensorshipInfo {
public censoredViews = new Set<number>();
public censoredColumns = new Set<number>();
public censoredFields = new Set<number>();
public censoredComments = new Set<number>();
public censored = {
_grist_Tables: this.censoredTables,
_grist_Tables_column: this.censoredColumns,
_grist_Views: this.censoredViews,
_grist_Views_section: this.censoredSections,
_grist_Views_section_field: this.censoredFields,
_grist_Cells: this.censoredComments,
};
public constructor(permInfo: PermissionInfo,
ruleCollection: ACLRuleCollection,
tables: {[key: string]: TableDataAction},
private _canViewACLs: boolean) {
private _canViewACLs: boolean,
cellAccessInfo?: CellAccessHelper) {
// Collect a list of censored columns (by "<tableRef> <colId>").
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
const censoredColumnCodes: Set<string> = new Set();
const tableRefToTableId: Map<number, string> = new Map();
const tableRefToIndex: Map<number, number> = new Map();
const columnRefToColId: Map<number, string> = new Map();
const uncensoredTables: Set<number> = new Set();
// Scan for forbidden tables.
let rec = new RecordView(tables._grist_Tables, undefined);
@@ -2365,10 +2626,12 @@ export class CensorshipInfo {
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const tableRef = rec.get('parentId') as number;
const colId = rec.get('colId') as string;
const colRef = ids[idx];
columnRefToColId.set(colRef, colId);
if (uncensoredTables.has(tableRef)) { continue; }
const tableId = tableRefToTableId.get(tableRef);
if (!tableId) { throw new Error('table not found'); }
const colId = rec.get('colId') as string;
if (this.censoredTables.has(tableRef) ||
(colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) {
censoredColumnCodes.add(columnCode(tableRef, colId));
@@ -2427,12 +2690,37 @@ export class CensorshipInfo {
const rawViewSectionRef = rec.get('rawViewSectionRef') as number;
this.censoredSections.delete(rawViewSectionRef);
}
// Collect a list of all cells metadata to which the user has no access.
rec = new RecordView(tables._grist_Cells, undefined);
ids = tables._grist_Cells ? getRowIdsFromDocAction(tables._grist_Cells) : [];
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const isTableCensored = () => this.censoredTables.has(rec.get('tableRef') as number);
const isColumnCensored = () => this.censoredColumns.has(rec.get('colRef') as number);
const isCellCensored = () => {
if (!cellAccessInfo) { return false; }
const cell = {
tableId: tableRefToTableId.get(rec.get('tableRef') as number)!,
colId: columnRefToColId.get(rec.get('colRef') as number)!,
rowId: rec.get('rowId') as number
};
return !cell.tableId || !cell.colId || cellAccessInfo.hasAccess(cell);
};
if (isTableCensored() || isColumnCensored() || isCellCensored()) {
this.censoredComments.add(ids[idx]);
}
}
}
public apply(a: DataAction) {
const tableId = getTableId(a);
const ids = getRowIdsFromDocAction(a);
if (!STRUCTURAL_TABLES.has(tableId)) { return true; }
return this.filter(a);
}
public filter(a: DataAction) {
const tableId = getTableId(a);
if (!(tableId in this.censored)) {
if (!this._canViewACLs && a[0] === 'TableData') {
a[2] = [];
@@ -2443,6 +2731,7 @@ export class CensorshipInfo {
const rec = new RecordEditor(a, undefined, true);
const method = getCensorMethod(getTableId(a));
const censoredRows = (this.censored as any)[tableId] as Set<number>;
const ids = getRowIdsFromDocAction(a);
for (const [index, id] of ids.entries()) {
if (censoredRows.has(id)) {
rec.index = index;
@@ -2470,6 +2759,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
return rec => rec;
case '_grist_ACLRules':
return rec => rec;
case '_grist_Cells':
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
default:
throw new Error(`cannot censor ${tableId}`);
}
@@ -2645,3 +2936,464 @@ function actionHasRuleChange(a: DocAction): boolean {
)
);
}
interface SingleCellInfo extends SingleCell {
userRef: string;
id: number;
}
/**
* Helper class that extends DocData with cell specific functions.
*/
export class CellData {
constructor(private _docData: DocData) {
}
public getCell(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row ? this.convertToCellInfo(row) : null;
}
public getCellRecord(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row || null;
}
/**
* Generates a patch for cell metadata. It assumes, that engine removes all
* cell metadata when cell (table/column/row) is removed and the bundle contains,
* all actions that are needed to remove the cell and cell metadata.
*/
public generatePatch(actions: DocAction[]) {
const removedCells: Set<number> = new Set();
const addedCells: Set<number> = new Set();
const updatedCells: Set<number> = new Set();
function applyCellAction(action: DataAction) {
if (isAddRecordAction(action) || isBulkAddRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (removedCells.has(id)) {
removedCells.delete(id);
updatedCells.add(id);
} else {
addedCells.add(id);
}
}
} else if (isRemoveRecordAction(action) || isBulkRemoveRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
addedCells.delete(id);
} else {
removedCells.add(id);
updatedCells.delete(id);
}
}
} else {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
// ignore
} else {
updatedCells.add(id);
}
}
}
}
// Scan all actions and collect all cell ids that are added, removed or updated.
// When some rows are updated, include all cells for that row. Keep track of table
// renames.
const updatedRows: Map<string, Set<number>> = new Map();
for(const action of actions) {
if (action[0] === 'RenameTable') {
updatedRows.set(action[2], updatedRows.get(action[1]) || new Set());
continue;
}
if (action[0] === 'RemoveTable') {
updatedRows.delete(action[1]);
continue;
}
if (isDataAction(action) && isCellDataAction(action)) {
applyCellAction(action);
continue;
}
if (!isDataAction(action)) { continue; }
// We don't care about new rows, as they don't have meta data at this moment.
// If regular rows are removed, we also don't care about them, as they will
// produce metadata removal.
// We only care about updates, as it might change the metadata visibility.
if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {
if (getTableId(action).startsWith("_grist")) { continue; }
// Updating a row, for us means that all metadata for this row should be refreshed.
for(const rowId of getRowIdsFromDocAction(action)) {
getSetMapValue(updatedRows, getTableId(action), () => new Set()).add(rowId);
}
}
}
for(const [tableId, rowIds] of updatedRows) {
for(const {id} of this.readCells(tableId, rowIds)) {
if (addedCells.has(id) || updatedCells.has(id) || removedCells.has(id)) {
// If we have this cell id in the list of added/updated/removed cells, ignore it.
} else {
updatedCells.add(id);
}
}
}
const insert = this.generateInsert([...addedCells]);
const update = this.generateUpdate([...updatedCells]);
const removes = this.generateRemovals([...removedCells]);
const patch: DocAction[] = [insert, update, removes].filter(Boolean) as DocAction[];
return patch.length ? patch : null;
}
public async censorCells(
docActions: DocAction[],
hasAccess: (cell: SingleCellInfo) => Promise<boolean>
) {
for (const action of docActions) {
if (!isDataAction(action) || isRemoveRecordAction(action)) {
continue;
} else if (isDataAction(action) && getTableId(action) === '_grist_Cells') {
if (!isBulkAction(action)) {
const cell = this.getCell(action[2]);
if (!cell || !await hasAccess(cell)) {
action[3].content = [GristObjCode.Censored];
action[3].userRef = '';
}
} else {
for (let idx = 0; idx < action[2].length; idx++) {
const cell = this.getCell(action[2][idx]);
if (!cell || !await hasAccess(cell)) {
action[3].content[idx] = [GristObjCode.Censored];
action[3].userRef[idx] = '';
}
}
}
}
}
return docActions;
}
public convertToCellInfo(cell: MetaRowRecord<'_grist_Cells'>): SingleCellInfo {
const singleCell = {
tableId: this.getTableId(cell.tableRef) as string,
colId: this.getColId(cell.colRef) as string,
rowId: cell.rowId,
userRef: cell.userRef,
id: cell.id,
};
return singleCell;
}
public getColId(colRef: number) {
return this._docData.getMetaTable("_grist_Tables_column").getRecord(colRef)?.colId;
}
public getColRef(table: number|string, colId: string) {
const tableRef = typeof table === 'string' ? this.getTableRef(table) : table;
return this._docData.getMetaTable("_grist_Tables_column").filterRecords({colId})
.find(c => c.parentId === tableRef)?.id;
}
public getTableId(tableRef: number) {
return this._docData.getMetaTable("_grist_Tables").getRecord(tableRef)?.tableId;
}
public getTableRef(tableId: string) {
return this._docData.getMetaTable("_grist_Tables").findRow('tableId', tableId) || undefined;
}
/**
* Returns all cells for a given table and row ids.
*/
public readCells(tableId: string, rowIds: Set<number>) {
const tableRef = this.getTableRef(tableId);
const cells = this._docData.getMetaTable("_grist_Cells").filterRecords({
tableRef,
}).filter(r => rowIds.has(r.rowId));
return cells.map(this.convertToCellInfo.bind(this));
}
// Helper function that tells if a cell can be determined fully from the action itself.
// Otherwise we need to look in the docData.
public hasCellInfo(docAction: DocAction):
docAction is UpdateRecord|BulkUpdateRecord|AddRecord|BulkAddRecord {
if (!isDataAction(docAction)) { return false; }
if ((isAddRecordAction(docAction) || isUpdateRecord(docAction) || isBulkUpdateRecord(docAction))
&& docAction[3].tableRef && docAction[3].colRef && docAction[3].rowId && docAction[3].userRef) {
return true;
}
return false;
}
/**
* Checks if cell is 'attached', i.e. it has a tableRef, colRef, rowId and userRef.
*/
public isAttached(cell: SingleCellInfo) {
return Boolean(cell.tableId && cell.rowId && cell.colId && cell.userRef);
}
/**
* Reads all SingleCellInfo from docActions or from docData if action doesn't have enough enough
* information.
*/
public convertToCells(action: DocAction): SingleCellInfo[] {
if (!isDataAction(action)) { return []; }
if (getTableId(action) !== '_grist_Cells') { return []; }
const result: { tableId: string, rowId: number, colId: string, id: number, userRef: string}[] = [];
if (isBulkAction(action)) {
for (let idx = 0; idx < action[2].length; idx++) {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef[idx] as number) as string,
colId: this.getColId(action[3].colRef[idx] as number) as string,
rowId: action[3].rowId[idx] as number,
userRef: (action[3].userRef[idx] ?? '') as string,
id: action[2][idx],
});
} else {
const cellInfo = this.getCell(action[2][idx]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
} else {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef as number) as string,
colId: this.getColId(action[3].colRef as number) as string,
rowId: action[3].rowId as number,
userRef: action[3].userRef as string,
id: action[2],
});
} else {
const cellInfo = this.getCell(action[2]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
return result;
}
public generateInsert(ids: number[]): DataAction | null {
const action: BulkAddRecord = [
'BulkAddRecord',
'_grist_Cells',
[],
{
tableRef: [],
colRef: [],
type: [],
root: [],
content: [],
rowId: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
action[3].tableRef.push(dataCell.tableRef);
action[3].colRef.push(dataCell.colRef);
action[3].type.push(dataCell.type);
action[3].root.push(dataCell.root);
action[3].rowId.push(dataCell.rowId);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateRemovals(ids: number[]) {
const action: BulkRemoveRecord = [
'BulkRemoveRecord',
'_grist_Cells',
ids
];
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateUpdate(ids: number[]) {
const action: BulkUpdateRecord = [
'BulkUpdateRecord',
'_grist_Cells',
[],
{
content: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
/**
* Tests if the user can modify cell's data. Will modify
*/
public async applyAndCheck(
docActions: DocAction[],
userIsOwner: boolean,
haveRules: boolean,
userRef: string,
hasAccess: (cell: SingleCellInfo, state: DocData) => Promise<boolean>
) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
// First check if we even have actions that modify cell's data.
const cellsActions = docActions.filter(
docAction => getTableId(docAction) === '_grist_Cells' && isDataAction(docAction)
);
// If we don't have any actions, we are good to go.
if (cellsActions.length === 0) { return; }
const fail = () => { throw new ErrorWithCode('ACL_DENY', 'Cannot access cell'); };
// In nutshell we will just test action one by one, and see if user
// can apply it. To do it, we need to keep track of a database state after
// each action (just like regular access is done). Unfortunately, cells' info
// can be partially updated, so we won't be able to determine what cells they
// are attached to. We will assume that bundle has a complete set of information, and
// with this assumption we will skip such actions, and wait for the whole cell to form.
// Create a minimal snapshot of all tables that will be touched by this bundle,
// with all cells info that is needed to check access.
const lastState = this._docData;
// Create a view for current state.
const cellData = this;
// Some cells meta data will be added before rows (for example, when undoing). We will
// postpone checking of such actions until we have a full set of information.
let postponed: Array<number> = [];
// Now one by one apply all actions to the snapshot recording all changes
// to the cell table.
for(const docAction of docActions) {
if (!(getTableId(docAction) === '_grist_Cells' && isDataAction(docAction))) {
lastState.receiveAction(docAction);
continue;
}
// Convert any bulk actions to normal actions
for(const single of getSingleAction(docAction)) {
const id = getRowIdsFromDocAction(single)[0];
if (isAddRecordAction(docAction)) {
// Apply this action, as it might not have full information yet.
lastState.receiveAction(single);
if (haveRules) {
const cell = cellData.getCell(id);
if (cell && cellData.isAttached(cell)) {
// If this is undo, action cell might not yet exist, so we need to check for that.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record) {
postponed.push(id);
} else if (!await hasAccess(cell, lastState)) {
fail();
}
} else {
postponed.push(id);
}
}
} else if (isRemoveRecordAction(docAction)) {
// See if we can remove this cell.
const cell = cellData.getCell(id);
lastState.receiveAction(single);
if (cell) {
// We can remove cell information for any row/column that was removed already.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record || !cell.colId || !(cell.colId in record)) {
continue;
}
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
}
postponed = postponed.filter((i) => i !== id);
} else {
// We are updating a cell metadata. We will need to check if we can update it.
let cell = cellData.getCell(id);
if (!cell) {
return fail();
}
// We can't update cells, that are not ours.
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
// And if the cell was attached before, we will need to check if we can access it.
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
// Now receive the action, and test if we can still see the cell (as the info might be moved
// to a diffrent cell).
lastState.receiveAction(single);
cell = cellData.getCell(id)!;
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
// Now test every cell that was added before row (so we added it, but without
// full information, like new rowId or tableId or colId).
for(const id of postponed) {
const cell = cellData.getCell(id);
if (cell && !this.isAttached(cell)) {
return fail();
}
if (haveRules && cell && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
/**
* Checks if the action is a data action that modifies a _grist_Cells table.
*/
export function isCellDataAction(a: DocAction) {
return getTableId(a) === '_grist_Cells' && isDataAction(a);
}
/**
* Converts a bulk like data action to its non-bulk equivalent. For actions like TableData or ReplaceTableData
* it will return a list of actions, one for each row.
*/
export function* getSingleAction(a: DataAction): Iterable<DataAction> {
if (isAddRecordAction(a) && isBulkAction(a)) {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['AddRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (isRemoveRecordAction(a) && isBulkAction(a)) {
for(const rowId of a[2]) {
yield ['RemoveRecord', a[1], rowId];
}
} else if (a[0] == 'BulkUpdateRecord') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['UpdateRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (a[0] == 'TableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['TableData', a[1], [a[2][idx]],
fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else if (a[0] == 'ReplaceTableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['ReplaceTableData', a[1], [a[2][idx]], fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else {
yield a;
}
}