mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
158
app/server/lib/DocWorkerUtils.ts
Normal file
158
app/server/lib/DocWorkerUtils.ts
Normal 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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user