2020-07-21 13:20:51 +00:00
|
|
|
import {ApiError} from 'app/common/ApiError';
|
2022-05-16 17:41:12 +00:00
|
|
|
import {DocumentUsage} from 'app/common/DocUsage';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {Role} from 'app/common/roles';
|
2023-07-04 21:21:34 +00:00
|
|
|
import {DocumentOptions, DocumentProperties, documentPropertyKeys, DocumentType,
|
|
|
|
NEW_DOCUMENT_CODE} from "app/common/UserAPI";
|
2020-07-21 13:20:51 +00:00
|
|
|
import {nativeValues} from 'app/gen-server/lib/values';
|
|
|
|
import {Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryColumn} from "typeorm";
|
|
|
|
import {AclRuleDoc} from "./AclRule";
|
|
|
|
import {Alias} from "./Alias";
|
|
|
|
import {Resource} from "./Resource";
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
import {Secret} from "./Secret";
|
2020-07-21 13:20:51 +00:00
|
|
|
import {Workspace} from "./Workspace";
|
|
|
|
|
|
|
|
// Acceptable ids for use in document urls.
|
|
|
|
const urlIdRegex = /^[-a-z0-9]+$/i;
|
|
|
|
|
|
|
|
function isValidUrlId(urlId: string) {
|
|
|
|
if (urlId === NEW_DOCUMENT_CODE) { return false; }
|
|
|
|
return urlIdRegex.exec(urlId);
|
|
|
|
}
|
|
|
|
|
|
|
|
@Entity({name: 'docs'})
|
|
|
|
export class Document extends Resource {
|
|
|
|
|
2023-05-23 19:17:28 +00:00
|
|
|
@PrimaryColumn({type: String})
|
2020-07-21 13:20:51 +00:00
|
|
|
public id: string;
|
|
|
|
|
|
|
|
@ManyToOne(type => Workspace)
|
|
|
|
@JoinColumn({name: 'workspace_id'})
|
|
|
|
public workspace: Workspace;
|
|
|
|
|
|
|
|
@OneToMany(type => AclRuleDoc, aclRule => aclRule.document)
|
|
|
|
public aclRules: AclRuleDoc[];
|
|
|
|
|
|
|
|
// Indicates whether the doc is pinned to the org it lives in.
|
2023-05-23 19:17:28 +00:00
|
|
|
@Column({name: 'is_pinned', type: Boolean, default: false})
|
2020-07-21 13:20:51 +00:00
|
|
|
public isPinned: boolean;
|
|
|
|
|
|
|
|
// Property that may be returned when the doc is fetched to indicate the access the
|
|
|
|
// fetching user has on the doc, i.e. 'owners', 'editors', 'viewers'
|
|
|
|
public access: Role|null;
|
|
|
|
|
(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
|
|
|
// Property that may be returned when the doc is fetched to indicate the share it
|
|
|
|
// is being accessed with. The identifier used is the linkId, which is the share
|
|
|
|
// identifier that is the same between the home database and the document.
|
|
|
|
// The linkId is not a secret, and need only be unique within a document.
|
|
|
|
public linkId?: string|null;
|
|
|
|
|
2020-07-27 17:26:28 +00:00
|
|
|
// Property set for forks, containing access the fetching user has on the trunk.
|
|
|
|
public trunkAccess?: Role|null;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
// a computed column with permissions.
|
|
|
|
// {insert: false} makes sure typeorm doesn't try to put values into such
|
|
|
|
// a column when creating documents.
|
|
|
|
@Column({name: 'permissions', type: 'text', select: false, insert: false, update: false})
|
|
|
|
public permissions?: any;
|
|
|
|
|
|
|
|
@Column({name: 'url_id', type: 'text', nullable: true})
|
|
|
|
public urlId: string|null;
|
|
|
|
|
|
|
|
@Column({name: 'removed_at', type: nativeValues.dateTimeType, nullable: true})
|
|
|
|
public removedAt: Date|null;
|
|
|
|
|
(core) Grace period and delete-only mode when exceeding row limit
Summary:
Builds upon https://phab.getgrist.com/D3328
- Add HomeDB column `Document.gracePeriodStart`
- When the row count moves above the limit, set it to the current date. When it moves below, set it to null.
- Add DataLimitStatus type indicating if the document is approaching the limit, is in a grace period, or is in delete only mode if the grace period started at least 14 days ago. Compute it in ActiveDoc and send it to client when opening.
- Only allow certain user actions when in delete-only mode.
Follow-up tasks related to this diff:
- When DataLimitStatus in the client is non-empty, show a banner to the appropriate users.
- Only send DataLimitStatus to users with the appropriate access. There's no risk landing this now since real users will only see null until free team sites are released.
- Update DataLimitStatus immediately in the client when it changes, e.g. when user actions are applied or the product is changed. Right now it's only sent when the document loads.
- Update row limit, grace period start, and data limit status in ActiveDoc when the product changes, i.e. the user upgrades/downgrades.
- Account for data size when computing data limit status, not just row counts.
See also the tasks mentioned in https://phab.getgrist.com/D3331
Test Plan: Extended FreeTeam nbrowser test, testing the 4 statuses.
Reviewers: georgegevoian
Reviewed By: georgegevoian
Differential Revision: https://phab.getgrist.com/D3331
2022-03-24 12:05:51 +00:00
|
|
|
@Column({name: 'grace_period_start', type: nativeValues.dateTimeType, nullable: true})
|
|
|
|
public gracePeriodStart: Date|null;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
@OneToMany(type => Alias, alias => alias.doc)
|
|
|
|
public aliases: Alias[];
|
|
|
|
|
2021-07-15 21:38:21 +00:00
|
|
|
@Column({name: 'options', type: nativeValues.jsonEntityType, nullable: true})
|
|
|
|
public options: DocumentOptions | null;
|
|
|
|
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
@OneToMany(_type => Secret, secret => secret.doc)
|
|
|
|
public secrets: Secret[];
|
|
|
|
|
2022-05-16 17:41:12 +00:00
|
|
|
@Column({name: 'usage', type: nativeValues.jsonEntityType, nullable: true})
|
|
|
|
public usage: DocumentUsage | null;
|
|
|
|
|
2023-02-20 02:51:40 +00:00
|
|
|
@Column({name: 'created_by', type: 'integer', nullable: true})
|
|
|
|
public createdBy: number|null;
|
|
|
|
|
|
|
|
@Column({name: 'trunk_id', type: 'text', nullable: true})
|
|
|
|
public trunkId: string|null;
|
|
|
|
|
|
|
|
@ManyToOne(_type => Document, document => document.forks)
|
|
|
|
@JoinColumn({name: 'trunk_id'})
|
|
|
|
public trunk: Document|null;
|
|
|
|
|
|
|
|
@OneToMany(_type => Document, document => document.trunk)
|
|
|
|
public forks: Document[];
|
|
|
|
|
|
|
|
@Column({name: 'type', type: 'text', nullable: true})
|
|
|
|
public type: DocumentType|null;
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
public checkProperties(props: any): props is Partial<DocumentProperties> {
|
|
|
|
return super.checkProperties(props, documentPropertyKeys);
|
|
|
|
}
|
|
|
|
|
2023-07-04 21:21:34 +00:00
|
|
|
public updateFromProperties(props: Partial<DocumentProperties>) {
|
2020-07-21 13:20:51 +00:00
|
|
|
super.updateFromProperties(props);
|
|
|
|
if (props.isPinned !== undefined) { this.isPinned = props.isPinned; }
|
|
|
|
if (props.urlId !== undefined) {
|
|
|
|
if (props.urlId !== null && !isValidUrlId(props.urlId)) {
|
|
|
|
throw new ApiError('invalid urlId', 400);
|
|
|
|
}
|
|
|
|
this.urlId = props.urlId;
|
|
|
|
}
|
2023-02-20 02:51:40 +00:00
|
|
|
if (props.type !== undefined) { this.type = props.type; }
|
2021-07-15 21:38:21 +00:00
|
|
|
if (props.options !== undefined) {
|
|
|
|
// Options are merged over the existing state - unless options
|
|
|
|
// object is set to "null", in which case the state is wiped
|
|
|
|
// completely.
|
|
|
|
if (props.options === null) {
|
|
|
|
this.options = null;
|
|
|
|
} else {
|
|
|
|
this.options = this.options || {};
|
|
|
|
if (props.options.description !== undefined) {
|
|
|
|
this.options.description = props.options.description;
|
|
|
|
}
|
|
|
|
if (props.options.openMode !== undefined) {
|
|
|
|
this.options.openMode = props.options.openMode;
|
|
|
|
}
|
|
|
|
if (props.options.icon !== undefined) {
|
|
|
|
this.options.icon = sanitizeIcon(props.options.icon);
|
|
|
|
}
|
2023-02-13 20:52:17 +00:00
|
|
|
if (props.options.externalId !== undefined) {
|
|
|
|
this.options.externalId = props.options.externalId;
|
|
|
|
}
|
2023-03-22 13:48:50 +00:00
|
|
|
if (props.options.tutorial !== undefined) {
|
|
|
|
// Tutorial metadata is merged over the existing state - unless
|
|
|
|
// metadata is set to "null", in which case the state is wiped
|
|
|
|
// completely.
|
|
|
|
if (props.options.tutorial === null) {
|
|
|
|
this.options.tutorial = null;
|
|
|
|
} else {
|
|
|
|
this.options.tutorial = this.options.tutorial || {};
|
2023-04-06 15:10:29 +00:00
|
|
|
if (props.options.tutorial.numSlides !== undefined) {
|
|
|
|
this.options.tutorial.numSlides = props.options.tutorial.numSlides;
|
2023-06-09 16:32:40 +00:00
|
|
|
}
|
|
|
|
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
|
|
|
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
2023-04-06 15:10:29 +00:00
|
|
|
}
|
2023-03-22 13:48:50 +00:00
|
|
|
}
|
|
|
|
}
|
2021-07-15 21:38:21 +00:00
|
|
|
// Normalize so that null equates with absence.
|
|
|
|
for (const key of Object.keys(this.options) as Array<keyof DocumentOptions>) {
|
|
|
|
if (this.options[key] === null) {
|
|
|
|
delete this.options[key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Normalize so that no options set equates with absense.
|
|
|
|
if (Object.keys(this.options).length === 0) {
|
|
|
|
this.options = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check that icon points to an expected location. This will definitely
|
|
|
|
// need changing, it is just a placeholder as the icon feature is developed.
|
|
|
|
function sanitizeIcon(icon: string|null) {
|
|
|
|
if (icon === null) { return icon; }
|
|
|
|
const url = new URL(icon);
|
|
|
|
if (url.protocol !== 'https:' || url.host !== 'grist-static.com' || !url.pathname.startsWith('/icons/')) {
|
|
|
|
throw new ApiError('invalid document icon', 400);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
2021-07-15 21:38:21 +00:00
|
|
|
return url.href;
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|