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-02-20 02:51:40 +00:00
|
|
|
import {DocumentOptions, DocumentProperties, documentPropertyKeys,
|
2023-04-06 15:10:29 +00:00
|
|
|
DocumentType, NEW_DOCUMENT_CODE, TutorialMetadata} from "app/common/UserAPI";
|
|
|
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
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 {
|
|
|
|
|
|
|
|
@PrimaryColumn()
|
|
|
|
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.
|
|
|
|
@Column({name: 'is_pinned', default: false})
|
|
|
|
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;
|
|
|
|
|
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-04-06 15:10:29 +00:00
|
|
|
public updateFromProperties(props: Partial<DocumentProperties>, dbManager?: HomeDBManager) {
|
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 || {};
|
|
|
|
if (props.options.tutorial.lastSlideIndex !== undefined) {
|
|
|
|
this.options.tutorial.lastSlideIndex = props.options.tutorial.lastSlideIndex;
|
|
|
|
}
|
2023-04-06 15:10:29 +00:00
|
|
|
if (props.options.tutorial.numSlides !== undefined) {
|
|
|
|
this.options.tutorial.numSlides = props.options.tutorial.numSlides;
|
|
|
|
if (dbManager && props.options?.tutorial?.lastSlideIndex !== undefined) {
|
|
|
|
this._emitTutorialProgressChangeEvent(dbManager, this.options.tutorial);
|
|
|
|
}
|
|
|
|
}
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-04-06 15:10:29 +00:00
|
|
|
|
|
|
|
private _emitTutorialProgressChangeEvent(
|
|
|
|
dbManager: HomeDBManager,
|
|
|
|
tutorialMetadata: TutorialMetadata
|
|
|
|
) {
|
|
|
|
const lastSlideIndex = tutorialMetadata.lastSlideIndex;
|
|
|
|
const numSlides = tutorialMetadata.numSlides;
|
|
|
|
const percentComplete = lastSlideIndex !== undefined && numSlides !== undefined
|
|
|
|
? Math.floor((lastSlideIndex / numSlides) * 100)
|
|
|
|
: undefined;
|
|
|
|
dbManager?.emit('tutorialProgressChange', {
|
|
|
|
tutorialForkId: this.id,
|
|
|
|
tutorialTrunkId: this.trunkId,
|
|
|
|
lastSlideIndex,
|
|
|
|
numSlides,
|
|
|
|
percentComplete,
|
|
|
|
});
|
|
|
|
}
|
2021-07-15 21:38:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
}
|