(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick 2024-07-08 08:52:56 -04:00
commit 6171a012db
59 changed files with 499 additions and 204 deletions

43
.github/workflows/fly-build.yml vendored Normal file
View File

@ -0,0 +1,43 @@
# fly-deploy will be triggered on completion of this workflow to actually deploy the code to fly.io.
name: fly.io Build
on:
pull_request:
branches: [ main ]
types: [labeled, opened, synchronize, reopened]
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
# Build when the 'preview' label is added, or when PR is updated with this label present.
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'preview'))
steps:
- uses: actions/checkout@v4
- name: Build and export Docker image
id: docker-build
run: >
docker build -t grist-core:preview . &&
docker image save grist-core:preview -o grist-core.tar
- name: Save PR information
run: |
echo PR_NUMBER=${{ github.event.number }} >> ./pr-info.txt
echo PR_SOURCE=${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }} >> ./pr-info.txt
echo PR_SHASUM=${{ github.event.pull_request.head.sha }} >> ./pr-info.txt
# PR_SOURCE looks like <owner>/<repo>-<branch>.
# For example, if the GitHub user "foo" forked grist-core as "grist-bar", and makes a PR from their branch named "baz",
# it will be "foo/grist-bar-baz". deploy.js later replaces "/" with "-", making it "foo-grist-bar-baz".
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: |
./grist-core.tar
./pr-info.txt
if-no-files-found: "error"

View File

@ -1,4 +1,4 @@
name: Fly Cleanup name: fly.io Cleanup
on: on:
schedule: schedule:
# Once a day, clean up jobs marked as expired # Once a day, clean up jobs marked as expired
@ -12,12 +12,12 @@ env:
jobs: jobs:
clean: clean:
name: Clean stale deployed apps name: Clean stale deployed apps
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository_owner == 'gristlabs' if: github.repository_owner == 'gristlabs'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master - uses: superfly/flyctl-actions/setup-flyctl@master
with: with:
version: 0.1.66 version: 0.2.72
- run: node buildtools/fly-deploy.js clean - run: node buildtools/fly-deploy.js clean

70
.github/workflows/fly-deploy.yml vendored Normal file
View File

@ -0,0 +1,70 @@
# Follow-up of fly-build, with access to secrets for making deployments.
# This workflow runs in the target repo context. It does not, and should never execute user-supplied code.
# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
name: fly.io Deploy
on:
workflow_run:
workflows: ["fly.io Build"]
types:
- completed
jobs:
deploy:
name: Deploy app to fly.io
runs-on: ubuntu-latest
if: |
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v4
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.2.72
- name: Download artifacts
uses: actions/github-script@v7
with:
script: |
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "docker-image"
})[0];
var download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/docker-image.zip', Buffer.from(download.data));
- name: Extract artifacts
id: extract_artifacts
run: |
unzip docker-image.zip
cat ./pr-info.txt >> $GITHUB_OUTPUT
- name: Load Docker image
run: docker load --input grist-core.tar
- name: Deploy to fly.io
id: fly_deploy
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
BRANCH_NAME: ${{ steps.extract_artifacts.outputs.PR_SOURCE }}
run: |
node buildtools/fly-deploy.js deploy
flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: ${{ steps.extract_artifacts.outputs.PR_NUMBER }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `Deployed commit \`${{ steps.extract_artifacts.outputs.PR_SHASUM }}\` as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
})

36
.github/workflows/fly-destroy.yml vendored Normal file
View File

@ -0,0 +1,36 @@
# This workflow runs in the target repo context, as it is triggered via pull_request_target.
# It does not, and should not have access to code in the PR.
# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/
name: fly.io Destroy
on:
pull_request_target:
branches: [ main ]
types: [unlabeled, closed]
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
jobs:
destroy:
name: Remove app from fly.io
runs-on: ubuntu-latest
# Remove the deployment when 'preview' label is removed, or the PR is closed.
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' && github.event.label.name == 'preview')))
steps:
- uses: actions/checkout@v4
- name: Set up flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.2.72
- name: Destroy fly.io app
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
BRANCH_NAME: ${{ github.event.pull_request.head.repo.full_name }}-${{ github.event.pull_request.head.ref }}
# See fly-build for what BRANCH_NAME looks like.
id: fly_destroy
run: node buildtools/fly-deploy.js destroy

View File

@ -1,64 +0,0 @@
name: Fly Deploy
on:
pull_request:
branches: [ main ]
types: [labeled, unlabeled, closed, opened, synchronize, reopened]
# Allows running this workflow manually from the Actions tab
workflow_dispatch:
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
# Deploy when the 'preview' label is added, or when PR is updated with this label present.
if: |
github.repository_owner == 'gristlabs' &&
github.event_name == 'pull_request' && (
github.event.action == 'labeled' ||
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
) &&
contains(github.event.pull_request.labels.*.name, 'preview')
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.1.89
- id: fly_deploy
run: |
node buildtools/fly-deploy.js deploy
flyctl config -c ./fly.toml env | awk '/APP_HOME_URL/{print "DEPLOY_URL=" $2}' >> $GITHUB_OUTPUT
flyctl config -c ./fly.toml env | awk '/FLY_DEPLOY_EXPIRATION/{print "EXPIRES=" $2}' >> $GITHUB_OUTPUT
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Deployed as ${{ steps.fly_deploy.outputs.DEPLOY_URL }} (until ${{ steps.fly_deploy.outputs.EXPIRES }})`
})
destroy:
name: Remove app
runs-on: ubuntu-latest
# Remove the deployment when 'preview' label is removed, or the PR is closed.
if: |
github.repository_owner == 'gristlabs' &&
github.event_name == 'pull_request' &&
(github.event.action == 'closed' ||
(github.event.action == 'unlabeled' && github.event.label.name == 'preview'))
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: 0.1.89
- id: fly_destroy
run: node buildtools/fly-deploy.js destroy

View File

