mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user