2020-07-21 13:20:51 +00:00
|
|
|
import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
|
|
|
|
import {OpenDocMode} from 'app/common/DocListAPI';
|
2021-09-15 23:35:21 +00:00
|
|
|
import {EngineCode} from 'app/common/DocumentSettings';
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
import {encodeQueryParams, isAffirmative, removePrefix} from 'app/common/gutil';
|
2021-08-05 15:12:46 +00:00
|
|
|
import {LocalPlugin} from 'app/common/plugin';
|
2021-05-17 18:19:34 +00:00
|
|
|
import {StringUnion} from 'app/common/StringUnion';
|
2023-06-06 17:08:50 +00:00
|
|
|
import {TelemetryLevel} from 'app/common/Telemetry';
|
2023-08-30 08:13:57 +00:00
|
|
|
import {ThemeAppearance, ThemeAppearanceChecker, ThemeName, ThemeNameChecker} from 'app/common/ThemePrefs';
|
2022-05-27 11:03:56 +00:00
|
|
|
import {getGristConfig} from 'app/common/urlUtils';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {Document} from 'app/common/UserAPI';
|
2023-08-29 14:50:42 +00:00
|
|
|
import {IAttachedCustomWidget} from "app/common/widgetTypes";
|
2023-08-30 08:13:57 +00:00
|
|
|
import {UIRowId} from 'app/plugin/GristAPI';
|
2021-05-17 18:19:34 +00:00
|
|
|
import clone = require('lodash/clone');
|
2020-07-21 13:20:51 +00:00
|
|
|
import pickBy = require('lodash/pickBy');
|
2024-01-26 04:58:52 +00:00
|
|
|
import slugify from 'slugify';
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2023-05-08 22:06:24 +00:00
|
|
|
export const SpecialDocPage = StringUnion('code', 'acl', 'data', 'GristDocTour', 'settings', 'webhook');
|
2022-02-07 14:02:26 +00:00
|
|
|
type SpecialDocPage = typeof SpecialDocPage.type;
|
|
|
|
export type IDocPage = number | SpecialDocPage;
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2022-03-22 12:38:56 +00:00
|
|
|
export type ViewDocPage = number | 'data';
|
|
|
|
/**
|
|
|
|
* ViewDocPage is a page that shows table data (either normal or raw data view).
|
|
|
|
*/
|
|
|
|
export function isViewDocPage(docPage: IDocPage): docPage is ViewDocPage {
|
|
|
|
return typeof docPage === 'number' || docPage === 'data';
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// What page to show in the user's home area. Defaults to 'workspace' if a workspace is set, and
|
|
|
|
// to 'all' otherwise.
|
2021-07-28 19:02:06 +00:00
|
|
|
export const HomePage = StringUnion('all', 'workspace', 'templates', 'trash');
|
2020-07-21 13:20:51 +00:00
|
|
|
export type IHomePage = typeof HomePage.type;
|
|
|
|
|
2022-02-22 03:09:18 +00:00
|
|
|
// TODO: Remove 'user' and 'info', since those pages are no longer part of any flow.
|
2022-02-24 05:50:26 +00:00
|
|
|
export const WelcomePage = StringUnion('teams', 'signup', 'verify', 'select-account');
|
2020-07-21 13:20:51 +00:00
|
|
|
export type WelcomePage = typeof WelcomePage.type;
|
|
|
|
|
2022-02-11 06:03:30 +00:00
|
|
|
export const AccountPage = StringUnion('account');
|
2022-01-07 18:11:52 +00:00
|
|
|
export type AccountPage = typeof AccountPage.type;
|
|
|
|
|
2022-08-22 19:46:25 +00:00
|
|
|
export const ActivationPage = StringUnion('activation');
|
|
|
|
export type ActivationPage = typeof ActivationPage.type;
|
|
|
|
|
2022-04-01 21:31:24 +00:00
|
|
|
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
|
2022-02-11 06:03:30 +00:00
|
|
|
export type LoginPage = typeof LoginPage.type;
|
|
|
|
|
2024-03-23 17:11:06 +00:00
|
|
|
export const AdminPanelPage = StringUnion('admin');
|
|
|
|
export type AdminPanelPage = typeof AdminPanelPage.type;
|
2023-07-04 21:21:34 +00:00
|
|
|
|
2023-08-15 17:29:29 +00:00
|
|
|
// Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience.
|
|
|
|
export const InterfaceStyle = StringUnion('singlePage', 'full');
|
2020-08-14 16:40:39 +00:00
|
|
|
export type InterfaceStyle = typeof InterfaceStyle.type;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// Default subdomain for home api service if not otherwise specified.
|
|
|
|
export const DEFAULT_HOME_SUBDOMAIN = 'api';
|
|
|
|
|
|
|
|
// This is the minimum length a urlId may have if it is chosen
|
|
|
|
// as a prefix of the docId.
|
|
|
|
export const MIN_URLID_PREFIX_LENGTH = 12;
|
|
|
|
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
// 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.';
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* 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)
|
|
|
|
* - fork: Open document in fork-ready mode. This means that while edits are
|
|
|
|
* permitted, those edits should go to a copy of the document rather than
|
|
|
|
* the original.
|
|
|
|
*/
|
|
|
|
|
|
|
|
export const commonUrls = {
|
2022-11-28 20:19:31 +00:00
|
|
|
help: getHelpCenterUrl(),
|
2022-10-19 23:06:05 +00:00
|
|
|
helpAccessRules: "https://support.getgrist.com/access-rules",
|
2023-07-21 17:06:17 +00:00
|
|
|
helpAssistantDataUse: "https://support.getgrist.com/ai-assistant/#data-use-policy",
|
2022-12-20 02:06:39 +00:00
|
|
|
helpColRefs: "https://support.getgrist.com/col-refs",
|
2022-10-19 23:06:05 +00:00
|
|
|
helpConditionalFormatting: "https://support.getgrist.com/conditional-formatting",
|
2022-12-20 02:06:39 +00:00
|
|
|
helpFilterButtons: "https://support.getgrist.com/search-sort-filter/#filter-buttons",
|
2022-09-28 23:01:46 +00:00
|
|
|
helpLinkingWidgets: "https://support.getgrist.com/linking-widgets",
|
2022-12-20 02:06:39 +00:00
|
|
|
helpRawData: "https://support.getgrist.com/raw-data",
|
|
|
|
helpUnderstandingReferenceColumns: "https://support.getgrist.com/col-refs/#understanding-reference-columns",
|
2022-10-19 23:06:05 +00:00
|
|
|
helpTriggerFormulas: "https://support.getgrist.com/formulas/#trigger-formulas",
|
|
|
|
helpTryingOutChanges: "https://support.getgrist.com/copying-docs/#trying-out-changes",
|
2023-04-14 10:09:50 +00:00
|
|
|
helpCustomWidgets: "https://support.getgrist.com/widget-custom",
|
2023-07-04 21:21:34 +00:00
|
|
|
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
|
2023-09-19 02:48:56 +00:00
|
|
|
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
|
2023-10-23 05:51:08 +00:00
|
|
|
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
|
2024-03-06 05:59:46 +00:00
|
|
|
freeCoachingCall: getFreeCoachingCallUrl(),
|
|
|
|
contactSupport: getContactSupportUrl(),
|
2020-07-21 13:20:51 +00:00
|
|
|
plans: "https://www.getgrist.com/pricing",
|
2022-06-03 14:58:07 +00:00
|
|
|
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
2022-08-22 19:46:25 +00:00
|
|
|
contact: "https://www.getgrist.com/contact",
|
2023-07-26 22:31:02 +00:00
|
|
|
templates: 'https://www.getgrist.com/templates',
|
2023-06-02 11:25:14 +00:00
|
|
|
community: 'https://community.getgrist.com',
|
|
|
|
functions: 'https://support.getgrist.com/functions',
|
|
|
|
formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet',
|
2023-11-22 10:02:04 +00:00
|
|
|
formulas: 'https://support.getgrist.com/formulas',
|
2024-02-14 21:18:09 +00:00
|
|
|
forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer',
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2023-03-28 16:11:40 +00:00
|
|
|
basicTutorial: 'https://templates.getgrist.com/woXtXUBmiN5T/Grist-Basics',
|
|
|
|
basicTutorialImage: 'https://www.getgrist.com/wp-content/uploads/2021/08/lightweight-crm.png',
|
2023-04-06 15:10:29 +00:00
|
|
|
gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/',
|
2023-06-02 08:17:09 +00:00
|
|
|
gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',
|
2023-07-04 21:21:34 +00:00
|
|
|
githubGristCore: 'https://github.com/gristlabs/grist-core',
|
|
|
|
githubSponsorGristLabs: 'https://github.com/sponsors/gristlabs',
|
2020-07-21 13:20:51 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Values representable in a URL. The current state is available as urlState().state observable
|
|
|
|
* in client. Updates to this state are expected by functions such as makeUrl() and setLinkUrl().
|
|
|
|
*/
|
|
|
|
export interface IGristUrlState {
|
|
|
|
org?: string;
|
|
|
|
homePage?: IHomePage;
|
|
|
|
ws?: number;
|
|
|
|
doc?: string;
|
|
|
|
slug?: string; // if present, this is based on the document title, and is not a stable id
|
|
|
|
mode?: OpenDocMode;
|
|
|
|
fork?: UrlIdParts;
|
|
|
|
docPage?: IDocPage;
|
2022-01-07 18:11:52 +00:00
|
|
|
account?: AccountPage;
|
2020-07-21 13:20:51 +00:00
|
|
|
billing?: BillingPage;
|
2022-08-22 19:46:25 +00:00
|
|
|
activation?: ActivationPage;
|
2022-02-11 06:03:30 +00:00
|
|
|
login?: LoginPage;
|
2020-07-21 13:20:51 +00:00
|
|
|
welcome?: WelcomePage;
|
2024-03-23 17:11:06 +00:00
|
|
|
adminPanel?: AdminPanelPage;
|
2021-07-19 08:49:44 +00:00
|
|
|
welcomeTour?: boolean;
|
2021-07-23 16:24:17 +00:00
|
|
|
docTour?: boolean;
|
2021-10-25 16:54:04 +00:00
|
|
|
manageUsers?: boolean;
|
2022-08-03 10:48:01 +00:00
|
|
|
createTeam?: boolean;
|
2020-07-21 13:20:51 +00:00
|
|
|
params?: {
|
|
|
|
billingPlan?: string;
|
2022-08-03 10:48:01 +00:00
|
|
|
planType?: string;
|
2020-07-21 13:20:51 +00:00
|
|
|
billingTask?: BillingTask;
|
2020-08-14 16:40:39 +00:00
|
|
|
embed?: boolean;
|
2022-04-01 21:31:24 +00:00
|
|
|
state?: string;
|
2023-09-06 18:35:46 +00:00
|
|
|
srcDocId?: string;
|
2020-08-14 16:40:39 +00:00
|
|
|
style?: InterfaceStyle;
|
2020-09-21 19:27:49 +00:00
|
|
|
compare?: string;
|
2020-12-09 13:57:35 +00:00
|
|
|
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
|
|
|
|
// Encoded in URL as query params with extra '_' suffix.
|
2023-08-15 17:29:29 +00:00
|
|
|
themeSyncWithOs?: boolean;
|
|
|
|
themeAppearance?: ThemeAppearance;
|
|
|
|
themeName?: ThemeName;
|
2020-07-21 13:20:51 +00:00
|
|
|
};
|
|
|
|
hash?: HashLink; // if present, this specifies an individual row within a section of a page.
|
2023-10-17 16:31:33 +00:00
|
|
|
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.
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
viaShare?: boolean; // Accessing document via a special share.
|
2024-01-23 21:24:57 +00:00
|
|
|
form?: {
|
|
|
|
vsId: number; // a view section id of a form.
|
|
|
|
shareKey?: string; // only one of shareKey or doc should be set.
|
|
|
|
},
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Subset of GristLoadConfig used by getOrgUrlInfo(), which affects the interpretation of the
|
|
|
|
// current URL.
|
|
|
|
export interface OrgUrlOptions {
|
|
|
|
// The org associated with the current URL.
|
|
|
|
org?: string;
|
|
|
|
|
|
|
|
// Base domain for constructing new URLs, should start with "." and not include port, e.g.
|
|
|
|
// ".getgrist.com". It should be unset for localhost operation and in single-org mode.
|
|
|
|
baseDomain?: string;
|
|
|
|
|
|
|
|
// In single-org mode, this is the single well-known org.
|
|
|
|
singleOrg?: string;
|
2020-07-22 15:43:24 +00:00
|
|
|
|
|
|
|
// Base URL used for accessing plugin material.
|
|
|
|
pluginUrl?: string;
|
2022-04-05 15:48:15 +00:00
|
|
|
|
|
|
|
// If set, org is expected to be encoded in the path, not domain.
|
|
|
|
pathOnly?: boolean;
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Result of getOrgUrlInfo().
|
|
|
|
export interface OrgUrlInfo {
|
|
|
|
hostname?: string; // If hostname should be changed to access the requested org.
|
|
|
|
orgInPath?: string; // If /o/{orgInPath} should be used to access the requested org.
|
|
|
|
}
|
|
|
|
|
2023-11-06 08:24:59 +00:00
|
|
|
function isDocInternalUrl(host: string) {
|
|
|
|
if (!process.env.APP_DOC_INTERNAL_URL) { return false; }
|
|
|
|
const internalUrl = new URL('/', process.env.APP_DOC_INTERNAL_URL);
|
|
|
|
return internalUrl.host === host;
|
|
|
|
}
|
|
|
|
|
2020-07-22 15:43:24 +00:00
|
|
|
/**
|
2021-07-13 07:29:38 +00:00
|
|
|
* Given host (optionally with port), baseDomain, and pluginUrl, determine whether to interpret host
|
2020-07-22 15:43:24 +00:00
|
|
|
* as a custom domain, a native domain, or a plugin domain.
|
|
|
|
*/
|
2021-07-13 07:29:38 +00:00
|
|
|
export function getHostType(host: string, options: {
|
2020-07-22 15:43:24 +00:00
|
|
|
baseDomain?: string, pluginUrl?: string
|
|
|
|
}): 'native' | 'custom' | 'plugin' {
|
2021-07-13 07:29:38 +00:00
|
|
|
|
|
|
|
if (options.pluginUrl) {
|
|
|
|
const url = new URL(options.pluginUrl);
|
|
|
|
if (url.host.toLowerCase() === host.toLowerCase()) {
|
|
|
|
return 'plugin';
|
|
|
|
}
|
2020-07-22 15:43:24 +00:00
|
|
|
}
|
2021-07-13 07:29:38 +00:00
|
|
|
|
|
|
|
const hostname = host.split(":")[0];
|
2020-07-22 15:43:24 +00:00
|
|
|
if (!options.baseDomain) { return 'native'; }
|
2023-11-06 08:24:59 +00:00
|
|
|
if (hostname === 'localhost' || isDocInternalUrl(host) || hostname.endsWith(options.baseDomain)) {
|
|
|
|
return 'native';
|
|
|
|
}
|
|
|
|
return 'custom';
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2021-07-13 07:29:38 +00:00
|
|
|
export function getOrgUrlInfo(newOrg: string, currentHost: string, options: OrgUrlOptions): OrgUrlInfo {
|
2020-07-21 13:20:51 +00:00
|
|
|
if (newOrg === options.singleOrg) {
|
|
|
|
return {};
|
|
|
|
}
|
2022-04-05 15:48:15 +00:00
|
|
|
if (options.pathOnly) {
|
|
|
|
return {orgInPath: newOrg};
|
|
|
|
}
|
2021-07-13 07:29:38 +00:00
|
|
|
const hostType = getHostType(currentHost, options);
|
|
|
|
if (hostType !== 'plugin') {
|
|
|
|
const hostname = currentHost.split(":")[0];
|
|
|
|
if (!options.baseDomain || hostname === 'localhost') {
|
|
|
|
return {orgInPath: newOrg};
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2021-07-13 07:29:38 +00:00
|
|
|
if (newOrg === options.org && hostType !== 'native') {
|
2020-07-21 13:20:51 +00:00
|
|
|
return {};
|
|
|
|
}
|
|
|
|
return {hostname: newOrg + options.baseDomain};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The actual serialization of a url state into a URL. The URL has the form
|
|
|
|
* <org-base>/
|
|
|
|
* <org-base>/ws/<ws>/
|
|
|
|
* <org-base>/doc/<doc>[/p/<docPage>]
|
|
|
|
*
|
|
|
|
* where <org-base> depends on whether subdomains are in use, e.g.
|
|
|
|
* <org>.getgrist.com
|
|
|
|
* localhost:8080/o/<org>
|
|
|
|
*/
|
|
|
|
export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
2022-07-19 15:39:49 +00:00
|
|
|
state: IGristUrlState, baseLocation: Location | URL,
|
|
|
|
options: {
|
2023-05-23 19:17:28 +00:00
|
|
|
tweaks?: UrlTweaks,
|
2022-07-19 15:39:49 +00:00
|
|
|
} = {}): string {
|
2020-07-21 13:20:51 +00:00
|
|
|
const url = new URL(baseLocation.href);
|
|
|
|
const parts = ['/'];
|
|
|
|
|
|
|
|
if (state.org) {
|
|
|
|
// We figure out where to stick the org using the gristConfig and the current host.
|
2021-07-13 07:29:38 +00:00
|
|
|
const {hostname, orgInPath} = getOrgUrlInfo(state.org, baseLocation.host, gristConfig);
|
2020-07-21 13:20:51 +00:00
|
|
|
if (hostname) {
|
|
|
|
url.hostname = hostname;
|
|
|
|
}
|
|
|
|
if (orgInPath) {
|
|
|
|
parts.push(`o/${orgInPath}/`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-17 16:31:33 +00:00
|
|
|
if (state.api) {
|
2022-07-19 15:39:49 +00:00
|
|
|
parts.push(`api/`);
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
if (state.ws) { parts.push(`ws/${state.ws}/`); }
|
|
|
|
if (state.doc) {
|
2023-10-17 16:31:33 +00:00
|
|
|
if (state.api) {
|
2022-07-19 15:39:49 +00:00
|
|
|
parts.push(`docs/${encodeURIComponent(state.doc)}`);
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
} 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)}`);
|
2022-07-19 15:39:49 +00:00
|
|
|
} else if (state.slug) {
|
2020-07-21 13:20:51 +00:00
|
|
|
parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`);
|
|
|
|
} else {
|
|
|
|
parts.push(`doc/${encodeURIComponent(state.doc)}`);
|
|
|
|
}
|
2020-08-19 03:08:58 +00:00
|
|
|
if (state.mode && OpenDocMode.guard(state.mode)) {
|
2020-07-21 13:20:51 +00:00
|
|
|
parts.push(`/m/${state.mode}`);
|
|
|
|
}
|
|
|
|
if (state.docPage) {
|
|
|
|
parts.push(`/p/${state.docPage}`);
|
|
|
|
}
|
2024-02-21 19:22:01 +00:00
|
|
|
if (state.form) {
|
|
|
|
parts.push(`/f/${state.form.vsId}`);
|
|
|
|
}
|
|
|
|
} else if (state.form?.shareKey) {
|
|
|
|
parts.push(`forms/${encodeURIComponent(state.form.shareKey)}/${encodeURIComponent(state.form.vsId)}`);
|
2021-07-28 19:02:06 +00:00
|
|
|
} else if (state.homePage === 'trash' || state.homePage === 'templates') {
|
|
|
|
parts.push(`p/${state.homePage}`);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2022-02-11 06:03:30 +00:00
|
|
|
if (state.account) {
|
|
|
|
parts.push(state.account === 'account' ? 'account' : `account/${state.account}`);
|
|
|
|
}
|
2022-01-07 18:11:52 +00:00
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
if (state.billing) {
|
|
|
|
parts.push(state.billing === 'billing' ? 'billing' : `billing/${state.billing}`);
|
|
|
|
}
|
|
|
|
|
2022-08-22 19:46:25 +00:00
|
|
|
if (state.activation) { parts.push(state.activation); }
|
|
|
|
|
2022-02-11 06:03:30 +00:00
|
|
|
if (state.login) { parts.push(state.login); }
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
if (state.welcome) {
|
|
|
|
parts.push(`welcome/${state.welcome}`);
|
|
|
|
}
|
|
|
|
|
2024-03-23 17:11:06 +00:00
|
|
|
if (state.adminPanel) { parts.push(state.adminPanel); }
|
2023-07-04 21:21:34 +00:00
|
|
|
|
2020-12-09 13:57:35 +00:00
|
|
|
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
|
|
|
|
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
|
|
|
|
queryParams[`${k}_`] = v;
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
const hashParts: string[] = [];
|
2023-11-20 00:46:32 +00:00
|
|
|
if (state.hash && (state.hash.rowId || state.hash.popup || state.hash.recordCard)) {
|
2020-07-21 13:20:51 +00:00
|
|
|
const hash = state.hash;
|
2023-11-20 00:46:32 +00:00
|
|
|
if (hash.recordCard) {
|
|
|
|
hashParts.push('a3');
|
|
|
|
} else if (hash.popup) {
|
|
|
|
hashParts.push('a2');
|
|
|
|
} else {
|
|
|
|
hashParts.push('a1');
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {
|
2023-12-29 05:17:50 +00:00
|
|
|
let enhancedRowId: string|undefined;
|
|
|
|
if (key === 'rowId' && hash.linkingRowIds?.length) {
|
|
|
|
enhancedRowId = [hash.rowId, ...hash.linkingRowIds].join("-");
|
|
|
|
}
|
|
|
|
const partValue = enhancedRowId ?? hash[key];
|
2023-03-27 17:25:25 +00:00
|
|
|
if (partValue) {
|
|
|
|
const partKey = key === 'rowId' && state.hash?.rickRow ? 'rr' : key[0];
|
|
|
|
hashParts.push(`${partKey}${partValue}`);
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
const queryStr = encodeQueryParams(queryParams);
|
2023-05-23 19:17:28 +00:00
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
url.pathname = parts.join('');
|
|
|
|
url.search = queryStr;
|
2023-05-23 19:17:28 +00:00
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
if (state.hash) {
|
|
|
|
// Project tests use hashes, so only set hash if there is an anchor.
|
|
|
|
url.hash = hashParts.join('.');
|
2021-07-30 15:16:33 +00:00
|
|
|
} else if (state.welcomeTour) {
|
|
|
|
url.hash = 'repeat-welcome-tour';
|
|
|
|
} else if (state.docTour) {
|
|
|
|
url.hash = 'repeat-doc-tour';
|
2021-10-25 16:54:04 +00:00
|
|
|
} else if (state.manageUsers) {
|
|
|
|
url.hash = 'manage-users';
|
2022-08-03 10:48:01 +00:00
|
|
|
} else if (state.createTeam) {
|
|
|
|
url.hash = 'create-team';
|
2021-07-30 15:16:33 +00:00
|
|
|
} else {
|
|
|
|
url.hash = '';
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2023-05-23 19:17:28 +00:00
|
|
|
options.tweaks?.postEncode?.({
|
|
|
|
url,
|
|
|
|
parts,
|
|
|
|
state,
|
|
|
|
baseLocation,
|
|
|
|
});
|
2020-07-21 13:20:51 +00:00
|
|
|
return url.href;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse a URL location into an IGristUrlState object. See encodeUrl() documentation.
|
|
|
|
*/
|
2023-05-23 19:17:28 +00:00
|
|
|
export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Location | URL, options?: {
|
|
|
|
tweaks?: UrlTweaks,
|
|
|
|
}): IGristUrlState {
|
|
|
|
location = new URL(location.href); // Make sure location is a URL.
|
|
|
|
options?.tweaks?.preDecode?.({ url: location });
|
2020-07-21 13:20:51 +00:00
|
|
|
const parts = location.pathname.slice(1).split('/');
|
2023-10-17 16:31:33 +00:00
|
|
|
const state: IGristUrlState = {};
|
|
|
|
|
2024-02-21 19:22:01 +00:00
|
|
|
// Bare minimum we can do to detect API URLs: if it starts with /api/ or /o/{org}/api/...
|
|
|
|
if (parts[0] === 'api' || (parts[0] === 'o' && parts[2] === 'api')) {
|
2023-10-17 16:31:33 +00:00
|
|
|
state.api = true;
|
2024-02-21 19:22:01 +00:00
|
|
|
parts.splice(parts[0] === 'api' ? 0 : 2, 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Bare minimum we can do to detect form URLs with share keys: if it starts with /forms/ or /o/{org}/forms/...
|
|
|
|
if (parts[0] === 'forms' || (parts[0] === 'o' && parts[2] === 'forms')) {
|
|
|
|
const startIndex = parts[0] === 'forms' ? 0 : 2;
|
|
|
|
// Form URLs have two parts to extract: the share key and the view section id.
|
|
|
|
state.form = {
|
|
|
|
shareKey: parts[startIndex + 1],
|
|
|
|
vsId: parseInt(parts[startIndex + 2], 10),
|
|
|
|
};
|
|
|
|
parts.splice(startIndex, 3);
|
2023-10-17 16:31:33 +00:00
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
const map = new Map<string, string>();
|
|
|
|
for (let i = 0; i < parts.length; i += 2) {
|
|
|
|
map.set(parts[i], decodeURIComponent(parts[i + 1]));
|
|
|
|
}
|
2023-10-17 16:31:33 +00:00
|
|
|
|
|
|
|
// For the API case, we need to map "docs" to "doc" (as this is what we did in encodeUrl and what API expects).
|
|
|
|
if (state.api && map.has('docs')) {
|
|
|
|
map.set('doc', map.get('docs')!);
|
|
|
|
}
|
|
|
|
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
// /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;
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// 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
|
|
|
|
// of any of the valid keys in the url.
|
|
|
|
for (const key of map.keys()) {
|
2024-03-23 17:11:06 +00:00
|
|
|
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
|
2020-07-21 13:20:51 +00:00
|
|
|
map.set('doc', key);
|
|
|
|
map.set('slug', map.get(key)!);
|
|
|
|
map.delete(key);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const subdomain = parseSubdomain(location.host);
|
|
|
|
if (gristConfig.org || gristConfig.singleOrg) {
|
|
|
|
state.org = gristConfig.org || gristConfig.singleOrg;
|
|
|
|
} else if (!gristConfig.pathOnly && subdomain.org) {
|
|
|
|
state.org = subdomain.org;
|
|
|
|
}
|
|
|
|
const sp = new URLSearchParams(location.search);
|
|
|
|
if (location.search) { state.params = {}; }
|
|
|
|
if (map.has('o')) { state.org = map.get('o'); }
|
|
|
|
if (map.has('ws')) { state.ws = parseInt(map.get('ws')!, 10); }
|
|
|
|
if (map.has('doc')) {
|
|
|
|
state.doc = map.get('doc');
|
|
|
|
const fork = parseUrlId(map.get('doc')!);
|
|
|
|
if (fork.forkId) { state.fork = fork; }
|
|
|
|
if (map.has('slug')) { state.slug = map.get('slug'); }
|
|
|
|
if (map.has('p')) { state.docPage = parseDocPage(map.get('p')!); }
|
2024-02-21 19:22:01 +00:00
|
|
|
if (map.has('f')) { state.form = {vsId: parseInt(map.get('f')!, 10)}; }
|
2020-07-21 13:20:51 +00:00
|
|
|
} else {
|
|
|
|
if (map.has('p')) {
|
|
|
|
const p = map.get('p')!;
|
2020-08-19 03:08:58 +00:00
|
|
|
state.homePage = HomePage.parse(p);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
2020-08-19 03:08:58 +00:00
|
|
|
if (map.has('m')) { state.mode = OpenDocMode.parse(map.get('m')); }
|
2022-02-11 06:03:30 +00:00
|
|
|
if (map.has('account')) { state.account = AccountPage.parse(map.get('account')) || 'account'; }
|
2020-08-19 03:08:58 +00:00
|
|
|
if (map.has('billing')) { state.billing = BillingSubPage.parse(map.get('billing')) || 'billing'; }
|
2022-08-22 19:46:25 +00:00
|
|
|
if (map.has('activation')) {
|
|
|
|
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
|
|
|
|
}
|
2022-02-24 05:50:26 +00:00
|
|
|
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
|
2024-03-23 17:11:06 +00:00
|
|
|
if (map.has('admin')) { state.adminPanel = AdminPanelPage.parse(map.get('admin')) || 'admin'; }
|
2022-08-03 10:48:01 +00:00
|
|
|
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
|
2020-07-21 13:20:51 +00:00
|
|
|
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
|
|
|
|
if (sp.has('billingTask')) {
|
2020-08-19 03:08:58 +00:00
|
|
|
state.params!.billingTask = BillingTask.parse(sp.get('billingTask'));
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2022-02-11 06:03:30 +00:00
|
|
|
|
|
|
|
if (map.has('signup')) {
|
|
|
|
state.login = 'signup';
|
2022-04-01 21:31:24 +00:00
|
|
|
} else if (map.has('login')) {
|
|
|
|
state.login = 'login';
|
2022-02-11 06:03:30 +00:00
|
|
|
} else if (map.has('verified')) {
|
|
|
|
state.login = 'verified';
|
2022-03-07 16:40:46 +00:00
|
|
|
} else if (map.has('forgot-password')) {
|
|
|
|
state.login = 'forgot-password';
|
2022-02-11 06:03:30 +00:00
|
|
|
}
|
2022-04-01 21:31:24 +00:00
|
|
|
if (sp.has('state')) {
|
|
|
|
state.params!.state = sp.get('state')!;
|
|
|
|
}
|
2023-09-06 18:35:46 +00:00
|
|
|
if (sp.has('srcDocId')) {
|
|
|
|
state.params!.srcDocId = sp.get('srcDocId')!;
|
|
|
|
}
|
2020-08-14 16:40:39 +00:00
|
|
|
if (sp.has('style')) {
|
2023-08-15 17:29:29 +00:00
|
|
|
let style = sp.get('style');
|
|
|
|
if (style === 'light') {
|
|
|
|
style = 'singlePage';
|
|
|
|
}
|
|
|
|
|
|
|
|
state.params!.style = InterfaceStyle.parse(style);
|
2020-08-14 16:40:39 +00:00
|
|
|
}
|
|
|
|
if (sp.has('embed')) {
|
|
|
|
const embed = state.params!.embed = isAffirmative(sp.get('embed'));
|
2021-02-04 19:28:18 +00:00
|
|
|
// Turn view mode on if no mode has been specified, and not a fork.
|
|
|
|
if (embed && !state.mode && !state.fork) { state.mode = 'view'; }
|
2023-08-15 17:29:29 +00:00
|
|
|
// Turn on single page style if no style has been specified.
|
|
|
|
if (embed && !state.params!.style) { state.params!.style = 'singlePage'; }
|
|
|
|
}
|
|
|
|
|
|
|
|
// Theme overrides
|
|
|
|
if (sp.has('themeSyncWithOs')) {
|
|
|
|
state.params!.themeSyncWithOs = isAffirmative(sp.get('themeSyncWithOs'));
|
2020-08-14 16:40:39 +00:00
|
|
|
}
|
2023-08-15 17:29:29 +00:00
|
|
|
|
|
|
|
if (sp.has('themeAppearance')) {
|
|
|
|
const appearance = sp.get('themeAppearance');
|
|
|
|
if (ThemeAppearanceChecker.strictTest(appearance)) {
|
|
|
|
state.params!.themeAppearance = appearance;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (sp.has('themeName')) {
|
|
|
|
const themeName = sp.get('themeName');
|
|
|
|
if (ThemeNameChecker.strictTest(themeName)) {
|
|
|
|
state.params!.themeName = themeName;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-21 19:27:49 +00:00
|
|
|
if (sp.has('compare')) {
|
|
|
|
state.params!.compare = sp.get('compare')!;
|
|
|
|
}
|
2020-12-09 13:57:35 +00:00
|
|
|
for (const [k, v] of sp.entries()) {
|
|
|
|
if (k.endsWith('_')) {
|
|
|
|
if (!state.params!.linkParameters) { state.params!.linkParameters = {}; }
|
|
|
|
state.params!.linkParameters[k.slice(0, k.length - 1)] = v;
|
|
|
|
}
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
if (location.hash) {
|
|
|
|
const hash = location.hash;
|
|
|
|
const hashParts = hash.split('.');
|
|
|
|
const hashMap = new Map<string, string>();
|
|
|
|
for (const part of hashParts) {
|
2023-03-27 17:25:25 +00:00
|
|
|
if (part.startsWith('rr')) {
|
|
|
|
hashMap.set(part.slice(0, 2), part.slice(2));
|
|
|
|
} else {
|
|
|
|
hashMap.set(part.slice(0, 1), part.slice(1));
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2023-11-20 00:46:32 +00:00
|
|
|
if (hashMap.has('#') && ['a1', 'a2', 'a3'].includes(hashMap.get('#') || '')) {
|
2020-07-21 13:20:51 +00:00
|
|
|
const link: HashLink = {};
|
2023-03-27 17:25:25 +00:00
|
|
|
const keys = [
|
|
|
|
'sectionId',
|
|
|
|
'rowId',
|
|
|
|
'colRef',
|
2023-12-29 05:17:50 +00:00
|
|
|
] as Array<'sectionId'|'rowId'|'colRef'>;
|
2023-03-27 17:25:25 +00:00
|
|
|
for (const key of keys) {
|
|
|
|
let ch: string;
|
|
|
|
if (key === 'rowId' && hashMap.has('rr')) {
|
|
|
|
ch = 'rr';
|
|
|
|
link.rickRow = true;
|
|
|
|
} else {
|
|
|
|
ch = key.substr(0, 1);
|
|
|
|
if (!hashMap.has(ch)) { continue; }
|
|
|
|
}
|
2021-09-23 22:47:36 +00:00
|
|
|
const value = hashMap.get(ch);
|
|
|
|
if (key === 'rowId' && value === 'new') {
|
|
|
|
link[key] = 'new';
|
2023-12-29 05:17:50 +00:00
|
|
|
} else if (key === 'rowId' && value && value.includes("-")) {
|
|
|
|
const rowIdParts = value.split("-").map(p => (p === 'new' ? p : parseInt(p, 10)));
|
|
|
|
link[key] = rowIdParts[0];
|
|
|
|
link.linkingRowIds = rowIdParts.slice(1);
|
2021-09-23 22:47:36 +00:00
|
|
|
} else {
|
|
|
|
link[key] = parseInt(value!, 10);
|
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2022-08-24 11:43:15 +00:00
|
|
|
if (hashMap.get('#') === 'a2') {
|
2023-11-20 00:46:32 +00:00
|
|
|
link.popup = true;
|
|
|
|
} else if (hashMap.get('#') === 'a3') {
|
|
|
|
link.recordCard = true;
|
2022-08-24 11:43:15 +00:00
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
state.hash = link;
|
|
|
|
}
|
2021-07-23 16:24:17 +00:00
|
|
|
state.welcomeTour = hashMap.get('#') === 'repeat-welcome-tour';
|
|
|
|
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
|
2021-10-25 16:54:04 +00:00
|
|
|
state.manageUsers = hashMap.get('#') === 'manage-users';
|
2022-08-03 10:48:01 +00:00
|
|
|
state.createTeam = hashMap.get('#') === 'create-team';
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
return state;
|
|
|
|
}
|
|
|
|
|
2021-03-08 21:08:13 +00:00
|
|
|
// Returns a function suitable for user with makeUrl/setHref/etc, which updates aclAsUser*
|
|
|
|
// linkParameters in the current state, unsetting them if email is null. Optional extraState
|
|
|
|
// allows setting other properties (e.g. 'docPage') at the same time.
|
|
|
|
export function userOverrideParams(email: string|null, extraState?: IGristUrlState) {
|
|
|
|
return function(prevState: IGristUrlState): IGristUrlState {
|
|
|
|
const combined = {...prevState, ...extraState};
|
2021-05-17 18:19:34 +00:00
|
|
|
const linkParameters = clone(combined.params?.linkParameters) || {};
|
2021-03-08 21:08:13 +00:00
|
|
|
if (email) {
|
|
|
|
linkParameters.aclAsUser = email;
|
|
|
|
} else {
|
|
|
|
delete linkParameters.aclAsUser;
|
|
|
|
}
|
|
|
|
delete linkParameters.aclAsUserId;
|
|
|
|
return {...combined, params: {...combined.params, linkParameters}};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
2022-02-07 14:02:26 +00:00
|
|
|
* parseDocPage is a noop for special pages, otherwise parse to integer
|
2020-07-21 13:20:51 +00:00
|
|
|
*/
|
2022-02-07 14:02:26 +00:00
|
|
|
function parseDocPage(p: string): IDocPage {
|
|
|
|
if (SpecialDocPage.guard(p)) {
|
|
|
|
return p;
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
return parseInt(p, 10);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parses the URL like "foo.bar.baz" into the pair {org: "foo", base: ".bar.baz"}.
|
|
|
|
* Port is allowed and included into base.
|
|
|
|
*
|
|
|
|
* The "base" part is required to have at least two periods. The "org" part must pass
|
|
|
|
* the subdomainRegex test.
|
|
|
|
*
|
|
|
|
* If there's no way to parse the URL into such a pair, then an empty object is returned.
|
|
|
|
*/
|
|
|
|
export function parseSubdomain(host: string|undefined): {org?: string, base?: string} {
|
|
|
|
if (!host) { return {}; }
|
|
|
|
const match = /^([^.]+)(\..+\..+)$/.exec(host.toLowerCase());
|
|
|
|
if (match) {
|
|
|
|
const org = match[1];
|
|
|
|
const base = match[2];
|
|
|
|
if (subdomainRegex.exec(org)) {
|
|
|
|
return {org, base};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Host has nowhere to put a subdomain.
|
|
|
|
return {};
|
|
|
|
}
|
|
|
|
|
2022-04-01 21:31:24 +00:00
|
|
|
// Allowed localhost addresses.
|
|
|
|
const localhostRegex = /^localhost(?::(\d+))?$/i;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Like parseSubdomain, but throws an error if neither of these cases apply:
|
|
|
|
* - host can be parsed into a valid subdomain and a valid base domain.
|
|
|
|
* - host is localhost:NNNN
|
|
|
|
* An empty object is only returned when host is localhost:NNNN.
|
|
|
|
*/
|
|
|
|
export function parseSubdomainStrictly(host: string|undefined): {org?: string, base?: string} {
|
|
|
|
if (!host) { throw new Error('host not known'); }
|
|
|
|
const result = parseSubdomain(host);
|
|
|
|
if (result.org) { return result; }
|
|
|
|
if (!host.match(localhostRegex)) {
|
|
|
|
throw new Error(`host not understood: ${host}`);
|
|
|
|
}
|
|
|
|
// Host is localhost[:NNNN], no org available.
|
|
|
|
return {};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* These settings get sent to the client along with the loaded page. At the minimum, the browser
|
|
|
|
* needs to know the URL of the home API server (e.g. api.getgrist.com).
|
|
|
|
*/
|
|
|
|
export interface GristLoadConfig {
|
|
|
|
// URL of the Home API server for the browser client to use.
|
|
|
|
homeUrl: string|null;
|
|
|
|
|
|
|
|
// When loading /doc/{docId}, we include the id used to assign the document (this is the docId).
|
|
|
|
assignmentId?: string;
|
|
|
|
|
|
|
|
// Org or "subdomain". When present, this overrides org information from the hostname. We rely
|
|
|
|
// on this for custom domains, but set it generally for all pages.
|
|
|
|
org?: string;
|
|
|
|
|
|
|
|
// Base domain for constructing new URLs, should start with "." and not include port, e.g.
|
|
|
|
// ".getgrist.com". It should be unset for localhost operation and in single-org mode.
|
|
|
|
baseDomain?: string;
|
|
|
|
|
|
|
|
// In single-org mode, this is the single well-known org. Suppress any org selection UI.
|
|
|
|
singleOrg?: string;
|
2022-12-13 01:06:15 +00:00
|
|
|
|
2022-11-28 20:19:31 +00:00
|
|
|
// Url for support for the browser client to use.
|
|
|
|
helpCenterUrl?: string;
|
2020-07-21 13:20:51 +00:00
|
|
|
|
2024-02-07 16:53:48 +00:00
|
|
|
// Url for free coaching call scheduling for the browser client to use.
|
|
|
|
freeCoachingCallUrl?: string;
|
|
|
|
|
2024-03-06 05:59:46 +00:00
|
|
|
// Url for "contact support" button on Grist's "not found" error page
|
|
|
|
contactSupportUrl?: string;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// When set, this directs the client to encode org information in path, not in domain.
|
|
|
|
pathOnly?: boolean;
|
|
|
|
|
|
|
|
// Type of error page to show. This is used for pages such as "signed-out" and "not-found",
|
|
|
|
// which don't include the full app.
|
|
|
|
errPage?: string;
|
|
|
|
|
|
|
|
// When errPage is a generic "other-error", this is the message to show.
|
|
|
|
errMessage?: string;
|
|
|
|
|
|
|
|
// URL for client to use for untrusted content.
|
2020-07-22 15:43:24 +00:00
|
|
|
pluginUrl?: string;
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
// Stripe API key for use on the client.
|
|
|
|
stripeAPIKey?: string;
|
|
|
|
|
|
|
|
// BeaconID for the support widget from HelpScout.
|
|
|
|
helpScoutBeaconId?: string;
|
|
|
|
|
|
|
|
// If set, enable anonymous sharing UI elements.
|
|
|
|
supportAnon?: boolean;
|
|
|
|
|
2023-09-08 13:05:52 +00:00
|
|
|
// If set, enable anonymous playground.
|
|
|
|
enableAnonPlayground?: boolean;
|
|
|
|
|
2021-09-15 23:35:21 +00:00
|
|
|
// If set, allow selection of the specified engines.
|
|
|
|
// TODO: move this list to a separate endpoint.
|
|
|
|
supportEngines?: EngineCode[];
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// Max upload allowed for imports (except .grist files), in bytes; 0 or omitted for unlimited.
|
|
|
|
maxUploadSizeImport?: number;
|
|
|
|
|
|
|
|
// Max upload allowed for attachments, in bytes; 0 or omitted for unlimited.
|
|
|
|
maxUploadSizeAttachment?: number;
|
|
|
|
|
|
|
|
// Pre-fetched call to getDoc for the doc being loaded.
|
|
|
|
getDoc?: {[id: string]: Document};
|
|
|
|
|
|
|
|
// Pre-fetched call to getWorker for the doc being loaded.
|
(core) make Grist easier to run with a single server
Summary:
This makes many small changes so that Grist is less fussy to run as a single instance behind a reverse proxy. Some users had difficulty with the self-connections Grist would make, due to internal network setup, and since these are unnecessary in any case in this scenario, they are now optimized away. Likewise some users had difficulties related to doc worker urls, which are now also optimized away. With these changes, users should be able to get a lot further on first try, at least far enough to open and edit documents.
The `GRIST_SINGLE_ORG` setting was proving a bit confusing, since it appeared to only work when set to `docs`. This diff
adds a check for whether the specified org exists, and if not, it creates it. This still depends on having a user email to make as the owner of the team, so there could be remaining difficulties there.
Test Plan: tested manually with nginx
Reviewers: jarek
Reviewed By: jarek
Differential Revision: https://phab.getgrist.com/D3299
2022-03-02 19:07:26 +00:00
|
|
|
getWorker?: {[id: string]: string|null};
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
// The timestamp when this gristConfig was generated.
|
|
|
|
timestampMs: number;
|
2021-07-13 07:29:38 +00:00
|
|
|
|
|
|
|
// Google Client Id, used in Google integration (ex: Google Drive Plugin)
|
|
|
|
googleClientId?: string;
|
2021-08-05 15:12:46 +00:00
|
|
|
|
2021-09-30 08:19:22 +00:00
|
|
|
// Max scope we can request for accessing files from Google Drive.
|
|
|
|
// Default used by Grist is https://www.googleapis.com/auth/drive.file:
|
|
|
|
// View and manage Google Drive files and folders that you have opened or created with this app.
|
|
|
|
// More on scopes: https://developers.google.com/identity/protocols/oauth2/scopes#drive
|
|
|
|
googleDriveScope?: string;
|
|
|
|
|
2021-08-05 15:12:46 +00:00
|
|
|
// List of registered plugins (used by HomePluginManager and DocPluginManager)
|
|
|
|
plugins?: LocalPlugin[];
|
2021-11-26 10:43:55 +00:00
|
|
|
|
|
|
|
// If custom widget list is available.
|
|
|
|
enableWidgetRepository?: boolean;
|
2022-01-19 22:16:16 +00:00
|
|
|
|
|
|
|
// Whether there is somewhere for survey data to go.
|
|
|
|
survey?: boolean;
|
2022-03-11 20:35:29 +00:00
|
|
|
|
|
|
|
// Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.
|
|
|
|
tagManagerId?: string;
|
2022-05-11 19:05:35 +00:00
|
|
|
|
2024-03-23 17:11:06 +00:00
|
|
|
activation?: ActivationState;
|
2022-05-27 11:03:56 +00:00
|
|
|
|
2023-05-20 23:58:41 +00:00
|
|
|
// List of enabled features.
|
|
|
|
features?: IFeature[];
|
2022-05-27 11:03:56 +00:00
|
|
|
|
|
|
|
// String to append to the end of the HTML document.title
|
|
|
|
pageTitleSuffix?: string;
|
2022-09-06 01:51:57 +00:00
|
|
|
|
|
|
|
// If custom CSS should be included in the head of each page.
|
|
|
|
enableCustomCss?: boolean;
|
2022-09-29 08:01:37 +00:00
|
|
|
|
|
|
|
// Supported languages for the UI. By default only english (en) is supported.
|
|
|
|
supportedLngs?: readonly string[];
|
|
|
|
|
|
|
|
// Loaded namespaces for translations.
|
|
|
|
namespaces?: readonly string[];
|
2022-10-17 09:47:16 +00:00
|
|
|
|
|
|
|
// TODO: remove when comments will be released.
|
|
|
|
featureComments?: boolean;
|
2022-12-13 01:06:15 +00:00
|
|
|
|
2023-03-23 18:22:28 +00:00
|
|
|
// TODO: remove once released.
|
|
|
|
featureFormulaAssistant?: boolean;
|
|
|
|
|
2023-08-29 14:50:42 +00:00
|
|
|
permittedCustomWidgets?: IAttachedCustomWidget[];
|
|
|
|
|
2023-08-18 20:14:42 +00:00
|
|
|
// Used to determine which disclosure links should be provided to user of
|
|
|
|
// formula assistance.
|
|
|
|
assistantService?: 'OpenAI' | undefined;
|
|
|
|
|
2022-12-13 01:06:15 +00:00
|
|
|
// Email address of the support user.
|
|
|
|
supportEmail?: string;
|
2023-01-24 13:13:18 +00:00
|
|
|
|
|
|
|
// Current user locale, read from the user options;
|
|
|
|
userLocale?: string;
|
2023-06-06 17:08:50 +00:00
|
|
|
|
|
|
|
// Telemetry config.
|
|
|
|
telemetry?: TelemetryConfig;
|
|
|
|
|
|
|
|
// The Grist deployment type (e.g. core, enterprise).
|
|
|
|
deploymentType?: GristDeploymentType;
|
2023-07-26 22:31:02 +00:00
|
|
|
|
|
|
|
// The org containing public templates and tutorials.
|
|
|
|
templateOrg?: string|null;
|
2023-09-26 08:09:06 +00:00
|
|
|
|
|
|
|
// Whether to show the "Delete Account" button in the account page.
|
|
|
|
canCloseAccount?: boolean;
|
2023-11-20 00:46:32 +00:00
|
|
|
|
|
|
|
experimentalPlugins?: boolean;
|
2022-05-27 11:03:56 +00:00
|
|
|
}
|
|
|
|
|
2023-05-20 23:58:41 +00:00
|
|
|
export const Features = StringUnion(
|
|
|
|
"helpCenter",
|
|
|
|
"billing",
|
|
|
|
"templates",
|
2024-01-08 16:26:30 +00:00
|
|
|
"createSite",
|
2023-05-20 23:58:41 +00:00
|
|
|
"multiSite",
|
|
|
|
"multiAccounts",
|
|
|
|
"sendToDrive",
|
|
|
|
"tutorials",
|
2024-03-23 17:11:06 +00:00
|
|
|
"supportGrist",
|
2023-05-20 23:58:41 +00:00
|
|
|
);
|
|
|
|
export type IFeature = typeof Features.type;
|
|
|
|
|
|
|
|
export function isFeatureEnabled(feature: IFeature): boolean {
|
|
|
|
return (getGristConfig().features || []).includes(feature);
|
2022-05-27 11:03:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export function getPageTitleSuffix(config?: GristLoadConfig) {
|
|
|
|
return config?.pageTitleSuffix ?? " - Grist";
|
2022-05-11 19:05:35 +00:00
|
|
|
}
|
|
|
|
|
2023-06-06 17:08:50 +00:00
|
|
|
export interface TelemetryConfig {
|
|
|
|
telemetryLevel: TelemetryLevel;
|
|
|
|
}
|
|
|
|
|
|
|
|
export const GristDeploymentTypes = StringUnion('saas', 'core', 'enterprise', 'electron', 'static');
|
|
|
|
export type GristDeploymentType = typeof GristDeploymentTypes.type;
|
|
|
|
|
2022-05-11 19:05:35 +00:00
|
|
|
/**
|
|
|
|
* For a packaged version of Grist that requires activation, this
|
|
|
|
* summarizes the current state. Not applicable to grist-core.
|
|
|
|
*/
|
|
|
|
export interface ActivationState {
|
2022-08-22 19:46:25 +00:00
|
|
|
trial?: { // Present when installation has not yet been activated.
|
|
|
|
days: number; // Max number of days allowed prior to activation.
|
|
|
|
expirationDate: string; // ISO8601 date that Grist will get cranky.
|
|
|
|
daysLeft: number; // Number of days left until Grist will get cranky.
|
2022-05-11 19:05:35 +00:00
|
|
|
}
|
2022-08-22 19:46:25 +00:00
|
|
|
needKey?: boolean; // Set when Grist is cranky and demanding activation.
|
|
|
|
key?: { // Set when Grist is activated.
|
|
|
|
expirationDate?: string; // ISO8601 date that Grist will need reactivation.
|
|
|
|
daysLeft?: number; // Number of days until Grist will need reactivation.
|
2022-05-11 19:05:35 +00:00
|
|
|
}
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
|
|
|
|
// non-zero length.
|
|
|
|
const subdomainRegex = /^[-a-z0-9]+$/i;
|
|
|
|
|
|
|
|
export interface OrgParts {
|
|
|
|
subdomain: string|null;
|
|
|
|
orgFromHost: string|null;
|
|
|
|
orgFromPath: string|null;
|
|
|
|
pathRemainder: string;
|
|
|
|
mismatch: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns true if code is running in client, false if running in server.
|
|
|
|
*/
|
|
|
|
export function isClient() {
|
|
|
|
return (typeof window !== 'undefined') && window && window.location && window.location.hostname;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a known org "subdomain" if Grist is configured in single-org mode
|
|
|
|
* (GRIST_SINGLE_ORG=<org> on the server) or if the page includes an org in gristConfig.
|
|
|
|
*/
|
|
|
|
export function getKnownOrg(): string|null {
|
|
|
|
if (isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
2022-03-07 16:40:46 +00:00
|
|
|
return (gristConfig && gristConfig.singleOrg) || null;
|
2020-07-21 13:20:51 +00:00
|
|
|
} else {
|
|
|
|
return process.env.GRIST_SINGLE_ORG || null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-06 05:59:46 +00:00
|
|
|
export function getHelpCenterUrl(): string {
|
|
|
|
const defaultUrl = "https://support.getgrist.com";
|
|
|
|
if(isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
|
|
|
return gristConfig && gristConfig.helpCenterUrl || defaultUrl;
|
|
|
|
} else {
|
|
|
|
return process.env.GRIST_HELP_CENTER || defaultUrl;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function getFreeCoachingCallUrl(): string {
|
|
|
|
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
|
2022-11-28 20:19:31 +00:00
|
|
|
if(isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
2024-03-06 05:59:46 +00:00
|
|
|
return gristConfig && gristConfig.freeCoachingCallUrl || defaultUrl;
|
2022-11-28 20:19:31 +00:00
|
|
|
} else {
|
2024-03-06 05:59:46 +00:00
|
|
|
return process.env.FREE_COACHING_CALL_URL || defaultUrl;
|
2022-11-28 20:19:31 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-03-06 05:59:46 +00:00
|
|
|
export function getContactSupportUrl(): string {
|
|
|
|
const defaultUrl = "https://www.getgrist.com/contact/";
|
2024-02-07 16:53:48 +00:00
|
|
|
if(isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
2024-03-06 05:59:46 +00:00
|
|
|
return gristConfig && gristConfig.contactSupportUrl || defaultUrl;
|
2024-02-07 16:53:48 +00:00
|
|
|
} else {
|
2024-03-06 05:59:46 +00:00
|
|
|
return process.env.GRIST_CONTACT_SUPPORT_URL || defaultUrl;
|
2024-02-07 16:53:48 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
(core) make Grist easier to run with a single server
Summary:
This makes many small changes so that Grist is less fussy to run as a single instance behind a reverse proxy. Some users had difficulty with the self-connections Grist would make, due to internal network setup, and since these are unnecessary in any case in this scenario, they are now optimized away. Likewise some users had difficulties related to doc worker urls, which are now also optimized away. With these changes, users should be able to get a lot further on first try, at least far enough to open and edit documents.
The `GRIST_SINGLE_ORG` setting was proving a bit confusing, since it appeared to only work when set to `docs`. This diff
adds a check for whether the specified org exists, and if not, it creates it. This still depends on having a user email to make as the owner of the team, so there could be remaining difficulties there.
Test Plan: tested manually with nginx
Reviewers: jarek
Reviewed By: jarek
Differential Revision: https://phab.getgrist.com/D3299
2022-03-02 19:07:26 +00:00
|
|
|
/**
|
|
|
|
* Like getKnownOrg, but respects singleOrg/GRIST_SINGLE_ORG strictly.
|
|
|
|
* The main difference in behavior would be for orgs with custom domains
|
|
|
|
* served from a shared pool of servers, for which gristConfig.org would
|
|
|
|
* be set, but not gristConfig.singleOrg.
|
|
|
|
*/
|
|
|
|
export function getSingleOrg(): string|null {
|
|
|
|
if (isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
|
|
|
return (gristConfig && gristConfig.singleOrg) || null;
|
|
|
|
} else {
|
|
|
|
return process.env.GRIST_SINGLE_ORG || null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* Returns true if org must be encoded in path, not in domain. Determined from
|
2022-03-07 16:40:46 +00:00
|
|
|
* gristConfig on the client. On the server, returns true if the host is
|
2020-07-21 13:20:51 +00:00
|
|
|
* supplied and is 'localhost', or if GRIST_ORG_IN_PATH is set to 'true'.
|
|
|
|
*/
|
|
|
|
export function isOrgInPathOnly(host?: string): boolean {
|
|
|
|
if (isClient()) {
|
|
|
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
|
|
|
return (gristConfig && gristConfig.pathOnly) || false;
|
|
|
|
} else {
|
2022-04-01 21:31:24 +00:00
|
|
|
if (host && host.match(localhostRegex)) { return true; }
|
2020-07-21 13:20:51 +00:00
|
|
|
return (process.env.GRIST_ORG_IN_PATH === 'true');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Extract an organization name from the host. Returns null if an organization name
|
|
|
|
// could not be recovered. Organization name may be overridden by server configuration.
|
|
|
|
export function getOrgFromHost(reqHost: string): string|null {
|
|
|
|
const singleOrg = getKnownOrg();
|
|
|
|
if (singleOrg) { return singleOrg; }
|
|
|
|
if (isOrgInPathOnly()) { return null; }
|
|
|
|
return parseSubdomain(reqHost).org || null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get any information about an organization that is embedded in the host name or the
|
|
|
|
* path.
|
|
|
|
* For example, on nasa.getgrist.com, orgFromHost and subdomain will be set to "nasa".
|
|
|
|
* On localhost:8000/o/nasa, orgFromPath and subdomain will be set to "nasa".
|
|
|
|
* On nasa.getgrist.com/o/nasa, orgFromHost, orgFromPath, and subdomain will all be "nasa".
|
|
|
|
* On spam.getgrist.com/o/nasa, orgFromHost will be "spam", orgFromPath will be "nasa",
|
|
|
|
* subdomain will be null, and mismatch will be true.
|
|
|
|
*/
|
|
|
|
export function extractOrgParts(reqHost: string|undefined, reqPath: string): OrgParts {
|
|
|
|
let orgFromHost: string|null = getKnownOrg();
|
|
|
|
|
|
|
|
if (!orgFromHost && reqHost) {
|
|
|
|
orgFromHost = getOrgFromHost(reqHost);
|
|
|
|
if (orgFromHost) {
|
|
|
|
// Some subdomains are shared, and do not reflect the name of an organization.
|
2024-03-05 13:35:48 +00:00
|
|
|
// See /documentation/urls.md for a list.
|
2020-07-21 13:20:51 +00:00
|
|
|
if (/^(api|v1-.*|doc-worker-.*)$/.test(orgFromHost)) {
|
|
|
|
orgFromHost = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const part = parseFirstUrlPart('o', reqPath);
|
|
|
|
if (part.value) {
|
|
|
|
const orgFromPath = part.value.toLowerCase();
|
|
|
|
const mismatch = Boolean(orgFromHost && orgFromPath && (orgFromHost !== orgFromPath));
|
|
|
|
const subdomain = mismatch ? null : orgFromPath;
|
|
|
|
return {orgFromHost, orgFromPath, pathRemainder: part.path, mismatch, subdomain};
|
|
|
|
}
|
|
|
|
return {orgFromHost, orgFromPath: null, pathRemainder: reqPath, mismatch: false, subdomain: orgFromHost};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* When a prefix is extracted from the path, the remainder of the path may be empty.
|
|
|
|
* This method makes sure there is at least a "/".
|
|
|
|
*/
|
|
|
|
export function sanitizePathTail(path: string|undefined) {
|
|
|
|
path = path || '/';
|
|
|
|
return (path.startsWith('/') ? '' : '/') + path;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
* If path starts with /{tag}/{value}{/rest}, returns value and the remaining path (/rest).
|
|
|
|
* Otherwise, returns value of undefined and the path unchanged.
|
|
|
|
* E.g. parseFirstUrlPart('o', '/o/foo/bar') returns {value: 'foo', path: '/bar'}.
|
|
|
|
*/
|
|
|
|
export function parseFirstUrlPart(tag: string, path: string): {value?: string, path: string} {
|
|
|
|
const match = path.match(/^\/([^/?#]+)\/([^/?#]+)(.*)$/);
|
|
|
|
if (match && match[1] === tag) {
|
|
|
|
return {value: match[2], path: sanitizePathTail(match[3])};
|
|
|
|
} else {
|
|
|
|
return {path};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
* 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.
|
2020-07-21 13:20:51 +00:00
|
|
|
*/
|
|
|
|
export interface UrlIdParts {
|
|
|
|
trunkId: string;
|
|
|
|
forkId?: string;
|
|
|
|
forkUserId?: number;
|
|
|
|
snapshotId?: string;
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
shareKey?: string;
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Parse a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId
|
|
|
|
// or trunkId[....]~v=snapshotId
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
// or <SHARE-KEY-PREFIX>shareKey
|
2020-07-21 13:20:51 +00:00
|
|
|
export function parseUrlId(urlId: string): UrlIdParts {
|
|
|
|
let snapshotId: string|undefined;
|
|
|
|
const parts = urlId.split('~');
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
const bareParts = parts.filter(part => !part.includes('v='));
|
2020-07-21 13:20:51 +00:00
|
|
|
for (const part of parts) {
|
|
|
|
if (part.startsWith('v=')) {
|
|
|
|
snapshotId = decodeURIComponent(part.substr(2).replace(/_/g, '%'));
|
|
|
|
}
|
|
|
|
}
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
const trunkId = bareParts[0];
|
|
|
|
// IDs starting with SHARE_KEY_PREFIX are in fact shares.
|
|
|
|
const shareKey = removePrefix(trunkId, SHARE_KEY_PREFIX) || undefined;
|
2020-07-21 13:20:51 +00:00
|
|
|
return {
|
|
|
|
trunkId: bareParts[0],
|
|
|
|
forkId: bareParts[1],
|
|
|
|
forkUserId: (bareParts[2] !== undefined) ? parseInt(bareParts[2], 10) : undefined,
|
|
|
|
snapshotId,
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
shareKey,
|
2020-07-21 13:20:51 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Construct a string of the form trunkId or trunkId~forkId or trunkId~forkId~forkUserId
|
|
|
|
// or trunkId[....]~v=snapshotId
|
|
|
|
export function buildUrlId(parts: UrlIdParts): string {
|
|
|
|
let token = [parts.trunkId, parts.forkId, parts.forkUserId].filter(x => x !== undefined).join('~');
|
|
|
|
if (parts.snapshotId) {
|
|
|
|
// This could be an S3 VersionId, about which AWS makes few promises.
|
|
|
|
// encodeURIComponent leaves untouched the following:
|
|
|
|
// alphabetic; decimal; any of: - _ . ! ~ * ' ( )
|
|
|
|
// We further encode _.!~*'() to fit within existing limits on what characters
|
|
|
|
// may be in a docId (leaving just the hyphen, which is permitted). The limits
|
|
|
|
// could be loosened, but without much benefit.
|
|
|
|
const codedSnapshotId = encodeURIComponent(parts.snapshotId)
|
2024-02-20 15:46:38 +00:00
|
|
|
.replace(/[_.!~*'()-]/g, ch => `_${ch.charCodeAt(0).toString(16).toUpperCase()}`)
|
2020-07-21 13:20:51 +00:00
|
|
|
.replace(/%/g, '_');
|
|
|
|
token = `${token}~v=${codedSnapshotId}`;
|
|
|
|
}
|
|
|
|
return token;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Values that may be encoded in a hash in a document url.
|
|
|
|
*/
|
|
|
|
export interface HashLink {
|
|
|
|
sectionId?: number;
|
2021-09-23 22:47:36 +00:00
|
|
|
rowId?: UIRowId;
|
2020-07-21 13:20:51 +00:00
|
|
|
colRef?: number;
|
2022-08-24 11:43:15 +00:00
|
|
|
popup?: boolean;
|
2023-03-27 17:25:25 +00:00
|
|
|
rickRow?: boolean;
|
2023-11-20 00:46:32 +00:00
|
|
|
recordCard?: boolean;
|
2023-12-29 05:17:50 +00:00
|
|
|
linkingRowIds?: UIRowId[];
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Check whether a urlId is a prefix of the docId, and adequately long to be
|
|
|
|
// 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; }
|
(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
2024-01-03 16:53:20 +00:00
|
|
|
return doc.id.startsWith(doc.urlId) || doc.urlId.startsWith(SHARE_KEY_PREFIX);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
2024-01-26 04:58:52 +00:00
|
|
|
// Convert the name of a document into a slug. The slugify library normalizes unicode characters,
|
|
|
|
// replaces those with a reasonable ascii representation. Only alphanumerics are retained, and
|
|
|
|
// spaces are replaced with hyphens.
|
2020-07-21 13:20:51 +00:00
|
|
|
function nameToSlug(name: string): string {
|
2024-01-26 04:58:52 +00:00
|
|
|
return slugify(name, {strict: true});
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Returns a slug for the given docId/urlId/name, or undefined if a slug should
|
|
|
|
// not be used.
|
|
|
|
export function getSlugIfNeeded(doc: {id: string, urlId: string|null, name: string}): string|undefined {
|
|
|
|
if (!shouldIncludeSlug(doc)) { return; }
|
|
|
|
return nameToSlug(doc.name);
|
|
|
|
}
|
2023-05-23 19:17:28 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* It is possible we want to remap Grist URLs in some way - specifically,
|
|
|
|
* grist-static does this. We allow for a hook that is called after
|
|
|
|
* encoding state as a URL, and a hook that is called before decoding
|
|
|
|
* state from a URL.
|
|
|
|
*/
|
|
|
|
export interface UrlTweaks {
|
|
|
|
/**
|
|
|
|
* Tweak an encoded URL. Operates on the URL directly, in place.
|
|
|
|
*/
|
|
|
|
postEncode?(options: {
|
|
|
|
url: URL,
|
|
|
|
parts: string[],
|
|
|
|
state: IGristUrlState,
|
|
|
|
baseLocation: Location | URL,
|
|
|
|
}): void;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Tweak a URL prior to decoding it. Operates on the URL directly, in place.
|
|
|
|
*/
|
|
|
|
preDecode?(options: {
|
|
|
|
url: URL,
|
|
|
|
}): void;
|
|
|
|
}
|