@ -107,6 +107,12 @@ const WEBHOOK_COLUMNS = [
type: 'Text', type: 'Text',
label: t('Status'), label: t('Status'),
}, },
{
id: VirtualId(),
colId: 'authorization',
type: 'Text',
label: t('Header Authorization'),
},
] as const; ] as const;
/** /**
@ -114,10 +120,11 @@ const WEBHOOK_COLUMNS = [
*/ */
const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [ const WEBHOOK_VIEW_FIELDS: Array<(typeof WEBHOOK_COLUMNS)[number]['colId']> = [
'name', 'memo', 'name', 'memo',
'eventTypes', 'url', 'eventTypes', 'tableId',
'tableId', 'isReadyColumn', 'watchedColIdsText', 'isReadyColumn',
'watchedColIdsText', 'webhookId', 'url', 'authorization',
'enabled', 'status' 'webhookId', 'enabled',
'status'
]; ];
/** /**
@ -136,7 +143,7 @@ class WebhookExternalTable implements IExternalTable {
public name = 'GristHidden_WebhookTable'; public name = 'GristHidden_WebhookTable';
public initialActions = _prepareWebhookInitialActions(this.name); public initialActions = _prepareWebhookInitialActions(this.name);
public saveableFields = [ public saveableFields = [
'tableId', 'watchedColIdsText', 'url', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn', 'tableId', 'watchedColIdsText', 'url', 'authorization', 'eventTypes', 'enabled', 'name', 'memo', 'isReadyColumn',
]; ];
public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]); public webhooks: ObservableArray<UIWebhookSummary> = observableArray<UIWebhookSummary>([]);

View File

@ -14,6 +14,7 @@ export const Webhook = t.iface([], {
export const WebhookFields = t.iface([], { export const WebhookFields = t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"tableId": "string", "tableId": "string",
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),
@ -29,6 +30,7 @@ export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('ret
export const WebhookSubscribe = t.iface([], { export const WebhookSubscribe = t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))), "eventTypes": t.array(t.union(t.lit("add"), t.lit("update"))),
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),
"enabled": t.opt("boolean"), "enabled": t.opt("boolean"),
@ -45,6 +47,7 @@ export const WebhookSummary = t.iface([], {
"id": "string", "id": "string",
"fields": t.iface([], { "fields": t.iface([], {
"url": "string", "url": "string",
"authorization": t.opt("string"),
"unsubscribeKey": "string", "unsubscribeKey": "string",
"eventTypes": t.array("string"), "eventTypes": t.array("string"),
"isReadyColumn": t.union("string", "null"), "isReadyColumn": t.union("string", "null"),
@ -64,6 +67,7 @@ export const WebhookUpdate = t.iface([], {
export const WebhookPatch = t.iface([], { export const WebhookPatch = t.iface([], {
"url": t.opt("string"), "url": t.opt("string"),
"authorization": t.opt("string"),
"eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))), "eventTypes": t.opt(t.array(t.union(t.lit("add"), t.lit("update")))),
"tableId": t.opt("string"), "tableId": t.opt("string"),
"watchedColIds": t.opt(t.array("string")), "watchedColIds": t.opt(t.array("string")),

View File

@ -8,6 +8,7 @@ export interface Webhook {
export interface WebhookFields { export interface WebhookFields {
url: string; url: string;
authorization?: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
tableId: string; tableId: string;
watchedColIds?: string[]; watchedColIds?: string[];
@ -26,6 +27,7 @@ export type WebhookStatus = 'idle'|'sending'|'retrying'|'postponed'|'error'|'inv
// tableId from the url) but generics are not yet supported by ts-interface-builder // tableId from the url) but generics are not yet supported by ts-interface-builder
export interface WebhookSubscribe { export interface WebhookSubscribe {
url: string; url: string;
authorization?: string;
eventTypes: Array<"add"|"update">; eventTypes: Array<"add"|"update">;
watchedColIds?: string[]; watchedColIds?: string[];
enabled?: boolean; enabled?: boolean;
@ -42,6 +44,7 @@ export interface WebhookSummary {
id: string; id: string;
fields: { fields: {
url: string; url: string;
authorization?: string;
unsubscribeKey: string; unsubscribeKey: string;
eventTypes: string[]; eventTypes: string[];
isReadyColumn: string|null; isReadyColumn: string|null;
@ -64,6 +67,7 @@ export interface WebhookUpdate {
// ts-interface-builder // ts-interface-builder
export interface WebhookPatch { export interface WebhookPatch {
url?: string; url?: string;
authorization?: string;
eventTypes?: Array<"add"|"update">; eventTypes?: Array<"add"|"update">;
tableId?: string; tableId?: string;
watchedColIds?: string[]; watchedColIds?: string[];

View File

@ -0,0 +1,27 @@
import moment from 'moment-timezone';
/**
* Output an ISO8601 format datetime string, with timezone.
* Any string fed in without timezone is expected to be in UTC.
*
* When connected to postgres, dates will be extracted as Date objects,
* with timezone information. The normalization done here is not
* really needed in this case.
*
* Timestamps in SQLite are stored as UTC, and read as strings
* (without timezone information). The normalization here is
* pretty important in this case.
*/
export function normalizedDateTimeString(dateTime: any): string {
if (!dateTime) { return dateTime; }
if (dateTime instanceof Date) {
return moment(dateTime).toISOString();
}
if (typeof dateTime === 'string' || typeof dateTime === 'number') {
// When SQLite returns a string, it will be in UTC.
// Need to make sure it actually have timezone info in it
// (will not by default).
return moment.utc(dateTime).toISOString();
}
throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
}

View File

