mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user