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.
 | 
				
			||||||
 | 
					        // We rely on this behavior for distributing attachment
 | 
				
			||||||
 | 
					        // metadata.
 | 
				
			||||||
 | 
					        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) {
 | 
					        for (const {colId, defl, values} of this._colArray) {
 | 
				
			||||||
      for (let i = 0; i < rowIds.length; i++) {
 | 
					          values[destIndex] = colValues.hasOwnProperty(colId) ? colValues[colId][i] : defl;
 | 
				
			||||||
        values[index + i] = 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