@ -9,7 +9,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
import {BasicRole} from 'app/common/roles'; import {BasicRole} from 'app/common/roles';
import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI'; import {OrganizationProperties, PermissionDelta} from 'app/common/UserAPI';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';
import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {BillingOptions, HomeDBManager, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession'; import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';

View File

@ -1,6 +1,6 @@
import { makeId } from 'app/server/lib/idUtils'; import { makeId } from 'app/server/lib/idUtils';
import { Activation } from 'app/gen-server/entity/Activation'; import { Activation } from 'app/gen-server/entity/Activation';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
/** /**
* Manage activations. Not much to do currently, there is at most one * Manage activations. Not much to do currently, there is at most one

View File

@ -5,7 +5,7 @@ import {AbortController} from 'node-abort-controller';
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { SHARE_KEY_PREFIX } from 'app/common/gristUrls'; import { SHARE_KEY_PREFIX } from 'app/common/gristUrls';
import { removeTrailingSlash } from 'app/common/gutil'; import { removeTrailingSlash } from 'app/common/gutil';
import { HomeDBManager } from "app/gen-server/lib/HomeDBManager"; import { HomeDBManager } from "app/gen-server/lib/homedb/HomeDBManager";
import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer'; import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer';
import { IDocWorkerMap } from "app/server/lib/DocWorkerMap"; import { IDocWorkerMap } from "app/server/lib/DocWorkerMap";
import { expressWrap } from "app/server/lib/expressWrap"; import { expressWrap } from "app/server/lib/expressWrap";

View File

@ -1,7 +1,7 @@
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { FullUser } from 'app/common/UserAPI'; import { FullUser } from 'app/common/UserAPI';
import { Organization } from 'app/gen-server/entity/Organization'; import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
import { INotifier } from 'app/server/lib/INotifier'; import { INotifier } from 'app/server/lib/INotifier';
import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg'; import { scrubUserFromOrg } from 'app/gen-server/lib/scrubUserFromOrg';
import { GristLoginSystem } from 'app/server/lib/GristServer'; import { GristLoginSystem } from 'app/server/lib/GristServer';

View File

@ -1,12 +1,13 @@
import { ApiError } from 'app/common/ApiError'; import { ApiError } from 'app/common/ApiError';
import { delay } from 'app/common/delay'; import { delay } from 'app/common/delay';
import { buildUrlId } from 'app/common/gristUrls'; import { buildUrlId } from 'app/common/gristUrls';
import { normalizedDateTimeString } from 'app/common/normalizedDateTimeString';
import { BillingAccount } from 'app/gen-server/entity/BillingAccount'; import { BillingAccount } from 'app/gen-server/entity/BillingAccount';
import { Document } from 'app/gen-server/entity/Document'; import { Document } from 'app/gen-server/entity/Document';
import { Organization } from 'app/gen-server/entity/Organization'; import { Organization } from 'app/gen-server/entity/Organization';
import { Product } from 'app/gen-server/entity/Product'; import { Product } from 'app/gen-server/entity/Product';
import { Workspace } from 'app/gen-server/entity/Workspace'; import { Workspace } from 'app/gen-server/entity/Workspace';
import { HomeDBManager, Scope } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
import { fromNow } from 'app/gen-server/sqlUtils'; import { fromNow } from 'app/gen-server/sqlUtils';
import { getAuthorizedUserId } from 'app/server/lib/Authorizer'; import { getAuthorizedUserId } from 'app/server/lib/Authorizer';
import { expressWrap } from 'app/server/lib/expressWrap'; import { expressWrap } from 'app/server/lib/expressWrap';
@ -16,7 +17,6 @@ import log from 'app/server/lib/log';
import { IPermitStore } from 'app/server/lib/Permit'; import { IPermitStore } from 'app/server/lib/Permit';
import { optStringParam, stringParam } from 'app/server/lib/requestUtils'; import { optStringParam, stringParam } from 'app/server/lib/requestUtils';
import * as express from 'express'; import * as express from 'express';
import moment from 'moment';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import * as Fetch from 'node-fetch'; import * as Fetch from 'node-fetch';
import { EntityManager } from 'typeorm'; import { EntityManager } from 'typeorm';
@ -416,32 +416,6 @@ export class Housekeeper {
} }
} }
/**
* Output an ISO8601 format datetime string, with timezone.
* Any string fed in without timezone is expected to be in UTC.
*
* When connected to postgres, dates will be extracted as Date objects,
* with timezone information. The normalization done here is not
* really needed in this case.
*
* Timestamps in SQLite are stored as UTC, and read as strings
* (without timezone information). The normalization here is
* pretty important in this case.
*/
function normalizedDateTimeString(dateTime: any): string {
if (!dateTime) { return dateTime; }
if (dateTime instanceof Date) {
return moment(dateTime).toISOString();
}
if (typeof dateTime === 'string') {
// When SQLite returns a string, it will be in UTC.
// Need to make sure it actually have timezone info in it
// (will not by default).
return moment.utc(dateTime).toISOString();
}
throw new Error(`normalizedDateTimeString cannot handle ${dateTime}`);
}
/** /**
* Call callback(item) for each item on the list, sleeping periodically to allow other works to * Call callback(item) for each item on the list, sleeping periodically to allow other works to
* happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS. * happen. Any time work takes more than SYNC_WORK_LIMIT_MS, will sleep for SYNC_WORK_BREAK_MS.

View File

@ -1,7 +1,7 @@
import {Document} from 'app/gen-server/entity/Document'; import {Document} from 'app/gen-server/entity/Document';
import {Organization} from 'app/gen-server/entity/Organization'; import {Organization} from 'app/gen-server/entity/Organization';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
// Frequency of logging usage information. Not something we need // Frequency of logging usage information. Not something we need

View File

@ -1608,7 +1608,7 @@ export class HomeDBManager extends EventEmitter {
.where("id = :id AND doc_id = :docId", {id, docId}) .where("id = :id AND doc_id = :docId", {id, docId})
.execute(); .execute();
if (res.affected !== 1) { if (res.affected !== 1) {
throw new ApiError('secret with given id not found', 404); throw new ApiError('secret with given id not found or nothing was updated', 404);
} }
} }
@ -1623,14 +1623,32 @@ export class HomeDBManager extends EventEmitter {
// Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is // Update the webhook url in the webhook's corresponding secret (note: the webhook identifier is
// its secret identifier). // its secret identifier).
public async updateWebhookUrl(id: string, docId: string, url: string, outerManager?: EntityManager) { public async updateWebhookUrlAndAuth(
props: {
id: string,
docId: string,
url: string | undefined,
auth: string | undefined,
outerManager?: EntityManager}
) {
const {id, docId, url, auth, outerManager} = props;
return await this._runInTransaction(outerManager, async manager => { return await this._runInTransaction(outerManager, async manager => {
if (url === undefined && auth === undefined) {
throw new ApiError('None of the Webhook url and auth are defined', 404);
}
const value = await this.getSecret(id, docId, manager); const value = await this.getSecret(id, docId, manager);
if (!value) { if (!value) {
throw new ApiError('Webhook with given id not found', 404); throw new ApiError('Webhook with given id not found', 404);
} }
const webhookSecret = JSON.parse(value); const webhookSecret = JSON.parse(value);
webhookSecret.url = url; // As we want to patch the webhookSecret object, only set the url and the authorization when they are defined.
// When the user wants to empty the value, we are expected to receive empty strings.
if (url !== undefined) {
webhookSecret.url = url;
}
if (auth !== undefined) {
webhookSecret.authorization = auth;
}
await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager); await this.updateSecret(id, docId, JSON.stringify(webhookSecret), manager);
}); });
} }

View File

@ -17,7 +17,7 @@ import { Group } from 'app/gen-server/entity/Group';
import { Login } from 'app/gen-server/entity/Login'; import { Login } from 'app/gen-server/entity/Login';
import { User } from 'app/gen-server/entity/User'; import { User } from 'app/gen-server/entity/User';
import { appSettings } from 'app/server/lib/AppSettings'; import { appSettings } from 'app/server/lib/AppSettings';
import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager, PermissionDeltaAnalysis, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
import { import {
AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange AvailableUsers, GetUserOptions, NonGuestGroup, QueryResult, Resource, RunInTransaction, UserProfileChange
} from 'app/gen-server/lib/homedb/Interfaces'; } from 'app/gen-server/lib/homedb/Interfaces';

View File

@ -1,7 +1,7 @@
import { Level, TelemetryContracts } from 'app/common/Telemetry'; import { Level, TelemetryContracts } from 'app/common/Telemetry';
import { version } from 'app/common/version'; import { version } from 'app/common/version';
import { synchronizeProducts } from 'app/gen-server/entity/Product'; import { synchronizeProducts } from 'app/gen-server/entity/Product';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { applyPatch } from 'app/gen-server/lib/TypeORMPatches'; import { applyPatch } from 'app/gen-server/lib/TypeORMPatches';
import { getMigrations, getOrCreateConnection, getTypeORMSettings, import { getMigrations, getOrCreateConnection, getTypeORMSettings,
undoLastMigration, updateDb } from 'app/server/lib/dbUtils'; undoLastMigration, updateDb } from 'app/server/lib/dbUtils';

View File

@ -69,6 +69,7 @@ import {commonUrls, parseUrlId} from 'app/common/gristUrls';
import {byteString, countIf, retryOnce, safeJsonParse, timeoutReached} from 'app/common/gutil'; import {byteString, countIf, retryOnce, safeJsonParse, timeoutReached} from 'app/common/gutil';
import {InactivityTimer} from 'app/common/InactivityTimer'; import {InactivityTimer} from 'app/common/InactivityTimer';
import {Interval} from 'app/common/Interval'; import {Interval} from 'app/common/Interval';
import {normalizedDateTimeString} from 'app/common/normalizedDateTimeString';
import { import {
compilePredicateFormula, compilePredicateFormula,
getPredicateFormulaProperties, getPredicateFormulaProperties,
@ -2496,6 +2497,24 @@ export class ActiveDoc extends EventEmitter {
} }
} }
private _logSnapshotProgress(docSession: OptDocSession) {
const snapshotProgress = this._docManager.storageManager.getSnapshotProgress(this.docName);
const lastWindowTime = (snapshotProgress.lastWindowStartedAt &&
snapshotProgress.lastWindowDoneAt &&
snapshotProgress.lastWindowDoneAt > snapshotProgress.lastWindowStartedAt) ?
snapshotProgress.lastWindowDoneAt : Date.now();
const delay = snapshotProgress.lastWindowStartedAt ?
lastWindowTime - snapshotProgress.lastWindowStartedAt : null;
log.rawInfo('snapshot status', {
...this.getLogMeta(docSession),
...snapshotProgress,
lastChangeAt: normalizedDateTimeString(snapshotProgress.lastChangeAt),
lastWindowStartedAt: normalizedDateTimeString(snapshotProgress.lastWindowStartedAt),
lastWindowDoneAt: normalizedDateTimeString(snapshotProgress.lastWindowDoneAt),
delay,
});
}
private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') { private _logDocMetrics(docSession: OptDocSession, triggeredBy: 'docOpen' | 'interval'| 'docClose') {
this.logTelemetryEvent(docSession, 'documentUsage', { this.logTelemetryEvent(docSession, 'documentUsage', {
limited: { limited: {
@ -2513,6 +2532,9 @@ export class ActiveDoc extends EventEmitter {
...this._getCustomWidgetMetrics(), ...this._getCustomWidgetMetrics(),
}, },
}); });
// Log progress on making snapshots periodically, to catch anything
// excessively slow.
this._logSnapshotProgress(docSession);
} }
private _getAccessRuleMetrics() { private _getAccessRuleMetrics() {

View File

@ -11,7 +11,7 @@ import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument, PublicDocWorkerUrlInfo} from 'app/common/UserAPI'; import {Document as APIDocument, PublicDocWorkerUrlInfo} from 'app/common/UserAPI';
import {Document} from "app/gen-server/entity/Document"; import {Document} from "app/gen-server/entity/Document";
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
RequestWithLogin} from 'app/server/lib/Authorizer'; RequestWithLogin} from 'app/server/lib/Authorizer';
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';

View File

@ -7,7 +7,7 @@ import {canEdit, canView, getWeakestRole, Role} from 'app/common/roles';
import {UserOptions} from 'app/common/UserAPI'; import {UserOptions} from 'app/common/UserAPI';
import {Document} from 'app/gen-server/entity/Document'; import {Document} from 'app/gen-server/entity/Document';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';
import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj, import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj,
SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession'; SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';

View File

@ -8,7 +8,7 @@ import {TelemetryMetadata} from 'app/common/Telemetry';
import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI'; import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
import {normalizeEmail} from 'app/common/emails'; import {normalizeEmail} from 'app/common/emails';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {Authorizer} from 'app/server/lib/Authorizer'; import {Authorizer} from 'app/server/lib/Authorizer';
import {ScopedSession} from 'app/server/lib/BrowserSession'; import {ScopedSession} from 'app/server/lib/BrowserSession';

View File

@ -30,7 +30,7 @@ import {TelemetryMetadataByLevel} from "app/common/Telemetry";
import {WebhookFields} from "app/common/Triggers"; import {WebhookFields} from "app/common/Triggers";
import TriggersTI from 'app/common/Triggers-ti'; import TriggersTI from 'app/common/Triggers-ti';
import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/homedb/HomeDBManager';
import * as Types from "app/plugin/DocApiTypes"; import * as Types from "app/plugin/DocApiTypes";
import DocApiTypesTI from "app/plugin/DocApiTypes-ti"; import DocApiTypesTI from "app/plugin/DocApiTypes-ti";
import {GristObjCode} from "app/plugin/GristData"; import {GristObjCode} from "app/plugin/GristData";
@ -324,7 +324,7 @@ export class DocWorkerApi {
); );
const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => { const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => {
const {fields, url} = await getWebhookSettings(activeDoc, req, null, webhook); const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, null, webhook);
if (!fields.eventTypes?.length) { if (!fields.eventTypes?.length) {
throw new ApiError(`eventTypes must be a non-empty array`, 400); throw new ApiError(`eventTypes must be a non-empty array`, 400);
} }
@ -336,7 +336,7 @@ export class DocWorkerApi {
} }
const unsubscribeKey = uuidv4(); const unsubscribeKey = uuidv4();
const webhookSecret: WebHookSecret = {unsubscribeKey, url}; const webhookSecret: WebHookSecret = {unsubscribeKey, url, authorization};
const secretValue = JSON.stringify(webhookSecret); const secretValue = JSON.stringify(webhookSecret);
const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id; const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id;
@ -392,7 +392,7 @@ export class DocWorkerApi {
const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables");
const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined;
let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined; let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined;
const {url, eventTypes, watchedColIds, isReadyColumn, name} = webhook; const {url, authorization, eventTypes, watchedColIds, isReadyColumn, name} = webhook;
const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables}); const tableId = await getRealTableId(req.params.tableId || webhook.tableId, {metaTables});
const fields: Partial<SchemaTypes['_grist_Triggers']> = {}; const fields: Partial<SchemaTypes['_grist_Triggers']> = {};
@ -454,6 +454,7 @@ export class DocWorkerApi {
return { return {
fields, fields,
url, url,
authorization,
}; };
} }
@ -926,16 +927,16 @@ export class DocWorkerApi {
const docId = activeDoc.docName; const docId = activeDoc.docName;
const webhookId = req.params.webhookId; const webhookId = req.params.webhookId;
const {fields, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const {fields, url, authorization} = await getWebhookSettings(activeDoc, req, webhookId, req.body);
if (fields.enabled === false) { if (fields.enabled === false) {
await activeDoc.triggers.clearSingleWebhookQueue(webhookId); await activeDoc.triggers.clearSingleWebhookQueue(webhookId);
} }
const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id;
// update url in homedb // update url and authorization header in homedb
if (url) { if (url || authorization) {
await this._dbManager.updateWebhookUrl(webhookId, docId, url); await this._dbManager.updateWebhookUrlAndAuth({id: webhookId, docId, url, auth: authorization});
activeDoc.triggers.webhookDeleted(webhookId); // clear cache activeDoc.triggers.webhookDeleted(webhookId); // clear cache
} }

View File

@ -15,7 +15,7 @@ import {Invite} from 'app/common/sharing';
import {tbind} from 'app/common/tbind'; import {tbind} from 'app/common/tbind';
import {TelemetryMetadataByLevel} from 'app/common/Telemetry'; import {TelemetryMetadataByLevel} from 'app/common/Telemetry';
import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer, isSingleUserMode, import {assertAccess, Authorizer, DocAuthorizer, DummyAuthorizer, isSingleUserMode,
RequestWithLogin} from 'app/server/lib/Authorizer'; RequestWithLogin} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client'; import {Client} from 'app/server/lib/Client';

View File

@ -11,7 +11,7 @@ import * as gutil from 'app/common/gutil';
import {Comm} from 'app/server/lib/Comm'; import {Comm} from 'app/server/lib/Comm';
import * as docUtils from 'app/server/lib/docUtils'; import * as docUtils from 'app/server/lib/docUtils';
import {GristServer} from 'app/server/lib/GristServer'; import {GristServer} from 'app/server/lib/GristServer';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
import {IShell} from 'app/server/lib/IShell'; import {IShell} from 'app/server/lib/IShell';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import uuidv4 from "uuid/v4"; import uuidv4 from "uuid/v4";
@ -257,6 +257,17 @@ export class DocStorageManager implements IDocStorageManager {
throw new Error('removeSnapshots not implemented'); throw new Error('removeSnapshots not implemented');
} }
public getSnapshotProgress(): SnapshotProgress {
return {
pushes: 0,
skippedPushes: 0,
errors: 0,
changes: 0,
windowsStarted: 0,
windowsDone: 0,
};
}
public async replace(docName: string, options: any): Promise<void> { public async replace(docName: string, options: any): Promise<void> {
throw new Error('replacement not implemented'); throw new Error('replacement not implemented');
} }

View File

@ -3,7 +3,7 @@
* In hosted environment, this comprises the functionality of the DocWorker instance type. * In hosted environment, this comprises the functionality of the DocWorker instance type.
*/ */
import {isAffirmative} from 'app/common/gutil'; import {isAffirmative} from 'app/common/gutil';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl'; import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer'; import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer';
import {Client} from 'app/server/lib/Client'; import {Client} from 'app/server/lib/Client';

View File

@ -20,7 +20,7 @@ import {Activations} from 'app/gen-server/lib/Activations';
import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder'; import {DocApiForwarder} from 'app/gen-server/lib/DocApiForwarder';
import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
import {Doom} from 'app/gen-server/lib/Doom'; import {Doom} from 'app/gen-server/lib/Doom';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {Housekeeper} from 'app/gen-server/lib/Housekeeper'; import {Housekeeper} from 'app/gen-server/lib/Housekeeper';
import {Usage} from 'app/gen-server/lib/Usage'; import {Usage} from 'app/gen-server/lib/Usage';
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens'; import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';

View File

@ -35,7 +35,7 @@ import { EmptyRecordView, InfoView, RecordView } from 'app/common/RecordView';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { User } from 'app/common/User'; import { User } from 'app/common/User';
import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { GristObjCode } from 'app/plugin/GristData'; import { GristObjCode } from 'app/plugin/GristData';
import { DocClients } from 'app/server/lib/DocClients'; import { DocClients } from 'app/server/lib/DocClients';
import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare, import { getDocSessionAccess, getDocSessionAltSessionId, getDocSessionShare,

View File

@ -8,7 +8,7 @@ import { Organization } from 'app/gen-server/entity/Organization';
import { User } from 'app/gen-server/entity/User'; import { User } from 'app/gen-server/entity/User';
import { Workspace } from 'app/gen-server/entity/Workspace'; import { Workspace } from 'app/gen-server/entity/Workspace';
import { Activations } from 'app/gen-server/lib/Activations'; import { Activations } from 'app/gen-server/lib/Activations';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { IAccessTokens } from 'app/server/lib/AccessTokens'; import { IAccessTokens } from 'app/server/lib/AccessTokens';
import { RequestWithLogin } from 'app/server/lib/Authorizer'; import { RequestWithLogin } from 'app/server/lib/Authorizer';
import { Comm } from 'app/server/lib/Comm'; import { Comm } from 'app/server/lib/Comm';

View File

@ -1,4 +1,4 @@
import {DocumentMetadata, HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {DocumentMetadata, HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
/** /**

View File

@ -8,14 +8,14 @@ import {DocumentUsage} from 'app/common/DocUsage';
import {buildUrlId, parseUrlId} from 'app/common/gristUrls'; import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
import {KeyedOps} from 'app/common/KeyedOps'; import {KeyedOps} from 'app/common/KeyedOps';
import {DocReplacementOptions, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {DocReplacementOptions, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {checksumFile} from 'app/server/lib/checksumFile'; import {checksumFile} from 'app/server/lib/checksumFile';
import {DocSnapshotInventory, DocSnapshotPruner} from 'app/server/lib/DocSnapshots'; import {DocSnapshotInventory, DocSnapshotPruner} from 'app/server/lib/DocSnapshots';
import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {ChecksummedExternalStorage, DELETED_TOKEN, ExternalStorage, Unchanged} from 'app/server/lib/ExternalStorage'; import {ChecksummedExternalStorage, DELETED_TOKEN, ExternalStorage, Unchanged} from 'app/server/lib/ExternalStorage';
import {HostedMetadataManager} from 'app/server/lib/HostedMetadataManager'; import {HostedMetadataManager} from 'app/server/lib/HostedMetadataManager';
import {ICreate} from 'app/server/lib/ICreate'; import {ICreate} from 'app/server/lib/ICreate';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; import {IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
import {LogMethods} from "app/server/lib/LogMethods"; import {LogMethods} from "app/server/lib/LogMethods";
import {fromCallback} from 'app/server/lib/serverUtils'; import {fromCallback} from 'app/server/lib/serverUtils';
import * as fse from 'fs-extra'; import * as fse from 'fs-extra';
@ -94,6 +94,9 @@ export class HostedStorageManager implements IDocStorageManager {
// Time at which document was last changed. // Time at which document was last changed.
private _timestamps = new Map<string, string>(); private _timestamps = new Map<string, string>();
// Statistics related to snapshot generation.
private _snapshotProgress = new Map<string, SnapshotProgress>();
// Access external storage. // Access external storage.
private _ext: ChecksummedExternalStorage; private _ext: ChecksummedExternalStorage;
private _extMeta: ChecksummedExternalStorage; private _extMeta: ChecksummedExternalStorage;
@ -223,6 +226,25 @@ export class HostedStorageManager implements IDocStorageManager {
return path.basename(altDocName, '.grist'); return path.basename(altDocName, '.grist');
} }
/**
* Read some statistics related to generating snapshots.
*/
public getSnapshotProgress(docName: string): SnapshotProgress {
let snapshotProgress = this._snapshotProgress.get(docName);
if (!snapshotProgress) {
snapshotProgress = {
pushes: 0,
skippedPushes: 0,
errors: 0,
changes: 0,
windowsStarted: 0,
windowsDone: 0,
};
this._snapshotProgress.set(docName, snapshotProgress);
}
return snapshotProgress;
}
/** /**
* Prepares a document for use locally. Here we sync the doc from S3 to the local filesystem. * Prepares a document for use locally. Here we sync the doc from S3 to the local filesystem.
* Returns whether the document is new (needs to be created). * Returns whether the document is new (needs to be created).
@ -476,7 +498,11 @@ export class HostedStorageManager implements IDocStorageManager {
* This is called when a document may have been changed, via edits or migrations etc. * This is called when a document may have been changed, via edits or migrations etc.
*/ */
public markAsChanged(docName: string, reason?: string): void { public markAsChanged(docName: string, reason?: string): void {
const timestamp = new Date().toISOString(); const now = new Date();
const snapshotProgress = this.getSnapshotProgress(docName);
snapshotProgress.lastChangeAt = now.getTime();
snapshotProgress.changes++;
const timestamp = now.toISOString();
this._timestamps.set(docName, timestamp); this._timestamps.set(docName, timestamp);
try { try {
if (parseUrlId(docName).snapshotId) { return; } if (parseUrlId(docName).snapshotId) { return; }
@ -486,6 +512,10 @@ export class HostedStorageManager implements IDocStorageManager {
} }
if (this._disableS3) { return; } if (this._disableS3) { return; }
if (this._closed) { throw new Error("HostedStorageManager.markAsChanged called after closing"); } if (this._closed) { throw new Error("HostedStorageManager.markAsChanged called after closing"); }
if (!this._uploads.hasPendingOperation(docName)) {
snapshotProgress.lastWindowStartedAt = now.getTime();
snapshotProgress.windowsStarted++;
}
this._uploads.addOperation(docName); this._uploads.addOperation(docName);
} finally { } finally {
if (reason === 'edit') { if (reason === 'edit') {
@ -729,6 +759,7 @@ export class HostedStorageManager implements IDocStorageManager {
private async _pushToS3(docId: string): Promise<void> { private async _pushToS3(docId: string): Promise<void> {
let tmpPath: string|null = null; let tmpPath: string|null = null;
const snapshotProgress = this.getSnapshotProgress(docId);
try { try {
if (this._prepareFiles.has(docId)) { if (this._prepareFiles.has(docId)) {
throw new Error('too soon to consider pushing'); throw new Error('too soon to consider pushing');
@ -748,14 +779,18 @@ export class HostedStorageManager implements IDocStorageManager {
await this._inventory.uploadAndAdd(docId, async () => { await this._inventory.uploadAndAdd(docId, async () => {
const prevSnapshotId = this._latestVersions.get(docId) || null; const prevSnapshotId = this._latestVersions.get(docId) || null;
const newSnapshotId = await this._ext.upload(docId, tmpPath as string, metadata); const newSnapshotId = await this._ext.upload(docId, tmpPath as string, metadata);
snapshotProgress.lastWindowDoneAt = Date.now();
snapshotProgress.windowsDone++;
if (newSnapshotId === Unchanged) { if (newSnapshotId === Unchanged) {
// Nothing uploaded because nothing changed // Nothing uploaded because nothing changed
snapshotProgress.skippedPushes++;
return { prevSnapshotId }; return { prevSnapshotId };
} }
if (!newSnapshotId) { if (!newSnapshotId) {
// This is unexpected. // This is unexpected.
throw new Error('No snapshotId allocated after upload'); throw new Error('No snapshotId allocated after upload');
} }
snapshotProgress.pushes++;
const snapshot = { const snapshot = {
lastModified: t, lastModified: t,
snapshotId: newSnapshotId, snapshotId: newSnapshotId,
@ -767,6 +802,10 @@ export class HostedStorageManager implements IDocStorageManager {
if (changeMade) { if (changeMade) {
await this._onInventoryChange(docId); await this._onInventoryChange(docId);
} }
} catch (e) {
snapshotProgress.errors++;
// Snapshot window completion time deliberately not set.
throw e;
} finally { } finally {
// Clean up backup. // Clean up backup.
// NOTE: fse.remove succeeds also when the file does not exist. // NOTE: fse.remove succeeds also when the file does not exist.

View File

@ -1,7 +1,7 @@
import {GristDeploymentType} from 'app/common/gristUrls'; import {GristDeploymentType} from 'app/common/gristUrls';
import {getThemeBackgroundSnippet} from 'app/common/Themes'; import {getThemeBackgroundSnippet} from 'app/common/Themes';
import {Document} from 'app/gen-server/entity/Document'; import {Document} from 'app/gen-server/entity/Document';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {ExternalStorage} from 'app/server/lib/ExternalStorage'; import {ExternalStorage} from 'app/server/lib/ExternalStorage';
import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer'; import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
import {IBilling} from 'app/server/lib/IBilling'; import {IBilling} from 'app/server/lib/IBilling';

View File

@ -36,6 +36,8 @@ export interface IDocStorageManager {
// Metadata may not be returned in this case. // Metadata may not be returned in this case.
getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots>; getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots>;
removeSnapshots(docName: string, snapshotIds: string[]): Promise<void>; removeSnapshots(docName: string, snapshotIds: string[]): Promise<void>;
// Get information about how snapshot generation is going.
getSnapshotProgress(docName: string): SnapshotProgress;
replace(docName: string, options: DocReplacementOptions): Promise<void>; replace(docName: string, options: DocReplacementOptions): Promise<void>;
} }
@ -66,5 +68,51 @@ export class TrivialDocStorageManager implements IDocStorageManager {
public async flushDoc() {} public async flushDoc() {}
public async getSnapshots(): Promise<never> { throw new Error('no'); } public async getSnapshots(): Promise<never> { throw new Error('no'); }
public async removeSnapshots(): Promise<never> { throw new Error('no'); } public async removeSnapshots(): Promise<never> { throw new Error('no'); }
public getSnapshotProgress(): SnapshotProgress { throw new Error('no'); }
public async replace(): Promise<never> { throw new Error('no'); } public async replace(): Promise<never> { throw new Error('no'); }
} }
/**
* Some summary information about how snapshot generation is going.
* Any times are in ms.
* All information is within the lifetime of a doc worker, not global.
*/
export interface SnapshotProgress {
/** The last time the document was marked as having changed. */
lastChangeAt?: number;
/**
* The last time a save window started for the document (checking to see
* if it needs to be pushed, and pushing it if so, possibly waiting
* quite some time to bundle any other changes).
*/
lastWindowStartedAt?: number;
/**
* The last time the document was either pushed or determined to not
* actually need to be pushed, after having been marked as changed.
*/
lastWindowDoneAt?: number;
/** Number of times the document was pushed. */
pushes: number;
/** Number of times the document was not pushed because no change found. */
skippedPushes: number;
/** Number of times there was an error trying to push. */
errors: number;
/**
* Number of times the document was marked as changed.
* Will generally be a lot greater than saves.
*/
changes: number;
/** Number of times a save window was started. */
windowsStarted: number;
/** Number of times a save window was completed. */
windowsDone: number;
}

View File

@ -1,5 +1,5 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {appSettings} from 'app/server/lib/AppSettings'; import {appSettings} from 'app/server/lib/AppSettings';
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';

View File

@ -17,7 +17,7 @@ import {
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI'; import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {Activation} from 'app/gen-server/entity/Activation'; import {Activation} from 'app/gen-server/entity/Activation';
import {Activations} from 'app/gen-server/lib/Activations'; import {Activations} from 'app/gen-server/lib/Activations';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {RequestWithLogin} from 'app/server/lib/Authorizer'; import {RequestWithLogin} from 'app/server/lib/Authorizer';
import {getDocSessionUser, OptDocSession} from 'app/server/lib/DocSession'; import {getDocSessionUser, OptDocSession} from 'app/server/lib/DocSession';
import {expressWrap} from 'app/server/lib/expressWrap'; import {expressWrap} from 'app/server/lib/expressWrap';

View File

@ -1,4 +1,4 @@
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer'; import {GristLoginSystem, GristServer} from 'app/server/lib/GristServer';
import {Request} from 'express'; import {Request} from 'express';

View File

@ -72,6 +72,7 @@ type Trigger = MetaRowRecord<"_grist_Triggers">;
export interface WebHookSecret { export interface WebHookSecret {
url: string; url: string;
unsubscribeKey: string; unsubscribeKey: string;
authorization?: string;
} }
// Work to do after fetching values from the document // Work to do after fetching values from the document
@ -259,6 +260,7 @@ export class DocTriggers {
const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId"); const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId");
const getColId = docData.getMetaTable("_grist_Tables_column").getRowPropFunc("colId"); const getColId = docData.getMetaTable("_grist_Tables_column").getRowPropFunc("colId");
const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? ''; const getUrl = async (id: string) => (await this._getWebHook(id))?.url ?? '';
const getAuthorization = async (id: string) => (await this._getWebHook(id))?.authorization ?? '';
const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? ''; const getUnsubscribeKey = async (id: string) => (await this._getWebHook(id))?.unsubscribeKey ?? '';
const resultTable: WebhookSummary[] = []; const resultTable: WebhookSummary[] = [];
@ -271,6 +273,7 @@ export class DocTriggers {
for (const act of webhookActions) { for (const act of webhookActions) {
// Url, probably should be hidden for non-owners (but currently this API is owners only). // Url, probably should be hidden for non-owners (but currently this API is owners only).
const url = await getUrl(act.id); const url = await getUrl(act.id);
const authorization = await getAuthorization(act.id);
// Same story, should be hidden. // Same story, should be hidden.
const unsubscribeKey = await getUnsubscribeKey(act.id); const unsubscribeKey = await getUnsubscribeKey(act.id);
if (!url || !unsubscribeKey) { if (!url || !unsubscribeKey) {
@ -285,6 +288,7 @@ export class DocTriggers {
fields: { fields: {
// Url, probably should be hidden for non-owners (but currently this API is owners only). // Url, probably should be hidden for non-owners (but currently this API is owners only).
url, url,
authorization,
unsubscribeKey, unsubscribeKey,
// Other fields used to register this webhook. // Other fields used to register this webhook.
eventTypes: decodeObject(t.eventTypes) as string[], eventTypes: decodeObject(t.eventTypes) as string[],
@ -683,6 +687,7 @@ export class DocTriggers {
const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), {id}); const batch = _.takeWhile(this._webHookEventQueue.slice(0, 100), {id});
const body = JSON.stringify(batch.map(e => e.payload)); const body = JSON.stringify(batch.map(e => e.payload));
const url = await this._getWebHookUrl(id); const url = await this._getWebHookUrl(id);
const authorization = (await this._getWebHook(id))?.authorization || "";
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;
} }
@ -698,7 +703,8 @@ export class DocTriggers {
this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', { this._activeDoc.logTelemetryEvent(null, 'sendingWebhooks', {
limited: {numEvents: meta.numEvents}, limited: {numEvents: meta.numEvents},
}); });
success = await this._sendWebhookWithRetries(id, url, body, batch.length, this._loopAbort.signal); success = await this._sendWebhookWithRetries(
id, url, authorization, body, batch.length, this._loopAbort.signal);
if (this._loopAbort.signal.aborted) { if (this._loopAbort.signal.aborted) {
continue; continue;
} }
@ -770,7 +776,8 @@ export class DocTriggers {
return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS; return this._drainingQueue ? Math.min(5, TRIGGER_MAX_ATTEMPTS) : TRIGGER_MAX_ATTEMPTS;
} }
private async _sendWebhookWithRetries(id: string, url: string, body: string, size: number, signal: AbortSignal) { private async _sendWebhookWithRetries(
id: string, url: string, authorization: string, body: string, size: number, signal: AbortSignal) {
const maxWait = 64; const maxWait = 64;
let wait = 1; let wait = 1;
for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) { for (let attempt = 0; attempt < this._maxWebhookAttempts; attempt++) {
@ -786,6 +793,7 @@ export class DocTriggers {
body, body,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(authorization ? {'Authorization': authorization} : {}),
}, },
signal, signal,
agent: proxyAgent(new URL(url)), agent: proxyAgent(new URL(url)),

View File

@ -3,7 +3,7 @@ import { mapGetOrSet, MapWithTTL } from 'app/common/AsyncCreate';
import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls'; import { extractOrgParts, getHostType, getKnownOrg } from 'app/common/gristUrls';
import { isAffirmative } from 'app/common/gutil'; import { isAffirmative } from 'app/common/gutil';
import { Organization } from 'app/gen-server/entity/Organization'; import { Organization } from 'app/gen-server/entity/Organization';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
import { GristServer } from 'app/server/lib/GristServer'; import { GristServer } from 'app/server/lib/GristServer';
import { getOriginUrl } from 'app/server/lib/requestUtils'; import { getOriginUrl } from 'app/server/lib/requestUtils';
import { NextFunction, Request, RequestHandler, Response } from 'express'; import { NextFunction, Request, RequestHandler, Response } from 'express';

View File

@ -1,7 +1,7 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from 'app/common/gristUrls'; import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil'; import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/homedb/HomeDBManager';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer'; import {RequestWithGrist} from 'app/server/lib/GristServer';

View File

@ -12,7 +12,7 @@ import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager'; import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes"; import {AttachedCustomWidgets, IAttachedCustomWidget} from "app/common/widgetTypes";
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager'; import {SUPPORT_EMAIL} from 'app/gen-server/lib/homedb/HomeDBManager';
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer'; import {GristServer} from 'app/server/lib/GristServer';

View File

@ -1,7 +1,6 @@
const util = require('util'); const util = require('util');
const childProcess = require('child_process'); const childProcess = require('child_process');
const fs = require('fs/promises'); const fs = require('fs/promises');
const {existsSync} = require('fs');
const exec = util.promisify(childProcess.exec); const exec = util.promisify(childProcess.exec);
@ -17,66 +16,81 @@ const getBranchName = () => {
}; };
async function main() { async function main() {
if (process.argv[2] === 'deploy') { switch (process.argv[2]) {
const appRoot = process.argv[3] || "."; case "deploy": {
if (!existsSync(`${appRoot}/Dockerfile`)) { const name = getAppName();
console.log(`Dockerfile not found in appRoot of ${appRoot}`); const volName = getVolumeName();
process.exit(1); if (!await appExists(name)) {
} await appCreate(name);
const name = getAppName();
const volName = getVolumeName();
if (!await appExists(name)) {
await appCreate(name);
await volCreate(name, volName);
} else {
// Check if volume exists, and create it if not. This is needed because there was an API
// change in flyctl (mandatory -y flag) and some apps were created without a volume.
if (!(await volList(name)).length) {
await volCreate(name, volName); await volCreate(name, volName);
} else {
// Check if volume exists, and create it if not. This is needed because there was an API
// change in flyctl (mandatory -y flag) and some apps were created without a volume.
if (!(await volList(name)).length) {
await volCreate(name, volName);
}
} }
await prepConfig(name, volName);
await appDeploy(name);
break;
} }
await prepConfig(name, appRoot, volName); case "destroy": {
await appDeploy(name, appRoot); const name = getAppName();
} else if (process.argv[2] === 'destroy') { if (await appExists(name)) {
const name = getAppName(); await appDestroy(name);
if (await appExists(name)) { }
await appDestroy(name); break;
} }
} else if (process.argv[2] === 'clean') { case "clean": {
const staleApps = await findStaleApps(); const staleApps = await findStaleApps();
for (const appName of staleApps) { for (const appName of staleApps) {
await appDestroy(appName); await appDestroy(appName);
}
break;
} }
} else { default: {
console.log(`Usage: console.log(`Usage:
deploy [appRoot]: deploy: create (if needed) and deploy fly app grist-{BRANCH_NAME}.
create (if needed) and deploy fly app grist-{BRANCH_NAME}.
appRoot may specify the working directory that contains the Dockerfile to build.
destroy: destroy fly app grist-{BRANCH_NAME} destroy: destroy fly app grist-{BRANCH_NAME}
clean: destroy all grist-* fly apps whose time has come clean: destroy all grist-* fly apps whose time has come
(according to FLY_DEPLOY_EXPIRATION env var set at deploy time) (according to FLY_DEPLOY_EXPIRATION env var set at deploy time)
DRYRUN=1 in environment will show what would be done DRYRUN=1 in environment will show what would be done
`); `);
process.exit(1); process.exit(1);
}
} }
} }
function getDockerTag(name) {
return `registry.fly.io/${name}:latest`;
}
const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false); const appExists = (name) => runFetch(`flyctl status -a ${name}`).then(() => true).catch(() => false);
const appCreate = (name) => runAction(`flyctl launch --auto-confirm --name ${name} -r ewr -o ${org} --vm-memory 1024`); // We do not deploy at the create stage, since the Docker image isn't ready yet.
// Assigning --image prevents flyctl from making inferences based on the codebase and provisioning unnecessary postgres/redis instances.
const appCreate = (name) => runAction(`flyctl launch --no-deploy --auto-confirm --image ${getDockerTag(name)} --name ${name} -r ewr -o ${org}`);
const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`); const volCreate = (name, vol) => runAction(`flyctl volumes create ${vol} -s 1 -r ewr -y -a ${name}`);
const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout)); const volList = (name) => runFetch(`flyctl volumes list -a ${name} -j`).then(({stdout}) => JSON.parse(stdout));
const appDeploy = (name, appRoot) => runAction(`flyctl deploy ${appRoot} --remote-only --region=ewr --vm-memory 1024`, const appDeploy = async (name) => {
{shell: true, stdio: 'inherit'}); try {
await runAction("flyctl auth docker")
await runAction(`docker image tag grist-core:preview ${getDockerTag(name)}`);
await runAction(`docker push ${getDockerTag(name)}`);
await runAction(`flyctl deploy --app ${name} --image ${getDockerTag(name)}`);
} catch (e) {
console.log(`Error occurred when deploying: ${e}`);
process.exit(1);
}
};
async function appDestroy(name) { async function appDestroy(name) {
await runAction(`flyctl apps destroy ${name} -y`); await runAction(`flyctl apps destroy ${name} -y`);
} }
async function prepConfig(name, appRoot, volName) { async function prepConfig(name, volName) {
const configPath = `${appRoot}/fly.toml`; const configPath = "./fly.toml";
const configTemplatePath = `${appRoot}/buildtools/fly-template.toml`; const configTemplatePath = "./buildtools/fly-template.toml";
const template = await fs.readFile(configTemplatePath, {encoding: 'utf8'}); const template = await fs.readFile(configTemplatePath, {encoding: 'utf8'});
// Calculate the time when we can destroy the app, used by findStaleApps. // Calculate the time when we can destroy the app, used by findStaleApps.

View File

@ -48,3 +48,8 @@ processes = []
[mounts] [mounts]
source="{VOLUME_NAME}" source="{VOLUME_NAME}"
destination="/persist" destination="/persist"
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

View File

@ -1241,7 +1241,8 @@
"URL": "URL", "URL": "URL",
"Webhook Id": "Webhook Id", "Webhook Id": "Webhook Id",
"Table": "Table", "Table": "Table",
"Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)" "Filter for changes in these columns (semicolon-separated ids)": "Filter for changes in these columns (semicolon-separated ids)",
"Header Authorization": "Header Authorization"
}, },
"FormulaAssistant": { "FormulaAssistant": {
"Ask the bot.": "Ask the bot.", "Ask the bot.": "Ask the bot.",

View File

@ -6,7 +6,7 @@
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
import {isAffirmative} from 'app/common/gutil'; import {isAffirmative} from 'app/common/gutil';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {fixSiteProducts} from 'app/gen-server/lib/Housekeeper'; import {fixSiteProducts} from 'app/gen-server/lib/Housekeeper';
const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE); const debugging = isAffirmative(process.env.DEBUG) || isAffirmative(process.env.VERBOSE);

View File

@ -8,7 +8,7 @@ import {createEmptyOrgUsageSummary, OrgUsageSummary} from 'app/common/DocUsage';
import {Document, Workspace} from 'app/common/UserAPI'; import {Document, Workspace} from 'app/common/UserAPI';
import {Organization} from 'app/gen-server/entity/Organization'; import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product'; import {Product} from 'app/gen-server/entity/Product';
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager, UserChange} from 'app/gen-server/lib/homedb/HomeDBManager';
import {TestServer} from 'test/gen-server/apiUtils'; import {TestServer} from 'test/gen-server/apiUtils';
import {TEAM_FREE_PLAN} from 'app/common/Features'; import {TEAM_FREE_PLAN} from 'app/common/Features';

View File

@ -4,7 +4,7 @@ import {Deps} from 'app/gen-server/ApiServer';
import {Organization} from 'app/gen-server/entity/Organization'; import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product'; import {Product} from 'app/gen-server/entity/Product';
import {User} from 'app/gen-server/entity/User'; import {User} from 'app/gen-server/entity/User';
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager, UserChange} from 'app/gen-server/lib/homedb/HomeDBManager';
import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes'; import {SendGridConfig, SendGridMail} from 'app/gen-server/lib/NotifierTypes';
import axios, {AxiosResponse} from 'axios'; import axios, {AxiosResponse} from 'axios';
import {delay} from 'bluebird'; import {delay} from 'bluebird';

View File

@ -4,7 +4,7 @@ import * as chai from 'chai';
import {configForUser} from 'test/gen-server/testUtils'; import {configForUser} from 'test/gen-server/testUtils';
import * as testUtils from 'test/server/testUtils'; import * as testUtils from 'test/server/testUtils';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {TestServer} from 'test/gen-server/apiUtils'; import {TestServer} from 'test/gen-server/apiUtils';

View File

@ -1,5 +1,5 @@
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {FlexServer} from 'app/server/lib/FlexServer'; import {FlexServer} from 'app/server/lib/FlexServer';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {main as mergedServerMain} from 'app/server/mergedServerMain'; import {main as mergedServerMain} from 'app/server/mergedServerMain';

View File

@ -11,7 +11,7 @@ import {User} from 'app/gen-server/entity/User';
import {Workspace} from 'app/gen-server/entity/Workspace'; import {Workspace} from 'app/gen-server/entity/Workspace';
import {SessionUserObj} from 'app/server/lib/BrowserSession'; import {SessionUserObj} from 'app/server/lib/BrowserSession';
import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import * as docUtils from 'app/server/lib/docUtils'; import * as docUtils from 'app/server/lib/docUtils';
import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer'; import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer';
import {main as mergedServerMain, ServerType} from 'app/server/mergedServerMain'; import {main as mergedServerMain, ServerType} from 'app/server/mergedServerMain';

View File

@ -1,7 +1,7 @@
import {QueryRunner} from "typeorm"; import {QueryRunner} from "typeorm";
import * as roles from "app/common/roles"; import * as roles from "app/common/roles";
import {Organization} from 'app/gen-server/entity/Organization'; import {Organization} from 'app/gen-server/entity/Organization';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {Permissions} from 'app/gen-server/lib/Permissions'; import {Permissions} from 'app/gen-server/lib/Permissions';
import {assert} from 'chai'; import {assert} from 'chai';
import {addSeedData, createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed'; import {addSeedData, createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';

View File

@ -40,7 +40,7 @@ import {Organization} from "app/gen-server/entity/Organization";
import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product"; import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product";
import {User} from "app/gen-server/entity/User"; import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace"; import {Workspace} from "app/gen-server/entity/Workspace";
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager'; import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/homedb/HomeDBManager';
import {Permissions} from 'app/gen-server/lib/Permissions'; import {Permissions} from 'app/gen-server/lib/Permissions';
import {getOrCreateConnection, runMigrations, undoLastMigration, updateDb} from 'app/server/lib/dbUtils'; import {getOrCreateConnection, runMigrations, undoLastMigration, updateDb} from 'app/server/lib/dbUtils';
import {FlexServer} from 'app/server/lib/FlexServer'; import {FlexServer} from 'app/server/lib/FlexServer';

View File

@ -2,7 +2,7 @@ import {GristLoadConfig} from 'app/common/gristUrls';
import {BillingAccount} from 'app/gen-server/entity/BillingAccount'; import {BillingAccount} from 'app/gen-server/entity/BillingAccount';
import {Organization} from 'app/gen-server/entity/Organization'; import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product'; import {Product} from 'app/gen-server/entity/Product';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {INotifier} from 'app/server/lib/INotifier'; import {INotifier} from 'app/server/lib/INotifier';
import {AxiosRequestConfig} from "axios"; import {AxiosRequestConfig} from "axios";
import {delay} from 'bluebird'; import {delay} from 'bluebird';

View File

@ -52,10 +52,11 @@ describe('WebhookPage', function () {
'Name', 'Name',
'Memo', 'Memo',
'Event Types', 'Event Types',
'URL',
'Table', 'Table',
'Ready Column',
'Filter for changes in these columns (semicolon-separated ids)', 'Filter for changes in these columns (semicolon-separated ids)',
'Ready Column',
'URL',
'Header Authorization',
'Webhook Id', 'Webhook Id',
'Enabled', 'Enabled',
'Status', 'Status',
@ -81,7 +82,7 @@ describe('WebhookPage', function () {
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Webhook Id'), id); assert.equal(await getField(1, 'Webhook Id'), id);
}); });
// Now other fields like name, memo and watchColIds are persisted. // Now other fields like name, memo, watchColIds, and Header Auth are persisted.
await setField(1, 'Name', 'Test Webhook'); await setField(1, 'Name', 'Test Webhook');
await setField(1, 'Memo', 'Test Memo'); await setField(1, 'Memo', 'Test Memo');
await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B'); await setField(1, 'Filter for changes in these columns (semicolon-separated ids)', 'A; B');
@ -115,6 +116,27 @@ describe('WebhookPage', function () {
assert.lengthOf((await docApi.getRows('Table2')).A, 0); assert.lengthOf((await docApi.getRows('Table2')).A, 0);
}); });
it('can create webhook with persistant header authorization', async function () {
// The webhook won't work because the header auth doesn't match the api key of the current test user.
await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n');
await setField(1, 'URL', `http://${host}/api/docs/${doc.id}/tables/Table2/records?flat=1`);
await setField(1, 'Table', 'Table1');
await gu.waitForServer();
await driver.navigate().refresh();
await waitForWebhookPage();
await setField(1, 'Header Authorization', 'Bearer 1234');
await gu.waitForServer();
await driver.navigate().refresh();
await waitForWebhookPage();
await gu.waitToPass(async () => {
assert.equal(await getField(1, 'Header Authorization'), 'Bearer 1234');
});
await gu.getDetailCell({col:'Header Authorization', rowNum: 1}).click();
await gu.enterCell(Key.DELETE, Key.ENTER);
await gu.waitForServer();
});
it('can create two webhooks', async function () { it('can create two webhooks', async function () {
await openWebhookPage(); await openWebhookPage();
await setField(1, 'Event Types', 'add\nupdate\n'); await setField(1, 'Event Types', 'add\nupdate\n');

View File

@ -13,7 +13,7 @@ import {normalizeEmail} from 'app/common/emails';
import {UserProfile} from 'app/common/LoginSessionAPI'; import {UserProfile} from 'app/common/LoginSessionAPI';
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs'; import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {TestingHooksClient} from 'app/server/lib/TestingHooks'; import {TestingHooksClient} from 'app/server/lib/TestingHooks';
import EventEmitter = require('events'); import EventEmitter = require('events');

View File

@ -11,7 +11,7 @@
* into a file whose path is printed when server starts. * into a file whose path is printed when server starts.
*/ */
import {encodeUrl, IGristUrlState, parseSubdomain} from 'app/common/gristUrls'; import {encodeUrl, IGristUrlState, parseSubdomain} from 'app/common/gristUrls';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {getAppRoot} from 'app/server/lib/places'; import {getAppRoot} from 'app/server/lib/places';
import {makeGristConfig} from 'app/server/lib/sendAppPage'; import {makeGristConfig} from 'app/server/lib/sendAppPage';

View File

@ -1,5 +1,5 @@
import {parseUrlId} from 'app/common/gristUrls'; import {parseUrlId} from 'app/common/gristUrls';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {DocManager} from 'app/server/lib/DocManager'; import {DocManager} from 'app/server/lib/DocManager';
import {FlexServer} from 'app/server/lib/FlexServer'; import {FlexServer} from 'app/server/lib/FlexServer';
import axios from 'axios'; import axios from 'axios';

View File

@ -4625,6 +4625,7 @@ function testDocApi() {
id: first.webhookId, id: first.webhookId,
fields: { fields: {
url: `${serving.url}/200`, url: `${serving.url}/200`,
authorization: '',
unsubscribeKey: first.unsubscribeKey, unsubscribeKey: first.unsubscribeKey,
eventTypes: ['add', 'update'], eventTypes: ['add', 'update'],
enabled: true, enabled: true,
@ -4643,6 +4644,7 @@ function testDocApi() {
id: second.webhookId, id: second.webhookId,
fields: { fields: {
url: `${serving.url}/404`, url: `${serving.url}/404`,
authorization: '',
unsubscribeKey: second.unsubscribeKey, unsubscribeKey: second.unsubscribeKey,
eventTypes: ['add', 'update'], eventTypes: ['add', 'update'],
enabled: true, enabled: true,
@ -5010,6 +5012,7 @@ function testDocApi() {
const expectedFields = { const expectedFields = {
url: `${serving.url}/foo`, url: `${serving.url}/foo`,
authorization: '',
eventTypes: ['add'], eventTypes: ['add'],
isReadyColumn: 'B', isReadyColumn: 'B',
tableId: 'Table1', tableId: 'Table1',
@ -5079,6 +5082,8 @@ function testDocApi() {
await check({isReadyColumn: null}, 200); await check({isReadyColumn: null}, 200);
await check({isReadyColumn: "bar"}, 404, `Column not found "bar"`); await check({isReadyColumn: "bar"}, 404, `Column not found "bar"`);
await check({authorization: 'Bearer fake-token'}, 200);
}); });
}); });

View File

@ -2,7 +2,7 @@ import {ErrorOrValue, freezeError, mapGetOrSet, MapWithTTL} from 'app/common/Asy
import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot'; import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot';
import {SCHEMA_VERSION} from 'app/common/schema'; import {SCHEMA_VERSION} from 'app/common/schema';
import {DocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; import {DocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
import {ActiveDoc} from 'app/server/lib/ActiveDoc'; import {ActiveDoc} from 'app/server/lib/ActiveDoc';
import {create} from 'app/server/lib/create'; import {create} from 'app/server/lib/create';
import {DocManager} from 'app/server/lib/DocManager'; import {DocManager} from 'app/server/lib/DocManager';

View File

@ -1,4 +1,4 @@
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager';
export async function getDatabase(typeormDb?: string): Promise<HomeDBManager> { export async function getDatabase(typeormDb?: string): Promise<HomeDBManager> {
const origTypeormDB = process.env.TYPEORM_DATABASE; const origTypeormDB = process.env.TYPEORM_DATABASE;