(core) Form Publishing

Summary:
Adds initial implementation of form publishing, built upon WYSIWYS shares.

A simple UI for publishing and unpublishing forms is included.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Subscribers: paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D4154
This commit is contained in:
George Gevoian
2024-01-12 09:35:24 -08:00
parent 8ddcff4310
commit e12471347b
17 changed files with 634 additions and 85 deletions

View File

@@ -80,6 +80,7 @@ import {convertFromColumn} from 'app/common/ValueConverter';
import {guessColInfo} from 'app/common/ValueGuesser';
import {parseUserAction} from 'app/common/ValueParser';
import {Document} from 'app/gen-server/entity/Document';
import {Share} from 'app/gen-server/entity/Share';
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
import {compileAclFormula} from 'app/server/lib/ACLFormula';
@@ -88,6 +89,7 @@ import {AssistanceContext} from 'app/common/AssistancePrompts';
import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
import {getMetaTables} from 'app/server/lib/DocApi';
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
import {makeForkIds} from 'app/server/lib/idUtils';
@@ -140,7 +142,6 @@ import remove = require('lodash/remove');
import sum = require('lodash/sum');
import without = require('lodash/without');
import zipObject = require('lodash/zipObject');
import { getMetaTables } from './DocApi';
bluebird.promisifyAll(tmp);
@@ -1367,11 +1368,7 @@ export class ActiveDoc extends EventEmitter {
* TODO: reconcile the two ways there are now of preparing a fork.
*/
public async fork(docSession: OptDocSession): Promise<ForkResult> {
const dbManager = this.getHomeDbManager();
if (!dbManager) {
throw new Error('HomeDbManager not available');
}
const dbManager = this._getHomeDbManagerOrFail();
const user = getDocSessionUser(docSession);
// For now, fork only if user can read everything (or is owner).
// TODO: allow forks with partial content.
@@ -1386,7 +1383,7 @@ export class ActiveDoc extends EventEmitter {
if (docSession.authorizer) {
doc = await docSession.authorizer.getDoc();
} else if (docSession.req) {
doc = await this.getHomeDbManager()?.getDoc(docSession.req);
doc = await dbManager.getDoc(docSession.req);
}
if (!doc) { throw new Error('Document not found'); }
@@ -1844,10 +1841,14 @@ export class ActiveDoc extends EventEmitter {
options: String(vals['options'][idx]),
};
});
await this.getHomeDbManager()?.syncShares(this.docName, goodShares);
await this._getHomeDbManagerOrFail().syncShares(this.docName, goodShares);
return goodShares;
}
public async getShare(_docSession: OptDocSession, linkId: string): Promise<Share|null> {
return await this._getHomeDbManagerOrFail().getShareByLinkId(this.docName, linkId);
}
/**
* Loads an open document from DocStorage. Returns a list of the tables it contains.
*/
@@ -2787,6 +2788,16 @@ export class ActiveDoc extends EventEmitter {
await this.shutdown();
}
}
private _getHomeDbManagerOrFail() {
const dbManager = this.getHomeDbManager();
if (!dbManager) {
throw new Error('HomeDbManager not available');
}
return dbManager;
}
}
// Helper to initialize a sandbox action bundle with no values.

View File

@@ -226,4 +226,18 @@ export function attachAppEndpoint(options: AttachOptions): void {
...docMiddleware, docHandler);
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
...docMiddleware, docHandler);
app.get('/forms/:urlId([^/]+)/:sectionId', ...middleware, expressWrap(async (req, res) => {
const formUrl = gristServer.getHomeUrl(req,
`/api/s/${req.params.urlId}/forms/${req.params.sectionId}`);
const response = await fetch(formUrl, {
headers: getTransitiveHeaders(req),
});
if (response.status === 200) {
const html = await response.text();
res.send(html);
} else {
const error = await response.json();
throw new ApiError(error?.error ?? 'Failed to fetch form', response.status);
}
}));
}

View File

