(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.<key>` (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
This commit is contained in:
Paul Fitzpatrick 2024-01-03 11:53:20 -05:00
parent f079d4b340
commit 2a206dfcf8
39 changed files with 897 additions and 68 deletions

View File

@ -27,7 +27,7 @@ import * as dispose from 'app/client/lib/dispose';
import * as log from 'app/client/lib/log'; import * as log from 'app/client/lib/log';
import {CommRequest, CommResponse, CommResponseBase, CommResponseError, ValidEvent} from 'app/common/CommTypes'; import {CommRequest, CommResponse, CommResponseBase, CommResponseError, ValidEvent} from 'app/common/CommTypes';
import {UserAction} from 'app/common/DocActions'; 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 {GristServerAPI} from 'app/common/GristServerAPI';
import {getInitialDocAssignment} from 'app/common/urlUtils'; import {getInitialDocAssignment} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone'; 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 * committed to a document that is called in hosted Grist - all other methods
* are called via DocComm. * are called via DocComm.
*/ */
public async openDoc(docName: string, mode?: string, public async openDoc(docName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult> {
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult> { return this._makeRequest(null, docName, 'openDoc', docName, options);
return this._makeRequest(null, docName, 'openDoc', docName, mode, linkParameters);
} }
/** /**

View File

@ -17,7 +17,7 @@ import {menu, menuDivider, menuIcon, menuItem, menuText} from 'app/client/ui2018
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow'; import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay'; 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 {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features'; import {Product} from 'app/common/Features';
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls'; import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
@ -182,8 +182,13 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
if (!urlId) { if (!urlId) {
this._openerHolder.clear(); this._openerHolder.clear();
} else { } else {
FlowRunner.create(this._openerHolder, FlowRunner.create(
(flow: AsyncFlow) => this._openDoc(flow, urlId, urlOpenMode, state.params?.compare, linkParameters) this._openerHolder,
(flow: AsyncFlow) => this._openDoc(flow, urlId, {
openMode: urlOpenMode,
linkParameters,
originalUrlId: state.doc,
}, state.params?.compare)
) )
.resultPromise.catch(err => this._onOpenError(err)); .resultPromise.catch(err => this._onOpenError(err));
} }
@ -325,9 +330,9 @@ It also disables formulas. [{{error}}]", {error: err.message})
this.offerRecovery(err); this.offerRecovery(err);
} }
private async _openDoc(flow: AsyncFlow, urlId: string, urlOpenMode: OpenDocMode | undefined, private async _openDoc(flow: AsyncFlow, urlId: string, options: OpenDocOptions,
comparisonUrlId: string | undefined, comparisonUrlId: string | undefined): Promise<void> {
linkParameters: Record<string, string> | undefined): Promise<void> { const {openMode: urlOpenMode, linkParameters} = options;
console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` + console.log(`DocPageModel _openDoc starting for ${urlId} (mode ${urlOpenMode})` +
(comparisonUrlId ? ` (compare ${comparisonUrlId})` : '')); (comparisonUrlId ? ` (compare ${comparisonUrlId})` : ''));
const gristDocModulePromise = loadGristDoc(); const gristDocModulePromise = loadGristDoc();
@ -383,7 +388,11 @@ It also disables formulas. [{{error}}]", {error: err.message})
comm.useDocConnection(doc.id); comm.useDocConnection(doc.id);
flow.onDispose(() => comm.releaseDocConnection(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) { if (openDocResponse.recoveryMode || openDocResponse.userOverride) {
doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode); doc.isRecoveryMode = Boolean(openDocResponse.recoveryMode);
doc.userOverride = openDocResponse.userOverride || null; doc.userOverride = openDocResponse.userOverride || null;
@ -493,7 +502,6 @@ function buildDocInfo(doc: Document, mode: OpenDocMode | undefined): DocInfo {
const isPreFork = openMode === 'fork'; const isPreFork = openMode === 'fork';
const isTemplate = doc.type === 'template' && (isFork || isPreFork); const isTemplate = doc.type === 'template' && (isFork || isPreFork);
const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork); const isEditable = !isSnapshot && (canEdit(doc.access) || isPreFork);
return { return {
...doc, ...doc,
isFork, isFork,

View File

@ -1,8 +1,10 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions'; import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules';
import {AclRuleProblem} from 'app/common/ActiveDocAPI'; import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData'; import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil'; import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {ShareOptions} from 'app/common/ShareOptions';
import {MetaRowRecord} from 'app/common/TableData'; import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy'); import sortBy = require('lodash/sortBy');
@ -347,7 +349,9 @@ export class ACLRuleCollection {
const names: string[] = []; const names: string[] = [];
for (const rule of this.getUserAttributeRules().values()) { for (const rule of this.getUserAttributeRules().values()) {
const tableRef = tablesTable.findRow('tableId', rule.tableId); 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) { if (!colRef) {
invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`); invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);
names.push(rule.name); names.push(rule.name);
@ -379,11 +383,13 @@ export class ACLRuleCollection {
export interface ReadAclOptions { export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing. log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc; compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
// If true, call addHelperCols to add helper columns of restricted columns to rule sets. // If true, add and modify access rules in some special ways.
// Used in the server for extra filtering, but not in the client, because: // Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,
// 1. They would show in the UI // and use ACLShareRules to implement any special shares as access rules.
// 2. They would be saved back after editing, causing them to accumulate // Used in the server, but not in the client, because of at least the following:
includeHelperCols?: boolean; // 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 // 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 // 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 * 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. * UserAttributeRules. This is used by both client-side code and server-side.
*/ */
function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults { function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getMetaTable('_grist_ACLResources'); // Wrap resources and rules tables so we can have "virtual" rules
const rulesTable = docData.getMetaTable('_grist_ACLRules'); // 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 ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = []; 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. // Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>(); const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) { 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); const resourceRec = resourcesTable.getRecord(resourceId);
if (!resourceRec) { if (!resourceRec) {
throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`); throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);
continue;
} }
if (!resourceRec.tableId || !resourceRec.colIds) { if (!resourceRec.tableId || !resourceRec.colIds) {
// This should only be the case for the old-style default rule/resource, which we // 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 tableId = resourceRec.tableId;
const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(','); const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(',');
if (includeHelperCols && Array.isArray(colIds)) { if (enrichRulesForImplementation && Array.isArray(colIds)) {
colIds.push(...getHelperCols(docData, tableId, colIds, log)); 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) { } else if (rule.aclFormula && !rule.aclFormulaParsed) {
throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`); throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
} else { } 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({ body.push({
origRecord: rule, origRecord: rule,
aclFormula: String(rule.aclFormula), aclFormula: String(rule.aclFormula),

296
app/common/ACLShareRules.ts Normal file
View File

@ -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<T extends keyof SchemaTypes> {
private _extraRecords = new Array<MetaRowRecord<T>>();
private _extraRecordsById = new Map<number, MetaRowRecord<T>>();
private _nextFreeVirtualId: number = -1;
public constructor(private _originalTable: MetaTableData<T>) {}
// 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<T>): number {
if (rec.id !== 0) { throw new Error('Expected a zero ID'); }
const id = this._nextFreeVirtualId;
const recWithCorrectId: MetaRowRecord<T> = {...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<MetaRowRecord<T>>): 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: '',
};
}
}

View File

@ -16,6 +16,37 @@ export const OpenDocMode = StringUnion(
); );
export type OpenDocMode = typeof OpenDocMode.type; 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<string, string>;
}
/** /**
* Represents an entry in the DocList. * 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. * Opens a document, loads it, subscribes to its userAction events, and returns its metadata.
*/ */
openDoc(userDocName: string, openMode?: OpenDocMode, openDoc(userDocName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult>;
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult>;
} }

View File

@ -47,6 +47,8 @@ export interface UserInfo {
UserID: number | null; UserID: number | null;
UserRef: string | null; UserRef: string | null;
SessionID: 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; [attributes: string]: unknown;
toJSON(): {[key: string]: any}; toJSON(): {[key: string]: any};
} }

View File

@ -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';
}

View File

@ -1,7 +1,7 @@
import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI'; import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {OpenDocMode} from 'app/common/DocListAPI'; import {OpenDocMode} from 'app/common/DocListAPI';
import {EngineCode} from 'app/common/DocumentSettings'; 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 {LocalPlugin} from 'app/common/plugin';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {TelemetryLevel} from 'app/common/Telemetry'; import {TelemetryLevel} from 'app/common/Telemetry';
@ -57,6 +57,10 @@ export const DEFAULT_HOME_SUBDOMAIN = 'api';
// as a prefix of the docId. // as a prefix of the docId.
export const MIN_URLID_PREFIX_LENGTH = 12; 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. * 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) * - 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. 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 // But this barely works, and is suitable only for documents. For decoding it
// indicates that the URL probably points to an API endpoint. // 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 // Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the
@ -253,6 +258,13 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.doc) { if (state.doc) {
if (state.api) { if (state.api) {
parts.push(`docs/${encodeURIComponent(state.doc)}`); 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) { } else if (state.slug) {
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`); parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
} else { } else {
@ -366,6 +378,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
map.set('doc', map.get('docs')!); map.set('doc', map.get('docs')!);
} }
// /s/<key> is accepted as another way to write -> /doc/<share-prefix><key>
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 // When the urlId is a prefix of the docId, documents are identified
// as "<urlId>/slug" instead of "doc/<urlId>". We can detect that because // as "<urlId>/slug" instead of "doc/<urlId>". We can detect that because
// the minimum length of a urlId prefix is longer than the maximum length // 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 * The internal structure of a UrlId. There is no internal structure,
* for a fork, in which case the fork has a separate id, and a user id may also be * except in the following cases. The id may be for a fork, in which
* embedded to track ownership. * 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 { export interface UrlIdParts {
trunkId: string; trunkId: string;
forkId?: string; forkId?: string;
forkUserId?: number; forkUserId?: number;
snapshotId?: string; snapshotId?: string;
shareKey?: string;
} }
// Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId // Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId
// or trunkId[....]~v=snapshotId // or trunkId[....]~v=snapshotId
// or <SHARE-KEY-PREFIX>shareKey
export function parseUrlId(urlId: string): UrlIdParts { export function parseUrlId(urlId: string): UrlIdParts {
let snapshotId: string|undefined; let snapshotId: string|undefined;
const parts = urlId.split('~'); const parts = urlId.split('~');
const bareParts = parts.filter(part => !part.includes('=')); const bareParts = parts.filter(part => !part.includes('v='));
for (const part of parts) { for (const part of parts) {
if (part.startsWith('v=')) { if (part.startsWith('v=')) {
snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, '%')); 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 { return {
trunkId: bareParts[0], trunkId: bareParts[0],
forkId: bareParts[1], forkId: bareParts[1],
forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined, forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined,
snapshotId, snapshotId,
shareKey,
}; };
} }
@ -984,7 +1011,7 @@ export interface HashLink {
// a candidate for use in prettier urls. // a candidate for use in prettier urls.
function shouldIncludeSlug(doc: {id: string, urlId: string|null}): boolean { function shouldIncludeSlug(doc: {id: string, urlId: string|null}): boolean {
if (!doc.urlId || doc.urlId.length < MIN_URLID_PREFIX_LENGTH) { return false; } 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, // Convert the name of a document into a slug. Only alphanumerics are retained,

View File

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 40; export const SCHEMA_VERSION = 41;
export const schema = { export const schema = {
@ -92,6 +92,7 @@ export const schema = {
viewRef : "Ref:_grist_Views", viewRef : "Ref:_grist_Views",
indentation : "Int", indentation : "Int",
pagePos : "PositionNumber", pagePos : "PositionNumber",
shareRef : "Ref:_grist_Shares",
}, },
"_grist_Views": { "_grist_Views": {
@ -119,6 +120,7 @@ export const schema = {
linkTargetColRef : "Ref:_grist_Tables_column", linkTargetColRef : "Ref:_grist_Tables_column",
embedId : "Text", embedId : "Text",
rules : "RefList:_grist_Tables_column", rules : "RefList:_grist_Tables_column",
shareOptions : "Text",
}, },
"_grist_Views_section_field": { "_grist_Views_section_field": {
@ -216,6 +218,13 @@ export const schema = {
userRef : "Text", userRef : "Text",
}, },
"_grist_Shares": {
linkId : "Text",
options : "Text",
label : "Text",
description : "Text",
},
}; };
export interface SchemaTypes { export interface SchemaTypes {
@ -304,6 +313,7 @@ export interface SchemaTypes {
viewRef: number; viewRef: number;
indentation: number; indentation: number;
pagePos: number; pagePos: number;
shareRef: number;
}; };
"_grist_Views": { "_grist_Views": {
@ -331,6 +341,7 @@ export interface SchemaTypes {
linkTargetColRef: number; linkTargetColRef: number;
embedId: string; embedId: string;
rules: [GristObjCode.List, ...number[]]|null; rules: [GristObjCode.List, ...number[]]|null;
shareOptions: string;
}; };
"_grist_Views_section_field": { "_grist_Views_section_field": {
@ -428,4 +439,11 @@ export interface SchemaTypes {
userRef: string; userRef: string;
}; };
"_grist_Shares": {
linkId: string;
options: string;
label: string;
description: string;
};
} }

View File

@ -40,6 +40,12 @@ export class Document extends Resource {
// fetching user has on the doc, i.e. 'owners', 'editors', 'viewers' // fetching user has on the doc, i.e. 'owners', 'editors', 'viewers'
public access: Role|null; 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. // Property set for forks, containing access the fetching user has on the trunk.
public trunkAccess?: Role|null; public trunkAccess?: Role|null;

View File

@ -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;
}

View File

@ -3,6 +3,7 @@ import fetch, { RequestInit } from 'node-fetch';
import {AbortController} from 'node-abort-controller'; import {AbortController} from 'node-abort-controller';
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
import { removeTrailingSlash } from 'app/common/gutil'; import { removeTrailingSlash } from 'app/common/gutil';
import { HomeDBManager } from "app/gen-server/lib/HomeDBManager"; import { HomeDBManager } from "app/gen-server/lib/HomeDBManager";
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer'; import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
@ -33,6 +34,13 @@ export class DocApiForwarder {
} }
public addEndpoints(app: express.Application) { 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. // 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 // We do not check whether the document has been soft-deleted; that will be checked by
// the worker if needed. // the worker if needed.

View File

@ -36,6 +36,7 @@ import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/e
import {Pref} from "app/gen-server/entity/Pref"; import {Pref} from "app/gen-server/entity/Pref";
import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-server/entity/Product"; import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-server/entity/Product";
import {Secret} from "app/gen-server/entity/Secret"; 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 {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace"; import {Workspace} from "app/gen-server/entity/Workspace";
import {Limit} from 'app/gen-server/entity/Limit'; 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 // 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 // we look up permissions of trunk if we are on a fork (we'll fix the permissions
// up for the fork immediately afterwards). // 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<Workspace>(
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; const urlId = trunkId;
if (forkId || snapshotId) { key = {...key, urlId}; } if (forkId || snapshotId) { key = {...key, urlId}; }
let doc: Document;
if (urlId === NEW_DOCUMENT_CODE) { if (urlId === NEW_DOCUMENT_CODE) {
if (!forkId) { throw new ApiError('invalid document identifier', 400); } if (!forkId) { throw new ApiError('invalid document identifier', 400); }
// We imagine current user owning trunk if there is no embedded userId, or // We imagine current user owning trunk if there is no embedded userId, or
@ -3022,6 +3054,41 @@ export class HomeDBManager extends EventEmitter {
return limitOrError; 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<Limit|null> { private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
if (accountId === 0) { if (accountId === 0) {
throw new Error(`getLimit: called for not existing account`); 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'); } if (!urlId) { throw new Error('document required'); }
return {urlId, userId, org}; return {urlId, userId, org};
} }
interface ShareInfo {
linkId: string;
options: string;
}

View File

@ -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<void> {
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<void> {
await queryRunner.dropTable('shares');
}
}

View File

@ -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. * Loads an open document from DocStorage. Returns a list of the tables it contains.
*/ */

View File

@ -6,7 +6,7 @@
import * as express from 'express'; import * as express from 'express';
import {ApiError} from 'app/common/ApiError'; 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 {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI'; import {Document as APIDocument} from 'app/common/UserAPI';
@ -105,7 +105,8 @@ export function attachAppEndpoint(options: AttachOptions): void {
const slug = getSlugIfNeeded(doc); const slug = getSlugIfNeeded(doc);
const slugMismatch = (req.params.slug || null) !== (slug || null); const slugMismatch = (req.params.slug || null) !== (slug || null);
const preferredUrlId = doc.urlId || doc.id; 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. // Prepare to redirect to canonical url for document.
// Preserve any query parameters or fragments. // Preserve any query parameters or fragments.
const queryOrFragmentCheck = req.originalUrl.match(/([#?].*)/); 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. // The * is a wildcard in express 4, rather than a regex symbol.
// See https://expressjs.com/en/guide/routing.html // See https://expressjs.com/en/guide/routing.html
app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler); app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler);
app.get('/s/:urlId([^/]+):remainder(*)',
(req, res, next) => {
// /s/<key> is another way of writing /doc/<prefix><key> for shares.
req.params.urlId = SHARE_KEY_PREFIX + req.params.urlId;
req.params.viaShare = "1";
next();
},
...docMiddleware, docHandler);
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
...docMiddleware, docHandler); ...docMiddleware, docHandler);
} }

View File

@ -13,7 +13,7 @@ import {
} from 'app/common/DocActions'; } from 'app/common/DocActions';
import {isRaisedException} from "app/common/gristTypes"; import {isRaisedException} from "app/common/gristTypes";
import {Box, RenderBox, RenderContext} from "app/common/Forms"; 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 {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema"; import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc'; import {SortFunc} from 'app/common/SortFunc';
@ -178,6 +178,12 @@ export class DocWorkerApi {
* to apply to these routes. * to apply to these routes.
*/ */
public addEndpoints() { 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 // check document exists (not soft deleted) and user can view it
const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false)); const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));

View File

@ -8,8 +8,9 @@ import * as path from 'path';
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate'; import {mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
import {BrowserSettings} from 'app/common/BrowserSettings'; 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 {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {parseUrlId} from 'app/common/gristUrls';
import {Invite} from 'app/common/sharing'; import {Invite} from 'app/common/sharing';
import {tbind} from 'app/common/tbind'; import {tbind} from 'app/common/tbind';
import {TelemetryMetadataByLevel} from 'app/common/Telemetry'; import {TelemetryMetadataByLevel} from 'app/common/Telemetry';
@ -298,8 +299,13 @@ export class DocManager extends EventEmitter {
* `doc` - the object with metadata tables. * `doc` - the object with metadata tables.
*/ */
public async openDoc(client: Client, docId: string, public async openDoc(client: Client, docId: string,
openMode: OpenDocMode = 'default', options?: OpenDocOptions): Promise<OpenLocalDocResult> {
linkParameters: Record<string, string> = {}): Promise<OpenLocalDocResult> { 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 auth: Authorizer;
let userId: number | undefined; let userId: number | undefined;
const dbManager = this._homeDbManager; 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 // 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. // 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); log.debug("DocManager.openDoc Authorizer key", key);
const docAuth = await dbManager.getDocAuthCached(key); const docAuth = await dbManager.getDocAuthCached(key);
assertAccess('viewers', docAuth); assertAccess('viewers', docAuth);

View File

@ -166,6 +166,16 @@ export function getDocSessionAccess(docSession: OptDocSession): Role {
throw new Error('getDocSessionAccess could not find access information in DocSession'); 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 { export function getDocSessionAccessOrNull(docSession: OptDocSession): Role|null {
try { try {
return getDocSessionAccess(docSession); return getDocSessionAccess(docSession);

View File

@ -37,8 +37,8 @@ import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData'; import { GristObjCode } from 'app/plugin/GristData';
import { compileAclFormula } from 'app/server/lib/ACLFormula'; import { compileAclFormula } from 'app/server/lib/ACLFormula';
import { DocClients } from 'app/server/lib/DocClients'; import { DocClients } from 'app/server/lib/DocClients';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionUser, import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,
OptDocSession } from 'app/server/lib/DocSession'; getDocSessionUser, OptDocSession } from 'app/server/lib/DocSession';
import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage'; import { DocStorage, REMOVE_UNUSED_ATTACHMENTS_DELAY } from 'app/server/lib/DocStorage';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import { IPermissionInfo, MixedPermissionSetWithContext, import { IPermissionInfo, MixedPermissionSetWithContext,
@ -98,7 +98,8 @@ function isAddOrUpdateRecordAction([actionName]: UserAction): boolean {
// specifically _grist_Attachments. // specifically _grist_Attachments.
const STRUCTURAL_TABLES = new Set(['_grist_Tables', '_grist_Tables_column', '_grist_Views', const STRUCTURAL_TABLES = new Set(['_grist_Tables', '_grist_Tables_column', '_grist_Views',
'_grist_Views_section', '_grist_Views_section_field', '_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. // 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. // 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). * (the UserAction has been compiled to DocActions).
* - canApplyBundle(), called when DocActions have been produced from UserActions, * - canApplyBundle(), called when DocActions have been produced from UserActions,
* but before those DocActions have been applied to the DB. If fails, the modification * 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 * - appliedBundle(), called when DocActions have been applied to the DB, but before
* those changes have been sent to clients. * those changes have been sent to clients.
* - sendDocUpdateForBundle() is called once a bundle has been applied, to notify * - sendDocUpdateForBundle() is called once a bundle has been applied, to notify
@ -279,7 +281,9 @@ export class GranularAccess implements GranularAccessForBundle {
// Flag for whether doc actions mention a rule change, even if passive due to // Flag for whether doc actions mention a rule change, even if passive due to
// schema changes. // schema changes.
hasAnyRuleChange: boolean, hasAnyRuleChange: boolean,
maybeHasShareChanges: boolean,
options: ApplyUAExtendedOptions|null, options: ApplyUAExtendedOptions|null,
shareRef?: number;
}|null; }|null;
public constructor( public constructor(
@ -308,6 +312,7 @@ export class GranularAccess implements GranularAccessForBundle {
this._activeBundle = { this._activeBundle = {
docSession, docActions, undo, userActions, isDirect, docSession, docActions, undo, userActions, isDirect,
applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false, applied: false, hasDeliberateRuleChange: false, hasAnyRuleChange: false,
maybeHasShareChanges: false,
options, options,
}; };
this._activeBundle.hasDeliberateRuleChange = this._activeBundle.hasDeliberateRuleChange =
@ -497,6 +502,35 @@ export class GranularAccess implements GranularAccessForBundle {
return this._checkIncomingDocAction({docSession, action, actionIdx}); 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); 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_Tables_column: this._docData.getMetaTable('_grist_Tables_column').getTableDataAction(),
_grist_ACLResources: this._docData.getMetaTable('_grist_ACLResources').getTableDataAction(), _grist_ACLResources: this._docData.getMetaTable('_grist_ACLResources').getTableDataAction(),
_grist_ACLRules: this._docData.getMetaTable('_grist_ACLRules').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) { for (const da of docActions) {
tmpDocData.receiveAction(da); tmpDocData.receiveAction(da);
@ -534,6 +570,8 @@ export class GranularAccess implements GranularAccessForBundle {
throw new ApiError(err.message, 400); 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'); 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. // Optimize case where there are no rules to enforce.
if (!this._ruler.haveRules()) { return docActions; } if (!this._ruler.haveRules()) { return docActions; }
@ -1354,7 +1397,15 @@ export class GranularAccess implements GranularAccessForBundle {
await this.update(); await this.update();
return; 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 there is a schema change, redo from scratch for now.
if (docActions.some(docAction => isSchemaAction(docAction))) { if (docActions.some(docAction => isSchemaAction(docAction))) {
await this.update(); await this.update();
@ -1799,6 +1850,20 @@ export class GranularAccess implements GranularAccessForBundle {
const attrs = this._getUserAttributes(docSession); const attrs = this._getUserAttributes(docSession);
access = getDocSessionAccess(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) { if (docSession.forkingAsOwner) {
// For granular access purposes, we become an owner. // For granular access purposes, we become an owner.
// It is a bit of a bluff, done on the understanding that this session will // 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(); const user = new User();
user.Access = access; user.Access = access;
user.ShareRef = shareRef || null;
const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() || const isAnonymous = fullUser?.id === this._homeDbManager?.getAnonymousUserId() ||
fullUser?.id === null; fullUser?.id === null;
user.UserID = (!isAnonymous && fullUser?.id) || null; user.UserID = (!isAnonymous && fullUser?.id) || null;
@ -2569,7 +2635,7 @@ export class Ruler {
* Update granular access from DocData. * Update granular access from DocData.
*/ */
public async update(docData: 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. // Also clear the per-docSession cache of rule evaluations.
this.clearCache(); this.clearCache();
@ -3039,6 +3105,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
return rec => rec; return rec => rec;
case '_grist_ACLRules': case '_grist_ACLRules':
return rec => rec; return rec => rec;
case '_grist_Shares':
return rec => rec;
case '_grist_Cells': case '_grist_Cells':
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', ''); return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
default: default:
@ -3174,6 +3242,7 @@ export class User implements UserInfo {
public Email: string | null = null; public Email: string | null = null;
public SessionID: string | null = null; public SessionID: string | null = null;
public UserRef: string | null = null; public UserRef: string | null = null;
public ShareRef: number | null = null;
[attribute: string]: any; [attribute: string]: any;
constructor(_info: Record<string, unknown> = {}) { constructor(_info: Record<string, unknown> = {}) {

View File

@ -338,6 +338,14 @@ export class Sharing {
const actionSummary = await this._activeDoc.handleTriggers(localActionBundle); 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); await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession);
// Broadcast the action to connected browsers. // Broadcast the action to connected browsers.

View File

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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" (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_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); 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_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_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_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" (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_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_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 ''); 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_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_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_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); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT; COMMIT;
`; `;
@ -43,7 +44,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; 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 ''); 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" (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); 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); 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_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_TabBar" (id INTEGER PRIMARY KEY, "viewRef" INTEGER DEFAULT 0, "tabPos" NUMERIC DEFAULT 1e999);
INSERT INTO _grist_TabBar VALUES(1,1,1); 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); 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); 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 ''); 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',''); 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); 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(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(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); 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); 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(1,1,1,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,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_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_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_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 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); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT; COMMIT;

View File

@ -1289,3 +1289,21 @@ def migration40(tdset):
new_view_section_id += 1 new_view_section_id += 1
return tdset.apply_doc_actions(doc_actions) 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)

View File

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 40 SCHEMA_VERSION = 41
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -163,6 +163,7 @@ def schema_create_actions():
make_column("viewRef", "Ref:_grist_Views"), make_column("viewRef", "Ref:_grist_Views"),
make_column("indentation", "Int"), make_column("indentation", "Int"),
make_column("pagePos", "PositionNumber"), make_column("pagePos", "PositionNumber"),
make_column("shareRef", "Ref:_grist_Shares"),
]), ]),
# All user views. # All user views.
@ -199,6 +200,7 @@ def schema_create_actions():
make_column("embedId", "Text"), make_column("embedId", "Text"),
# Points to formula columns that hold conditional formatting rules for this view section. # Points to formula columns that hold conditional formatting rules for this view section.
make_column("rules", "RefList:_grist_Tables_column"), make_column("rules", "RefList:_grist_Tables_column"),
make_column("shareOptions", "Text"),
]), ]),
# The fields of a view section. # The fields of a view section.
actions.AddTable("_grist_Views_section_field", [ actions.AddTable("_grist_Views_section_field", [
@ -350,6 +352,14 @@ def schema_create_actions():
make_column("content", "Text"), make_column("content", "Text"),
make_column("userRef", "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'),
]),
] ]

View File

@ -18,7 +18,8 @@ class TestCompletion(test_engine.EngineTestCase):
'Email': 'foo@example.com', 'Email': 'foo@example.com',
'Access': 'owners', 'Access': 'owners',
'SessionID': 'u1', 'SessionID': 'u1',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
def setUp(self): def setUp(self):
@ -100,9 +101,10 @@ class TestCompletion(test_engine.EngineTestCase):
('user.Name', "'Foo'"), ('user.Name', "'Foo'"),
('user.Origin', 'None'), ('user.Origin', 'None'),
('user.SessionID', "'u1'"), ('user.SessionID', "'u1'"),
('user.ShareRef', 'None'),
('user.StudentInfo', 'Students[1]'), ('user.StudentInfo', 'Students[1]'),
('user.UserID', '1'), ('user.UserID', '1'),
('user.UserRef', "'1'"), ('user.UserRef', "'1'")
] ]
) )
# Should follow user attribute references and autocomplete those types. # Should follow user attribute references and autocomplete those types.
@ -133,7 +135,8 @@ class TestCompletion(test_engine.EngineTestCase):
'UserRef': '2', 'UserRef': '2',
'Access': 'owners', 'Access': 'owners',
'SessionID': 'u2', 'SessionID': 'u2',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
self.assertEqual( self.assertEqual(
self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2), self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2),
@ -145,6 +148,7 @@ class TestCompletion(test_engine.EngineTestCase):
('user.Name', "'Bar'"), ('user.Name', "'Bar'"),
('user.Origin', 'None'), ('user.Origin', 'None'),
('user.SessionID', "'u2'"), ('user.SessionID', "'u2'"),
('user.ShareRef', 'None'),
('user.UserID', '2'), ('user.UserID', '2'),
('user.UserRef', "'2'"), ('user.UserRef', "'2'"),
] ]

View File

@ -346,7 +346,8 @@ class TestRenames(test_engine.EngineTestCase):
'Email': 'foo@example.com', 'Email': 'foo@example.com',
'Access': 'owners', 'Access': 'owners',
'SessionID': 'u1', 'SessionID': 'u1',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
# Renaming a table should not leave the old name available for auto-complete. # Renaming a table should not leave the old name available for auto-complete.

View File

@ -572,7 +572,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
'Email': 'foo.bar@getgrist.com', 'Email': 'foo.bar@getgrist.com',
'Access': 'owners', 'Access': 'owners',
'SessionID': 'u1', 'SessionID': 'u1',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
user2 = { user2 = {
'Name': 'Baz Qux', 'Name': 'Baz Qux',
@ -584,7 +585,8 @@ class TestTriggerFormulas(test_engine.EngineTestCase):
'Email': 'baz.qux@getgrist.com', 'Email': 'baz.qux@getgrist.com',
'Access': 'owners', 'Access': 'owners',
'SessionID': 'u2', '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. # Use formula to store last modified by data (user name and email). Check that it works as expected.
self.load_sample(self.sample) self.load_sample(self.sample)

View File

@ -22,7 +22,8 @@ class TestUser(test_engine.EngineTestCase):
'Origin': 'https://getgrist.com', 'Origin': 'https://getgrist.com',
'StudentInfo': ['Students', 1], 'StudentInfo': ['Students', 1],
'SessionID': 'u1', 'SessionID': 'u1',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
u = User(data, self.engine.tables) u = User(data, self.engine.tables)
self.assertEqual(u.Name, 'Foo Bar') self.assertEqual(u.Name, 'Foo Bar')
@ -51,7 +52,8 @@ class TestUser(test_engine.EngineTestCase):
'Origin': 'https://getgrist.com', 'Origin': 'https://getgrist.com',
'StudentInfo': ['Students', 1], 'StudentInfo': ['Students', 1],
'SessionID': 'u1', 'SessionID': 'u1',
'IsLoggedIn': True 'IsLoggedIn': True,
'ShareRef': None
} }
u = User(data, self.engine.tables, is_sample=True) u = User(data, self.engine.tables, is_sample=True)
self.assertEqual(u.StudentInfo.id, 0) self.assertEqual(u.StudentInfo.id, 0)

View File

@ -20,6 +20,7 @@ the following fields:
- LinkKey: dictionary - LinkKey: dictionary
- SessionID: string or None - SessionID: string or None
- IsLoggedIn: boolean - IsLoggedIn: boolean
- ShareRef: integer or None
Additional keys may be included, which may have a value that is Additional keys may be included, which may have a value that is
either None or of type tuple with the following shape: 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): def __init__(self, data, tables, is_sample=False):
for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin', 'SessionID', for attr in ('Access', 'UserID', 'Email', 'Name', 'Origin', 'SessionID',
'IsLoggedIn', 'UserRef'): 'IsLoggedIn', 'UserRef', 'ShareRef'):
setattr(self, attr, data[attr]) setattr(self, attr, data[attr])
self.LinkKey = LinkKey(data['LinkKey']) self.LinkKey = LinkKey(data['LinkKey'])

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
test/fixtures/docs/ManyRefs.grist vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
import {ActionSummary} from 'app/common/ActionSummary'; import {ActionSummary} from 'app/common/ActionSummary';
import {BulkColValues, UserAction} from 'app/common/DocActions'; import {BulkColValues, UserAction} from 'app/common/DocActions';
import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
import {arrayRepeat} from 'app/common/gutil'; import {arrayRepeat} from 'app/common/gutil';
import {WebhookSummary} from 'app/common/Triggers'; import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI'; 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 {waitForIt} from 'test/server/wait';
import defaultsDeep = require('lodash/defaultsDeep'); import defaultsDeep = require('lodash/defaultsDeep');
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import { getDatabase } from 'test/testUtils';
const chimpy = configForUser('Chimpy'); const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi'); const kiwi = configForUser('Kiwi');
@ -2814,6 +2816,44 @@ function testDocApi() {
assert.notEqual(resp.data.id, ''); 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 () { it("document is protected during upload-and-import sequence", async function () {
if (!process.env.TEST_REDIS_URL) { if (!process.env.TEST_REDIS_URL) {
this.skip(); this.skip();