(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

@@ -1,8 +1,10 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules';
import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil';
import {ShareOptions} from 'app/common/ShareOptions';
import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy');
@@ -347,7 +349,9 @@ export class ACLRuleCollection {
const names: string[] = [];
for (const rule of this.getUserAttributeRules().values()) {
const tableRef = tablesTable.findRow('tableId', rule.tableId);
const colRef = columnsTable.findMatchingRowId({parentId: tableRef, colId: rule.lookupColId});
const colRef = columnsTable.findMatchingRowId({
parentId: tableRef, colId: rule.lookupColId,
});
if (!colRef) {
invalidUAColumns.push(`${rule.tableId}.${rule.lookupColId}`);
names.push(rule.name);
@@ -379,11 +383,13 @@ export class ACLRuleCollection {
export interface ReadAclOptions {
log: ILogger; // For logging warnings during rule processing.
compile?: (parsed: ParsedAclFormula) => AclMatchFunc;
// If true, call addHelperCols to add helper columns of restricted columns to rule sets.
// Used in the server for extra filtering, but not in the client, because:
// 1. They would show in the UI
// 2. They would be saved back after editing, causing them to accumulate
includeHelperCols?: boolean;
// If true, add and modify access rules in some special ways.
// Specifically, call addHelperCols to add helper columns of restricted columns to rule sets,
// and use ACLShareRules to implement any special shares as access rules.
// Used in the server, but not in the client, because of at least the following:
// 1. Rules would show in the UI
// 2. Rules would be saved back after editing, causing them to accumulate
enrichRulesForImplementation?: boolean;
// If true, rules with 'schemaEdit' permission are moved out of the '*:*' resource into a
// fictitious '*SPECIAL:SchemaEdit' resource. This is used only on the client, to present
@@ -455,13 +461,32 @@ function getHelperCols(docData: DocData, tableId: string, colIds: string[], log:
* Parse all ACL rules in the document from DocData into a list of RuleSets and of
* UserAttributeRules. This is used by both client-side code and server-side.
*/
function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadAclOptions): ReadAclResults {
const resourcesTable = docData.getMetaTable('_grist_ACLResources');
const rulesTable = docData.getMetaTable('_grist_ACLRules');
function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementation}: ReadAclOptions): ReadAclResults {
// Wrap resources and rules tables so we can have "virtual" rules
// to implement special shares.
const resourcesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLResources'));
const rulesTable = new TableWithOverlay(docData.getMetaTable('_grist_ACLRules'));
const sharesTable = docData.getMetaTable('_grist_Shares');
const ruleSets: RuleSet[] = [];
const userAttributes: UserAttributeRule[] = [];
let hasShares: boolean = false;
const shares = sharesTable.getRecords();
// ACLShareRules is used to edit resourcesTable and rulesTable in place.
const shareRules = new ACLShareRules(docData, resourcesTable, rulesTable);
// Add virtual rules to implement shares, if there are any.
// Add the virtual rules only when implementing/interpreting them, as
// opposed to accessing them for presentation or manipulation in the UI.
if (enrichRulesForImplementation && shares.length > 0) {
for (const share of shares) {
const options: ShareOptions = JSON.parse(share.options || '{}');
shareRules.addRulesForShare(share.id, options);
}
shareRules.addDefaultRulesForShares();
hasShares = true;
}
// Group rules by resource first, ordering by rulePos. Each group will become a RuleSet.
const rulesByResource = new Map<number, Array<MetaRowRecord<'_grist_ACLRules'>>>();
for (const ruleRecord of sortBy(rulesTable.getRecords(), 'rulePos')) {
@@ -472,7 +497,6 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
const resourceRec = resourcesTable.getRecord(resourceId);
if (!resourceRec) {
throw new Error(`ACLRule ${rules[0].id} refers to an invalid ACLResource ${resourceId}`);
continue;
}
if (!resourceRec.tableId || !resourceRec.colIds) {
// This should only be the case for the old-style default rule/resource, which we
@@ -482,7 +506,7 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
const tableId = resourceRec.tableId;
const colIds = resourceRec.colIds === '*' ? '*' : resourceRec.colIds.split(',');
if (includeHelperCols && Array.isArray(colIds)) {
if (enrichRulesForImplementation && Array.isArray(colIds)) {
colIds.push(...getHelperCols(docData, tableId, colIds, log));
}
@@ -506,7 +530,13 @@ function readAclRules(docData: DocData, {log, compile, includeHelperCols}: ReadA
} else if (rule.aclFormula && !rule.aclFormulaParsed) {
throw new Error(`ACLRule ${rule.id} invalid because missing its parsed formula`);
} else {
const aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
let aclFormulaParsed = rule.aclFormula && JSON.parse(String(rule.aclFormulaParsed));
// If we have "virtual" rules to implement shares, then regular
// rules need to be tweaked so that they don't apply when the
// share is active.
if (hasShares && rule.id >= 0) {
aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed});
}
body.push({
origRecord: rule,
aclFormula: String(rule.aclFormula),

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;
/**
* 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.
*/
@@ -89,6 +120,5 @@ export interface DocListAPI {
/**
* Opens a document, loads it, subscribes to its userAction events, and returns its metadata.
*/
openDoc(userDocName: string, openMode?: OpenDocMode,
linkParameters?: Record<string, string>): Promise<OpenLocalDocResult>;
openDoc(userDocName: string, options?: OpenDocOptions): Promise<OpenLocalDocResult>;
}

View File

@@ -47,6 +47,8 @@ export interface UserInfo {
UserID: number | null;
UserRef: string | null;
SessionID: string | null;
ShareRef: number | null; // This is a rowId in the _grist_Shares table, if the user
// is accessing a document via a share. Otherwise null.
[attributes: string]: unknown;
toJSON(): {[key: string]: any};
}

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 {OpenDocMode} from 'app/common/DocListAPI';
import {EngineCode} from 'app/common/DocumentSettings';
import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
import {encodeQueryParams, isAffirmative, removePrefix} from 'app/common/gutil';
import {LocalPlugin} from 'app/common/plugin';
import {StringUnion} from 'app/common/StringUnion';
import {TelemetryLevel} from 'app/common/Telemetry';
@@ -57,6 +57,10 @@ export const DEFAULT_HOME_SUBDOMAIN = 'api';
// as a prefix of the docId.
export const MIN_URLID_PREFIX_LENGTH = 12;
// A prefix that identifies a urlId as a share key.
// Important that this not be part of a valid docId.
export const SHARE_KEY_PREFIX = 's.';
/**
* Special ways to open a document, based on what the user intends to do.
* - view: Open document in read-only mode (even if user has edit rights)
@@ -140,6 +144,7 @@ export interface IGristUrlState {
api?: boolean; // indicates that the URL should be encoded as an API URL, not as a landing page.
// But this barely works, and is suitable only for documents. For decoding it
// indicates that the URL probably points to an API endpoint.
viaShare?: boolean; // Accessing document via a special share.
}
// Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the
@@ -253,6 +258,13 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
if (state.doc) {
if (state.api) {
parts.push(`docs/${encodeURIComponent(state.doc)}`);
} else if (state.viaShare) {
// Use a special path, and remove SHARE_KEY_PREFIX from id.
let id = state.doc;
if (id.startsWith(SHARE_KEY_PREFIX)) {
id = id.substring(SHARE_KEY_PREFIX.length);
}
parts.push(`s/${encodeURIComponent(id)}`);
} else if (state.slug) {
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
} else {
@@ -366,6 +378,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
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
// 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
@@ -919,33 +938,41 @@ export function parseFirstUrlPart(tag: string, path: string): {value?: string, p
}
/**
* The internal structure of a UrlId. There is no internal structure. unless the id is
* for a fork, in which case the fork has a separate id, and a user id may also be
* embedded to track ownership.
* The internal structure of a UrlId. There is no internal structure,
* except in the following cases. The id may be for a fork, in which
* case the fork has a separate id, and a user id may also be embedded
* to track ownership. The id may be a share key, in which case it
* has some special syntax to identify it as so.
*/
export interface UrlIdParts {
trunkId: string;
forkId?: string;
forkUserId?: number;
snapshotId?: string;
shareKey?: string;
}
// Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId
// or trunkId[....]~v=snapshotId
// or <SHARE-KEY-PREFIX>shareKey
export function parseUrlId(urlId: string): UrlIdParts {
let snapshotId: string|undefined;
const parts = urlId.split('~');
const bareParts = parts.filter(part => !part.includes('='));
const bareParts = parts.filter(part => !part.includes('v='));
for (const part of parts) {
if (part.startsWith('v=')) {
snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, '%'));
}
}
const trunkId = bareParts[0];
// IDs starting with SHARE_KEY_PREFIX are in fact shares.
const shareKey = removePrefix(trunkId, SHARE_KEY_PREFIX) || undefined;
return {
trunkId: bareParts[0],
forkId: bareParts[1],
forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined,
snapshotId,
shareKey,
};
}
@@ -984,7 +1011,7 @@ export interface HashLink {
// a candidate for use in prettier urls.
function shouldIncludeSlug(doc: {id: string, urlId: string|null}): boolean {
if (!doc.urlId || doc.urlId.length < MIN_URLID_PREFIX_LENGTH) { return false; }
return doc.id.startsWith(doc.urlId);
return doc.id.startsWith(doc.urlId) || doc.urlId.startsWith(SHARE_KEY_PREFIX);
}
// Convert the name of a document into a slug. Only alphanumerics are retained,

View File

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