(core) Add support for auto-copying docs on signup

Summary:
The new "copyDoc" query parameter on the login page sets a short-lived cookie, which is
then read when welcoming a new user to copy that document to their Home workspace, and
redirect to it. Currently, only templates and bare forks set this parameter.

A new API endpoint for copying a document to a workspace was also added.

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3992
This commit is contained in:
George Gevoian
2023-09-06 14:35:46 -04:00
parent 90fb4434cc
commit 3dadf93c98
26 changed files with 1057 additions and 276 deletions

View File

@@ -1,5 +1,6 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {IUndoState} from 'app/client/components/UndoStack';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {loadGristDoc} from 'app/client/lib/imports';
import {AppModel, getOrgNameOrGuest, reportError} from 'app/client/models/AppModel';
import {getDoc} from 'app/client/models/gristConfigCache';
@@ -94,6 +95,7 @@ export interface DocPageModel {
// the error that prompted the offer. If user is not owner, just flag that
// document needs attention of an owner.
offerRecovery(err: Error): void;
clearUnsavedChanges(): void;
}
export interface ImportSource {
@@ -154,6 +156,15 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
// (with the previous promise cancelled) when _openerDocKey changes.
private _openerHolder = Holder.create<FlowRunner>(this);
private readonly _unsavedChangeHolder = Holder.create<UnsavedChange>(this);
private readonly _isUnsavedFork = Computed.create(this,
this.isFork,
this.isSnapshot,
this.isTutorialFork,
(use, isFork, isSnapshot, isTutorialFork) => isFork && !isSnapshot && !isTutorialFork
);
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
super();
@@ -185,6 +196,14 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
this.currentProduct.set(org?.billingAccount?.product ?? null);
}
}));
this.autoDispose(this._isUnsavedFork.addListener((isUnsavedFork) => {
if (isUnsavedFork) {
UnsavedChange.create(this._unsavedChangeHolder);
} else {
this._unsavedChangeHolder.clear();
}
}));
}
public createLeftPane(leftPanelOpen: Observable<boolean>) {
@@ -289,6 +308,10 @@ It also disables formulas. [{{error}}]", {error: err.message})
);
}
public clearUnsavedChanges(): void {
this._unsavedChangeHolder.clear();
}
private _onOpenError(err: Error) {
if (err instanceof CancelledError) {
// This means that we started loading a new doc before the previous one finished loading.

View File

@@ -66,24 +66,30 @@ export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
// Get url for the login page, which will then redirect to `nextUrl` (current page by default).
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
return _getLoginLogoutUrl('login', nextUrl);
export interface GetLoginOrSignupUrlOptions {
srcDocId?: string | null;
/** Defaults to the current URL. */
nextUrl?: string | null;
}
// Get url for the signup page, which will then redirect to `nextUrl` (current page by default).
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signup', nextUrl);
// Get URL for the login page.
export function getLoginUrl(options: GetLoginOrSignupUrlOptions = {}): string {
return _getLoginLogoutUrl('login', options);
}
// Get url for the logout page.
// Get URL for the signup page.
export function getSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
return _getLoginLogoutUrl('signup', options);
}
// Get URL for the logout page.
export function getLogoutUrl(): string {
return _getLoginLogoutUrl('logout');
}
// Get url for the signin page, which will then redirect to `nextUrl` (current page by default).
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signin', nextUrl);
// Get URL for the signin page.
export function getLoginOrSignupUrl(options: GetLoginOrSignupUrlOptions = {}): string {
return _getLoginLogoutUrl('signin', options);
}
export function getWelcomeHomeUrl() {
@@ -100,9 +106,14 @@ function _getCurrentUrl(): string {
return parseFirstUrlPart('o', pathname).path + search + hash;
}
// Returns the URL for the given login page, with 'next' param optionally set.
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
// Returns the URL for the given login page.
function _getLoginLogoutUrl(
page: 'login'|'logout'|'signin'|'signup',
options: GetLoginOrSignupUrlOptions = {}
): string {
const {srcDocId, nextUrl = _getCurrentUrl()} = options;
const startUrl = _buildUrl(page);
if (srcDocId) { startUrl.searchParams.set('srcDocId', srcDocId); }
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
return startUrl.href;
}

View File

@@ -81,10 +81,10 @@ export class AccountWidget extends Disposable {
private _buildUseThisTemplateButton() {
return cssUseThisTemplateButton(t('Use This Template'),
dom.attr('href', use => {
// Keep the redirect param of the login/signup URL fresh.
use(urlState().state);
return getLoginOrSignupUrl();
const {doc: srcDocId} = use(urlState().state);
return getLoginOrSignupUrl({srcDocId});
}),
dom.on('click', () => { this._docPageModel?.clearUnsavedChanges(); }),
testId('dm-account-use-this-template'),
);
}
@@ -109,7 +109,7 @@ export class AccountWidget extends Disposable {
t("Toggle Mobile Mode"),
cssCheckmark('Tick', dom.show(viewport.viewportEnabled)),
testId('usermenu-toggle-mobile'),
);
);
if (!user) {
return [

View File

@@ -172,10 +172,9 @@ export class App extends DisposableWithEvents {
G.window.addEventListener('beforeunload', (ev: BeforeUnloadEvent) => {
if (unsavedChanges.haveUnsavedChanges()) {
// Following https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
const msg = 'You have some unsaved changes';
ev.returnValue = msg;
ev.returnValue = true;
ev.preventDefault();
return msg;
return true;
}
this.dispose();
});

View File

@@ -5,8 +5,8 @@
import {makeT} from 'app/client/lib/localization';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocPageModel} from "app/client/models/DocPageModel";
import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {urlState} from 'app/client/models/gristUrlState';
import {getWorkspaceInfo, ownerName, workspaceName} from 'app/client/models/WorkspaceInfo';
import {cssInput} from 'app/client/ui/cssInput';
import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
@@ -14,16 +14,7 @@ import {cssRadioCheckboxOptions, labeledSquareCheckbox, radioCheckboxOption} fro
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {select} from 'app/client/ui2018/menus';
import {
confirmModal,
cssModalBody,
cssModalButtons,
cssModalTitle,
cssModalWidth,
modal,
saveModal
} from 'app/client/ui2018/modals';
import {FullUser} from 'app/common/LoginSessionAPI';
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, modal, saveModal} from 'app/client/ui2018/modals';
import * as roles from 'app/common/roles';
import {Document, isTemplatesOrg, Organization, Workspace} from 'app/common/UserAPI';
import {Computed, Disposable, dom, input, Observable, styled, subscribe} from 'grainjs';
@@ -31,8 +22,9 @@ import sortBy = require('lodash/sortBy');
const t = makeT('MakeCopyMenu');
export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, app: AppModel, origUrlId: string) {
const trunkAccess = (await app.api.getDoc(origUrlId)).access;
export async function replaceTrunkWithFork(doc: Document, pageModel: DocPageModel, origUrlId: string) {
const {appModel} = pageModel;
const trunkAccess = (await appModel.api.getDoc(origUrlId)).access;
if (!roles.canEdit(trunkAccess)) {
modal((ctl) => [
cssModalBody(t("Replacing the original requires editing rights on the original document.")),
@@ -42,7 +34,7 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a
]);
return;
}
const docApi = app.api.getDocAPI(origUrlId);
const docApi = appModel.api.getDocAPI(origUrlId);
const cmp = await docApi.compareDoc(doc.id);
let titleText = t("Update Original");
let buttonText = t("Update");
@@ -64,6 +56,7 @@ not in this document. Those changes will be overwritten.")}`;
async () => {
try {
await docApi.replace({sourceDocId: doc.id});
pageModel.clearUnsavedChanges();
await urlState().pushUrl({doc: origUrlId});
} catch (e) {
reportError(e); // For example: no write access on trunk.
@@ -71,18 +64,6 @@ not in this document. Those changes will be overwritten.")}`;
}, {explanation: warningText});
}
// Show message in a modal with a `Sign up` button that redirects to the login page.
function signupModal(message: string) {
return modal((ctl) => [
cssModalBody(message),
cssModalButtons(
bigPrimaryButtonLink(t("Sign up"), {href: getLoginOrSignupUrl(), target: '_blank'}, testId('modal-signup')),
bigBasicButton(t("Cancel"), dom.on('click', () => ctl.close())),
),
cssModalWidth('normal'),
]);
}
/**
* Whether we should offer user the option to copy this doc to other orgs.
* We allow copying out of source org when the source org is a personal org, or user has owner
@@ -104,12 +85,14 @@ function allowOtherOrgs(doc: Document, app: AppModel): boolean {
/**
* Ask user for the destination and new name, and make a copy of the doc using those.
*/
export async function makeCopy(doc: Document, app: AppModel, modalTitle: string): Promise<void> {
if (!app.currentValidUser) {
signupModal(t("To save your changes, please sign up, then reload this page."));
return;
}
let orgs = allowOtherOrgs(doc, app) ? await app.api.getOrgs(true) : null;
export async function makeCopy(options: {
pageModel: DocPageModel,
doc: Document,
modalTitle: string,
}): Promise<void> {
const {pageModel, doc, modalTitle} = options;
const {appModel} = pageModel;
let orgs = allowOtherOrgs(doc, appModel) ? await appModel.api.getOrgs(true) : null;
if (orgs) {
// Don't show the templates org since it's selected by default, and
// is not writable to.
@@ -118,7 +101,7 @@ export async function makeCopy(doc: Document, app: AppModel, modalTitle: string)
// Show a dialog with a form to select destination.
saveModal((ctl, owner) => {
const saveCopyModal = SaveCopyModal.create(owner, doc, app, orgs);
const saveCopyModal = SaveCopyModal.create(owner, {pageModel, doc, orgs});
return {
title: modalTitle,
body: saveCopyModal.buildDom(),
@@ -129,7 +112,17 @@ export async function makeCopy(doc: Document, app: AppModel, modalTitle: string)
});
}
interface SaveCopyModalParams {
pageModel: DocPageModel;
doc: Document;
orgs: Organization[]|null;
}
class SaveCopyModal extends Disposable {
private _pageModel = this._params.pageModel;
private _app = this._pageModel.appModel;
private _doc = this._params.doc;
private _orgs = this._params.orgs;
private _workspaces = Observable.create<Workspace[]|null>(this, null);
private _destName = Observable.create<string>(this, '');
private _destOrg = Observable.create<Organization|null>(this, this._app.currentOrg);
@@ -142,10 +135,10 @@ class SaveCopyModal extends Disposable {
private _showWorkspaces = Computed.create(this, this._destOrg, (use, org) => Boolean(org && !org.owner));
// If orgs is non-null, then we show a selector for orgs.
constructor(private _doc: Document, private _app: AppModel, private _orgs: Organization[]|null) {
constructor(private _params: SaveCopyModalParams) {
super();
if (_doc.name !== 'Untitled') {
this._destName.set(_doc.name + ' (copy)');
if (this._doc.name !== 'Untitled') {
this._destName.set(this._doc.name + ' (copy)');
}
if (this._orgs && this._app.currentOrg) {
// Set _destOrg to an Organization object from _orgs array; there should be one equivalent
@@ -163,12 +156,14 @@ class SaveCopyModal extends Disposable {
if (!ws) { throw new Error(t("No destination workspace")); }
const api = this._app.api;
const org = this._destOrg.get();
const docWorker = await api.getWorkerAPI('import');
const destName = this._destName.get() + '.grist';
const destName = this._destName.get();
try {
const uploadId = await docWorker.copyDoc(this._doc.id, this._asTemplate.get(), destName);
const {id} = await docWorker.importDocToWorkspace(uploadId, ws.id);
await urlState().pushUrl({org: org?.domain || undefined, doc: id, docPage: urlState().state.get().docPage});
const doc = await api.copyDoc(this._doc.id, ws.id, {
documentName: destName,
asTemplate: this._asTemplate.get(),
});
this._pageModel.clearUnsavedChanges();
await urlState().pushUrl({org: org?.domain || undefined, doc, docPage: urlState().state.get().docPage});
} catch(err) {
// Convert access denied errors to normal Error to make it consistent with other endpoints.
// TODO: Should not allow to click this button when user doesn't have permissions.

View File

@@ -1,7 +1,7 @@
import {loadUserManager} from 'app/client/lib/imports';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {docUrl, getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {downloadDocModal, makeCopy, replaceTrunkWithFork} from 'app/client/ui/MakeCopyMenu';
import {sendToDrive} from 'app/client/ui/sendToDrive';
@@ -34,20 +34,19 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
// to render its contents, but we handle by merely skipping such content if gristDoc is not yet
// available (a user quick enough to open the menu in this state would have to re-open it).
return dom.maybe(pageModel.currentDoc, (doc) => {
const appModel = pageModel.appModel;
const saveCopy = () => makeCopy(doc, appModel, t("Save Document")).catch(reportError);
const saveCopy = () => handleSaveCopy({pageModel, doc, modalTitle: t("Save Document")});
if (doc.isSnapshot) {
const backToCurrent = () => urlState().pushUrl({doc: buildOriginalUrlId(doc.id, true)});
return shareButton(t("Back to Current"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel, {isSnapshot: true}),
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
menuOriginal(doc, pageModel, {isSnapshot: true}),
menuExports(doc, pageModel),
], {buttonAction: backToCurrent});
} else if (doc.isTutorialFork) {
return shareButton(t("Save Copy"), () => [
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel, {isTutorialFork: true}),
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
menuOriginal(doc, pageModel, {isTutorialFork: true}),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else if (doc.isPreFork || doc.isBareFork) {
@@ -55,7 +54,7 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
const saveActionTitle = doc.isBareFork ? t("Save Document") : t("Save Copy");
return shareButton(saveActionTitle, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(saveActionTitle, doc, appModel),
menuSaveCopy({pageModel, doc, saveActionTitle}),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else if (doc.isFork) {
@@ -66,22 +65,22 @@ export function buildShareMenuButton(pageModel: DocPageModel): DomContents {
if (!roles.canEdit(doc.trunkAccess || null)) {
return shareButton(t("Save Copy"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel),
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
menuOriginal(doc, pageModel),
menuExports(doc, pageModel),
], {buttonAction: saveCopy});
} else {
return shareButton(t("Unsaved"), () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Save Copy"), doc, appModel),
menuOriginal(doc, appModel),
menuSaveCopy({pageModel, doc, saveActionTitle: t("Save Copy")}),
menuOriginal(doc, pageModel),
menuExports(doc, pageModel),
]);
}
} else {
return shareButton(null, () => [
menuManageUsers(doc, pageModel),
menuSaveCopy(t("Duplicate Document"), doc, appModel),
menuSaveCopy({pageModel, doc, saveActionTitle: t("Duplicate Document")}),
menuWorkOnCopy(pageModel),
menuExports(doc, pageModel),
]);
@@ -134,6 +133,22 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc,
}
}
async function handleSaveCopy(options: {
pageModel: DocPageModel,
doc: Document,
modalTitle: string,
}) {
const {pageModel} = options;
const {appModel} = pageModel;
if (!appModel.currentValidUser) {
pageModel.clearUnsavedChanges();
window.location.href = getLoginOrSignupUrl({srcDocId: urlState().state.get().doc});
return;
}
return makeCopy(options);
}
// Renders "Manage Users" menu item.
function menuManageUsers(doc: DocInfo, pageModel: DocPageModel) {
return [
@@ -163,7 +178,7 @@ interface MenuOriginalOptions {
* setting the open mode in the URL to "/m/default" - if the menu item were to ever be included
* again, it should likely be a shortcut to setting the open mode back to default.
*/
function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOptions = {}) {
function menuOriginal(doc: Document, pageModel: DocPageModel, options: MenuOriginalOptions = {}) {
const {isSnapshot = false, isTutorialFork = false} = options;
const termToUse = isSnapshot ? t("Current Version") : t("Original");
const origUrlId = buildOriginalUrlId(doc.id, isSnapshot);
@@ -183,15 +198,17 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
const comparingSnapshots: boolean = isSnapshot && Boolean(compareUrlId && parseUrlId(compareUrlId).snapshotId);
function replaceOriginal() {
const user = appModel.currentValidUser;
replaceTrunkWithFork(user, doc, appModel, origUrlId).catch(reportError);
replaceTrunkWithFork(doc, pageModel, origUrlId).catch(reportError);
}
return [
isTutorialFork ? null : cssMenuSplitLink({href: originalUrl},
cssMenuSplitLinkText(t("Return to {{termToUse}}", {termToUse})), testId('return-to-original'),
cssMenuIconLink({href: originalUrl, target: '_blank'}, testId('open-original'),
cssMenuSplitLinkText(t("Return to {{termToUse}}", {termToUse})),
cssMenuIconLink({href: originalUrl, target: '_blank'},
cssMenuIcon('FieldLink'),
)
testId('open-original'),
),
dom.on('click', () => { pageModel.clearUnsavedChanges(); }),
testId('return-to-original'),
),
menuItem(replaceOriginal, t("Replace {{termToUse}}...", {termToUse}),
// Disable if original is not writable, and also when comparing snapshots (since it's
@@ -201,6 +218,7 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
),
isTutorialFork ? null : menuItemLink(compareHref, {target: '_blank'}, t("Compare to {{termToUse}}", {termToUse}),
menuAnnotate('Beta'),
dom.on('click', () => { pageModel.clearUnsavedChanges(); }),
testId('compare-original'),
),
];
@@ -208,8 +226,13 @@ function menuOriginal(doc: Document, appModel: AppModel, options: MenuOriginalOp
// Renders "Save Copy..." and "Copy as Template..." menu items. The name of the first action is
// specified in saveActionTitle.
function menuSaveCopy(saveActionTitle: string, doc: Document, appModel: AppModel) {
const saveCopy = () => makeCopy(doc, appModel, saveActionTitle).catch(reportError);
function menuSaveCopy(options: {
pageModel: DocPageModel,
doc: Document,
saveActionTitle: string,
}) {
const {pageModel, doc, saveActionTitle} = options;
const saveCopy = () => handleSaveCopy({pageModel, doc, modalTitle: saveActionTitle});
return [
// TODO Disable these when user has no accessible destinations.
menuItem(saveCopy, `${saveActionTitle}...`, testId('save-copy')),

View File

@@ -99,7 +99,7 @@ export class WelcomePage extends Disposable {
`If you already have a Grist account as `,
dom('b', email.get()),
` you can just `,
cssLink({href: getLoginUrl('')}, 'log in'),
cssLink({href: getLoginUrl({nextUrl: null})}, 'log in'),
` now. Otherwise, please pick a password.`
),
cssSeparatedLabel('The email address you activated Grist with:'),
@@ -184,7 +184,7 @@ export class WelcomePage extends Disposable {
'Apply verification code' : 'Resend verification email')
),
bigBasicButtonLink('More sign-up options',
{href: getSignupUrl('')})
{href: getSignupUrl({nextUrl: null})})
)
);
}

View File

@@ -340,6 +340,11 @@ export interface DocStateComparisonDetails {
rightChanges: ActionSummary;
}
export interface CopyDocOptions {
documentName: string;
asTemplate?: boolean;
}
export interface UserAPI {
getSessionActive(): Promise<ActiveSessionInfo>;
setSessionActive(email: string, org?: string): Promise<void>;
@@ -355,6 +360,7 @@ export interface UserAPI {
newWorkspace(props: Partial<WorkspaceProperties>, orgId: number|string): Promise<number>;
newDoc(props: Partial<DocumentProperties>, workspaceId: number): Promise<string>;
newUnsavedDoc(options?: {timezone?: string}): Promise<string>;
copyDoc(sourceDocumentId: string, workspaceId: number, options: CopyDocOptions): Promise<string>;
renameOrg(orgId: number|string, name: string): Promise<void>;
renameWorkspace(workspaceId: number, name: string): Promise<void>;
renameDoc(docId: string, name: string): Promise<void>;
@@ -569,6 +575,21 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
});
}
public async copyDoc(
sourceDocumentId: string,
workspaceId: number,
options: CopyDocOptions
): Promise<string> {
return this.requestJson(`${this._url}/api/docs`, {
method: 'POST',
body: JSON.stringify({
sourceDocumentId,
workspaceId,
...options,
}),
});
}
public async renameOrg(orgId: number|string, name: string): Promise<void> {
await this.request(`${this._url}/api/orgs/${orgId}`, {
method: 'PATCH',
@@ -984,6 +1005,14 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
return this.requestJson(`${this._url}/compare/${remoteDocId}${q}`);
}
public async copyDoc(workspaceId: number, options: CopyDocOptions): Promise<string> {
const {documentName, asTemplate} = options;
return this.requestJson(`${this._url}/copy`, {
body: JSON.stringify({workspaceId, documentName, asTemplate}),
method: 'POST'
});
}
public async compareVersion(leftHash: string, rightHash: string): Promise<DocStateComparison> {
const url = new URL(`${this._url}/compare`);
url.searchParams.append('left', leftHash);

View File

@@ -124,6 +124,7 @@ export interface IGristUrlState {
billingTask?: BillingTask;
embed?: boolean;
state?: string;
srcDocId?: string;
style?: InterfaceStyle;
compare?: string;
linkParameters?: Record<string, string>; // Parameters to pass as 'user.Link' in granular ACLs.
@@ -395,7 +396,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (sp.has('state')) {
state.params!.state = sp.get('state')!;
}
if (sp.has('srcDocId')) {
state.params!.srcDocId = sp.get('srcDocId')!;
}
if (sp.has('style')) {
let style = sp.get('style');
if (style === 'light') {

View File

@@ -4,11 +4,9 @@
* of the client-side code.
*/
import * as express from 'express';
import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
import {ApiError} from 'app/common/ApiError';
import {getSlugIfNeeded, parseSubdomainStrictly, parseUrlId} from 'app/common/gristUrls';
import {removeTrailingSlash} from 'app/common/gutil';
import {getSlugIfNeeded, parseUrlId} from 'app/common/gristUrls';
import {LocalPlugin} from "app/common/plugin";
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
import {Document as APIDocument} from 'app/common/UserAPI';
@@ -17,13 +15,14 @@ import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
RequestWithLogin} from 'app/server/lib/Authorizer';
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {customizeDocWorkerUrl, getWorker, useWorkerPool} from 'app/server/lib/DocWorkerUtils';
import {expressWrap} from 'app/server/lib/expressWrap';
import {DocTemplate, GristServer} from 'app/server/lib/GristServer';
import {getCookieDomain} from 'app/server/lib/gristSessions';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import {getAssignmentId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log';
import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
import {addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions} from 'app/server/lib/sendAppPage';
export interface AttachOptions {
@@ -38,144 +37,6 @@ export interface AttachOptions {
gristServer: GristServer;
}
/**
* This method transforms a doc worker's public url as needed based on the request.
*
* For historic reasons, doc workers are assigned a public url at the time
* of creation. In production/staging, this is of the form:
* https://doc-worker-NNN-NNN-NNN-NNN.getgrist.com/v/VVVV/
* and in dev:
* http://localhost:NNNN/v/VVVV/
*
* Prior to support for different base domains, this was fine. Now that different
* base domains are supported, a wrinkle arises. When a web client communicates
* with a doc worker, it is important that it accesses the doc worker via a url
* containing the same base domain as the web page the client is on (for cookie
* purposes). Hence this method.
*
* If both the request and docWorkerUrl contain identifiable base domains (not localhost),
* then the base domain of docWorkerUrl is replaced with that of the request.
*
* But wait, there's another wrinkle: custom domains. In this case, we have a single
* domain available to serve a particular org from. This method will use the origin of req
* and include a /dw/doc-worker-NNN-NNN-NNN-NNN/
* (or /dw/local-NNNN/) prefix in all doc worker paths. Once this is in place, it
* will allow doc worker routing to be changed so it can be overlaid on a custom
* domain.
*
* TODO: doc worker registration could be redesigned to remove the assumption
* of a fixed base domain.
*/
function customizeDocWorkerUrl(docWorkerUrlSeed: string|undefined, req: express.Request): string|null {
if (!docWorkerUrlSeed) {
// When no doc worker seed, we're in single server mode.
// Return null, to signify that the URL prefix serving the
// current endpoint is the only one available.
return null;
}
const docWorkerUrl = new URL(docWorkerUrlSeed);
const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;
adaptServerUrl(docWorkerUrl, req);
// We wish to migrate to routing doc workers by path, so insert a doc worker identifier
// in the path (if not already present).
if (!docWorkerUrl.pathname.startsWith('/dw/')) {
// When doc worker is localhost, the port number is necessary and sufficient for routing.
// Let's add a /dw/... prefix just for consistency.
const workerIdent = workerSubdomain || `local-${docWorkerUrl.port}`;
docWorkerUrl.pathname = `/dw/${workerIdent}${docWorkerUrl.pathname}`;
}
return docWorkerUrl.href;
}
/**
*
* Gets the worker responsible for a given assignment, and fetches a url
* from the worker.
*
* If the fetch fails, we throw an exception, unless we see enough evidence
* to unassign the worker and try again.
*
* - If GRIST_MANAGED_WORKERS is set, we assume that we've arranged
* for unhealthy workers to be removed automatically, and that if a
* fetch returns a 404 with specific content, it is proof that the
* worker is no longer in existence. So if we see a 404 with that
* specific content, we can safely de-list the worker from redis,
* and repeat.
* - If GRIST_MANAGED_WORKERS is not set, we accept a broader set
* of failures as evidence of a missing worker.
*
* The specific content of a 404 that will be treated as evidence of
* a doc worker not being present is:
* - A json format body
* - With a key called "message"
* - With the value of "message" being "document worker not present"
* In production, this is provided by a special doc-worker-* load balancer
* rule.
*
*/
async function getWorker(docWorkerMap: IDocWorkerMap, assignmentId: string,
urlPath: string, config: RequestInit = {}) {
if (!useWorkerPool()) {
// This should never happen. We are careful to not use getWorker
// when everything is on a single server, since it is burdensome
// for self-hosted users to figure out the correct settings for
// the server to be able to contact itself, and there are cases
// of the defaults not working.
throw new Error("AppEndpoint.getWorker was called unnecessarily");
}
let docStatus: DocStatus|undefined;
const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS);
for (;;) {
docStatus = await docWorkerMap.assignDocWorker(assignmentId);
const configWithTimeout = {timeout: 10000, ...config};
const fullUrl = removeTrailingSlash(docStatus.docWorker.internalUrl) + urlPath;
try {
const resp: FetchResponse = await fetch(fullUrl, configWithTimeout);
if (resp.ok) {
return {
resp,
docStatus,
};
}
if (resp.status === 403) {
throw new ApiError("You do not have access to this document.", resp.status);
}
if (resp.status !== 404) {
throw new ApiError(resp.statusText, resp.status);
}
let body: any;
try {
body = await resp.json();
} catch (e) {
throw new ApiError(resp.statusText, resp.status);
}
if (!(body && body.message && body.message === 'document worker not present')) {
throw new ApiError(resp.statusText, resp.status);
}
// This is a 404 with the expected content for a missing worker.
} catch (e) {
log.rawDebug(`AppEndpoint.getWorker failure`, {
url: fullUrl,
docId: assignmentId,
status: e.status,
message: String(e),
workerId: docStatus.docWorker.id,
});
// If workers are managed, no errors merit continuing except a 404.
// Otherwise, we continue if we see a system error (e.g. ECONNREFUSED).
// We don't accept timeouts since there is too much potential to
// bring down a single-worker deployment that has a hiccup.
if (workersAreManaged || !(e.type === 'system')) {
throw e;
}
}
log.warn(`fetch from ${fullUrl} failed convincingly, removing that worker`);
await docWorkerMap.removeWorker(docStatus.docWorker.id);
docStatus = undefined;
}
}
export function attachAppEndpoint(options: AttachOptions): void {
const {app, middleware, docMiddleware, docWorkerMap, forceLogin,
sendAppPage, dbManager, plugins, gristServer} = options;
@@ -358,8 +219,3 @@ export function attachAppEndpoint(options: AttachOptions): void {
app.get('/:urlId([^/]{12,})/:slug([^/]+):remainder(*)',
...docMiddleware, docHandler);
}
// Return true if document related endpoints are served by separate workers.
function useWorkerPool() {
return process.env.GRIST_SINGLE_PORT !== 'true';
}

View File

@@ -36,6 +36,7 @@ import {appSettings} from "app/server/lib/AppSettings";
import {sendForCompletion} from 'app/server/lib/Assistance';
import {
assertAccess,
getAuthorizedUserId,
getOrSetDocAuth,
getTransitiveHeaders,
getUserId,
@@ -64,6 +65,7 @@ import {
getScope,
integerParam,
isParameterOn,
optBooleanParam,
optIntegerParam,
optStringParam,
sendOkReply,
@@ -73,7 +75,8 @@ import {
import {ServerColumnGetters} from 'app/server/lib/ServerColumnGetters';
import {localeFromRequest} from "app/server/lib/ServerLocale";
import {isUrlAllowed, WebhookAction, WebHookSecret} from "app/server/lib/Triggers";
import {handleOptionalUpload, handleUpload} from "app/server/lib/uploads";
import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
makeAccessId} from "app/server/lib/uploads";
import * as assert from 'assert';
import contentDisposition from 'content-disposition';
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
@@ -1225,7 +1228,11 @@ export class DocWorkerApi {
* Create a document.
*
* When an upload is included, it is imported as the initial state of the document.
* Otherwise, the document is left empty.
*
* When a source document id is included, its structure and (optionally) data is
* included in the new document.
*
* In all other cases, the document is left empty.
*
* If a workspace id is included, the document will be saved there instead of
* being left "unsaved".
@@ -1249,54 +1256,117 @@ export class DocWorkerApi {
parameters = req.body;
}
const documentName = optStringParam(parameters.documentName, 'documentName', {
allowEmpty: false,
});
const sourceDocumentId = optStringParam(parameters.sourceDocumentId, 'sourceDocumentId');
const workspaceId = optIntegerParam(parameters.workspaceId, 'workspaceId');
const browserSettings: BrowserSettings = {};
if (parameters.timezone) { browserSettings.timezone = parameters.timezone; }
browserSettings.locale = localeFromRequest(req);
let docId: string;
if (uploadId !== undefined) {
if (sourceDocumentId !== undefined) {
docId = await this._copyDocToWorkspace(req, {
userId,
sourceDocumentId,
workspaceId: integerParam(parameters.workspaceId, 'workspaceId'),
documentName: stringParam(parameters.documentName, 'documentName'),
asTemplate: optBooleanParam(parameters.asTemplate, 'asTemplate'),
});
} else if (uploadId !== undefined) {
const result = await this._docManager.importDocToWorkspace({
userId,
uploadId,
documentName,
documentName: optStringParam(parameters.documentName, 'documentName'),
workspaceId,
browserSettings,
});
docId = result.id;
} else if (workspaceId !== undefined) {
const {status, data, errMessage} = await this._dbManager.addDocument(getScope(req), workspaceId, {
name: documentName ?? 'Untitled document',
docId = await this._createNewSavedDoc(req, {
workspaceId: workspaceId,
documentName: optStringParam(parameters.documentName, 'documentName'),
});
if (status !== 200) {
throw new ApiError(errMessage || 'unable to create document', status);
}
docId = data!;
} else {
const isAnonymous = isAnonymousUser(req);
const result = makeForkIds({
docId = await this._createNewUnsavedDoc(req, {
userId,
isAnonymous,
trunkDocId: NEW_DOCUMENT_CODE,
trunkUrlId: NEW_DOCUMENT_CODE,
browserSettings,
});
docId = result.docId;
await this._docManager.createNamedDoc(
makeExceptionalDocSession('nascent', {
req: req as RequestWithLogin,
browserSettings,
}),
docId
);
}
return res.status(200).json(docId);
}));
}
private async _copyDocToWorkspace(req: Request, options: {
userId: number,
sourceDocumentId: string,
workspaceId: number,
documentName: string,
asTemplate?: boolean,
}): Promise<string> {
const {userId, sourceDocumentId, workspaceId, documentName, asTemplate = false} = options;
// First, upload a copy of the document.
let uploadResult;
try {
const accessId = makeAccessId(req, getAuthorizedUserId(req));
uploadResult = await fetchDoc(this._grist, sourceDocumentId, req, accessId, asTemplate);
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, `${documentName}.grist`);
} catch (err) {
if ((err as ApiError).status === 403) {
throw new ApiError('Insufficient access to document to copy it entirely', 403);
}
throw err;
}
// Then, import the copy to the workspace.
const result = await this._docManager.importDocToWorkspace({
userId,
uploadId: uploadResult.uploadId,
documentName,
workspaceId,
});
return result.id;
}
private async _createNewSavedDoc(req: Request, options: {
workspaceId: number,
documentName?: string,
}): Promise<string> {
const {documentName, workspaceId} = options;
const {status, data, errMessage} = await this._dbManager.addDocument(getScope(req), workspaceId, {
name: documentName ?? 'Untitled document',
});
if (status !== 200) {
throw new ApiError(errMessage || 'unable to create document', status);
}
return data!;
}
private async _createNewUnsavedDoc(req: Request, options: {
userId: number,
browserSettings?: BrowserSettings,
}): Promise<string> {
const {userId, browserSettings} = options;
const isAnonymous = isAnonymousUser(req);
const result = makeForkIds({
userId,
isAnonymous,
trunkDocId: NEW_DOCUMENT_CODE,
trunkUrlId: NEW_DOCUMENT_CODE,
});
const docId = result.docId;
await this._docManager.createNamedDoc(
makeExceptionalDocSession('nascent', {
req: req as RequestWithLogin,
browserSettings,
}),
docId
);
return docId;
}
/**
* Check for read access to the given document, and return its
* canonical docId. Throws error if read access not available.

View File

@@ -0,0 +1,158 @@
import {ApiError} from 'app/common/ApiError';
import {parseSubdomainStrictly} from 'app/common/gristUrls';
import {removeTrailingSlash} from 'app/common/gutil';
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import log from 'app/server/lib/log';
import {adaptServerUrl} from 'app/server/lib/requestUtils';
import * as express from 'express';
import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
/**
* This method transforms a doc worker's public url as needed based on the request.
*
* For historic reasons, doc workers are assigned a public url at the time
* of creation. In production/staging, this is of the form:
* https://doc-worker-NNN-NNN-NNN-NNN.getgrist.com/v/VVVV/
* and in dev:
* http://localhost:NNNN/v/VVVV/
*
* Prior to support for different base domains, this was fine. Now that different
* base domains are supported, a wrinkle arises. When a web client communicates
* with a doc worker, it is important that it accesses the doc worker via a url
* containing the same base domain as the web page the client is on (for cookie
* purposes). Hence this method.
*
* If both the request and docWorkerUrl contain identifiable base domains (not localhost),
* then the base domain of docWorkerUrl is replaced with that of the request.
*
* But wait, there's another wrinkle: custom domains. In this case, we have a single
* domain available to serve a particular org from. This method will use the origin of req
* and include a /dw/doc-worker-NNN-NNN-NNN-NNN/
* (or /dw/local-NNNN/) prefix in all doc worker paths. Once this is in place, it
* will allow doc worker routing to be changed so it can be overlaid on a custom
* domain.
*
* TODO: doc worker registration could be redesigned to remove the assumption
* of a fixed base domain.
*/
export function customizeDocWorkerUrl(
docWorkerUrlSeed: string|undefined,
req: express.Request
): string|null {
if (!docWorkerUrlSeed) {
// When no doc worker seed, we're in single server mode.
// Return null, to signify that the URL prefix serving the
// current endpoint is the only one available.
return null;
}
const docWorkerUrl = new URL(docWorkerUrlSeed);
const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;
adaptServerUrl(docWorkerUrl, req);
// We wish to migrate to routing doc workers by path, so insert a doc worker identifier
// in the path (if not already present).
if (!docWorkerUrl.pathname.startsWith('/dw/')) {
// When doc worker is localhost, the port number is necessary and sufficient for routing.
// Let's add a /dw/... prefix just for consistency.
const workerIdent = workerSubdomain || `local-${docWorkerUrl.port}`;
docWorkerUrl.pathname = `/dw/${workerIdent}${docWorkerUrl.pathname}`;
}
return docWorkerUrl.href;
}
/**
*
* Gets the worker responsible for a given assignment, and fetches a url
* from the worker.
*
* If the fetch fails, we throw an exception, unless we see enough evidence
* to unassign the worker and try again.
*
* - If GRIST_MANAGED_WORKERS is set, we assume that we've arranged
* for unhealthy workers to be removed automatically, and that if a
* fetch returns a 404 with specific content, it is proof that the
* worker is no longer in existence. So if we see a 404 with that
* specific content, we can safely de-list the worker from redis,
* and repeat.
* - If GRIST_MANAGED_WORKERS is not set, we accept a broader set
* of failures as evidence of a missing worker.
*
* The specific content of a 404 that will be treated as evidence of
* a doc worker not being present is:
* - A json format body
* - With a key called "message"
* - With the value of "message" being "document worker not present"
* In production, this is provided by a special doc-worker-* load balancer
* rule.
*
*/
export async function getWorker(
docWorkerMap: IDocWorkerMap,
assignmentId: string,
urlPath: string,
config: RequestInit = {}
) {
if (!useWorkerPool()) {
// This should never happen. We are careful to not use getWorker
// when everything is on a single server, since it is burdensome
// for self-hosted users to figure out the correct settings for
// the server to be able to contact itself, and there are cases
// of the defaults not working.
throw new Error("AppEndpoint.getWorker was called unnecessarily");
}
let docStatus: DocStatus|undefined;
const workersAreManaged = Boolean(process.env.GRIST_MANAGED_WORKERS);
for (;;) {
docStatus = await docWorkerMap.assignDocWorker(assignmentId);
const configWithTimeout = {timeout: 10000, ...config};
const fullUrl = removeTrailingSlash(docStatus.docWorker.internalUrl) + urlPath;
try {
const resp: FetchResponse = await fetch(fullUrl, configWithTimeout);
if (resp.ok) {
return {
resp,
docStatus,
};
}
if (resp.status === 403) {
throw new ApiError("You do not have access to this document.", resp.status);
}
if (resp.status !== 404) {
throw new ApiError(resp.statusText, resp.status);
}
let body: any;
try {
body = await resp.json();
} catch (e) {
throw new ApiError(resp.statusText, resp.status);
}
if (!(body && body.message && body.message === 'document worker not present')) {
throw new ApiError(resp.statusText, resp.status);
}
// This is a 404 with the expected content for a missing worker.
} catch (e) {
log.rawDebug(`AppEndpoint.getWorker failure`, {
url: fullUrl,
docId: assignmentId,
status: e.status,
message: String(e),
workerId: docStatus.docWorker.id,
});
// If workers are managed, no errors merit continuing except a 404.
// Otherwise, we continue if we see a system error (e.g. ECONNREFUSED).
// We don't accept timeouts since there is too much potential to
// bring down a single-worker deployment that has a hiccup.
if (workersAreManaged || !(e.type === 'system')) {
throw e;
}
}
log.warn(`fetch from ${fullUrl} failed convincingly, removing that worker`);
await docWorkerMap.removeWorker(docStatus.docWorker.id);
docStatus = undefined;
}
}
// Return true if document related endpoints are served by separate workers.
export function useWorkerPool() {
return process.env.GRIST_SINGLE_PORT !== 'true';
}

View File

@@ -5,6 +5,7 @@ import {encodeUrl, getSlugIfNeeded, GristDeploymentType, GristDeploymentTypes,
GristLoadConfig, IGristUrlState, isOrgInPathOnly, parseSubdomain,
sanitizePathTail} from 'app/common/gristUrls';
import {getOrgUrlInfo} from 'app/common/gristUrls';
import {safeJsonParse} from 'app/common/gutil';
import {InstallProperties} from 'app/common/InstallAPI';
import {UserProfile} from 'app/common/LoginSessionAPI';
import {tbind} from 'app/common/tbind';
@@ -22,7 +23,7 @@ import {Usage} from 'app/gen-server/lib/Usage';
import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens';
import {attachAppEndpoint} from 'app/server/lib/AppEndpoint';
import {appSettings} from 'app/server/lib/AppSettings';
import {addRequestUser, getUser, getUserId, isAnonymousUser,
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
import {forceSessionChange} from 'app/server/lib/BrowserSession';
@@ -67,6 +68,7 @@ import {buildWidgetRepository, IWidgetRepository} from 'app/server/lib/WidgetRep
import {setupLocale} from 'app/server/localization';
import axios from 'axios';
import * as bodyParser from 'body-parser';
import * as cookie from 'cookie';
import express from 'express';
import * as fse from 'fs-extra';
import * as http from 'http';
@@ -877,6 +879,13 @@ export class FlexServer implements GristServer {
// Give a chance to the login system to react to the first visit after signup.
this._loginMiddleware.onFirstVisit?.(req);
// If we need to copy an unsaved document or template as part of sign-up, do so now
// and redirect to it.
const docId = await this._maybeCopyDocToHomeWorkspace(mreq, res);
if (docId) {
return res.redirect(this.getMergedOrgUrl(mreq, `/doc/${docId}`));
}
const domain = mreq.org ?? null;
if (!process.env.GRIST_SINGLE_ORG && this._dbManager.isMergedOrg(domain)) {
// We're logging in for the first time on the merged org; if the user has
@@ -1915,6 +1924,66 @@ export class FlexServer implements GristServer {
resp.redirect(redirectToMergedOrg ? this.getMergedOrgUrl(mreq) : getOrgUrl(mreq));
}
}
/**
* If a valid cookie was set during sign-up to copy a document to the
* user's Home workspace, copy it and return the id of the new document.
*
* If a valid cookie wasn't set or copying failed, return `null`.
*/
private async _maybeCopyDocToHomeWorkspace(
req: RequestWithLogin,
resp: express.Response
): Promise<string|null> {
const cookies = cookie.parse(req.headers.cookie || '');
if (!cookies) { return null; }
const stateCookie = cookies['gr_signup_state'];
if (!stateCookie) { return null; }
const state = safeJsonParse(stateCookie, {});
const {srcDocId} = state;
if (!srcDocId) { return null; }
let newDocId: string | null = null;
try {
newDocId = await this._copyDocToHomeWorkspace(req, srcDocId);
} catch (e) {
log.error(`FlexServer failed to copy doc ${srcDocId} to Home workspace`, e);
} finally {
resp.clearCookie('gr_signup_state');
}
return newDocId;
}
private async _copyDocToHomeWorkspace(
req: express.Request,
docId: string,
): Promise<string> {
const userId = getUserId(req);
const doc = await this._dbManager.getDoc({userId, urlId: docId});
if (!doc) { throw new Error(`Doc ${docId} not found`); }
const workspacesQueryResult = await this._dbManager.getOrgWorkspaces(getScope(req), 0);
const workspaces = this._dbManager.unwrapQueryResult(workspacesQueryResult);
const workspace = workspaces.find(w => w.name === 'Home');
if (!workspace) { throw new Error('Home workspace not found'); }
const copyDocUrl = this.getHomeUrl(req, '/api/docs');
const response = await fetch(copyDocUrl, {
headers: {
...getTransitiveHeaders(req),
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
sourceDocumentId: doc.id,
workspaceId: workspace.id,
documentName: doc.name,
}),
});
return await response.json();
}
}
/**

View File

@@ -300,6 +300,17 @@ export function integerParam(p: any, name: string): number {
throw new ApiError(`${name} parameter should be an integer: ${p}`, 400);
}
export function optBooleanParam(p: any, name: string): boolean|undefined {
if (p === undefined) { return p; }
return booleanParam(p, name);
}
export function booleanParam(p: any, name: string): boolean {
if (typeof p === 'boolean') { return p; }
throw new ApiError(`${name} parameter should be a boolean: ${p}`, 400);
}
export function optJsonParam(p: any, defaultValue: any): any {
if (typeof p !== 'string') { return defaultValue; }
return gutil.safeJsonParse(p, defaultValue);

View File

@@ -404,7 +404,7 @@ async function _fetchURL(url: string, accessId: string|null, options?: FetchUrlO
* Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials
* supplied in the current request.
*/
async function fetchDoc(server: GristServer, docId: string, req: Request, accessId: string|null,
export async function fetchDoc(server: GristServer, docId: string, req: Request, accessId: string|null,
template: boolean): Promise<UploadResult> {
// Prepare headers that preserve credentials of current user.
const headers = getTransitiveHeaders(req);