@@ -11,6 +11,7 @@ import {
TableRecordValue,
UserAction
} from 'app/common/DocActions';
import {DocData} from 'app/common/DocData';
import {isRaisedException} from "app/common/gristTypes";
import {Box, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
@@ -47,7 +48,8 @@ import {
RequestWithLogin
} from 'app/server/lib/Authorizer';
import {DocManager} from "app/server/lib/DocManager";
import {docSessionFromRequest, makeExceptionalDocSession, OptDocSession} from "app/server/lib/DocSession";
import {docSessionFromRequest, getDocSessionShare, makeExceptionalDocSession,
OptDocSession} from "app/server/lib/DocSession";
import {DocWorker} from "app/server/lib/DocWorker";
import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
@@ -1373,29 +1375,49 @@ export class DocWorkerApi {
return res.status(200).json(docId);
}));
// Get the specified table in record-oriented format
/**
* Get the specified section's form as HTML.
*
* Forms are typically accessed via shares, with URLs like: https://docs.getgrist.com/forms/${shareKey}/${id}.
*
* AppEndpoint.ts handles forwarding of such URLs to this endpoint.
*/
this._app.get('/api/docs/:docId/forms/:id', canView,
withDoc(async (activeDoc, req, res) => {
const docSession = docSessionFromRequest(req);
const linkId = getDocSessionShare(docSession);
const sectionId = integerParam(req.params.id, 'id');
if (linkId) {
/* If accessed via a share, the share's `linkId` will be present and
* we'll need to check that the form is in fact published, and that the
* share key is associated with the form, before granting access to the
* form. */
this._assertFormIsPublished({
docData: activeDoc.docData,
linkId,
sectionId,
});
}
// Get the viewSection record for the specified id.
const id = integerParam(req.params.id, 'id');
const records = asRecords(await readTable(
req, activeDoc, '_grist_Views_section', { id: [id] }, { }
req, activeDoc, '_grist_Views_section', { id: [sectionId] }, {}
));
const vs = records.find(r => r.id === id);
if (!vs) {
throw new ApiError(`ViewSection ${id} not found`, 404);
const section = records.find(r => r.id === sectionId);
if (!section) {
throw new ApiError('Form not found', 404);
}
// Prepare the context that will be needed for rendering this form.
const fields = asRecords(await readTable(
req, activeDoc, '_grist_Views_section_field', { parentId: [id] }, { }
req, activeDoc, '_grist_Views_section_field', { parentId: [sectionId] }, { }
));
const cols = asRecords(await readTable(
req, activeDoc, '_grist_Tables_column', { parentId: [vs.fields.tableRef] }, { }
req, activeDoc, '_grist_Tables_column', { parentId: [section.fields.tableRef] }, { }
));
// Read the box specs
const spec = vs.fields.layoutSpec;
const spec = section.fields.layoutSpec;
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
if (!box) {
const editable = fields.filter(f => {
@@ -1453,20 +1475,67 @@ export class DocWorkerApi {
// Fill out the blanks and send the result.
const doc = await this._dbManager.getDoc(req);
const docUrl = await this._grist.getResourceUrl(doc, 'html');
const tableId = await getRealTableId(String(vs.fields.tableRef), {activeDoc, req});
const tableId = await getRealTableId(String(section.fields.tableRef), {activeDoc, req});
res.status(200).send(form
.replace('<!-- INSERT CONTENT -->', escaped || '')
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
.replace('<!-- INSERT DOC URL -->', docUrl)
.replace('<!-- INSERT TABLE ID -->', tableId)
);
// Return the HTML if it exists, otherwise return 404.
res.send(html);
})
);
}
/**
* Throws if the specified section is not of a published form.
*/
private _assertFormIsPublished(params: {
docData: DocData | null,
linkId: string,
sectionId: number,
}) {
const {docData, linkId, sectionId} = params;
if (!docData) {
throw new ApiError('DocData not available', 500);
}
// Check that the request is for a valid section in the document.
const sections = docData.getMetaTable('_grist_Views_section');
const section = sections.getRecords().find(s => s.id === sectionId);
if (!section) {
throw new ApiError('Form not found', 404);
}
// Check that the section is for a form.
const sectionShareOptions = safeJsonParse(section.shareOptions, {});
if (!sectionShareOptions.form) {
throw new ApiError('Form not found', 400);
}
// Check that the form is associated with a share.
const viewId = section.parentId;
const pages = docData.getMetaTable('_grist_Pages');
const page = pages.getRecords().find(p => p.viewRef === viewId);
if (!page) {
throw new ApiError('Form not found', 404);
}
const shares = docData.getMetaTable('_grist_Shares');
const share = shares.getRecord(page.shareRef);
if (!share) {
throw new ApiError('Form not found', 404);
}
// Check that the share's link id matches the expected link id.
if (share.linkId !== linkId) {
throw new ApiError('Form not found', 404);
}
// Finally, check that both the section and share are published.
if (!sectionShareOptions.publish || !safeJsonParse(share.options, {})?.publish) {
throw new ApiError('Form not published', 400);
}
}
private async _copyDocToWorkspace(req: Request, options: {
userId: number,
sourceDocumentId: string,

View File

@@ -135,6 +135,7 @@ export class DocWorker {
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
getShare: activeDocMethod.bind(null, 'owners', 'getShare'),
});
}
@@ -193,7 +194,7 @@ export class DocWorker {
* Translates calls from the browser client into calls of the form
* `activeDoc.method(docSession, ...args)`.
*/
async function activeDocMethod(role: 'viewers'|'editors'|null, methodName: string, client: Client,
async function activeDocMethod(role: 'viewers'|'editors'|'owners'|null, methodName: string, client: Client,
docFD: number, ...args: any[]): Promise<any> {
const docSession = client.getDocSession(docFD);
const activeDoc = docSession.activeDoc;