mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) control the distribution of attachment metadata
Summary: for users who don't automatically have deep rights to the document, provide them with attachment metadata only for rows they have access to. This is a little tricky to do efficiently. We provide attachment metadata when an individual table is fetched, rather than on initial document load, so we don't block that load on a full document scan. We provide attachment metadata to a client when we see that we are shipping rows mentioning particular attachments, without making any effort to keep track of the metadata they already have. Test Plan: updated tests Reviewers: dsagal, jarek Reviewed By: dsagal, jarek Differential Revision: https://phab.getgrist.com/D3722
This commit is contained in:
		
							parent
							
								
									6dce083484
								
							
						
					
					
						commit
						472a9a186e
					
				| @ -306,9 +306,9 @@ export class GristDocAPIImpl implements GristDocAPI { | |||||||
| 
 | 
 | ||||||
|   public async listTables(): Promise<string[]> { |   public async listTables(): Promise<string[]> { | ||||||
|     // Could perhaps read tableIds from this.gristDoc.docModel.visibleTableIds.all()?
 |     // Could perhaps read tableIds from this.gristDoc.docModel.visibleTableIds.all()?
 | ||||||
|     const tables = await this._doc.docComm.fetchTable('_grist_Tables'); |     const {tableData} = await this._doc.docComm.fetchTable('_grist_Tables'); | ||||||
|     // Tables the user doesn't have access to are just blanked out.
 |     // Tables the user doesn't have access to are just blanked out.
 | ||||||
|     return tables[3].tableId.filter(tableId => tableId !== '') as string[]; |     return tableData[3].tableId.filter(tableId => tableId !== '') as string[]; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   public async fetchTable(tableId: string) { |   public async fetchTable(tableId: string) { | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| import {ActionGroup} from 'app/common/ActionGroup'; | import {ActionGroup} from 'app/common/ActionGroup'; | ||||||
| import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; | import {BulkAddRecord, CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; | ||||||
| import {FormulaProperties} from 'app/common/GranularAccessClause'; | import {FormulaProperties} from 'app/common/GranularAccessClause'; | ||||||
| import {UIRowId} from 'app/common/UIRowId'; | import {UIRowId} from 'app/common/UIRowId'; | ||||||
| import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; | import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; | ||||||
| @ -137,14 +137,30 @@ export interface QueryFilters { | |||||||
| // - empty: value should be falsy (e.g. null) or an empty list, filters is ignored
 | // - empty: value should be falsy (e.g. null) or an empty list, filters is ignored
 | ||||||
| export type QueryOperation = "in" | "intersects" | "empty"; | export type QueryOperation = "in" | "intersects" | "empty"; | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Results of fetching a table. Includes the table data you would | ||||||
|  |  * expect. May now also include attachment metadata referred to in the table | ||||||
|  |  * data. Attachment data is expressed as a BulkAddRecord, since it is | ||||||
|  |  * not a complete table, just selected rows. Attachment data is | ||||||
|  |  * currently included in fetches when (1) granular access control is | ||||||
|  |  * in effect, and (2) the user is neither an owner nor someone with | ||||||
|  |  * read access to the entire document, and (3) there is an attachment | ||||||
|  |  * column in the fetched table. This is exactly what the standard | ||||||
|  |  * Grist client needs, but in future it might be desirable to give | ||||||
|  |  * more control over this behavior. | ||||||
|  |  */ | ||||||
|  | export interface TableFetchResult { | ||||||
|  |   tableData: TableDataAction; | ||||||
|  |   attachments?: BulkAddRecord; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Response from useQuerySet(). A query returns data AND creates a subscription to receive |  * Response from useQuerySet(). A query returns data AND creates a subscription to receive | ||||||
|  * DocActions that affect this data. The querySubId field identifies this subscription, and must |  * DocActions that affect this data. The querySubId field identifies this subscription, and must | ||||||
|  * be used in a disposeQuerySet() call to unsubscribe. |  * be used in a disposeQuerySet() call to unsubscribe. | ||||||
|  */ |  */ | ||||||
| export interface QueryResult { | export interface QueryResult extends TableFetchResult { | ||||||
|   querySubId: number;     // ID of the subscription, to use with disposeQuerySet.
 |   querySubId: number;     // ID of the subscription, to use with disposeQuerySet.
 | ||||||
|   tableData: TableDataAction; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -229,7 +245,7 @@ export interface ActiveDocAPI { | |||||||
|   /** |   /** | ||||||
|    * Fetches a particular table from the data engine to return to the client. |    * Fetches a particular table from the data engine to return to the client. | ||||||
|    */ |    */ | ||||||
|   fetchTable(tableId: string): Promise<TableDataAction>; |   fetchTable(tableId: string): Promise<TableFetchResult>; | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Fetches the generated Python code for this document. (TODO rename this misnomer.) |    * Fetches the generated Python code for this document. (TODO rename this misnomer.) | ||||||
|  | |||||||
							
								
								
									
										82
									
								
								app/common/AttachmentColumns.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								app/common/AttachmentColumns.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord, | ||||||
|  |          getColIdsFromDocAction, getColValuesFromDocAction, | ||||||
|  |          getTableId, RemoveRecord, ReplaceTableData, TableDataAction, | ||||||
|  |          UpdateRecord } from 'app/common/DocActions'; | ||||||
|  | import { DocData } from 'app/common/DocData'; | ||||||
|  | import { isNumber } from 'app/common/gutil'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Represent current attachment columns as a map from tableId to a set of | ||||||
|  |  * colIds. | ||||||
|  |  */ | ||||||
|  | export type AttachmentColumns = Map<string, Set<string>>; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Enumerate attachment columns, represented as a map from tableId to | ||||||
|  |  * a set of colIds. | ||||||
|  |  */ | ||||||
|  | export function getAttachmentColumns(metaDocData: DocData): AttachmentColumns { | ||||||
|  |   const tablesTable = metaDocData.getMetaTable('_grist_Tables'); | ||||||
|  |   const columnsTable = metaDocData.getMetaTable('_grist_Tables_column'); | ||||||
|  |   const attachmentColumns: Map<string, Set<string>> = new Map(); | ||||||
|  |   for (const column of columnsTable.filterRecords({type: 'Attachments'})) { | ||||||
|  |     const table = tablesTable.getRecord(column.parentId); | ||||||
|  |     const tableId = table?.tableId; | ||||||
|  |     if (!tableId) { | ||||||
|  |       /* should never happen */ | ||||||
|  |       throw new Error('table not found'); | ||||||
|  |     } | ||||||
|  |     if (!attachmentColumns.has(tableId)) { | ||||||
|  |       attachmentColumns.set(tableId, new Set()); | ||||||
|  |     } | ||||||
|  |     attachmentColumns.get(tableId)!.add(column.colId); | ||||||
|  |   } | ||||||
|  |   return attachmentColumns; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Get IDs of attachments that are present in attachment columns in an action. | ||||||
|  |  */ | ||||||
|  | export function gatherAttachmentIds( | ||||||
|  |   attachmentColumns: AttachmentColumns, | ||||||
|  |   action: AddRecord | BulkAddRecord | UpdateRecord | BulkUpdateRecord | | ||||||
|  |     RemoveRecord | BulkRemoveRecord | ReplaceTableData | TableDataAction | ||||||
|  | ): Set<number> { | ||||||
|  |   const tableId = getTableId(action); | ||||||
|  |   const attColumns = attachmentColumns.get(tableId); | ||||||
|  |   const colIds = getColIdsFromDocAction(action) || []; | ||||||
|  |   const attIds = new Set<number>(); | ||||||
|  |   if (!attColumns || !colIds.some(colId => attColumns.has(colId))) { | ||||||
|  |     return attIds; | ||||||
|  |   } | ||||||
|  |   for (const colId of colIds) { | ||||||
|  |     if (!attColumns.has(colId)) { continue; } | ||||||
|  |     const values = getColValuesFromDocAction(action, colId); | ||||||
|  |     if (!values) { continue; } | ||||||
|  |     for (const v of values) { | ||||||
|  |       // We expect an array. What should we do with other types?
 | ||||||
|  |       // If we were confident no part of Grist would interpret non-array
 | ||||||
|  |       // values as attachment ids, then we should let them be added, as
 | ||||||
|  |       // part of Grist's spreadsheet-style willingness to allow invalid
 | ||||||
|  |       // data. I decided to go ahead and require that numbers or number-like
 | ||||||
|  |       // strings should be checked as if they were attachment ids, just in
 | ||||||
|  |       // case. But if this proves awkward for someone, it could be reasonable
 | ||||||
|  |       // to only check ids in an array after confirming Grist is strict in
 | ||||||
|  |       // how it interprets material in attachment cells.
 | ||||||
|  |       if (typeof v === 'number') { | ||||||
|  |         attIds.add(v); | ||||||
|  |       } else if (Array.isArray(v)) { | ||||||
|  |         for (const p of v) { | ||||||
|  |           if (typeof p === 'number') { | ||||||
|  |             attIds.add(p); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } else if (typeof v === 'boolean' || v === null) { | ||||||
|  |         // Nothing obvious to do here.
 | ||||||
|  |       } else if (isNumber(v)) { | ||||||
|  |         attIds.add(Math.round(parseFloat(v))); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return attIds; | ||||||
|  | } | ||||||
| @ -178,9 +178,12 @@ export function toTableDataAction(tableId: string, colValues: TableColValues): T | |||||||
| 
 | 
 | ||||||
| // Convert from TableDataAction (used mainly by the sandbox) to TableColValues (used by DocStorage
 | // Convert from TableDataAction (used mainly by the sandbox) to TableColValues (used by DocStorage
 | ||||||
| // and external APIs).
 | // and external APIs).
 | ||||||
| export function fromTableDataAction(tableData: TableDataAction): TableColValues { | // Also accepts a TableDataAction nested as a tableData member of a larger structure,
 | ||||||
|   const rowIds: number[] = tableData[2]; | // for convenience in dealing with the result of fetches.
 | ||||||
|   const colValues: BulkColValues = tableData[3]; | export function fromTableDataAction(tableData: TableDataAction|{tableData: TableDataAction}): TableColValues { | ||||||
|  |   const data = ('tableData' in tableData) ? tableData.tableData : tableData; | ||||||
|  |   const rowIds: number[] = data[2]; | ||||||
|  |   const colValues: BulkColValues = data[3]; | ||||||
|   return {id: rowIds, ...colValues}; |   return {id: rowIds, ...colValues}; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -203,3 +206,33 @@ export function getColValues(records: Partial<RowRecord>[]): BulkColValues { | |||||||
|   } |   } | ||||||
|   return result; |   return result; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Extract the col ids mentioned in a record-related DocAction as a list | ||||||
|  |  * (even if the action is not a bulk action). Returns undefined if no col ids | ||||||
|  |  * mentioned. | ||||||
|  |  */ | ||||||
|  | export function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord | | ||||||
|  |   BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | | ||||||
|  |   TableDataAction): string[] | undefined { | ||||||
|  |   if (docActions[3]) { return Object.keys(docActions[3]); } | ||||||
|  |   return undefined; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Extract column values for a particular column as CellValue[] from a | ||||||
|  |  * record-related DocAction. Undefined if absent. | ||||||
|  |  */ | ||||||
|  | export function getColValuesFromDocAction(docAction: RemoveRecord | BulkRemoveRecord | AddRecord | | ||||||
|  |   BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | | ||||||
|  |   TableDataAction, colId: string): CellValue[]|undefined { | ||||||
|  |   const colValues = docAction[3]; | ||||||
|  |   if (!colValues) { return undefined; } | ||||||
|  |   const cellValues = colValues[colId]; | ||||||
|  |   if (!cellValues) { return undefined; } | ||||||
|  |   if (Array.isArray(docAction[2])) { | ||||||
|  |     return cellValues as CellValue[]; | ||||||
|  |   } else { | ||||||
|  |     return [cellValues as CellValue]; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | |||||||
| @ -9,24 +9,39 @@ import {schema, SchemaTypes} from 'app/common/schema'; | |||||||
| import fromPairs = require('lodash/fromPairs'); | import fromPairs = require('lodash/fromPairs'); | ||||||
| import groupBy = require('lodash/groupBy'); | import groupBy = require('lodash/groupBy'); | ||||||
| import {ActionDispatcher} from './ActionDispatcher'; | import {ActionDispatcher} from './ActionDispatcher'; | ||||||
|  | import {TableFetchResult} from './ActiveDocAPI'; | ||||||
| import { | import { | ||||||
|   BulkColValues, ColInfo, ColInfoWithId, ColValues, DocAction, |   BulkColValues, ColInfo, ColInfoWithId, ColValues, DocAction, | ||||||
|   RowRecord, TableDataAction |   RowRecord, TableDataAction | ||||||
| } from './DocActions'; | } from './DocActions'; | ||||||
| import {ColTypeMap, MetaRowRecord, MetaTableData, TableData} from './TableData'; | import {ColTypeMap, MetaRowRecord, MetaTableData, TableData} from './TableData'; | ||||||
| 
 | 
 | ||||||
| type FetchTableFunc = (tableId: string) => Promise<TableDataAction>; | type FetchTableFunc = (tableId: string) => Promise<TableFetchResult>; | ||||||
| 
 | 
 | ||||||
| export class DocData extends ActionDispatcher { | export class DocData extends ActionDispatcher { | ||||||
|   private _tables: Map<string, TableData> = new Map(); |   private _tables: Map<string, TableData> = new Map(); | ||||||
| 
 | 
 | ||||||
|  |   private _fetchTableFunc: (tableId: string) => Promise<TableDataAction>; | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * If metaTableData is not supplied, then any tables needed should be loaded manually, |    * If metaTableData is not supplied, then any tables needed should be loaded manually, | ||||||
|    * using syncTable(). All column types will be set to Any, which will affect default |    * using syncTable(). All column types will be set to Any, which will affect default | ||||||
|    * values. |    * values. | ||||||
|    */ |    */ | ||||||
|   constructor(private _fetchTableFunc: FetchTableFunc, metaTableData: {[tableId: string]: TableDataAction} | null) { |   constructor(fetchTableFunc: FetchTableFunc, metaTableData: {[tableId: string]: TableDataAction} | null) { | ||||||
|     super(); |     super(); | ||||||
|  |     // Wrap fetchTableFunc slightly to handle any extra attachment data that
 | ||||||
|  |     // may come along for the ride.
 | ||||||
|  |     this._fetchTableFunc = async (tableId: string) => { | ||||||
|  |       const {tableData, attachments} = await fetchTableFunc(tableId); | ||||||
|  |       if (attachments) { | ||||||
|  |         // Back-end doesn't keep track of which attachments we already have,
 | ||||||
|  |         // so there may be duplicates of rows we already have - but happily
 | ||||||
|  |         // BulkAddRecord overwrites duplicates now.
 | ||||||
|  |         this.receiveAction(attachments); | ||||||
|  |       } | ||||||
|  |       return tableData; | ||||||
|  |     }; | ||||||
|     if (metaTableData === null) { return; } |     if (metaTableData === null) { return; } | ||||||
|     // Create all meta tables, and populate data we already have.
 |     // Create all meta tables, and populate data we already have.
 | ||||||
|     for (const tableId in schema) { |     for (const tableId in schema) { | ||||||
|  | |||||||
| @ -2,7 +2,8 @@ | |||||||
|  * TableData maintains a single table's data. |  * TableData maintains a single table's data. | ||||||
|  */ |  */ | ||||||
| import {ActionDispatcher} from 'app/common/ActionDispatcher'; | import {ActionDispatcher} from 'app/common/ActionDispatcher'; | ||||||
| import {BulkColValues, CellValue, ColInfo, ColInfoWithId, ColValues, DocAction, | import { | ||||||
|  |   BulkAddRecord, BulkColValues, CellValue, ColInfo, ColInfoWithId, ColValues, DocAction, | ||||||
|         isSchemaAction, ReplaceTableData, RowRecord, TableDataAction} from 'app/common/DocActions'; |         isSchemaAction, ReplaceTableData, RowRecord, TableDataAction} from 'app/common/DocActions'; | ||||||
| import {getDefaultForType} from 'app/common/gristTypes'; | import {getDefaultForType} from 'app/common/gristTypes'; | ||||||
| import {arrayRemove, arraySplice, getDistinctValues} from 'app/common/gutil'; | import {arrayRemove, arraySplice, getDistinctValues} from 'app/common/gutil'; | ||||||
| @ -250,16 +251,40 @@ export class TableData extends ActionDispatcher implements SkippableRows { | |||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Return data in TableDataAction form ['TableData', tableId, [...rowIds], {...}] |    * Return data in TableDataAction form ['TableData', tableId, [...rowIds], {...}] | ||||||
|  |    * Optionally takes a list of row ids to return data from. If a row id is | ||||||
|  |    * not actually present in the table, a row of nulls will be returned for it. | ||||||
|    */ |    */ | ||||||
|   public getTableDataAction(): TableDataAction { |   public getTableDataAction(desiredRowIds?: number[]): TableDataAction { | ||||||
|     const rowIds = this.getRowIds(); |     const rowIds = desiredRowIds || this.getRowIds(); | ||||||
|  |     let bulkColValues: {[colId: string]: CellValue[]}; | ||||||
|  |     if (desiredRowIds) { | ||||||
|  |       const len = rowIds.length; | ||||||
|  |       bulkColValues = {}; | ||||||
|  |       for (const colId of this.getColIds()) { bulkColValues[colId] = Array(len); } | ||||||
|  |       for (let i = 0; i < len; i++) { | ||||||
|  |         const index = this._rowMap.get(rowIds[i]); | ||||||
|  |         for (const {colId, values} of this._colArray) { | ||||||
|  |           const value = (index === undefined) ? null : values[index]; | ||||||
|  |           bulkColValues[colId][i] = value; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       bulkColValues = fromPairs( | ||||||
|  |         this.getColIds() | ||||||
|  |           .filter(colId => colId !== 'id') | ||||||
|  |           .map(colId => [colId, this.getColValues(colId)! as CellValue[]])); | ||||||
|  |     } | ||||||
|     return ['TableData', |     return ['TableData', | ||||||
|             this.tableId, |             this.tableId, | ||||||
|             rowIds as number[], |             rowIds as number[], | ||||||
|             fromPairs( |             bulkColValues]; | ||||||
|               this.getColIds() |   } | ||||||
|                 .filter(colId => colId !== 'id') | 
 | ||||||
|                 .map(colId => [colId, this.getColValues(colId)! as CellValue[]]))]; |   public getBulkAddRecord(desiredRowIds?: number[]): BulkAddRecord { | ||||||
|  |     const tableData = this.getTableDataAction(desiredRowIds?.sort((a, b) => a - b)); | ||||||
|  |     return [ | ||||||
|  |       'BulkAddRecord', tableData[1], tableData[2], tableData[3], | ||||||
|  |     ]; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -357,6 +382,13 @@ export class TableData extends ActionDispatcher implements SkippableRows { | |||||||
|   // ---- The following methods implement ActionDispatcher interface ----
 |   // ---- The following methods implement ActionDispatcher interface ----
 | ||||||
| 
 | 
 | ||||||
|   protected onAddRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void { |   protected onAddRecord(action: DocAction, tableId: string, rowId: number, colValues: ColValues): void { | ||||||
|  |     if (this._rowMap.get(rowId) !== undefined) { | ||||||
|  |       // If adding a record that already exists, act like an update.
 | ||||||
|  |       // We rely on this behavior for distributing attachment
 | ||||||
|  |       // metadata.
 | ||||||
|  |       this.onUpdateRecord(action, tableId, rowId, colValues); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|     const index: number = this._rowIdCol.length; |     const index: number = this._rowIdCol.length; | ||||||
|     this._rowMap.set(rowId, index); |     this._rowMap.set(rowId, index); | ||||||
|     this._rowIdCol[index] = rowId; |     this._rowIdCol[index] = rowId; | ||||||
| @ -366,14 +398,28 @@ export class TableData extends ActionDispatcher implements SkippableRows { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   protected onBulkAddRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void { |   protected onBulkAddRecord(action: DocAction, tableId: string, rowIds: number[], colValues: BulkColValues): void { | ||||||
|     const index: number = this._rowIdCol.length; |     let destIndex: number = this._rowIdCol.length; | ||||||
|     for (let i = 0; i < rowIds.length; i++) { |     for (let i = 0; i < rowIds.length; i++) { | ||||||
|       this._rowMap.set(rowIds[i], index + i); |       const srcIndex = this._rowMap.get(rowIds[i]); | ||||||
|       this._rowIdCol[index + i] = rowIds[i]; |       if (srcIndex !== undefined) { | ||||||
|     } |         // If adding a record that already exists, act like an update.
 | ||||||
|     for (const {colId, defl, values} of this._colArray) { |         // We rely on this behavior for distributing attachment
 | ||||||
|       for (let i = 0; i < rowIds.length; i++) { |         // metadata.
 | ||||||
|         values[index + i] = colValues.hasOwnProperty(colId) ? colValues[colId][i] : defl; |         for (const colId in colValues) { | ||||||
|  |           if (colValues.hasOwnProperty(colId)) { | ||||||
|  |             const colData = this._columns.get(colId); | ||||||
|  |             if (colData) { | ||||||
|  |               colData.values[srcIndex] = colValues[colId][i]; | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         this._rowMap.set(rowIds[i], destIndex); | ||||||
|  |         this._rowIdCol[destIndex] = rowIds[i]; | ||||||
|  |         for (const {colId, defl, values} of this._colArray) { | ||||||
|  |           values[destIndex] = colValues.hasOwnProperty(colId) ? colValues[colId][i] : defl; | ||||||
|  |         } | ||||||
|  |         destIndex++; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -29,16 +29,20 @@ import { | |||||||
|   PermissionDataWithExtraUsers, |   PermissionDataWithExtraUsers, | ||||||
|   QueryResult, |   QueryResult, | ||||||
|   ServerQuery, |   ServerQuery, | ||||||
|  |   TableFetchResult, | ||||||
|   TransformRule |   TransformRule | ||||||
| } from 'app/common/ActiveDocAPI'; | } from 'app/common/ActiveDocAPI'; | ||||||
| import {ApiError} from 'app/common/ApiError'; | import {ApiError} from 'app/common/ApiError'; | ||||||
| import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate'; | import {mapGetOrSet, MapWithTTL} from 'app/common/AsyncCreate'; | ||||||
|  | import {AttachmentColumns, gatherAttachmentIds, getAttachmentColumns} from 'app/common/AttachmentColumns'; | ||||||
| import { | import { | ||||||
|  |   BulkAddRecord, | ||||||
|   BulkRemoveRecord, |   BulkRemoveRecord, | ||||||
|   BulkUpdateRecord, |   BulkUpdateRecord, | ||||||
|   CellValue, |   CellValue, | ||||||
|   DocAction, |   DocAction, | ||||||
|   getTableId, |   getTableId, | ||||||
|  |   isSchemaAction, | ||||||
|   TableDataAction, |   TableDataAction, | ||||||
|   TableRecordValue, |   TableRecordValue, | ||||||
|   toTableDataAction, |   toTableDataAction, | ||||||
| @ -213,6 +217,8 @@ export class ActiveDoc extends EventEmitter { | |||||||
|   private _gracePeriodStart: Date|null = null; |   private _gracePeriodStart: Date|null = null; | ||||||
|   private _isForkOrSnapshot: boolean = false; |   private _isForkOrSnapshot: boolean = false; | ||||||
|   private _onlyAllowMetaDataActionsOnDb: boolean = false; |   private _onlyAllowMetaDataActionsOnDb: boolean = false; | ||||||
|  |   // Cache of which columns are attachment columns.
 | ||||||
|  |   private _attachmentColumns?: AttachmentColumns; | ||||||
| 
 | 
 | ||||||
|   // Client watching for 'product changed' event published by Billing to update usage
 |   // Client watching for 'product changed' event published by Billing to update usage
 | ||||||
|   private _redisSubscriber?: RedisClient; |   private _redisSubscriber?: RedisClient; | ||||||
| @ -970,7 +976,7 @@ export class ActiveDoc extends EventEmitter { | |||||||
|    *      form of the form ["TableData", table_id, row_ids, column_values]. |    *      form of the form ["TableData", table_id, row_ids, column_values]. | ||||||
|    */ |    */ | ||||||
|   public async fetchTable(docSession: OptDocSession, tableId: string, |   public async fetchTable(docSession: OptDocSession, tableId: string, | ||||||
|                           waitForFormulas: boolean = false): Promise<TableDataAction> { |                           waitForFormulas: boolean = false): Promise<TableFetchResult> { | ||||||
|     return this.fetchQuery(docSession, {tableId, filters: {}}, waitForFormulas); |     return this.fetchQuery(docSession, {tableId, filters: {}}, waitForFormulas); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -982,7 +988,7 @@ export class ActiveDoc extends EventEmitter { | |||||||
|    * special "pending" values may be returned. |    * special "pending" values may be returned. | ||||||
|    */ |    */ | ||||||
|   public async fetchQuery(docSession: OptDocSession, query: ServerQuery, |   public async fetchQuery(docSession: OptDocSession, query: ServerQuery, | ||||||
|                           waitForFormulas: boolean = false): Promise<TableDataAction> { |                           waitForFormulas: boolean = false): Promise<TableFetchResult> { | ||||||
|     this._inactivityTimer.ping();     // The doc is in active use; ping it to stay open longer.
 |     this._inactivityTimer.ping();     // The doc is in active use; ping it to stay open longer.
 | ||||||
| 
 | 
 | ||||||
|     // If user does not have rights to access what this query is asking for, fail.
 |     // If user does not have rights to access what this query is asking for, fail.
 | ||||||
| @ -999,8 +1005,8 @@ export class ActiveDoc extends EventEmitter { | |||||||
|       // table.  So we pick out the table we want from fetchMetaTables (which has applied
 |       // table.  So we pick out the table we want from fetchMetaTables (which has applied
 | ||||||
|       // filtering).
 |       // filtering).
 | ||||||
|       const tables = await this.fetchMetaTables(docSession); |       const tables = await this.fetchMetaTables(docSession); | ||||||
|       const table = tables[query.tableId]; |       const tableData = tables[query.tableId]; | ||||||
|       if (table) { return table; } |       if (tableData) { return {tableData}; } | ||||||
|       // If table not found, continue, to give a consistent error for a table not found.
 |       // If table not found, continue, to give a consistent error for a table not found.
 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -1036,9 +1042,23 @@ export class ActiveDoc extends EventEmitter { | |||||||
|       data = cloneDeep(data!);  // Clone since underlying fetch may be cached and shared.
 |       data = cloneDeep(data!);  // Clone since underlying fetch may be cached and shared.
 | ||||||
|       await this._granularAccess.filterData(docSession, data); |       await this._granularAccess.filterData(docSession, data); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     // Consider whether we need to add attachment metadata.
 | ||||||
|  |     // TODO: it might be desirable to always send attachment data, or allow
 | ||||||
|  |     // this to be an option in api calls related to fetching.
 | ||||||
|  |     let attachments: BulkAddRecord | undefined; | ||||||
|  |     const attachmentColumns = this._getCachedAttachmentColumns(); | ||||||
|  |     if (attachmentColumns?.size && await this._granularAccess.needAttachmentControl(docSession)) { | ||||||
|  |       const attIds = gatherAttachmentIds(attachmentColumns, data!); | ||||||
|  |       if (attIds.size > 0) { | ||||||
|  |         attachments = this.docData!.getMetaTable('_grist_Attachments') | ||||||
|  |           .getBulkAddRecord([...attIds]); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     this._log.info(docSession, "fetchQuery -> %d rows, cols: %s", |     this._log.info(docSession, "fetchQuery -> %d rows, cols: %s", | ||||||
|              data![2].length, Object.keys(data![3]).join(", ")); |              data![2].length, Object.keys(data![3]).join(", ")); | ||||||
|     return data!; |     return {tableData: data!, ...(attachments && {attachments})}; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -1071,8 +1091,8 @@ export class ActiveDoc extends EventEmitter { | |||||||
|     // - Subscription should not be affected by renames (so don't hold on to query/tableId/colIds)
 |     // - Subscription should not be affected by renames (so don't hold on to query/tableId/colIds)
 | ||||||
|     // - Table/column deletion should make subscription inactive, and unsubscribing an inactive
 |     // - Table/column deletion should make subscription inactive, and unsubscribing an inactive
 | ||||||
|     //   subscription should not produce an error.
 |     //   subscription should not produce an error.
 | ||||||
|     const tableData: TableDataAction = await this.fetchQuery(docSession, query); |     const tableFetchResult = await this.fetchQuery(docSession, query); | ||||||
|     return {querySubId: 0, tableData}; |     return {querySubId: 0, ...tableFetchResult}; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -1241,6 +1261,9 @@ export class ActiveDoc extends EventEmitter { | |||||||
|       await this.docStorage.updateIndexes(indexes); |       await this.docStorage.updateIndexes(indexes); | ||||||
|       // TODO: should probably add indexes for user attribute tables.
 |       // TODO: should probably add indexes for user attribute tables.
 | ||||||
|     } |     } | ||||||
|  |     if (docActions.some(docAction => isSchemaAction(docAction))) { | ||||||
|  |       this._attachmentColumns = undefined; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -2330,6 +2353,14 @@ export class ActiveDoc extends EventEmitter { | |||||||
|     await this._updateDocUsage({attachmentsSizeBytes}, options); |     await this._updateDocUsage({attachmentsSizeBytes}, options); | ||||||
|     return attachmentsSizeBytes; |     return attachmentsSizeBytes; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   private _getCachedAttachmentColumns(): AttachmentColumns { | ||||||
|  |     if (!this.docData) { return new Map(); } | ||||||
|  |     if (!this._attachmentColumns) { | ||||||
|  |       this._attachmentColumns = getAttachmentColumns(this.docData); | ||||||
|  |     } | ||||||
|  |     return this._attachmentColumns; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Helper to initialize a sandbox action bundle with no values.
 | // Helper to initialize a sandbox action bundle with no values.
 | ||||||
|  | |||||||
| @ -164,7 +164,7 @@ export class DocWorkerApi { | |||||||
|       } |       } | ||||||
|       const tableId = optTableId || req.params.tableId; |       const tableId = optTableId || req.params.tableId; | ||||||
|       const session = docSessionFromRequest(req); |       const session = docSessionFromRequest(req); | ||||||
|       const tableData = await handleSandboxError(tableId, [], activeDoc.fetchQuery( |       const {tableData} = await handleSandboxError(tableId, [], activeDoc.fetchQuery( | ||||||
|         session, {tableId, filters}, !immediate)); |         session, {tableId, filters}, !immediate)); | ||||||
|       // For metaTables we don't need to specify columns, search will infer it from the sort expression.
 |       // For metaTables we don't need to specify columns, search will infer it from the sort expression.
 | ||||||
|       const isMetaTable = tableId.startsWith('_grist'); |       const isMetaTable = tableId.startsWith('_grist'); | ||||||
|  | |||||||
| @ -209,9 +209,9 @@ export async function exportTable( | |||||||
|   }).filter(tc => tc !== emptyCol); |   }).filter(tc => tc !== emptyCol); | ||||||
| 
 | 
 | ||||||
|   // fetch actual data
 |   // fetch actual data
 | ||||||
|   const data = await activeDoc.fetchTable(docSessionFromRequest(req as RequestWithLogin), table.tableId, true); |   const {tableData} = await activeDoc.fetchTable(docSessionFromRequest(req as RequestWithLogin), table.tableId, true); | ||||||
|   const rowIds = data[2]; |   const rowIds = tableData[2]; | ||||||
|   const dataByColId = data[3]; |   const dataByColId = tableData[3]; | ||||||
|   // sort rows
 |   // sort rows
 | ||||||
|   const getters = new ServerColumnGetters(rowIds, dataByColId, columns); |   const getters = new ServerColumnGetters(rowIds, dataByColId, columns); | ||||||
|   // create cell accessors
 |   // create cell accessors
 | ||||||
| @ -311,9 +311,9 @@ export async function exportSection( | |||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   // fetch actual data
 |   // fetch actual data
 | ||||||
|   const data = await activeDoc.fetchTable(docSessionFromRequest(req as RequestWithLogin), table.tableId, true); |   const {tableData} = await activeDoc.fetchTable(docSessionFromRequest(req as RequestWithLogin), table.tableId, true); | ||||||
|   let rowIds = data[2]; |   let rowIds = tableData[2]; | ||||||
|   const dataByColId = data[3]; |   const dataByColId = tableData[3]; | ||||||
|   // sort rows
 |   // sort rows
 | ||||||
|   const getters = new ServerColumnGetters(rowIds, dataByColId, columns); |   const getters = new ServerColumnGetters(rowIds, dataByColId, columns); | ||||||
|   const sorter = new SortFunc(getters); |   const sorter = new SortFunc(getters); | ||||||
|  | |||||||
| @ -17,9 +17,10 @@ import { | |||||||
|   isBulkUpdateRecord, |   isBulkUpdateRecord, | ||||||
|   isUpdateRecord, |   isUpdateRecord, | ||||||
| } from 'app/common/DocActions'; | } from 'app/common/DocActions'; | ||||||
|  | import { AttachmentColumns, gatherAttachmentIds, getAttachmentColumns } from 'app/common/AttachmentColumns'; | ||||||
| import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions'; | import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions'; | ||||||
| import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions'; | import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions'; | ||||||
| import { TableDataAction, UserAction } from 'app/common/DocActions'; | import { getColIdsFromDocAction, TableDataAction, UserAction } from 'app/common/DocActions'; | ||||||
| import { DocData } from 'app/common/DocData'; | import { DocData } from 'app/common/DocData'; | ||||||
| import { UserOverride } from 'app/common/DocListAPI'; | import { UserOverride } from 'app/common/DocListAPI'; | ||||||
| import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage'; | import { DocUsageSummary, FilteredDocUsageSummary } from 'app/common/DocUsage'; | ||||||
| @ -28,7 +29,7 @@ import { ErrorWithCode } from 'app/common/ErrorWithCode'; | |||||||
| import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause'; | import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessClause'; | ||||||
| import { UserInfo } from 'app/common/GranularAccessClause'; | import { UserInfo } from 'app/common/GranularAccessClause'; | ||||||
| import * as gristTypes from 'app/common/gristTypes'; | import * as gristTypes from 'app/common/gristTypes'; | ||||||
| import { getSetMapValue, isNonNullish, isNumber, pruneArray } from 'app/common/gutil'; | import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil'; | ||||||
| import { MetaRowRecord, SingleCell } from 'app/common/TableData'; | import { MetaRowRecord, SingleCell } from 'app/common/TableData'; | ||||||
| import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; | import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; | ||||||
| import { FullUser, UserAccessData } from 'app/common/UserAPI'; | import { FullUser, UserAccessData } from 'app/common/UserAPI'; | ||||||
| @ -44,12 +45,11 @@ import { IPermissionInfo, MixedPermissionSetWithContext, | |||||||
|          PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo'; |          PermissionInfo, PermissionSetWithContext } from 'app/server/lib/PermissionInfo'; | ||||||
| import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo'; | import { TablePermissionSetWithContext } from 'app/server/lib/PermissionInfo'; | ||||||
| import { integerParam } from 'app/server/lib/requestUtils'; | import { integerParam } from 'app/server/lib/requestUtils'; | ||||||
| import { getColIdsFromDocAction, getColValuesFromDocAction, getRelatedRows, | import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess'; | ||||||
|          getRowIdsFromDocAction } from 'app/server/lib/RowAccess'; |  | ||||||
| import cloneDeep = require('lodash/cloneDeep'); | import cloneDeep = require('lodash/cloneDeep'); | ||||||
| import fromPairs = require('lodash/fromPairs'); | import fromPairs = require('lodash/fromPairs'); | ||||||
| import memoize = require('lodash/memoize'); |  | ||||||
| import get = require('lodash/get'); | import get = require('lodash/get'); | ||||||
|  | import memoize = require('lodash/memoize'); | ||||||
| 
 | 
 | ||||||
| // tslint:disable:no-bitwise
 | // tslint:disable:no-bitwise
 | ||||||
| 
 | 
 | ||||||
| @ -597,9 +597,11 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
| 
 | 
 | ||||||
|     const actions = await Promise.all( |     const actions = await Promise.all( | ||||||
|       docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx}))); |       docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx}))); | ||||||
|     const result = ([] as DocAction[]).concat(...actions); |     let result = ([] as ActionCursor[]).concat(...actions); | ||||||
|  |     result = await this._filterOutgoingAttachments(result); | ||||||
| 
 | 
 | ||||||
|     return await this._filterOutgoingCellInfo(docSession, docActions, result); |     return await this._filterOutgoingCellInfo(docSession, docActions, | ||||||
|  |                                               result.map(a => a.action)); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -820,8 +822,12 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * An odd little right for findColFromValues and autocomplete.  Allow if user can read |    * Allow if user can read all data, or is an owner. | ||||||
|    * all data, or is an owner.  Might be worth making a special permission. |    * Might be worth making a special permission. | ||||||
|  |    * At the time of writing, used for: | ||||||
|  |    *   - findColFromValues | ||||||
|  |    *   - autocomplete | ||||||
|  |    *   - unfiltered access to attachment metadata | ||||||
|    */ |    */ | ||||||
|   public async canScanData(docSession: OptDocSession): Promise<boolean> { |   public async canScanData(docSession: OptDocSession): Promise<boolean> { | ||||||
|     return await this.isOwner(docSession) || await this.canReadEverything(docSession); |     return await this.isOwner(docSession) || await this.canReadEverything(docSession); | ||||||
| @ -903,6 +909,17 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     for (const tableId of STRUCTURAL_TABLES) { |     for (const tableId of STRUCTURAL_TABLES) { | ||||||
|       censor.apply(tables[tableId]); |       censor.apply(tables[tableId]); | ||||||
|     } |     } | ||||||
|  |     if (await this.needAttachmentControl(docSession)) { | ||||||
|  |       // Attachments? No attachments here (whistles innocently).
 | ||||||
|  |       // Computing which attachments user has access to would require
 | ||||||
|  |       // looking at entire document, which we don't want to do. So instead
 | ||||||
|  |       // we'll be sending this info on a need-to-know basis later.
 | ||||||
|  |       const attachments = tables['_grist_Attachments']; | ||||||
|  |       attachments[2] = []; | ||||||
|  |       Object.values(attachments[3]).forEach(values => { | ||||||
|  |         values.length = 0; | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|     return tables; |     return tables; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -1083,7 +1100,12 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     rows.delete('_grist_Cells'); |     rows.delete('_grist_Cells'); | ||||||
|     // Populate a minimal in-memory version of the database with these rows.
 |     // Populate a minimal in-memory version of the database with these rows.
 | ||||||
|     const docData = new DocData( |     const docData = new DocData( | ||||||
|       (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), { |       async (tableId) => { | ||||||
|  |         return { | ||||||
|  |           tableData: await this._fetchQueryFromDB( | ||||||
|  |             {tableId, filters: {id: [...rows.get(tableId)!]}}) | ||||||
|  |         }; | ||||||
|  |       }, { | ||||||
|         _grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(), |         _grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(), | ||||||
|         // We need some basic table information to translate numeric ids to string ids (refs to ids).
 |         // 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: this._docData.getMetaTable('_grist_Tables')!.getTableDataAction(), | ||||||
| @ -1095,6 +1117,11 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     return docData; |     return docData; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // Return true if attachment info must be sent on a need-to-know basis.
 | ||||||
|  |   public async needAttachmentControl(docSession: OptDocSession) { | ||||||
|  |     return !await this.canScanData(docSession); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * An optimization to catch obvious access problems for simple data |    * An optimization to catch obvious access problems for simple data | ||||||
|    * actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks |    * actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks | ||||||
| @ -1971,7 +1998,11 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     const rows = new Map(getRelatedRows(applied ? [...undo].reverse() : docActions)); |     const rows = new Map(getRelatedRows(applied ? [...undo].reverse() : docActions)); | ||||||
|     // Populate a minimal in-memory version of the database with these rows.
 |     // Populate a minimal in-memory version of the database with these rows.
 | ||||||
|     const docData = new DocData( |     const docData = new DocData( | ||||||
|       (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), |       async (tableId) => { | ||||||
|  |         return { | ||||||
|  |           tableData: await this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}) | ||||||
|  |         }; | ||||||
|  |       }, | ||||||
|       null, |       null, | ||||||
|     ); |     ); | ||||||
|     // Load pre-existing rows touched by the bundle.
 |     // Load pre-existing rows touched by the bundle.
 | ||||||
| @ -2015,14 +2046,14 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     if (!needMeta) { |     if (!needMeta) { | ||||||
|       // Sometimes, the intermediate states are trivial.
 |       // Sometimes, the intermediate states are trivial.
 | ||||||
|       // TODO: look into whether it would be worth caching attachment columns.
 |       // TODO: look into whether it would be worth caching attachment columns.
 | ||||||
|       const attachmentColumns = this._getAttachmentColumns(this._docData); |       const attachmentColumns = getAttachmentColumns(this._docData); | ||||||
|       return docActions.map(action => ({action, attachmentColumns})); |       return docActions.map(action => ({action, attachmentColumns})); | ||||||
|     } |     } | ||||||
|     const metaDocData = new DocData( |     const metaDocData = new DocData( | ||||||
|       async (tableId) => { |       async (tableId) => { | ||||||
|         const result = this._docData.getTable(tableId)?.getTableDataAction(); |         const result = this._docData.getTable(tableId)?.getTableDataAction(); | ||||||
|         if (!result) { throw new Error('surprising load'); } |         if (!result) { throw new Error('surprising load'); } | ||||||
|         return result; |         return {tableData: result}; | ||||||
|       }, |       }, | ||||||
|       null, |       null, | ||||||
|     ); |     ); | ||||||
| @ -2067,33 +2098,12 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|         replaceRuler = false; |         replaceRuler = false; | ||||||
|       } |       } | ||||||
|       step.ruler = ruler; |       step.ruler = ruler; | ||||||
|       step.attachmentColumns = this._getAttachmentColumns(metaDocData); |       step.attachmentColumns = getAttachmentColumns(metaDocData); | ||||||
|       steps.push(step); |       steps.push(step); | ||||||
|     } |     } | ||||||
|     return steps; |     return steps; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |  | ||||||
|    * Enumerate attachment columns, represented as a map from tableId to |  | ||||||
|    * a set of colIds. |  | ||||||
|    */ |  | ||||||
|   private _getAttachmentColumns(metaDocData: DocData): Map<string, Set<string>> { |  | ||||||
|     const tablesTable = metaDocData.getMetaTable('_grist_Tables'); |  | ||||||
|     const columnsTable = metaDocData.getMetaTable('_grist_Tables_column'); |  | ||||||
|     const attachmentColumns: Map<string, Set<string>> = new Map(); |  | ||||||
|     for (const col of columnsTable.filterRecords({type: 'Attachments'})) { |  | ||||||
|       const table = tablesTable.getRecord(col.parentId); |  | ||||||
|       const tableId = table?.tableId; |  | ||||||
|       if (!tableId) { throw new Error('table not found'); /* should not happen */ } |  | ||||||
|       const colId = col.colId; |  | ||||||
|       if (!attachmentColumns.has(tableId)) { |  | ||||||
|         attachmentColumns.set(tableId, new Set()); |  | ||||||
|       } |  | ||||||
|       attachmentColumns.get(tableId)!.add(colId); |  | ||||||
|     } |  | ||||||
|     return attachmentColumns; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |   /** | ||||||
|    * Return any permitted parts of an action.  A completely forbidden |    * Return any permitted parts of an action.  A completely forbidden | ||||||
|    * action results in an empty list.  Forbidden columns and rows will |    * action results in an empty list.  Forbidden columns and rows will | ||||||
| @ -2163,7 +2173,7 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|    * TODO: I think that column rules controlling READ access using rec are not fully supported |    * TODO: I think that column rules controlling READ access using rec are not fully supported | ||||||
|    * yet.  They work on first load, but if READ access is lost/gained updates won't be made. |    * yet.  They work on first load, but if READ access is lost/gained updates won't be made. | ||||||
|    */ |    */ | ||||||
|   private async _filterOutgoingDocAction(cursor: ActionCursor): Promise<DocAction[]> { |   private async _filterOutgoingDocAction(cursor: ActionCursor): Promise<ActionCursor[]> { | ||||||
|     const {action} = cursor; |     const {action} = cursor; | ||||||
|     const tableId = getTableId(action); |     const tableId = getTableId(action); | ||||||
| 
 | 
 | ||||||
| @ -2200,7 +2210,7 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|         secondPass.push(act); |         secondPass.push(act); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return secondPass; |     return secondPass.map(act => ({ ...cursor, action: act })); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private async _filterOutgoingStructuralTables(cursor: ActionCursor, act: DataAction, results: DocAction[]) { |   private async _filterOutgoingStructuralTables(cursor: ActionCursor, act: DataAction, results: DocAction[]) { | ||||||
| @ -2270,53 +2280,8 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|    * has the right to access any attachments mentioned. |    * has the right to access any attachments mentioned. | ||||||
|    */ |    */ | ||||||
|   private async _checkIncomingAttachmentChanges(cursor: ActionCursor): Promise<void> { |   private async _checkIncomingAttachmentChanges(cursor: ActionCursor): Promise<void> { | ||||||
|     const options = this._activeBundle?.options; |     const {docSession} = cursor; | ||||||
|     if (options?.fromOwnHistory && options.oldestSource && |     const attIds = await this._gatherAttachmentChanges(cursor); | ||||||
|       Date.now() - options.oldestSource < HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|     const {action, docSession} = cursor; |  | ||||||
|     if (!isDataAction(action)) { return; } |  | ||||||
|     if (isRemoveRecordAction(action)) { return; } |  | ||||||
|     const tableId = getTableId(action); |  | ||||||
|     const step = await this._getMetaStep(cursor); |  | ||||||
|     const attachmentColumns = step.attachmentColumns; |  | ||||||
|     if (!attachmentColumns) { return; } |  | ||||||
|     const ac = attachmentColumns.get(tableId); |  | ||||||
|     if (!ac) { return; } |  | ||||||
|     const colIds = getColIdsFromDocAction(action); |  | ||||||
|     if (!colIds.some(colId => ac.has(colId))) { return; } |  | ||||||
|     if (await this.isOwner(docSession) || await this.canReadEverything(docSession)) { return; } |  | ||||||
|     const attIds = new Set<number>(); |  | ||||||
|     for (const colId of colIds) { |  | ||||||
|       if (!ac.has(colId)) { continue; } |  | ||||||
|       const values = getColValuesFromDocAction(action, colId); |  | ||||||
|       if (!values) { continue; } |  | ||||||
|       for (const v of values) { |  | ||||||
|         // We expect an array. What should we do with other types?
 |  | ||||||
|         // If we were confident no part of Grist would interpret non-array
 |  | ||||||
|         // values as attachment ids, then we should let them be added, as
 |  | ||||||
|         // part of Grist's spreadsheet-style willingness to allow invalid
 |  | ||||||
|         // data. I decided to go ahead and require that numbers or number-like
 |  | ||||||
|         // strings should be checked as if they were attachment ids, just in
 |  | ||||||
|         // case. But if this proves awkward for someone, it could be reasonable
 |  | ||||||
|         // to only check ids in an array after confirming Grist is strict in
 |  | ||||||
|         // how it interprets material in attachment cells.
 |  | ||||||
|         if (typeof v === 'number') { |  | ||||||
|           attIds.add(v); |  | ||||||
|         } else if (Array.isArray(v)) { |  | ||||||
|           for (const p of v) { |  | ||||||
|             if (typeof p === 'number') { |  | ||||||
|               attIds.add(p); |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } else if (typeof v === 'boolean' || v === null) { |  | ||||||
|           // Nothing obvious to do here.
 |  | ||||||
|         } else if (isNumber(v)) { |  | ||||||
|           attIds.add(Math.round(parseFloat(v))); |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     for (const attId of attIds) { |     for (const attId of attIds) { | ||||||
|       if (!await this.isAttachmentUploadedByUser(docSession, attId) && |       if (!await this.isAttachmentUploadedByUser(docSession, attId) && | ||||||
|         !await this.findAttachmentCellForUser(docSession, attId)) { |         !await this.findAttachmentCellForUser(docSession, attId)) { | ||||||
| @ -2327,6 +2292,79 @@ export class GranularAccess implements GranularAccessForBundle { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * If user doesn't have sufficient rights, rewrite any attachment information | ||||||
|  |    * as follows: | ||||||
|  |    *   - Remove data actions (other than [Bulk]RemoveRecord) on the _grist_Attachments table | ||||||
|  |    *   - Gather any attachment ids mentioned in data actions | ||||||
|  |    *   - Prepend a BulkAddRecord for _grist_Attachments giving metadata for the attachments | ||||||
|  |    * This will result in metadata being sent to clients more than necessary, | ||||||
|  |    * but saves us keeping track of which clients already know about which | ||||||
|  |    * attachments. | ||||||
|  |    * We don't make any particular effort to retract attachment metadata from | ||||||
|  |    * clients if they lose access to it later. They won't have access to the | ||||||
|  |    * content of the attachment, and will lose metadata on a document reload. | ||||||
|  |    */ | ||||||
|  |   private async _filterOutgoingAttachments(cursors: ActionCursor[]) { | ||||||
|  |     if (cursors.length === 0) { return []; } | ||||||
|  |     const docSession = cursors[0].docSession; | ||||||
|  |     if (!await this.needAttachmentControl(docSession)) { | ||||||
|  |       return cursors; | ||||||
|  |     } | ||||||
|  |     const result = [] as ActionCursor[]; | ||||||
|  |     const attIds = new Set<number>(); | ||||||
|  |     for (const cursor of cursors) { | ||||||
|  |       const changes = await this._gatherAttachmentChanges(cursor); | ||||||
|  |       // We assume here that ACL rules were already applied and columns were
 | ||||||
|  |       // either removed or censored.
 | ||||||
|  |       // Gather all attachment ids stored in user tables.
 | ||||||
|  |       for (const attId of changes) { | ||||||
|  |         attIds.add(attId); | ||||||
|  |       } | ||||||
|  |       const {action} = cursor; | ||||||
|  |       // Remove any additions or updates to the _grist_Attachments table.
 | ||||||
|  |       if (!isDataAction(action) || isRemoveRecordAction(action) || getTableId(action) !== '_grist_Attachments') { | ||||||
|  |         result.push(cursor); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // We removed all actions that created attachments, now send all attachments metadata
 | ||||||
|  |     // we currently have that are related to actions being broadcast.
 | ||||||
|  |     if (attIds.size > 0) { | ||||||
|  |       const act = this._docData.getMetaTable('_grist_Attachments') | ||||||
|  |         .getBulkAddRecord([...attIds]); | ||||||
|  |       result.unshift({ | ||||||
|  |         action: act, | ||||||
|  |         docSession, | ||||||
|  |         // For access control purposes, this new action will be under the
 | ||||||
|  |         // same access rules as the first DocAction.
 | ||||||
|  |         actionIdx: cursors[0].actionIdx, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private async _gatherAttachmentChanges(cursor: ActionCursor): Promise<Set<number>> { | ||||||
|  |     const empty = new Set<number>(); | ||||||
|  |     const options = this._activeBundle?.options; | ||||||
|  |     if (options?.fromOwnHistory && options.oldestSource && | ||||||
|  |       Date.now() - options.oldestSource < HISTORICAL_ATTACHMENT_OWNERSHIP_PERIOD) { | ||||||
|  |       return empty; | ||||||
|  |     } | ||||||
|  |     const {action, docSession} = cursor; | ||||||
|  |     if (!isDataAction(action)) { return empty; } | ||||||
|  |     if (isRemoveRecordAction(action)) { return empty; } | ||||||
|  |     const tableId = getTableId(action); | ||||||
|  |     const step = await this._getMetaStep(cursor); | ||||||
|  |     const attachmentColumns = step.attachmentColumns; | ||||||
|  |     if (!attachmentColumns) { return empty; } | ||||||
|  |     const ac = attachmentColumns.get(tableId); | ||||||
|  |     if (!ac) { return empty; } | ||||||
|  |     const colIds = getColIdsFromDocAction(action) || []; | ||||||
|  |     if (!colIds.some(colId => ac.has(colId))) { return empty; } | ||||||
|  |     if (!await this.needAttachmentControl(docSession)) { return empty; } | ||||||
|  |     return gatherAttachmentIds(attachmentColumns, action); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   private async _getRuler(cursor: ActionCursor) { |   private async _getRuler(cursor: ActionCursor) { | ||||||
|     if (cursor.actionIdx === null) { return this._ruler; } |     if (cursor.actionIdx === null) { return this._ruler; } | ||||||
|     const step = await this._getMetaStep(cursor); |     const step = await this._getMetaStep(cursor); | ||||||
| @ -2563,7 +2601,7 @@ export interface MetaStep { | |||||||
|   metaBefore?: {[key: string]: TableDataAction};  // cached structural metadata before action
 |   metaBefore?: {[key: string]: TableDataAction};  // cached structural metadata before action
 | ||||||
|   metaAfter?: {[key: string]: TableDataAction};   // cached structural metadata after action
 |   metaAfter?: {[key: string]: TableDataAction};   // cached structural metadata after action
 | ||||||
|   ruler?: Ruler;                          // rules at this step
 |   ruler?: Ruler;                          // rules at this step
 | ||||||
|   attachmentColumns?: Map<string, Set<string>>;        // attachment columns after this step
 |   attachmentColumns?: AttachmentColumns;        // attachment columns after this step
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
| @ -2572,7 +2610,10 @@ export interface MetaStep { | |||||||
| interface ActionCursor { | interface ActionCursor { | ||||||
|   action: DocAction; |   action: DocAction; | ||||||
|   docSession: OptDocSession; |   docSession: OptDocSession; | ||||||
|   actionIdx: number|null; |   actionIdx: number|null;     // an index into where we are within the original
 | ||||||
|  |                               // DocActions, for access control purposes.
 | ||||||
|  |                               // Used for referencing a cache of intermediate
 | ||||||
|  |                               // access control state.
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord, | import { AddRecord, BulkAddRecord, BulkRemoveRecord, BulkUpdateRecord, | ||||||
|          CellValue, DocAction, getTableId, RemoveRecord, ReplaceTableData, |          DocAction, getTableId, RemoveRecord, ReplaceTableData, | ||||||
|          TableDataAction, UpdateRecord } from "app/common/DocActions"; |          TableDataAction, UpdateRecord } from "app/common/DocActions"; | ||||||
| import { getSetMapValue } from "app/common/gutil"; | import { getSetMapValue } from "app/common/gutil"; | ||||||
| 
 | 
 | ||||||
| @ -77,33 +77,3 @@ export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveReco | |||||||
|   const ids = docActions[2]; |   const ids = docActions[2]; | ||||||
|   return (typeof ids === 'number') ? [ids] : ids; |   return (typeof ids === 'number') ? [ids] : ids; | ||||||
| } | } | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Tiny helper to get the col ids mentioned in a record-related DocAction as a list |  | ||||||
|  * (even if the action is not a bulk action). When the action touches the whole row, |  | ||||||
|  * it returns ["*"]. |  | ||||||
|  */ |  | ||||||
| export function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord | |  | ||||||
|   BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | |  | ||||||
|   TableDataAction) { |  | ||||||
|   if (docActions[3]) { return Object.keys(docActions[3]); } |  | ||||||
|   return ['*']; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Extract column values for a particular column as CellValue[] from a |  | ||||||
|  * record-related DocAction. Undefined if absent. |  | ||||||
|  */ |  | ||||||
| export function getColValuesFromDocAction(docAction: RemoveRecord | BulkRemoveRecord | AddRecord | |  | ||||||
|   BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | |  | ||||||
|   TableDataAction, colId: string): CellValue[]|undefined { |  | ||||||
|   const colValues = docAction[3]; |  | ||||||
|   if (!colValues) { return undefined; } |  | ||||||
|   const cellValues = colValues[colId]; |  | ||||||
|   if (!cellValues) { return undefined; } |  | ||||||
|   if (Array.isArray(docAction[2])) { |  | ||||||
|     return cellValues as CellValue[]; |  | ||||||
|   } else { |  | ||||||
|     return [cellValues as CellValue]; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -236,7 +236,8 @@ export class DocTriggers { | |||||||
| 
 | 
 | ||||||
|         // Fetch the modified records in full so they can be sent in webhooks
 |         // Fetch the modified records in full so they can be sent in webhooks
 | ||||||
|         // They will also be used to check if the record is ready
 |         // They will also be used to check if the record is ready
 | ||||||
|         const tableDataAction = this._activeDoc.fetchQuery(docSession, {tableId, filters}); |         const tableDataAction = this._activeDoc.fetchQuery(docSession, {tableId, filters}) | ||||||
|  |           .then(tableFetchResult => tableFetchResult.tableData); | ||||||
|         tasks.push({tableDelta, triggers, tableDataAction, recordDeltas}); |         tasks.push({tableDelta, triggers, tableDataAction, recordDeltas}); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -81,6 +81,15 @@ settings = { | |||||||
|   "ociVersion": "1.0.0", |   "ociVersion": "1.0.0", | ||||||
|   "process": { |   "process": { | ||||||
|     "terminal": include_bash, |     "terminal": include_bash, | ||||||
|  |     # Match current user id, for convenience with mounts. For some versions of | ||||||
|  |     # gvisor, default behavior may be better - if you see "access denied" problems | ||||||
|  |     # during imports, try commenting this section out. We could make imports work | ||||||
|  |     # for any version of gvisor by setting mode when using tmp.dir to allow | ||||||
|  |     # others to list directory contents. | ||||||
|  |     "user": { | ||||||
|  |       "uid": os.getuid(), | ||||||
|  |       "gid": 0 | ||||||
|  |     }, | ||||||
|     "args": cmd_args, |     "args": cmd_args, | ||||||
|     "env": env, |     "env": env, | ||||||
|     "cwd": "/" |     "cwd": "/" | ||||||
| @ -112,18 +121,6 @@ settings = { | |||||||
|     ] |     ] | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| if not os.environ.get('GVISOR_USE_DEFAULT_USER'): |  | ||||||
|   # Match current user id, for convenience with mounts. For some versions of |  | ||||||
|   # gvisor, default behavior may be better - if you see "access denied" problems |  | ||||||
|   # during imports, try setting GVISOR_USE_DEFAULT_USER. We could make imports work |  | ||||||
|   # for any version of gvisor by setting mode when using tmp.dir to allow |  | ||||||
|   # others to list directory contents. |  | ||||||
|   settings['process']['user'] = { |  | ||||||
|     "uid": os.getuid(), |  | ||||||
|     "gid": 0 |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| memory_limit = os.environ.get('GVISOR_LIMIT_MEMORY') | memory_limit = os.environ.get('GVISOR_LIMIT_MEMORY') | ||||||
| if memory_limit: | if memory_limit: | ||||||
|   settings['process']['rlimits'] = [ |   settings['process']['rlimits'] = [ | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| import { DocAction } from 'app/common/DocActions'; | import { DocAction } from 'app/common/DocActions'; | ||||||
|  | import { DocData } from 'app/common/DocData'; | ||||||
|  | import { SchemaTypes } from 'app/common/schema'; | ||||||
| import { FlexServer } from 'app/server/lib/FlexServer'; | import { FlexServer } from 'app/server/lib/FlexServer'; | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import pick = require('lodash/pick'); | import pick = require('lodash/pick'); | ||||||
| @ -28,6 +30,7 @@ export class GristClient { | |||||||
| 
 | 
 | ||||||
|   private _requestId: number = 0; |   private _requestId: number = 0; | ||||||
|   private _pending: Array<GristResponse|GristMessage> = []; |   private _pending: Array<GristResponse|GristMessage> = []; | ||||||
|  |   private _docData?: DocData;  // accumulate tabular info like a real client.
 | ||||||
|   private _consumer: () => void; |   private _consumer: () => void; | ||||||
|   private _ignoreTrivialActions: boolean = false; |   private _ignoreTrivialActions: boolean = false; | ||||||
| 
 | 
 | ||||||
| @ -41,6 +44,17 @@ export class GristClient { | |||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|       this._pending.push(msg); |       this._pending.push(msg); | ||||||
|  |       if (msg.data?.doc) { | ||||||
|  |         this._docData = new DocData(() => { | ||||||
|  |           throw new Error('no fetches'); | ||||||
|  |         }, msg.data.doc); | ||||||
|  |       } | ||||||
|  |       if (this._docData && msg.type === 'docUserAction') { | ||||||
|  |         const docActions = msg.data?.docActions || []; | ||||||
|  |         for (const docAction of docActions) { | ||||||
|  |           this._docData.receiveAction(docAction); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|       if (this._consumer) { this._consumer(); } |       if (this._consumer) { this._consumer(); } | ||||||
|     }; |     }; | ||||||
|   } |   } | ||||||
| @ -65,6 +79,15 @@ export class GristClient { | |||||||
|     return this._pending.length; |     return this._pending.length; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public get docData() { | ||||||
|  |     if (!this._docData) { throw new Error('no DocData'); } | ||||||
|  |     return this._docData; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   public getMetaRecords(tableId: keyof SchemaTypes) { | ||||||
|  |     return this.docData.getMetaTable(tableId).getRecords(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   public async read(): Promise<any> { |   public async read(): Promise<any> { | ||||||
|     for (;;) { |     for (;;) { | ||||||
|       if (this._pending.length) { |       if (this._pending.length) { | ||||||
| @ -97,6 +120,11 @@ export class GristClient { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   public waitForServer() { | ||||||
|  |     // send an arbitrary failing message and wait for response.
 | ||||||
|  |     return this.send('ping'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // Helper to read the next docUserAction ignoring anything else (e.g. a duplicate clientConnect).
 |   // Helper to read the next docUserAction ignoring anything else (e.g. a duplicate clientConnect).
 | ||||||
|   public async readDocUserAction(): Promise<DocAction[]> { |   public async readDocUserAction(): Promise<DocAction[]> { | ||||||
|     while (true) {    // eslint-disable-line no-constant-condition
 |     while (true) {    // eslint-disable-line no-constant-condition
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user