From 2a206dfcf8eb84c9f2e1509a41e37d25539707b7 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 3 Jan 2024 11:53:20 -0500 Subject: [PATCH] (core) add initial support for special shares Summary: This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules. It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with: * When reading rules, if there are shares, extra rules are added. * If there are shares, all rules are made conditional on a "ShareRef" user property. * "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share. There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do: ``` gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}]) ``` If you look at the home db now there should be something in the `shares` table: ``` $ sqlite3 -table landing.db "select * from shares" +----+------------------------+------------------------+--------------+---------+ | id | key | doc_id | link_id | options | +----+------------------------+------------------------+--------------+---------+ | 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... | +----+------------------------+------------------------+--------------+---------+ ``` If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share. E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc. To actually share some material - useful commands: ``` gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}]) gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords() gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}]) ``` For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options. I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard. Test Plan: tests added Reviewers: dsagal, georgegevoian Reviewed By: dsagal, georgegevoian Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D4144 --- app/client/components/Comm.ts | 7 +- app/client/models/DocPageModel.ts | 24 +- app/common/ACLRuleCollection.ts | 54 +++- app/common/ACLShareRules.ts | 296 ++++++++++++++++++ app/common/DocListAPI.ts | 34 +- app/common/GranularAccessClause.ts | 2 + app/common/ShareOptions.ts | 22 ++ app/common/gristUrls.ts | 39 ++- app/common/schema.ts | 20 +- app/gen-server/entity/Document.ts | 6 + app/gen-server/entity/Share.ts | 48 +++ app/gen-server/lib/DocApiForwarder.ts | 8 + app/gen-server/lib/HomeDBManager.ts | 76 ++++- .../migration/1701557445716-Shares.ts | 54 ++++ app/server/lib/ActiveDoc.ts | 16 + app/server/lib/AppEndpoint.ts | 13 +- app/server/lib/DocApi.ts | 8 +- app/server/lib/DocManager.ts | 19 +- app/server/lib/DocSession.ts | 10 + app/server/lib/GranularAccess.ts | 81 ++++- app/server/lib/Sharing.ts | 8 + app/server/lib/initialDocSql.ts | 22 +- sandbox/grist/migrations.py | 18 ++ sandbox/grist/schema.py | 12 +- sandbox/grist/test_completion.py | 10 +- sandbox/grist/test_renames.py | 3 +- sandbox/grist/test_trigger_formulas.py | 6 +- sandbox/grist/test_user.py | 6 +- sandbox/grist/user.py | 3 +- test/fixtures/docs/Covid-19.grist | Bin 1318912 -> 1323008 bytes .../docs/Favorite_Films_With_Linked_Ref.grist | Bin 324608 -> 324608 bytes test/fixtures/docs/Hello.grist | Bin 61440 -> 62464 bytes test/fixtures/docs/Landlord.grist | Bin 1088512 -> 1079296 bytes test/fixtures/docs/ManyRefs.grist | Bin 0 -> 307200 bytes test/fixtures/docs/SchoolsSample.grist | Bin 165888 -> 164864 bytes test/fixtures/docs/SelectByRefList.grist | Bin 397312 -> 409600 bytes test/fixtures/docs/Teams.grist | Bin 192512 -> 196608 bytes test/fixtures/docs/World.grist | Bin 459776 -> 461824 bytes test/server/lib/DocApi.ts | 40 +++ 39 files changed, 897 insertions(+), 68 deletions(-) create mode 100644 app/common/ACLShareRules.ts create mode 100644 app/common/ShareOptions.ts create mode 100644 app/gen-server/entity/Share.ts create mode 100644 app/gen-server/migration/1701557445716-Shares.ts create mode 100644 test/fixtures/docs/ManyRefs.grist diff --git a/app/client/components/Comm.ts b/app/client/components/Comm.ts index fe50123f..57e5bcd9 100644 --- a/app/client/components/Comm.ts +++ b/app/client/components/Comm.ts @@ -27,7 +27,7 @@ import * as dispose from 'app/client/lib/dispose'; import * as log from 'app/client/lib/log'; import {CommRequest, CommResponse, CommResponseBase, CommResponseError, ValidEvent} from 'app/common/CommTypes'; import {UserAction} from 'app/common/DocActions'; -import {DocListAPI, OpenLocalDocResult} from 'app/common/DocListAPI'; +import {DocListAPI, OpenDocOptions, OpenLocalDocResult} from 'app/common/DocListAPI'; import {GristServerAPI} from 'app/common/GristServerAPI'; import {getInitialDocAssignment} from 'app/common/urlUtils'; import {Events as BackboneEvents} from 'backbone'; @@ -149,9 +149,8 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA * committed to a document that is called in hosted Grist - all other methods * are called via DocComm. */ - public async openDoc(docName: string, mode?: string, - linkParameters?: Record): Promise { - return this._makeRequest(null, docName, 'openDoc', docName, mode, linkParameters); + public async openDoc(docName: string, options?: OpenDocOptions): Promise { + return this._makeRequest(null, docName, 'openDoc', docName, options); } /** diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 689b23c2..453840fd 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -17,7 +17,7 @@ import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018 import {confirmModal} from 'app/client/ui2018/modals'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; import {delay} from 'app/common/delay'; -import {OpenDocMode, UserOverride} from 'app/common/DocListAPI'; +import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI'; import {FilteredDocUsageSummary} from 'app/common/DocUsage'; import {Product} from 'app/common/Features'; import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; @@ -182,8 +182,13 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { if (!urlId) { this._openerHolder.clear(); } else { - FlowRunner.create(this._openerHolder, - (flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode, state.params?.compare, linkParameters) + FlowRunner.create( + this._openerHolder, + (flow: AsyncFlow) => this._openDoc(flow, urlId, { + openMode: urlOpenMode, + linkParameters, + originalUrlId: state.doc, + }, state.params?.compare) ) .resultPromise.catch(err => this._onOpenError(err)); } @@ -325,9 +330,9 @@ It also disables formulas. [{{error}}]", {error: err.message}) this.offerRecovery(err); } - private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined, - comparisonUrlId: string | undefined, - linkParameters: Record | undefined): Promise { + private async _openDoc(flow: AsyncFlow, urlId: string, options: OpenDocOptions, + comparisonUrlId: string | undefined): Promise { + const {openMode: urlOpenMode, linkParameters} = options; console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` + (comparisonUrlId ? ` (compare ${comparisonUrlId})` : '')); const gristDocModulePromise = loadGristDoc(); @@ -383,7 +388,11 @@ It also disables formulas. [{{error}}]", {error: err.message}) comm.useDocConnection(doc.id); flow.onDispose(() => comm.releaseDocConnection(doc.id)); - const openDocResponse = await comm.openDoc(doc.id, doc.openMode, linkParameters); + const openDocResponse = await comm.openDoc(doc.id, { + openMode: doc.openMode, + linkParameters, + originalUrlId: options.originalUrlId, + }); if (openDocResponse.recoveryMode || openDocResponse.userOverride) { doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode); doc.userOverride = openDocResponse.userOverride || null; @@ -493,7 +502,6 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo { const isPreFork = openMode === 'fork'; const isTemplate = doc.type === 'template' && (isFork || isPreFork); const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork); - return { ...doc, isFork, diff --git a/app/common/ACLRuleCollection.ts b/app/common/ACLRuleCollection.ts index 7afdc3ec..92375530 100644 --- a/app/common/ACLRuleCollection.ts +++ b/app/common/ACLRuleCollection.ts @@ -1,8 +1,10 @@ import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions'; +import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules'; import {AclRuleProblem} from 'app/common/ActiveDocAPI'; import {DocData} from 'app/common/DocData'; import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; import {getSetMapValue, isNonNullish} from 'app/common/gutil'; +import {ShareOptions} from 'app/common/ShareOptions'; import {MetaRowRecord} from 'app/common/TableData'; import {decodeObject} from 'app/plugin/objtypes'; import sortBy = require('lodash/sortBy'); @@ -347,7 +349,9 @@ export class ACLRuleCollection { const names: string[] = []; for (const rule of this.getUserAttributeRules().values()) { const tableRef = tablesTable.findRow('tableId', rule.tableId); - const colRef = columnsTable.findMatchingRowId({parentId: tableRef, colId: rule.lookupColId}); + const colRef = columnsTable.findMatchingRowId({ + parentId: tableRef, colId: rule.lookupColId, + }); if (!colRef) { invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`); names.push(rule.name); @@ -379,11 +383,13 @@ export class ACLRuleCollection { export interface ReadAclOptions { log: ILogger; // For logging warnings during rule processing. compile?: (parsed: ParsedAclFormula) => AclMatchFunc; - // If true, call addHelperCols to add helper columns of restricted columns to rule sets. - // Used in the server for extra filtering, but not in the client, because: - // 1. They would show in the UI - // 2. They would be saved back after editing, causing them to accumulate - includeHelperCols?: boolean; + // If true, add and modify access rules in some special ways. + // Specifically, call addHelperCols to add helper columns of restricted columns to rule sets, + // and use ACLShareRules to implement any special shares as access rules. + // Used in the server, but not in the client, because of at least the following: + // 1. Rules would show in the UI + // 2. Rules would be saved back after editing, causing them to accumulate + enrichRulesForImplementation?: boolean; // If true, rules with 'schemaEdit' permission are moved out of the '*:*' resource into a // fictitious '*SPECIAL:SchemaEdit' resource. This is used only on the client, to present @@ -455,13 +461,32 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log: * Parse all ACL rules in the document from DocData into a list of RuleSets and of * UserAttributeRules. This is used by both client-side code and server-side. */ -function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults { - const resourcesTable = docData.getMetaTable('_grist_ACLResources'); - const rulesTable = docData.getMetaTable('_grist_ACLRules'); +function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults { + // Wrap resources and rules tables so we can have "virtual" rules + // to implement special shares. + const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources')); + const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules')); + const sharesTable = docData.getMetaTable('_grist_Shares'); const ruleSets: RuleSet[] = []; const userAttributes: UserAttributeRule[] = []; + let hasShares: boolean = false; + const shares = sharesTable.getRecords(); + // ACLShareRules is used to edit resourcesTable and rulesTable in place. + const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable); + // Add virtual rules to implement shares, if there are any. + // Add the virtual rules only when implementing/interpreting them, as + // opposed to accessing them for presentation or manipulation in the UI. + if (enrichRulesForImplementation && shares.length > 0) { + for (const share of shares) { + const options: ShareOptions = JSON.parse(share.options || '{}'); + shareRules.addRulesForShare(share.id, options); + } + shareRules.addDefaultRulesForShares(); + hasShares = true; + } + // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet. const rulesByResource = new Map>>(); for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) { @@ -472,7 +497,6 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA const resourceRec = resourcesTable.getRecord(resourceId); if (!resourceRec) { throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`); - continue; } if (!resourceRec.tableId || !resourceRec.colIds) { // This should only be the case for the old-style default rule/resource, which we @@ -482,7 +506,7 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA const tableId = resourceRec.tableId; const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(','); - if (includeHelperCols && Array.isArray(colIds)) { + if (enrichRulesForImplementation && Array.isArray(colIds)) { colIds.push(...getHelperCols(docData, tableId, colIds, log)); } @@ -506,7 +530,13 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA } else if (rule.aclFormula && !rule.aclFormulaParsed) { throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`); } else { - const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); + let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed)); + // If we have "virtual" rules to implement shares, then regular + // rules need to be tweaked so that they don't apply when the + // share is active. + if (hasShares && rule.id >= 0) { + aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed}); + } body.push({ origRecord: rule, aclFormula: String(rule.aclFormula), diff --git a/app/common/ACLShareRules.ts b/app/common/ACLShareRules.ts new file mode 100644 index 00000000..24e3c2ea --- /dev/null +++ b/app/common/ACLShareRules.ts @@ -0,0 +1,296 @@ +import { DocData } from 'app/common/DocData'; +import { SchemaTypes } from 'app/common/schema'; +import { ShareOptions } from 'app/common/ShareOptions'; +import { MetaRowRecord, MetaTableData } from 'app/common/TableData'; +import { isEqual } from 'lodash'; + +/** + * For special shares, we need to refer to resources that may not + * be listed in the _grist_ACLResources table, and have rules that + * aren't backed by storage in _grist_ACLRules. So we implement + * a small helper to add an overlay of extra resources and rules. + * They are distinguishable from real, stored resources and rules + * by having negative IDs. + */ +export class TableWithOverlay { + private _extraRecords = new Array>(); + private _extraRecordsById = new Map>(); + private _nextFreeVirtualId: number = -1; + + public constructor(private _originalTable: MetaTableData) {} + + // Add a record to the table, but only as an overlay - no + // persistent changes are made. Uses negative row IDs. + // Returns the ID assigned to the record. The passed in + // record is expected to have an ID of zero. + public addRecord(rec: MetaRowRecord): number { + if (rec.id !== 0) { throw new Error('Expected a zero ID'); } + const id = this._nextFreeVirtualId; + const recWithCorrectId: MetaRowRecord = {...rec, id}; + this._extraRecords.push({...rec, id}); + this._extraRecordsById.set(id, recWithCorrectId); + this._nextFreeVirtualId--; + return id; + } + + // Support the few MetaTableData methods we actually use + // in ACLRuleCollection and ACLShareRules. + + public getRecord(resourceId: number) { + // Reroute negative IDs to our local stash of records. + if (resourceId < 0) { + return this._extraRecordsById.get(resourceId); + } + // Everything else, we just pass along. + return this._originalTable.getRecord(resourceId); + } + + public getRecords() { + return [...this._originalTable.getRecords(), ...this._extraRecords]; + } + + public findMatchingRowId(properties: Partial>): number { + // Check stored records. + const rowId = this._originalTable.findMatchingRowId(properties); + if (rowId) { return rowId; } + // Check overlay. + return this._extraRecords.find((rec) => + Object.keys(properties).every((p) => isEqual( + (rec as any)[p], + (properties as any)[p])))?.id || 0; + } +} + +/** + * Helper for managing special share rules. + */ +export class ACLShareRules { + + public constructor( + public docData: DocData, + public resourcesTable: TableWithOverlay<'_grist_ACLResources'>, + public rulesTable: TableWithOverlay<'_grist_ACLRules'>, + ) {} + + /** + * Add any rules needed for the specified share. + * + * The only kind of share we support for now is form endpoint + * sharing. + */ + public addRulesForShare(shareRef: number, shareOptions: ShareOptions) { + // TODO: Unpublished shares could and should be blocked earlier, + // by home server + if (!shareOptions.publish) { + this._blockShare(shareRef); + return; + } + + // Let's go looking for sections related to the share. + // It was decided that the relationship between sections and + // shares is via pages. Every section on a given page can belong + // to at most one share. + // Ignore sections which do not have `publish` set to `true` in + // `shareOptions`. + const pages = this.docData.getMetaTable('_grist_Pages').filterRecords({ + shareRef, + }); + const parentViews = new Set(pages.map(page => page.viewRef)); + const sections = this.docData.getMetaTable('_grist_Views_section').getRecords().filter( + section => { + if (!parentViews.has(section.parentId)) { return false; } + const options = JSON.parse(section.shareOptions || '{}'); + return Boolean(options.publish) && Boolean(options.form); + } + ); + + const tableRefs = new Set(sections.map(section => section.tableRef)); + const tables = this.docData.getMetaTable('_grist_Tables').getRecords().filter( + table => tableRefs.has(table.id) + ); + + // For tables associated with forms, allow creation of records, + // and reading of referenced columns. + // TODO: should probably be limiting to a set of columns associated + // with section - but for form widget that could potentially be very + // confusing since it may not be easy to see that certain columns + // haven't been made visible for it? For now, just working at table + // level. + for (const table of tables) { + this._shareTableForForm(table, shareRef); + } + } + + /** + * When accessing a document via a share, by default no user tables are + * accessible. Everything added to the share gives additional + * access, and never reduces access, making it easy to grant + * access to multiple parts of the document. + * + * We do leave access unchanged for metadata tables, since they are + * censored via an alternative mechanism. + */ + public addDefaultRulesForShares() { + const tableIds = this.docData.getMetaTable('_grist_Tables').getRecords() + .map(table => table.tableId) + .filter(tableId => !tableId.startsWith('_grist_')) + .sort(); + for (const tableId of tableIds) { + const resource = this._findOrAddResource({ + tableId, colIds: '*', + }); + const aclFormula = `user.ShareRef is not None`; + const aclFormulaParsed = JSON.stringify([ + 'NotEq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', null] ]); + this.rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', + })); + } + } + + /** + * When accessing a document via a share, any regular granular access + * rules should not apply. This requires an extra conditional. + */ + public transformNonShareRules(state: { + rule: MetaRowRecord<'_grist_ACLRules'>, + aclFormulaParsed: object, + }) { + state.rule.aclFormula = 'user.ShareRef is None and (' + String(state.rule.aclFormula || 'True') + ')'; + state.aclFormulaParsed = [ + 'And', + [ 'Eq', [ 'Attr', [ 'Name', 'user' ], 'ShareRef' ], ['Const', null] ], + state.aclFormulaParsed || [ 'Const', true ] + ]; + state.rule.aclFormulaParsed = JSON.stringify(state.aclFormulaParsed); + return state.aclFormulaParsed; + } + + /** + * Allow creating records in a table. + */ + private _shareTableForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const resource = this._findOrAddResource({ + tableId: table.tableId, + colIds: '*', + }); + let aclFormula = `user.ShareRef == ${shareRef}`; + let aclFormulaParsed = JSON.stringify([ + 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + [ 'Const', shareRef ] ]); + this.rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+C', + })); + + // This is a hack to grant read schema access, needed for forms - + // Should not be needed once forms are actually available, but + // until them is very handy to allow using the web client to + // submit records. + aclFormula = `user.ShareRef == ${shareRef} and rec.id == 0`; + aclFormulaParsed = JSON.stringify( + [ 'And', + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ], + [ 'Eq', [ 'Attr', ['Name', 'rec'], 'id'], ['Const', 0]]]); + this.rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + + this._shareTableReferencesForForm(table, shareRef); + } + + /** + * Give read access to referenced columns. + */ + private _shareTableReferencesForForm(table: MetaRowRecord<'_grist_Tables'>, + shareRef: number) { + const tables = this.docData.getMetaTable('_grist_Tables'); + const columns = this.docData.getMetaTable('_grist_Tables_column'); + const tableColumns = columns.filterRecords({ + parentId: table.id, + }).filter(c => c.type.startsWith('Ref:') || c.type.startsWith('RefList:')); + for (const column of tableColumns) { + const visibleColRef = column.visibleCol; + // This could be blank in tests, not sure about real life. + if (!visibleColRef) { continue; } + const visibleCol = columns.getRecord(visibleColRef); + if (!visibleCol) { continue; } + const referencedTable = tables.getRecord(visibleCol.parentId); + if (!referencedTable) { continue; } + + const tableId = referencedTable.tableId; + const colId = visibleCol.colId; + const resource = this._findOrAddResource({ + tableId: tableId, + colIds: colId, + }); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this.rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '+R', + })); + } + } + + /** + * Find a resource we need, and return its rowId. The resource is + * added if it is not already present. + */ + private _findOrAddResource(properties: { + tableId: string, + colIds: string, + }): number { + const resource = this.resourcesTable.findMatchingRowId(properties); + if (resource !== 0) { return resource; } + return this.resourcesTable.addRecord({ + id: 0, + ...properties, + }); + } + + private _blockShare(shareRef: number) { + const resource = this._findOrAddResource({ + tableId: '*', colIds: '*', + }); + const aclFormula = `user.ShareRef == ${shareRef}`; + const aclFormulaParsed = JSON.stringify( + [ 'Eq', + [ 'Attr', [ "Name", "user" ], "ShareRef" ], + ['Const', shareRef] ]); + this.rulesTable.addRecord(this._makeRule({ + resource, aclFormula, aclFormulaParsed, permissionsText: '-CRUDS', + })); + } + + private _makeRule(options: { + resource: number, + aclFormula: string, + aclFormulaParsed: string, + permissionsText: string, + }): MetaRowRecord<'_grist_ACLRules'> { + const {resource, aclFormula, aclFormulaParsed, permissionsText} = options; + return { + id: 0, + resource, + aclFormula, + aclFormulaParsed, + memo: '', + permissionsText, + userAttributes: '', + rulePos: 0, + + // The following fields are unused and deprecated. + aclColumn: 0, + permissions: 0, + principals: '', + }; + } +} diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index be76442e..97cf86b1 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -16,6 +16,37 @@ export const OpenDocMode = StringUnion( ); export type OpenDocMode = typeof OpenDocMode.type; +/** + * A collection of options for opening documents on behalf of + * a user in special circumstances we have accumulated. This is + * specifically for the Grist front end, when setting up a websocket + * connection to a doc worker willing to serve a document. Remember + * that the front end is untrusted and any information it passes should + * be treated as user input. + */ +export interface OpenDocOptions { + /** + * Users may now access a single specific document (with a given docId) + * using distinct keys for which different access rules apply. When opening + * a document, the ID used in the URL may now be passed along so + * that the back-end can grant appropriate access. + */ + originalUrlId?: string; + + /** + * Access to a document by a user may be voluntarily limited to + * read-only, or to trigger forking on edits. + */ + openMode?: OpenDocMode; + + /** + * Access to a document may be modulated by URL parameters. + * These parameters become an attribute of the user, for + * access control. + */ + linkParameters?: Record; +} + /** * Represents an entry in the DocList. */ @@ -89,6 +120,5 @@ export interface DocListAPI { /** * Opens a document, loads it, subscribes to its userAction events, and returns its metadata. */ - openDoc(userDocName: string, openMode?: OpenDocMode, - linkParameters?: Record): Promise; + openDoc(userDocName: string, options?: OpenDocOptions): Promise; } diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index 5509314b..b5ed6707 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -47,6 +47,8 @@ export interface UserInfo { UserID: number | null; UserRef: string | null; SessionID: string | null; + ShareRef: number | null; // This is a rowId in the _grist_Shares table, if the user + // is accessing a document via a share. Otherwise null. [attributes: string]: unknown; toJSON(): {[key: string]: any}; } diff --git a/app/common/ShareOptions.ts b/app/common/ShareOptions.ts new file mode 100644 index 00000000..b07c9924 --- /dev/null +++ b/app/common/ShareOptions.ts @@ -0,0 +1,22 @@ +/** + * + * Options on a share, or a shared widget. This is mostly + * a placeholder currently. The same structure is currently + * used both for shares and for specific shared widgets, but + * this is just to save a little time right now, and should + * not be preserved in future work. + * + * The only flag that matter today is "publish". + * The "access" flag could be stripped for now without consequences. + * + */ +export interface ShareOptions { + // A share or widget that does not have publish set to true + // will not be available via the share mechanism. + publish?: boolean; + + // Can be set to 'viewers' to label the share as readonly. + // Half-baked, just here to exercise an aspect of homedb + // syncing. + access?: 'editors' | 'viewers'; +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 1138d077..eee562dd 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -1,7 +1,7 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI'; import {OpenDocMode} from 'app/common/DocListAPI'; import {EngineCode} from 'app/common/DocumentSettings'; -import {encodeQueryParams, isAffirmative} from 'app/common/gutil'; +import {encodeQueryParams, isAffirmative, removePrefix} from 'app/common/gutil'; import {LocalPlugin} from 'app/common/plugin'; import {StringUnion} from 'app/common/StringUnion'; import {TelemetryLevel} from 'app/common/Telemetry'; @@ -57,6 +57,10 @@ export const DEFAULT_HOME_SUBDOMAIN = 'api'; // as a prefix of the docId. export const MIN_URLID_PREFIX_LENGTH = 12; +// A prefix that identifies a urlId as a share key. +// Important that this not be part of a valid docId. +export const SHARE_KEY_PREFIX = 's.'; + /** * Special ways to open a document, based on what the user intends to do. * - view: Open document in read-only mode (even if user has edit rights) @@ -140,6 +144,7 @@ export interface IGristUrlState { api?: boolean; // indicates that the URL should be encoded as an API URL, not as a landing page. // But this barely works, and is suitable only for documents. For decoding it // indicates that the URL probably points to an API endpoint. + viaShare?: boolean; // Accessing document via a special share. } // Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the @@ -253,6 +258,13 @@ export function encodeUrl(gristConfig: Partial, if (state.doc) { if (state.api) { parts.push(`docs/${encodeURIComponent(state.doc)}`); + } else if (state.viaShare) { + // Use a special path, and remove SHARE_KEY_PREFIX from id. + let id = state.doc; + if (id.startsWith(SHARE_KEY_PREFIX)) { + id = id.substring(SHARE_KEY_PREFIX.length); + } + parts.push(`s/${encodeURIComponent(id)}`); } else if (state.slug) { parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`); } else { @@ -366,6 +378,13 @@ export function decodeUrl(gristConfig: Partial, location: Locat map.set('doc', map.get('docs')!); } + // /s/ is accepted as another way to write -> /doc/ + if (map.has('s')) { + const key = map.get('s'); + map.set('doc', `${SHARE_KEY_PREFIX}${key}`); + state.viaShare = true; + } + // When the urlId is a prefix of the docId, documents are identified // as "/slug" instead of "doc/". We can detect that because // the minimum length of a urlId prefix is longer than the maximum length @@ -919,33 +938,41 @@ export function parseFirstUrlPart(tag: string, path: string): {value?: string, p } /** - * The internal structure of a UrlId. There is no internal structure. unless the id is - * for a fork, in which case the fork has a separate id, and a user id may also be - * embedded to track ownership. + * The internal structure of a UrlId. There is no internal structure, + * except in the following cases. The id may be for a fork, in which + * case the fork has a separate id, and a user id may also be embedded + * to track ownership. The id may be a share key, in which case it + * has some special syntax to identify it as so. */ export interface UrlIdParts { trunkId: string; forkId?: string; forkUserId?: number; snapshotId?: string; + shareKey?: string; } // Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId // or trunkId[....]~v=snapshotId +// or shareKey export function parseUrlId(urlId: string): UrlIdParts { let snapshotId: string|undefined; const parts = urlId.split('~'); - const bareParts = parts.filter(part => !part.includes('=')); + const bareParts = parts.filter(part => !part.includes('v=')); for (const part of parts) { if (part.startsWith('v=')) { snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, '%')); } } + const trunkId = bareParts[0]; + // IDs starting with SHARE_KEY_PREFIX are in fact shares. + const shareKey = removePrefix(trunkId, SHARE_KEY_PREFIX) || undefined; return { trunkId: bareParts[0], forkId: bareParts[1], forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined, snapshotId, + shareKey, }; } @@ -984,7 +1011,7 @@ export interface HashLink { // a candidate for use in prettier urls. function shouldIncludeSlug(doc: {id: string, urlId: string|null}): boolean { if (!doc.urlId || doc.urlId.length < MIN_URLID_PREFIX_LENGTH) { return false; } - return doc.id.startsWith(doc.urlId); + return doc.id.startsWith(doc.urlId) || doc.urlId.startsWith(SHARE_KEY_PREFIX); } // Convert the name of a document into a slug. Only alphanumerics are retained, diff --git a/app/common/schema.ts b/app/common/schema.ts index 853141a6..996e457e 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 40; +export const SCHEMA_VERSION = 41; export const schema = { @@ -92,6 +92,7 @@ export const schema = { viewRef : "Ref:_grist_Views", indentation : "Int", pagePos : "PositionNumber", + shareRef : "Ref:_grist_Shares", }, "_grist_Views": { @@ -119,6 +120,7 @@ export const schema = { linkTargetColRef : "Ref:_grist_Tables_column", embedId : "Text", rules : "RefList:_grist_Tables_column", + shareOptions : "Text", }, "_grist_Views_section_field": { @@ -216,6 +218,13 @@ export const schema = { userRef : "Text", }, + "_grist_Shares": { + linkId : "Text", + options : "Text", + label : "Text", + description : "Text", + }, + }; export interface SchemaTypes { @@ -304,6 +313,7 @@ export interface SchemaTypes { viewRef: number; indentation: number; pagePos: number; + shareRef: number; }; "_grist_Views": { @@ -331,6 +341,7 @@ export interface SchemaTypes { linkTargetColRef: number; embedId: string; rules: [GristObjCode.List, ...number[]]|null; + shareOptions: string; }; "_grist_Views_section_field": { @@ -428,4 +439,11 @@ export interface SchemaTypes { userRef: string; }; + "_grist_Shares": { + linkId: string; + options: string; + label: string; + description: string; + }; + } diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index 2414830a..44e678ff 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -40,6 +40,12 @@ export class Document extends Resource { // fetching user has on the doc, i.e. 'owners', 'editors', 'viewers' public access: Role|null; + // Property that may be returned when the doc is fetched to indicate the share it + // is being accessed with. The identifier used is the linkId, which is the share + // identifier that is the same between the home database and the document. + // The linkId is not a secret, and need only be unique within a document. + public linkId?: string|null; + // Property set for forks, containing access the fetching user has on the trunk. public trunkAccess?: Role|null; diff --git a/app/gen-server/entity/Share.ts b/app/gen-server/entity/Share.ts new file mode 100644 index 00000000..cdc4d7da --- /dev/null +++ b/app/gen-server/entity/Share.ts @@ -0,0 +1,48 @@ +import {ShareOptions} from 'app/common/ShareOptions'; +import {Document} from 'app/gen-server/entity/Document'; +import {nativeValues} from 'app/gen-server/lib/values'; +import {BaseEntity, Column, Entity, JoinColumn, ManyToOne, + PrimaryColumn} from 'typeorm'; + +@Entity({name: 'shares'}) +export class Share extends BaseEntity { + /** + * A simple integer auto-incrementing identifier for a share. + * Suitable for use in within-database references. + */ + @PrimaryColumn({name: 'id', type: Number}) + public id: number; + + /** + * A long string secret to identify the share. Suitable for URLs. + * Unique across the database / installation. + */ + @Column({name: 'key', type: String}) + public key: string; + + /** + * A string to identify the share. This identifier is common to the home + * database and the document specified by docId. It need only be unique + * within that document, and is not a secret. These two properties are + * important when you imagine handling documents that are transferred + * between installations, or copied, etc. + */ + @Column({name: 'link_id', type: String}) + public linkId: string; + + /** + * The document to which the share belongs. + */ + @Column({name: 'doc_id', type: String}) + public docId: string; + + /** + * Any overall qualifiers on the share. + */ + @Column({name: 'options', type: nativeValues.jsonEntityType}) + public options: ShareOptions; + + @ManyToOne(type => Document) + @JoinColumn({name: 'doc_id'}) + public doc: Document; +} diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 36d58b9d..1df8d4ca 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -3,6 +3,7 @@ import fetch, { RequestInit } from 'node-fetch'; import {AbortController} from 'node-abort-controller'; import { ApiError } from 'app/common/ApiError'; +import { SHARE_KEY_PREFIX } from 'app/common/gristUrls'; import { removeTrailingSlash } from 'app/common/gutil'; import { HomeDBManager } from "app/gen-server/lib/HomeDBManager"; import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer'; @@ -33,6 +34,13 @@ export class DocApiForwarder { } public addEndpoints(app: express.Application) { + app.use((req, res, next) => { + if (req.url.startsWith('/api/s/')) { + req.url = req.url.replace('/api/s/', `/api/docs/${SHARE_KEY_PREFIX}`); + } + next(); + }); + // Middleware to forward a request about an existing document that user has access to. // We do not check whether the document has been soft-deleted; that will be checked by // the worker if needed. diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index ccbfb5fe..a5f4c091 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -36,6 +36,7 @@ import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/e import {Pref} from "app/gen-server/entity/Pref"; import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-server/entity/Product"; import {Secret} from "app/gen-server/entity/Secret"; +import {Share} from "app/gen-server/entity/Share"; import {User} from "app/gen-server/entity/User"; import {Workspace} from "app/gen-server/entity/Workspace"; import {Limit} from 'app/gen-server/entity/Limit'; @@ -1194,10 +1195,41 @@ export class HomeDBManager extends EventEmitter { // Doc permissions of forks are based on the "trunk" document, so make sure // we look up permissions of trunk if we are on a fork (we'll fix the permissions // up for the fork immediately afterwards). - const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(key.urlId); + const {trunkId, forkId, forkUserId, snapshotId, + shareKey} = parseUrlId(key.urlId); + let doc: Document; + if (shareKey) { + const res = await (transaction || this._connection).createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .leftJoinAndSelect('shares.doc', 'doc') + .where('key = :key', {key: shareKey}) + .getOne(); + if (!res) { + throw new ApiError('Share not known', 404); + } + doc = { + name: res.doc?.name, + id: res.docId, + linkId: res.linkId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + isPinned: false, + urlId: key.urlId, + // For the moment, I don't include a useful workspace. + // TODO: look up the document properly, perhaps delegating + // to the regular path through this method. + workspace: this.unwrapQueryResult( + await this.getWorkspace({userId: this.getSupportUserId()}, + this._exampleWorkspaceId)), + aliases: [], + access: 'editors', // a share may have view/edit access, + // need to check at granular level + } as any; + return doc; + } const urlId = trunkId; if (forkId || snapshotId) { key = {...key, urlId}; } - let doc: Document; if (urlId === NEW_DOCUMENT_CODE) { if (!forkId) { throw new ApiError('invalid document identifier', 400); } // We imagine current user owning trunk if there is no embedded userId, or @@ -3022,6 +3054,41 @@ export class HomeDBManager extends EventEmitter { return limitOrError; } + public async syncShares(docId: string, shares: ShareInfo[]) { + return this._connection.transaction(async manager => { + for (const share of shares) { + const key = makeId(); + await manager.createQueryBuilder() + .insert() + // if urlId has been used before, update it + .onConflict(`(doc_id, link_id) DO UPDATE SET options = :options`) + .setParameter('options', share.options) + .into(Share) + .values({ + linkId: share.linkId, + docId, + options: JSON.parse(share.options), + key, + }) + .execute(); + } + const dbShares = await manager.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('doc_id = :docId', {docId}) + .getMany(); + const activeLinkIds = new Set(shares.map(share => share.linkId)); + const oldShares = dbShares.filter(share => !activeLinkIds.has(share.linkId)); + if (oldShares.length > 0) { + await manager.createQueryBuilder() + .delete() + .from('shares') + .whereInIds(oldShares.map(share => share.id)) + .execute(); + } + }); + } + private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise { if (accountId === 0) { throw new Error(`getLimit: called for not existing account`); @@ -4866,3 +4933,8 @@ export function getDocAuthKeyFromScope(scope: Scope): DocAuthKey { if (!urlId) { throw new Error('document required'); } return {urlId, userId, org}; } + +interface ShareInfo { + linkId: string; + options: string; +} diff --git a/app/gen-server/migration/1701557445716-Shares.ts b/app/gen-server/migration/1701557445716-Shares.ts new file mode 100644 index 00000000..a539ca0b --- /dev/null +++ b/app/gen-server/migration/1701557445716-Shares.ts @@ -0,0 +1,54 @@ +import { nativeValues } from 'app/gen-server/lib/values'; +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableUnique } from 'typeorm'; + +export class Shares1701557445716 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(new Table({ + name: 'shares', + columns: [ + { + name: 'id', + type: 'integer', + isGenerated: true, + generationStrategy: 'increment', + isPrimary: true + }, + { + name: 'key', + type: 'varchar', + isUnique: true, + }, + { + name: 'doc_id', + type: 'varchar', + }, + { + name: 'link_id', + type: 'varchar', + }, + { + name: 'options', + type: nativeValues.jsonType, + }, + ] + })); + await queryRunner.createForeignKeys('shares', [ + new TableForeignKey({ + columnNames: ['doc_id'], + referencedTableName: 'docs', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', // delete share if doc goes away + }), + ]); + await queryRunner.createUniqueConstraints('shares', [ + new TableUnique({ + columnNames: ['doc_id', 'link_id'], + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('shares'); + } +} diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 041129a2..5a30dd5f 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -1832,6 +1832,22 @@ export class ActiveDoc extends EventEmitter { )); } + public async syncShares(docSession: OptDocSession) { + const metaTables = await this.fetchMetaTables(docSession); + const shares = metaTables['_grist_Shares']; + const ids = shares[2]; + const vals = shares[3]; + const goodShares = ids.map((id, idx) => { + return { + id, + linkId: String(vals['linkId'][idx]), + options: String(vals['options'][idx]), + }; + }); + await this.getHomeDbManager()?.syncShares(this.docName, goodShares); + return goodShares; + } + /** * Loads an open document from DocStorage. Returns a list of the tables it contains. */ diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 313d8ce3..f9df04c5 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -6,7 +6,7 @@ import * as express from 'express'; import {ApiError} from 'app/common/ApiError'; -import {getSlugIfNeeded, parseUrlId} from 'app/common/gristUrls'; +import {getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX} from 'app/common/gristUrls'; import {LocalPlugin} from "app/common/plugin"; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {Document as APIDocument} from 'app/common/UserAPI'; @@ -105,7 +105,8 @@ export function attachAppEndpoint(options: AttachOptions): void { const slug = getSlugIfNeeded(doc); const slugMismatch = (req.params.slug || null) !== (slug || null); const preferredUrlId = doc.urlId || doc.id; - if (urlId !== preferredUrlId || slugMismatch) { + if (!req.params.viaShare && // Don't bother canonicalizing for shares yet. + (urlId !== preferredUrlId || slugMismatch)) { // Prepare to redirect to canonical url for document. // Preserve any query parameters or fragments. const queryOrFragmentCheck = req.originalUrl.match(/([#?].*)/); @@ -215,6 +216,14 @@ export function attachAppEndpoint(options: AttachOptions): void { // The * is a wildcard in express 4, rather than a regex symbol. // See https://expressjs.com/en/guide/routing.html app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler); + app.get('/s/:urlId([^/]+):remainder(*)', + (req, res, next) => { + // /s/ is another way of writing /doc/ for shares. + req.params.urlId = SHARE_KEY_PREFIX + req.params.urlId; + req.params.viaShare = "1"; + next(); + }, + ...docMiddleware, docHandler); app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', ...docMiddleware, docHandler); } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index c0327e5c..3ea0a042 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -13,7 +13,7 @@ import { } from 'app/common/DocActions'; import {isRaisedException} from "app/common/gristTypes"; import {Box, RenderBox, RenderContext} from "app/common/Forms"; -import {buildUrlId, parseUrlId} from "app/common/gristUrls"; +import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil"; import {SchemaTypes} from "app/common/schema"; import {SortFunc} from 'app/common/SortFunc'; @@ -178,6 +178,12 @@ export class DocWorkerApi { * to apply to these routes. */ public addEndpoints() { + this._app.use((req, res, next) => { + if (req.url.startsWith('/api/s/')) { + req.url = req.url.replace('/api/s/', `/api/docs/${SHARE_KEY_PREFIX}`); + } + next(); + }); // check document exists (not soft deleted) and user can view it const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false)); diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 9963af87..8e4f6c66 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -8,8 +8,9 @@ import * as path from 'path'; import {ApiError} from 'app/common/ApiError'; import {mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate'; import {BrowserSettings} from 'app/common/BrowserSettings'; -import {DocCreationInfo, DocEntry, DocListAPI, OpenDocMode, OpenLocalDocResult} from 'app/common/DocListAPI'; +import {DocCreationInfo, DocEntry, DocListAPI, OpenDocOptions, OpenLocalDocResult} from 'app/common/DocListAPI'; import {FilteredDocUsageSummary} from 'app/common/DocUsage'; +import {parseUrlId} from 'app/common/gristUrls'; import {Invite} from 'app/common/sharing'; import {tbind} from 'app/common/tbind'; import {TelemetryMetadataByLevel} from 'app/common/Telemetry'; @@ -298,8 +299,13 @@ export class DocManager extends EventEmitter { * `doc` - the object with metadata tables. */ public async openDoc(client: Client, docId: string, - openMode: OpenDocMode = 'default', - linkParameters: Record = {}): Promise { + options?: OpenDocOptions): Promise { + if (typeof options === 'string') { + throw new Error('openDoc call with outdated parameter type'); + } + const openMode = options?.openMode || 'default'; + const linkParameters = options?.linkParameters || {}; + const originalUrlId = options?.originalUrlId; let auth: Authorizer; let userId: number | undefined; const dbManager = this._homeDbManager; @@ -313,7 +319,12 @@ export class DocManager extends EventEmitter { // We use docId in the key, and disallow urlId, so we can be sure that we are looking at the // right doc when we re-query the DB over the life of the websocket. - const key = {urlId: docId, userId, org}; + const useShareUrlId = Boolean(originalUrlId && parseUrlId(originalUrlId).shareKey); + const key = { + urlId: useShareUrlId ? originalUrlId! : docId, + userId, + org + }; log.debug("DocManager.openDoc Authorizer key", key); const docAuth = await dbManager.getDocAuthCached(key); assertAccess('viewers', docAuth); diff --git a/app/server/lib/DocSession.ts b/app/server/lib/DocSession.ts index 3563f0cf..148d49c2 100644 --- a/app/server/lib/DocSession.ts +++ b/app/server/lib/DocSession.ts @@ -166,6 +166,16 @@ export function getDocSessionAccess(docSession: OptDocSession): Role { throw new Error('getDocSessionAccess could not find access information in DocSession'); } +export function getDocSessionShare(docSession: OptDocSession): string|null { + if (docSession.authorizer) { + return docSession.authorizer.getCachedAuth().cachedDoc?.linkId || null; + } + if (docSession.req) { + return docSession.req.docAuth?.cachedDoc?.linkId || null; + } + return null; +} + export function getDocSessionAccessOrNull(docSession: OptDocSession): Role|null { try { return getDocSessionAccess(docSession); diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 2da069f8..73241aab 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -37,8 +37,8 @@ import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { GristObjCode } from 'app/plugin/GristData'; import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { DocClients } from 'app/server/lib/DocClients'; -import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser, - OptDocSession } from 'app/server/lib/DocSession'; +import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare, + getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession'; import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage'; import log from 'app/server/lib/log'; import { IPermissionInfo, MixedPermissionSetWithContext, @@ -98,7 +98,8 @@ function isAddOrUpdateRecordAction([actionName]: UserAction): boolean { // specifically _grist_Attachments. const STRUCTURAL_TABLES = new Set(['_grist_Tables', '_grist_Tables_column', '_grist_Views', '_grist_Views_section', '_grist_Views_section_field', - '_grist_ACLResources', '_grist_ACLRules']); + '_grist_ACLResources', '_grist_ACLRules', + '_grist_Shares']); // Actions that won't be allowed (yet) for a user with nuanced access to a document. // A few may be innocuous, but that hasn't been figured out yet. @@ -238,7 +239,8 @@ export interface GranularAccessForBundle { * (the UserAction has been compiled to DocActions). * - canApplyBundle(), called when DocActions have been produced from UserActions, * but before those DocActions have been applied to the DB. If fails, the modification - * will be abandoned. + * will be abandoned. This method will also finalize some bundle state, + * specifically the `maybeHasShareChanges` flag. * - appliedBundle(), called when DocActions have been applied to the DB, but before * those changes have been sent to clients. * - sendDocUpdateForBundle() is called once a bundle has been applied, to notify @@ -279,7 +281,9 @@ export class GranularAccess implements GranularAccessForBundle { // Flag for whether doc actions mention a rule change, even if passive due to // schema changes. hasAnyRuleChange: boolean, + maybeHasShareChanges: boolean, options: ApplyUAExtendedOptions|null, + shareRef?: number; }|null; public constructor( @@ -308,6 +312,7 @@ export class GranularAccess implements GranularAccessForBundle { this._activeBundle = { docSession, docActions, undo, userActions, isDirect, applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false, + maybeHasShareChanges: false, options, }; this._activeBundle.hasDeliberateRuleChange = @@ -497,6 +502,35 @@ export class GranularAccess implements GranularAccessForBundle { return this._checkIncomingDocAction({docSession, action, actionIdx}); } })); + const shares = this._docData.getMetaTable('_grist_Shares'); + /** + * This is a good point at which to determine whether we may be + * making a change to special shares. If we may be, then currently + * we will reload any connected web clients accessing the document + * via a share. + * + * The role of the `maybeHasShareChanges` flag is to trigger + * reloads of web clients that are accessing the document via a + * share, if share configuration may have changed. It doesn't + * actually impact access control itself. The sketch of order of + * operations given in the docstring for the GranularAccess + * class is helpful for understanding this flow. + * + * At the time of writing, web client support for special shares + * is not an official feature - but it is super convenient for testing + * and will be important later. + */ + if (shares.getRowIds().length > 0 && + docActions.some( + action => getTableId(action).startsWith('_grist'))) { + // TODO: could actually compare new rules with old rules and + // see if they've changed. Or could exclude some tables that + // could easily change without an impact on share rules, + // such as _grist_Attachments. Either improvement could + // greatly reduce unnecessary web client reloads for shares + // if that becomes an issue. + this._activeBundle.maybeHasShareChanges = true; + } } await this._canApplyCellActions(currentUser, userIsOwner); @@ -517,6 +551,8 @@ export class GranularAccess implements GranularAccessForBundle { _grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column').getTableDataAction(), _grist_ACLResources: this._docData.getMetaTable('_grist_ACLResources').getTableDataAction(), _grist_ACLRules: this._docData.getMetaTable('_grist_ACLRules').getTableDataAction(), + _grist_Shares: this._docData.getMetaTable('_grist_Shares').getTableDataAction(), + // WATCH OUT - Shares may need more tables, check. }); for (const da of docActions) { tmpDocData.receiveAction(da); @@ -534,6 +570,8 @@ export class GranularAccess implements GranularAccessForBundle { throw new ApiError(err.message, 400); } } + + // TODO: any changes needed to this logic for shares? } /** @@ -593,6 +631,11 @@ export class GranularAccess implements GranularAccessForBundle { throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, access rules changed'); } + const linkId = getDocSessionShare(docSession); + if (linkId && this._activeBundle?.maybeHasShareChanges) { + throw new ErrorWithCode('NEED_RELOAD', 'document needs reload, share may have changed'); + } + // Optimize case where there are no rules to enforce. if (!this._ruler.haveRules()) { return docActions; } @@ -1354,7 +1397,15 @@ export class GranularAccess implements GranularAccessForBundle { await this.update(); return; } - if (!this._ruler.haveRules()) { return; } + const shares = this._docData.getMetaTable('_grist_Shares'); + if (shares.getRowIds().length > 0 && + docActions.some(action => getTableId(action).startsWith('_grist'))) { + await this.update(); + return; + } + if (!shares && !this._ruler.haveRules()) { + return; + } // If there is a schema change, redo from scratch for now. if (docActions.some(docAction => isSchemaAction(docAction))) { await this.update(); @@ -1799,6 +1850,20 @@ export class GranularAccess implements GranularAccessForBundle { const attrs = this._getUserAttributes(docSession); access = getDocSessionAccess(docSession); + const linkId = getDocSessionShare(docSession); + let shareRef: number = 0; + if (linkId) { + const rowIds = this._docData.getMetaTable('_grist_Shares').filterRowIds({ + linkId, + }); + if (rowIds.length > 1) { + throw new Error('Share identifier is not unique'); + } + if (rowIds.length === 1) { + shareRef = rowIds[0]; + } + } + if (docSession.forkingAsOwner) { // For granular access purposes, we become an owner. // It is a bit of a bluff, done on the understanding that this session will @@ -1823,6 +1888,7 @@ export class GranularAccess implements GranularAccessForBundle { } const user = new User(); user.Access = access; + user.ShareRef = shareRef || null; const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || fullUser?.id === null; user.UserID = (!isAnonymous && fullUser?.id) || null; @@ -2569,7 +2635,7 @@ export class Ruler { * Update granular access from DocData. */ public async update(docData: DocData) { - await this.ruleCollection.update(docData, {log, compile: compileAclFormula, includeHelperCols: true}); + await this.ruleCollection.update(docData, {log, compile: compileAclFormula, enrichRulesForImplementation: true}); // Also clear the per-docSession cache of rule evaluations. this.clearCache(); @@ -3039,6 +3105,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void { return rec => rec; case '_grist_ACLRules': return rec => rec; + case '_grist_Shares': + return rec => rec; case '_grist_Cells': return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', ''); default: @@ -3174,6 +3242,7 @@ export class User implements UserInfo { public Email: string | null = null; public SessionID: string | null = null; public UserRef: string | null = null; + public ShareRef: number | null = null; [attribute: string]: any; constructor(_info: Record = {}) { diff --git a/app/server/lib/Sharing.ts b/app/server/lib/Sharing.ts index 77743e63..5f410ab9 100644 --- a/app/server/lib/Sharing.ts +++ b/app/server/lib/Sharing.ts @@ -338,6 +338,14 @@ export class Sharing { const actionSummary = await this._activeDoc.handleTriggers(localActionBundle); + // Opportunistically use actionSummary to see if _grist_Shares was + // changed. + if (actionSummary.tableDeltas._grist_Shares) { + // This is a little risky, since it entangles us with home db + // availability. But we aren't doing a lot...? + await this._activeDoc.syncShares(makeExceptionalDocSession('system')); + } + await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession); // Broadcast the action to connected browsers. diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index d853adcc..0b57a95d 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); @@ -15,9 +15,9 @@ CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tab CREATE TABLE IF NOT EXISTS "_grist_TableViews" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999); -CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999); +CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999, "shareRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT ''); -CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); +CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL, "shareOptions" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); @@ -35,6 +35,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); +CREATE TABLE IF NOT EXISTS "_grist_Shares" (id INTEGER PRIMARY KEY, "linkId" TEXT DEFAULT '', "options" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT ''); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); COMMIT; `; @@ -43,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',41,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); @@ -58,14 +59,14 @@ CREATE TABLE IF NOT EXISTS "_grist_TableViews" (id INTEGER PRIMARY KEY, "tableRe CREATE TABLE IF NOT EXISTS "_grist_TabItems" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "viewRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999); INSERT INTO _grist_TabBar VALUES(1,1,1); -CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999); -INSERT INTO _grist_Pages VALUES(1,1,0,1); +CREATE TABLE IF NOT EXISTS "_grist_Pages" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "indentation" INTEGER DEFAULT 0, "pagePos" NUMERIC DEFAULT 1e999, "shareRef" INTEGER DEFAULT 0); +INSERT INTO _grist_Pages VALUES(1,1,0,1,0); CREATE TABLE IF NOT EXISTS "_grist_Views" (id INTEGER PRIMARY KEY, "name" TEXT DEFAULT '', "type" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT ''); INSERT INTO _grist_Views VALUES(1,'Table1','raw_data',''); -CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); -INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL); -INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL); -INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL); +CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL, "shareOptions" TEXT DEFAULT ''); +INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL,''); +INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL,''); +INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL,''); CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL); @@ -92,6 +93,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); +CREATE TABLE IF NOT EXISTS "_grist_Shares" (id INTEGER PRIMARY KEY, "linkId" TEXT DEFAULT '', "options" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); COMMIT; diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index b971e0e5..b6d7c42c 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1289,3 +1289,21 @@ def migration40(tdset): new_view_section_id += 1 return tdset.apply_doc_actions(doc_actions) + +@migration(schema_version=41) +def migration41(tdset): + """ + Add a table for tracking special shares. + """ + doc_actions = [ + actions.AddTable("_grist_Shares", [ + schema.make_column("linkId", "Text"), + schema.make_column("options", "Text"), + schema.make_column("label", "Text"), + schema.make_column("description", "Text"), + ]), + add_column('_grist_Pages', 'shareRef', 'Ref:_grist_Shares'), + add_column('_grist_Views_section', 'shareOptions', 'Text'), + ] + + return tdset.apply_doc_actions(doc_actions) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 0e4ecdde..8126e76b 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 40 +SCHEMA_VERSION = 41 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -163,6 +163,7 @@ def schema_create_actions(): make_column("viewRef", "Ref:_grist_Views"), make_column("indentation", "Int"), make_column("pagePos", "PositionNumber"), + make_column("shareRef", "Ref:_grist_Shares"), ]), # All user views. @@ -199,6 +200,7 @@ def schema_create_actions(): make_column("embedId", "Text"), # Points to formula columns that hold conditional formatting rules for this view section. make_column("rules", "RefList:_grist_Tables_column"), + make_column("shareOptions", "Text"), ]), # The fields of a view section. actions.AddTable("_grist_Views_section_field", [ @@ -350,6 +352,14 @@ def schema_create_actions(): make_column("content", "Text"), make_column("userRef", "Text"), ]), + + actions.AddTable('_grist_Shares', [ + make_column('linkId', 'Text'), # Used to match records in home db without + # necessarily trusting the document much. + make_column('options', 'Text'), + make_column('label', 'Text'), + make_column('description', 'Text'), + ]), ] diff --git a/sandbox/grist/test_completion.py b/sandbox/grist/test_completion.py index 21e86a89..5455c345 100644 --- a/sandbox/grist/test_completion.py +++ b/sandbox/grist/test_completion.py @@ -18,7 +18,8 @@ class TestCompletion(test_engine.EngineTestCase): 'Email': 'foo@example.com', 'Access': 'owners', 'SessionID': 'u1', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } def setUp(self): @@ -100,9 +101,10 @@ class TestCompletion(test_engine.EngineTestCase): ('user.Name', "'Foo'"), ('user.Origin', 'None'), ('user.SessionID', "'u1'"), + ('user.ShareRef', 'None'), ('user.StudentInfo', 'Students[1]'), ('user.UserID', '1'), - ('user.UserRef', "'1'"), + ('user.UserRef', "'1'") ] ) # Should follow user attribute references and autocomplete those types. @@ -133,7 +135,8 @@ class TestCompletion(test_engine.EngineTestCase): 'UserRef': '2', 'Access': 'owners', 'SessionID': 'u2', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } self.assertEqual( self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2), @@ -145,6 +148,7 @@ class TestCompletion(test_engine.EngineTestCase): ('user.Name', "'Bar'"), ('user.Origin', 'None'), ('user.SessionID', "'u2'"), + ('user.ShareRef', 'None'), ('user.UserID', '2'), ('user.UserRef', "'2'"), ] diff --git a/sandbox/grist/test_renames.py b/sandbox/grist/test_renames.py index 5a1f635c..15fc95b4 100644 --- a/sandbox/grist/test_renames.py +++ b/sandbox/grist/test_renames.py @@ -346,7 +346,8 @@ class TestRenames(test_engine.EngineTestCase): 'Email': 'foo@example.com', 'Access': 'owners', 'SessionID': 'u1', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } # Renaming a table should not leave the old name available for auto-complete. diff --git a/sandbox/grist/test_trigger_formulas.py b/sandbox/grist/test_trigger_formulas.py index e1574357..9d1a3303 100644 --- a/sandbox/grist/test_trigger_formulas.py +++ b/sandbox/grist/test_trigger_formulas.py @@ -572,7 +572,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase): 'Email': 'foo.bar@getgrist.com', 'Access': 'owners', 'SessionID': 'u1', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } user2 = { 'Name': 'Baz Qux', @@ -584,7 +585,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase): 'Email': 'baz.qux@getgrist.com', 'Access': 'owners', 'SessionID': 'u2', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } # Use formula to store last modified by data (user name and email). Check that it works as expected. self.load_sample(self.sample) diff --git a/sandbox/grist/test_user.py b/sandbox/grist/test_user.py index 6dbc20fb..87786ea2 100644 --- a/sandbox/grist/test_user.py +++ b/sandbox/grist/test_user.py @@ -22,7 +22,8 @@ class TestUser(test_engine.EngineTestCase): 'Origin': 'https://getgrist.com', 'StudentInfo': ['Students', 1], 'SessionID': 'u1', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } u = User(data, self.engine.tables) self.assertEqual(u.Name, 'Foo Bar') @@ -51,7 +52,8 @@ class TestUser(test_engine.EngineTestCase): 'Origin': 'https://getgrist.com', 'StudentInfo': ['Students', 1], 'SessionID': 'u1', - 'IsLoggedIn': True + 'IsLoggedIn': True, + 'ShareRef': None } u = User(data, self.engine.tables, is_sample=True) self.assertEqual(u.StudentInfo.id, 0) diff --git a/sandbox/grist/user.py b/sandbox/grist/user.py index f6d3f37d..7a464921 100644 --- a/sandbox/grist/user.py +++ b/sandbox/grist/user.py @@ -20,6 +20,7 @@ the following fields: - LinkKey: dictionary - SessionID: string or None - IsLoggedIn: boolean + - ShareRef: integer or None Additional keys may be included, which may have a value that is either None or of type tuple with the following shape: @@ -44,7 +45,7 @@ class User(object): """ def __init__(self, data, tables, is_sample=False): for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin', 'SessionID', - 'IsLoggedIn', 'UserRef'): + 'IsLoggedIn', 'UserRef', 'ShareRef'): setattr(self, attr, data[attr]) self.LinkKey = LinkKey(data['LinkKey']) diff --git a/test/fixtures/docs/Covid-19.grist b/test/fixtures/docs/Covid-19.grist index 3b737cd3a58fad6bfde64f8a2087f2ab8949f979..555cb35d8838109077acb3f9e474f6e75222d7ba 100644 GIT binary patch delta 582 zcmZoT5YX@-V1l$DF9QRkGXnxRP1G@F)IX4sfAjh|6&^Kbq~>lsxx-`lU@AUB_Z zU!MOU?_nMzE(Pw#9Md?DvL9y4WsPNe%P^n$-DE+5YOWS=P4nB$v z=YCF2j&k<1Os#C*{4RW*EUy_{c^~sUX1qJmQI@M&RGM8}Tbr>pal5cAV=1Eq(D>Zc z+V!MhH9~kYicq!rB3fvW7_Bjw4xw0FE2GkNx{kA-^bO_5AJ>g%_ehQ zc5zizrWXB@#H5_m`1GR8;*xmh)SR4RWHzJo!oCQfJ)mpw7g{y#0>?6D#v}KP{$u#%(G~ixY`W3+YETx40ziN_}UEk+YAKS3zPDe)L2e%d zzdZjz-ordbTngNeIi_(QWk1Z8%Non{mSG?ByUBtAv$$Gxc-h5GO&Qy(Hy1K-GELva z#3;S}4HKgTuM$5aD+8AsXC=o?4tw^MY~R^@8Ch9Q^SkhMGH3I0@+5O715LQUy;X>j zg@>!DR+?R0Tbr@faJ#<%V<}^MhaBVf4mqYeMup!D{1JSOyb(MR+-F%BxO|y18Gdto z<4|C?XKQAC#u^7S&5csOH=>2yVHCh6${>P-C8Ej5_Lz^v`B)tJ^YZI{qy zieucKpv0ug$Z4s?F0QG`*rYo>T!Crh_GV=!0p{&9G@0rdx0mWLbu&-DE6BK_&48)R zfVs_prOkk~&48`VfW6Itqs@S`&48=TfV<6rr_F%3&491XfWOT^pv^$A%|K|Gf$#?a Dq4!|) diff --git a/test/fixtures/docs/Favorite_Films_With_Linked_Ref.grist b/test/fixtures/docs/Favorite_Films_With_Linked_Ref.grist index f08e57125b83a51a2140cda396fd066f64ab755a..48f425511f69c2f5040332108b2ac8fa7b6d0634 100644 GIT binary patch delta 126 zcmZp8A>8mnc!IPb69WUIEdv5DP1G@FWn$1vX4sfgDbA>}`JT9j0Gl%dgEP~_$$|pr zTrJ-0?Bb@Tj2)Jnb2L<#8mnc!IPbBLf4YEdv5DPSi1GWn|E^zqv7`Qk+qF^F4750k$Lth9stmlLZCB nxLS1B*~Lvw8QZHj`)a5#HBZoLpP=xJyqGo)zulB0!tE;a#G{di!zH#;)6lf7Nc-%Cl{EAvVtWhZ_JQY%*o8l u_DoSy2yu-FQE+i}a}4zfQBYS0+MN$^?qs&rx|2(^O delta 167 zcmZp8!QAkGd4jZ{I0FNN4G_bC^h6zFM)8dawc?B#o9~Hhm~dTWU=Cxr$jHnjH(8M7 zAXh6BKfAc8DPyPl=G{J3Oq)af*F*?2Ze(Bq8pG_zB+p>OD8kUg&@yqN#^#unrHoua zlk>ziH5r?9C#S7U*?e)8J|jz`zC8P6{VCHXtF08@{9l`qWzz;08xAHWdj{qTCi~5T NEXGWm?cW__1OO3xD}4X} diff --git a/test/fixtures/docs/Landlord.grist b/test/fixtures/docs/Landlord.grist index ee42397dd9df65a0c98324195f8467a88129f2d4..444a7db11d92954a3f8b81e7429ab885ca68f62b 100644 GIT binary patch delta 17490 zcmbuG2YeM(-uLIsOmb6aCXf&k+6_e@A)Ve*Xi`H8y@uq1p(HU0MOp|KRuNS2ss|JW z1QgU&@dAU`!1AoP7O)^H7SvT-LBVBRM3ndUn;YS}<$0g?eXf4acjnBQbIzRae@cck zD-PtZIG4A+MTc-j(OWCmez928udONq%VEBxur}%8N?Mr`f>l%7#{xaHpS2&g^V%8h zYwdIGW9@|Yj`pT@MBAsmti7Ne#6AUkKK@88>C^&+v#vvu))sXqw82t`1SIX6BT0%! z(xw@b7Hm4c36fY7i4lz?G7L#rC=!>>rZw$6)8+msJyH`#e9c{z;-v3p*SyFf*sQff z&$YJxE3QvW(cb?_d^??$sN#D?Jtw|b4S!PDDG|}IJk%B6v7_+z@eOSKmTz>Lu9Yqv zE0dR7ZqeDF+{zNoqP5!;mo`;sPiZe{A8CuU4chnGRDXIQ8rlBq+iCgljOKa-pVOwozoVG*JW5QfYD{dIHsM=Fm>!}Tk*WOi( z1Q9mcQ&m0J6K)8Xk`RspBRr+poS>LGGPuXk%Cf3?9-TwwY9qp$grvI^FiezqDoZQN zDm=9zEzI!7!i}{ha;Uwrgi;8Lm^gHH{>E+)zAT{w*hr(D;pqKYbZss zqBoNIrp|~d0~BR|w5~VeeHmeGBUTM}x#GKZ6Sws8WwgBHZ%8#oXcu?en)!33S#LFO zS?;MVtEs52tEj4M>n<5IzQjFn(BQl=BTC#IJNnkAbrDg%Q+?z7Z>MF2TOrC|Mg2l; zCKf0svDhDRk{UwIiodL1sSp;6`abMGK{i`p^%5pmZO;O0OUQOTDr~zxFf2+{&MDeh z<($ye*4X{Ch*f1~pnny<7Yb8F9QH|+soxo4ezWLYa986*XW!F(AGtGEZ*PexMLVh4 z+U=T0>#rrK7uAo{aPfsWAnp=1qEMux*GXYt)+e=SqKL4B79u>Fzp${d=;)9dPgzw> zx#OU&w%ncQ6iEm!5*{cLP*UC|p@|Y6mhih0fdT>Js=RYEr%Xz4naDty#&C5vhHH8IIHzb*aM92}(QENr-r$cI^`mGKIQhPV zCB3u{s=j z66Mj?j1d3A(ftHZJv9(R6w`LfccQs3X6h|ZW#|xHu7{~2MG-0LT0gEoB4Xu~rmpzR zOtH%2e`{)n=zs{UtC;5*=$Y%O^OU!B56mkWR5EnrpsT?*YRrfczFR9>ikAL|E2jv* zenX{-c&_k&G`lm_s(;a%qO+mmvZ6hzjZv?tyVYWG8I7G5{$Cbo!vEELA)M=wDyAq} zl9(dKVCyj9@3HuZ@I5x~(>s^yv5nWH+XI(F(N%dy>5Ic|FT>gx7iDN`3wH9k`3Ij z9@ky%j;N<@(e(_W{mh0pqTv!PMbp%is$acTEmeD|O~hZtL9td$6v@iZ%G=5gWxg_G zLtaz4z22B&4_ItGC+yM%#=Qdf5KT6te4*a0;D27jaqay>d{UBFQ|N2GzLPPnZeI1Y ztFh%fHLt(#w)J~t(|Rk`_v@{p{_z`jsdrZER;Y+mw42oL)un2L@Zoe$3fn8*l#A9H zBchgu#wxBD;c|70ZYmqr8odX0pE5~BOY?cBDz8{E_J@-3d!Jj8qJ|hHSp(divNAHe zrDkNOW@L6ARyxm9>mD$-v}Vo(Pwo7W#+o{yrsJ!Oti-I})uh~zGk#*5VM?anIzy(f zHrhm0>)a#UZNL7so zE#IgGwY=_TBTLPkBHy^#h!?$NRl3pKLYW4a+Su4|G&X6qF;ML~MLxdTXf8U-Ke0Eu z2rBRc3uI4`8&(-{nyTi=^~FYliF$bA_EA4Ts<-wf2p%T1 z-7!dZSx6q@S+^4*s_lqEvON;XgM|@Dx8W&B+lrfzw#9|y0X%SM_haO=&3vG^7vZIC z64BajMOAPFoz&f;P93GZu3f~iV%u(YeLRx2@hK@{%@BX=`eYFt)cwTv-46&pRE_if zamzSd^G;w{kJt8VbG5eW2{m3E!zngMxuQI+)G8Rf#@`^lAzH{A$D6tS16z*^IeM}g zD<52G4hactY4BMYs8(+#53Dl>^GPl)up#XOBR2xWpvMN*&z&-ILoh&kQ(x-V!B6ct zrM1a3RUHp2bF0cq=T57|qe@;Rx|kqpZz!$tl;=IQD>N$V6-86jiOMU=d!hj6pg+Rf zq^*kwxq6cS>%BF?g)8tp&%7#GSZ_7;PujmbI$BpAQM3+fKd}}IGN#B$`P$h z{Yc1|uEzbic(;mZ7}L=e-?_6`>5*?Pvyw!2d48F7$6)lOx}vg@H-h|vf)RuAMqRxR zWOQEHBiR+-p@X_Ay{>e|T+g&wH5IjW(*}6v&aDkz))Uz=o%y&rwen5xA0fNr>Lf=l zw_1kOR4p7@F3&Hwnnl-CRn=W>$0t&os*PLpx|^y!PMNl4Rh4xZ*ME5+LPzJyOnRw3%OZV$hm$e7ObcSvp zzy9tOP4xBaCxCu_{Up%euYU&Q`1LP=T)%!A$n)!GfPBAx4jACqF8~Am`bA)nU%w0t z-lq#-h^;%k-qypoKGfEu$YHi_k;830mK9r-jW<;>u$2Z*4vY#ZM_p&XzN|b zA{+NKu-MkSk|nm@lN@8~eaW%5o=c9i^?~GgTfd&1VCy5uiMBqPoMh`Ida9pH5D*^>T8$t=~YF+WK5_hOJkVWwu^NmfQLw(qrpO$(gpkf}CaRtH>K{{T8yq z*4L4yME&w*DkpYwLT-I$J+L z&bRev$pyB4h+JsvFO!RG{Z;ZNTYsHgZ0kqKCAR)9xzyHAkjrfSB)QzyKO^gH{Y!F% zt)C_vZ2b(m($>$BUR%FFuCn!u35v&`-V!{p1no zCtrhp^3Tvuz7GB58_-X_3H{_-&`%zPe)1UflW#*m`404x??ONM9`uvPp`Uyo`pFZ} zPksRXVNKz2k3kev_$WM_l`nT!x1QxF1V7lZ(riVz^v z5CUX6LV(Ob2#}cw0Wu3AKxQKZ$gT(hvKvBx?2Zs1dmseJo(KW57eavSjSwLFAOy(1 z2m!JmLV)a#5Fm390%R^CfXqVxkooXW4uF1gAoP=ipr6G3KLi*8{p9t~PY#8Cav1cJ z!=axX0sZ7i=qE=(KUo0%ezFw$$r;d3mO(#R4*jGD`bpmZ$yv}(-T?h%1@x1% zp`V-s{p4KeC+9&wSqc4Q74(zU&`;h7{bUXFleN%K) zxfuG%CD2bUg?@4w^pne>pR9*|as~904bV@ngnrTs{p2d>Cs#v1iTi&ja5MCiw?IF+ z2Kvccp`Tm}{p336CvSs(@^p`Y9c z{p2R-C+~%R@;>M%H$y*pKlGCiKtH(!`pK=(Pi}*L@$DyBmANt7?&`*8<{p5$xPksdbt}8C*zR&efpq>0w&M$YR+L`69w*xnSW}bq3 z@^iQ+zkqx4OSmWh0{0|u{^ZwiPo9Q*@*B7({|fiyw{TCMfqU{S+>_tIJ^4M{ljq=` zJP-He4{%TZ4erSca8Ldn?#UnFp8N;glNaHh{0Z*KOK?yA4EN+^xF>&sd-7MfC$GT0 z3pamNfqZ8{z`nDfLcg=1!9UCD@K1)oKZ%=v7~q0`G7SF7aQG)9;Gc|ye=-XG$!Pc| z4frQb_$Mv+Cu88BYy$seQ}`!i;h&6yf3g|;lkxCRHiv&Q0shHE_$OPyKiLweKiLYW zKiL{_K(@i@Pr7mXlSv2xvMoY@Y=;ma+am210<$Ls2mvx1AwYIT2$0^pnG&pBxVTs+F=qD#bKRE^Z$*Is!PJ@1OI`osJ&`-{QezFYu$#Uo?JNspS%V7$u-bV-U|KXTIeU&K|gsL^pm$kKY0iAlk1_M+yMRLozPGEpr5=8`pLVY zpS%b9NeTVrM(8IuK|gsf^pp2NKe-wD$@`(7d;t2%EznPHg?@4y^pg)lKe-+H$sN#7 z?u34lH-GYB=qGnUKe-$F$w#1{d=&c0$Dp75BlMGxLqGWh^pj6QKe-3`$)}*7^g}5ce)1LQCvo$S1pWm5Ho%|Tu$xoo2{1n>B&!C+= z1?}YL&`y2-)1cW;aDvUb|8kDoF4&`JBl#{sMM*%JEWE-d_-B3>^L48!y$lqq6$eR~NEfT%=FT%EvpYX3j9iX1&c)uq*LOt0D>dDSf zPbNb>nF94>7pNywp`J{GdNLjA$qc9`GohZ$f_gF=>dCH9Pj-WPvOCn1J)oZK3H4+z zobhCDgaFwGAwc#;2$1~{0%U)L0GWdjAafA`WF7*5%!hw+0Q8dsp`RQC{Uq-9(LYCq zw8xMPh4N@*4TExWIFyqkpqv~D<>V+RCkvpQ91Z1UA(WFvP)-&@IavbbU=ePF6rUIUCB!IZ#f{g>rHpl#`WEPF6uVSqr{f>{wQsfK+F|W6O=>H( zL0Y<&poObHso$s{s7KVNF=JwbxY~>zG{k^h$%D|#n{yPi6bH`8viw~&mFPV*mBjC?4wTh{8;bs-(??ivddzsLn-`Q*p=>q0xj`8>C|e9~PEf`yqHXGi)K^bMry@t0> zP{s^pli}^{WYh<`R;p|?yuE^oOjo4g?RhODZjthm;q4KWu~2!@@OBT%SfCs>yxoE_ z<|~H`Z`YuVI^|izn;n!r^$pV?J+w~i%{>xT)0glQQ3x!px=_~;^n7FMx1Qh z(fCCE)WNu{E^aFul8xr_x^~8g+7fr;_7-i8Pnrj6?2pNPnxSq~TZ;!p7iFI_yY4#K zB;EK<8fnH4(n>MT$?#<32a7xCY6nc*Lf`LGJNT2r;#7HSwz0r&nPqrHYZi!91_h=@ z{h~gs7U0C*CWb2)f@dTpi){INcf%bu7_$ZfQ){vYaF$ZCNR{dNMhm$<-w2UQS{Tus zwv;TF_A!#8@+;=fbr;uFReCZ9a3WK(NRZ3=7%803lq}M@?2Z~yQCoVo5au{_7X4&K zZzGjcosvZfn@#16r(}^C)Y9M{MmLe+)F3zaK&zMzl`ICz_j;gu%!=wHa#)L7R<$;w z8V@o@(H_TdeFL618%WgSn0QylZmkcn*iLQjZ<-pPf73J=cRW-Gqth|z#IK?IK z@7_YuF`ZLRz*vbUR}aLJ+xnxjxT_f<@=SjtDrJoMyWMm>UNlWfipd?l-U*%1;cK-O zOB=ptg}v%`!exw*iIl)DR1TcVQ@SyD02oi}%`r!p&FjvVE* z;uPF8%7~I1h9M(^1vBK?VMZGI)N;7dMV-)Eem@LT<7Nyu;$RthZn<$dc6eww3grYc zu4JL9^Fxu5Dc!>`+*=2uwoJKnEJ}Ynm;uI&c*l{vz6`sM9)gU-K*oJTjOa$+#h59i zjn_idC)LSnsCZnID8DEVE5-2LO)W~7Vw}-b&KYOKp!Q>9F^F5n8JURZW8;i;SvdB$ zR=dcNMb7e)St#>eGJ3F!83W}zMMjZaQf#!BLyM6+4of(SrNu@Gj_z17YPnSCWMR3j zT$F~c?i^)wkZlT#cJk~f?DF#{XS=(=NNenLH=c>()L+z{>IiW`+$(aGuaw)AZX5FA z<%OweHbEYnX0&yJ^S4uLr($(?ZWkHeAOg=f$ zXo5(6X`+#yGPWC|OhIsBf<_o7fW(XVlr}NB!`)7lg}AO8yQ*3laC<*QS(`~X4M$Ew zg>hVNEAO3z!`a1*OeZ6HSZSrlJ)(4uCo6w^R-iG9jim<4FYahWt3{d4s5e0)Lw{?; z9cTasQstGg#&u}Gv2p#lt7q;|p1H;9W%VJoP+S%}#7N~I$^*(!m`stEW@4WVnKa8t zlcQ%j{eh`8wFGBv4Gh093x?z6u~`^}G5LYKmNSu;$h>yuFi&OWO~0M!WAj*`nVB2t zofe;V`Qz3rdN?JeEHw-f>mv&$8O@qvwjF<3bi1Oze!ZgiJwL5zMt$u%2VFe_=j~|Bb<;{P zfpwQ!Bz_SOiy}Ytv6 z<444B!`XT0Shlll1Yh1uM#w`IsBrddR9F%yJLfu7Qas#g$t(;!afLQ%9k>u3osGax zkU(2GWDZ*!8fa~4h0~g|;mHcaP>Zi`tif^)J=AQiZ|k}u+u2oTALRZDTmVX#(OxcX zgV8D(9583oTsm#HlV@fcQO;acm)MNyd5Dmo)g9_6oHm>NNnw8wvalB6&`B<>MQH81 z5zC#qoMaVM&G6LJxd+r#)z)U_myB_$Zi4G;$>>0bnxq5{=~yj;rXYA0;!4>FE7`qdliz%G^jjEVi>Rb_chKDqfVxiFyXLsj? z*f&)cEksS3@+M{ul-rouPrkO$NKMVjY&=R1nSnWm+FRNdty&wRxpDVCirZF|nx{4u z-*V0}OwSVX>`GMDNyd6{B%S3TFZ!M-YrUvCR^G?8M0vz(q=lzv*YNjHw6WW$$_nNTQrwZ)Ak`$Q%&ZfB!n;iYe~fzuBnj zfVX_rhmDgGudzl>FfX;kYpCm&nFmzPJbk~aa@72J75(KgpOqqeZnPHLPu^t~YvGt2 zT8?M^$H)4v%x~%IGCxMX_JG-0j{eommTziSQ`vuuInY_P+iWtwRK*9fbf+0F-J4Ba zR{zNemGwK!4tEz=J!NNM#n`^hW;I@L$tw?-UF?jl<~w}LHD{;UPskGyR&#s8PV*5} zoRseyRvWwBF7q=@{}?lt=2@ z4V^69&m;blM|?(Z`^Ib|<6krl+2$JzQ2*2BE1Gr|Pf4FXibJ^hQM0W)bjIvxSDi8M z!fQM^?FlnoR-H3la@0Aq6O&jDZ6_Ov2k-af`Ki_-*{a%VWpBD*R^W}>IeGH$CjPzg zqInE!=Y7YPt6Q%UIS+ewJdnrTLJ#$^O)^+6h(r zQEpSM!Oq_H*Ot`+JO0CWY;{IZNrVLEoog3mp0LIRN?vSkMIh@Zc|OLvB%j!a5dGIC z>vM<}Bmo;4kZ9wEa zhhF|78(uUI%bdezOS>f5dQ3>}CP$@PdsXdMc|Oeghjf=&pUbUB%^&QyvaD?Ebj5Dy zWi3>>Az2o@3J-5|x4Pq@T)ML?zis!lMrZ<)kK0=dZTCp)fRKNG!;H6w^|zkJ-|luy zSL-X`)M}&FNFlKyMb$NXPl;8i$&5>8e|hK!a~@}D>-Olc%;CaDZ0=R17G-sixk=W^ zpgtiIQ-lb$3#VHvoh~_jy;s#;GVg11qK&xR$@iy)eXY-hjhJku;w|d=p4NuOK0JBK z3>R|6G|MYBw^d}1uD1T{RP=?2#6`%wAn*Gcqx01$tEI3rW?JtEsdcjUT?3)}#nLXSDHI zp9$lkjh2qT;c>ObUlH0ttxikByQA&ul>g17_uSB&ftVT}E??MSIXyVaj zW=?3oz_!c;Gk08jXSqjKc6MfYdFG6=vaGUR-FkG-&d$inF6~<0tE{ZNYj#Gj>~1~E zyZ4%z*`r&JjP9Nu-DdPE^>oefbnBVjEhD>hCf*1&_Bt?_&haMM+HK|;ArIeS<}`Ng z_MI4-)H}>OeJR`X0(tlJLhmQ7w1!x$BSd}hHzBB_+;|M1gdmpdeC9ge$p;6^F6+#7 z<9VO$q?}N+Z?t3D)7pdDty+yXLmRGTYc28j(^>Un^$m4Dp1$u;7pb$S%8bGXk%NT8vz7cyEkQ z>aXsSudI#pRtMKTa(bZd7vj8Czst`J53J0mEgKv@y%Ek#D-3)zI+c7ev4#(Jj* zXSZG9yXJ0^I4El^C^5a%rk%FnsfnE!`ZZ?RLa^VT#W=f^;PL7cZJxcJ{1i$53F z--~aLt{!F1`4xdJFUNXE2iNmiAbTuNMP0m$51-Wc)urNHJl?)6e6Mfb}U)sRwA!X%r?wGGa0PF2_T=*1#EnBWU7rGoEAD>C^CX&H)h+y^Fr7A>4m#a;j{jQso#S!_;olwCe?Kt)_AuGSOngoEKRH$%-?Ie`$u&RMmadt) zrpsBn)-|X1zt5}xzli*IJ^y!kPOaA@{YU@*ya8XwH3rl_Z@Idm(eSBFR*zwhSCgA! zm^iI=@qnV6B5^SBj3=aG<=HppJnO|96H7P`9-iJTsKl{ndkTX_WapCw3QK=|G zB1>I(nsuH8c{0oloCm$RIiHCW5h+}1%Eal66R}R~PBqRrJMj|BBCIv&9CCu(VxCi# zvqu-XVU5)^(NX1OIKhX{DjUy~lr+dX(UeYVz$4;3Ot5iz**y2Qx)C+CGJ_&`$}%=q6v)aB;M>i3Lh1^>%u!};MD zxl+6a3cOm#yY^}!^k}bf@4vQ49@uE5%Xjaz@}lsGt5REs?~%)S@_dcgp@;s6KaQQZ k3`d2fx67O5nZ!bY>y&fWI#&wks^BCgS1;2Qs@~%N0Nu(5n*aa+ delta 23917 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
nADO6U?0ZU&$2jRDnr+j# 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/docs/SchoolsSample.grist b/test/fixtures/docs/SchoolsSample.grist index 2cd8078e4715ace4ba0600097fb28f4510f4d7f8..04a23380eee68c5a266f46d335b3c3d267b74a59 100644 GIT binary patch delta 3692 zcmaKv2~?EV702Jb?}HgehDC4&29aR^0oh$K#)J`uieYghTr5j z_*MQfzr@e;)BNxJ9C=PXdj2a+jkA->ASWTIKOrWPVDC$a3?+nE34P22v;H-|907^q zf;}Oe-=eY4Gd_}^KqouTZ&A2+j-J1P;{BLGHswtprurgWWq2K~Vjn$Ry8xDz5NNU| zC&MbYaJ%9lNL0CwnbMuE=BmY0WP33>AyrR~4};D`EfDxM#;;Mok9c?}m{?4Z*$k7a z=QTCew`j)_HO-7HkFh-5LcX;gJ9u~>ZiYyoe2k~n>&Lw zO8_fmco}W5NQ7kP%It}l3VmrMQL=d=wnMnT89WKsKo~V0^2j7Cm$phADYr~QoAg%V zAHk}E8oAT(l%_+j7S@MdXM75~4jc#2axR9ht`5DkoLXPi?0T*+( z9oWmPY1X7(l5=ZfFNvtHnb3w74jKcE7vK}LlAEEcFwA6kyJ1b}-RwKW*r^V%^<>{F zJ5E5L*f90GpUwWd%4~@CcR5{o7@|~5-!!eod@Masi|A&!k9D(9j&A_7j~WH5!o`P= z^%n!%><}yBmnVwZZLMNlT`ct1=hbbemuE(V$)1w~tGyz+HWeKHz!kMO2Ga)c)i!tw zy!Bq6x5nixaF=_^i%LBIv)5BqO$g)96WN0zxZbHAnO?g27>J>pgs^QqJ)JBMR1-$e zAlyHNFsh7jUlCzsJ|TBBVZ;c+@NB}cL4=`+gdqb6gQEyJs(54v6S4x;h#5SN$bO{< z*V}m8wjtDhYA|7I4ne3Womqq}nS_oE!lrb>MiqHOD&dI~!s7!8Ym*3T)MzVRgmx!k zg$mxL245aacudu>r7?uoXu=XTp~b4gEmBo!VZSz_PnEcq2tu=(>Vj~>{4he3s(6hd zga%c*9#w0ovk+>72y<0Un`0t)0|_;1ZPls*&*p@DB#Z$?&J&1iFE@AtPr&I^C(gin zD4;jxxbB=E$YGC6&&ER4cehy3QUtriOD%;FSjsr~7-x8tzIN~^JWNk$jYjuB5C@lK zfs6KpU0hw(p|wA3H;LGJ9&pmF4i%_ctaxlnP&`e=UwXyS<)tv7vu*Ufjdb8?&1crN z72leU1#4PJ02|8qL3|r7!B94o#&Mb3AM5WgII?04X2C$Ux#XTP_<|`d#$+Ec0_4pj#!ld|Lr(x@P{?bik|~GizE{AluD&Jzl~xcpcK{`Gspw+Pt< zSR~i$8@A?xDBITA6LvG>J{r9UPC+8uO#VxC$Bru~FU-b`Jub9`>hXOX1$)5EeB|k(4Xv z;&M6GiyQUxze@n!^;|lL_`@ANp?5Lfgzu97NyuWmXz+FV+ee3^Q9cJV@8;|{FIb&yJJNGrIkS1E429|Lk3t)r`-e>^}(4K?GY(F7x%h#|MF^IJrfv z?JH_um%8f@g*@iYx?|LHx`tkwV=g<$R7GM;&v+vi^!=~Ag*^jrQrUS22g4!g$2QS4 zTa9Urco5{U2Hc^)^JZ=Uxb=N!r-A7Bi$^q_>kzN})u$gi{jfD4kK%a@M>JePz57Bm z1QwQfCN7K-IXfan+fIuyIu>3h%cW?5Ul@*fzuOfOV+}`=Uv!fwG90NS#P!{p{`u$q z0LB@P48QdYvOZ)ua{S^ZiDJVs%rCwsQDQho`o(P$rG{fP38Ncv)NAqrtKE#3x(BNjeIm?GRqBE*@GG*NocDnc%QA@-Cx*1>^s@VNXmf=2-EcL zo60&&S*PpWUn?O?&Vz>%2I}|Kx?P!65t_t`*2Ih77pg92Qjh0r}ISPJhguDnhLSR|fpJ7`V ztTN<#FfSBVcl{f-gu;5+`6V>Kx~{KaZ7^(p@o`)d3Qs;8hEXcp+hw}}xyH3tlwh1& zfY}H;49$n%bJ%HUOYoaMAiGj9PLpvd*jFxZg*<7bV~HG`jvj+&;B7SVlk{Ugok>dy zIzmbGTpq363Ud7#$TN&=EaGaoLYbY7W1`ftX@)b5pX5HCiJ#zsI|rd2t{n%%HXOS_ zB)1eMtG7jCrx-iMtMLP%|8VeJPxL!qu+pBI3NoRX&V+P-U7k^lwU+kD@g`+h>6J;v z82^K5shLiT`L&IWUh%!FU+T&ciK^n`>a5-sKi^wlf4AcM+ibrIt*N$nH9J|3E5S(l zOfmKkXl`0uR3p#kqs`jf)Z}wH$CQ;7d)%es$s)TvmyeO$*E(Oum!aKM-PGvwHu?m( zBDdwE);7^Q=O>P=fCQ7>lLPJXwT(62B|XQE+vlsQp4ULVTV~Cvt@jqyQ1i#O%>0QS t`f2AZDlPC-IDc&ap|7*7H2x3ynaFARw!_1O*ip2nq^OK%;0}FeqEQphju;U7$?9 z$wbACm~~=)#vh%ykjc>`=AcKDiJ3%WOx(~p6Ens$6Qh||AIZq%ocZT)`R~`OSMR-g z?^bogI%7klvC-}vObFS7e_hWOe37~Y{15mibF<_?&4i&}dZ1`z?LuK2yU*^ic6OfK zVYk_Jc7a`C7g4k1;KjQ%#$-Sh#fV@JL}&^_2oFK9_CN^mLwMeV;HCb|z6*d5e#sgc zin2tm5|+izykXo&2X|OiWRX(k9hElFTAfMjnd-aDfe=xdBJ!NZj zG_0uO_d*?D;MJ+YJR$5Q?-;alTez~)#D5O^mjCMT*egXzsx*}v42uceB#Wtq`=$=S z{J~kCo9gs74lUA&y`l?)Q9ItVz}2)Z5z%wSwG%U`x; zWx=WvTx1{w>0wMR@dv)HG!cSzXWC0&1&cngxvb8REGi{{?G~9&(JSTcM|FZ#g0=EG z)=fM6n6QG+?Ph=Cg`Wf}f6oOo-+w$mv`kdqd;tY+gQp*n3)7r2A!Y|JdTKxxPYfBy5_ zQQg)yF;vdac1Cw8^YVFh-ONc8HHK&WS0j_?=1!QwL&?kqFK(^sZqpZV#g3vD)AubJ}w?D$m(-D%&5E6?K{xAk1AsZn+6XB(S2z`?fV*4V*I1&0d z5TZr1y%>Pt^hSvC6g@+6Veq55aG!*b?f9Ydp-I+Jqxh2gVGzcT)i+1M1nc0zu+prz z$CO#M6?Lw<`noAu(_JeD1jyE;Bv@tVug!_UwiRV~bIu)z;T^L>!KNB2wu6t@`* z!P7;DP4h)47de{ZgYdLquE|~qWrD~`WrPw>goz@y34$S?VhHIJp+5*@EE17yVH%su zBIzV*fwQmyMv#Z(JvH3r$zZc`WCjs^*Q?9H1dOs$3STFk__m{hb%d%N~?a6-!Xs`1gD56!Dw5E z1yY%deoeV8_8N(tm*?!;*$EJl*}JoC@}LJFa z8f(S;FK8wlz!=x^UN>Cab$*k&=F{6<8uf%#(tC6=ya&Ogp7;86CZG1jPIcqi8`NPU zt>}Y;V7|@~2U}#~5OC19zVe;%|NL$y>h4d?*Zsbc9yf3Q}8Dsf4aXZQb+tP4uo%8kqievvV`24ncKkI+JboAmjyxH z^WTg{ik7UKiQA*`|+zY`Q+<+McM8ka&f=TAxTi>o=>bzK;c-d2K6y>uQq-eLazfj_TYNNrXO5 z4_$e)2?(kZ z_o~#>_Xc=Cn$`kP2E6f?Y^91pBR}!Lr5<=V!Q_#O(*|m`9Jofc4qpfX&q4Xwr#Y}hHAEQUFt2QOwBe! zmtuvKrP(TUDM3iXG}~NVN*2;^%~pew(vk+H%F`}54kI+%d@)`y2rMADHCw3}1Xr|Q z>On%khlp7t6&MQNX%C(gpKc9YZ)F%4hjaIFQS#HR9yB9(acNXHYkZh!6*WzMi_8t9sFvCNr(19t%uLl)kbdv zahm|v`zT4(G*dhFDvTWrRhsWrxZ?}ehwj1){;*I}Bs$v<799$tO@6RUX^zC3 zbd3+J(AFl>MSifdHIoJ(s-zC7M;v*N)MEDaCtE`M%83f3!&D`%nl@`v4ShfrsHBO? z@r5)?i*wN}%pwk1nOX?m|6D8qd1Q|`Husuwm(JGH4gix128K86Z4X!&?8c79bN(>t z$qH!!yvlMOEl1ooyE1b*9SV_3>vCGkuQa7&;~cBE^7~CjKDc?SIOYxF-r|l5ldUN! zl%L!mPf4?WeVMvX;-=P#^0H8Ik*55K#(tDE>O=0vcjdUAvNb&&KQ|>&vQ_`QvS~{5 z0co$a-QbXjozMkvgj^tYTH56haSw`+@uvb+qLA9JTNRtvls`;j-1~q&)u>2PH4)Ypr-?nQ^7;(A8HD=h6Gn92X<%)P30? zeuNkqO(bk8JxLQ)vlK!3$kZ6TL|3Q2h}T*1<0T?(HTdaoC(@p3jzh;=Bm>2~)9^dE@iQPBS!UY85{hpAcVCnOUsBMixE@#C7n?$=3Wr{ca21GImXV-aiTOK2j6U79qB S{>q?5!ykb6yaRcUa6EQBQj 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 delta 4603 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 diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 8311050c..74bc00a3 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-shadow */ import {ActionSummary} from 'app/common/ActionSummary'; import {BulkColValues, UserAction} from 'app/common/DocActions'; +import {SHARE_KEY_PREFIX} from 'app/common/gristUrls'; import {arrayRepeat} from 'app/common/gutil'; import {WebhookSummary} from 'app/common/Triggers'; import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI'; @@ -39,6 +40,7 @@ import * as testUtils from 'test/server/testUtils'; import {waitForIt} from 'test/server/wait'; import defaultsDeep = require('lodash/defaultsDeep'); import pick = require('lodash/pick'); +import { getDatabase } from 'test/testUtils'; const chimpy = configForUser('Chimpy'); const kiwi = configForUser('Kiwi'); @@ -2814,6 +2816,44 @@ function testDocApi() { assert.notEqual(resp.data.id, ''); }); + + it("handles /s/ variants for shares", async function () { + const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id; + const docId = await userApi.newDoc({name: 'BlankTest'}, wid); + // const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`; + const userActions = [ + ['AddRecord', '_grist_Shares', null, { + linkId: 'x', + options: '{"publish": true}' + }], + ['UpdateRecord', '_grist_Views_section', 1, + {shareOptions: '{"publish": true, "form": true}'}], + ['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}], + ]; + let resp: AxiosResponse; + resp = await axios.post(`${serverUrl}/api/docs/${docId}/apply`, userActions, chimpy); + assert.equal(resp.status, 200); + + const db = await getDatabase(); + const shares = await db.connection.query('select * from shares'); + const {key} = shares[0]; + + resp = await axios.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`, chimpy); + assert.equal(resp.status, 200); + + resp = await axios.get(`${serverUrl}/api/s/${key}/tables/Table1/records`, chimpy); + assert.equal(resp.status, 200); + + resp = await axios.get(`${serverUrl}/api/docs/${key}/tables/Table1/records`, chimpy); + assert.equal(resp.status, 404); + + resp = await axios.get(`${serverUrl}/api/docs/${SHARE_KEY_PREFIX}${key}/tables/Table1/records`, chimpy); + assert.equal(resp.status, 200); + + resp = await axios.get(`${serverUrl}/api/s/${key}xxx/tables/Table1/records`, chimpy); + assert.equal(resp.status, 404); + }); + it("document is protected during upload-and-import sequence", async function () { if (!process.env.TEST_REDIS_URL) { this.skip();