mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
546096fcc9
Summary: Firstly I just wanted some more consistency and less repetition in places where Documents are retrieved from the DB, so it's more obvious when code differs from the norm. Main changes for that part: - Let HomeDBManager accept a `Request` directly and convert it to a `Scope`, and use this in a few places. - `getScope` tries `req.docAuth.docId` if `req.params` doesn't have a docId. I also refactored how `_createActiveDoc` gets the document URL, separating out getting the document from getting a URL for it. This is because I want to use that document object in a future diff, but I also just find it cleaner. Notable changes for that: - Extracted a new method `HomeDBManager.getRawDocById` as an alternative to `getDoc` that's explicitly for when you only have a document ID. - Removed the interface method `GristServer.getDocUrl` and its two implementations because it wasn't used elsewhere and it didn't really add anything on top of getting a doc (now done by `getRawDocById`) and `getResourceUrl`. - Between `cachedDoc` and `getRawDocById` (which represent previously existing code paths) also try `getDoc(getScope(docSession.req))`, which is new, because it seems better to only `getRawDocById` as a last resort. Test Plan: Existing tests Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3328
198 lines
9.4 KiB
TypeScript
198 lines
9.4 KiB
TypeScript
/**
|
|
* DocWorker collects the methods and endpoints that relate to a single Grist document.
|
|
* In hosted environment, this comprises the functionality of the DocWorker instance type.
|
|
*/
|
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
|
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
|
|
import {assertAccess, getOrSetDocAuth, RequestWithLogin} from 'app/server/lib/Authorizer';
|
|
import {Client} from 'app/server/lib/Client';
|
|
import * as Comm from 'app/server/lib/Comm';
|
|
import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession';
|
|
import {filterDocumentInPlace} from 'app/server/lib/filterUtils';
|
|
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
|
import * as log from 'app/server/lib/log';
|
|
import {getDocId, integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
|
|
import {OpenMode, quoteIdent, SQLiteDB} from 'app/server/lib/SQLiteDB';
|
|
import * as contentDisposition from 'content-disposition';
|
|
import * as express from 'express';
|
|
import * as fse from 'fs-extra';
|
|
import * as mimeTypes from 'mime-types';
|
|
import * as path from 'path';
|
|
|
|
export interface AttachOptions {
|
|
comm: Comm; // Comm object for methods called via websocket
|
|
}
|
|
|
|
export class DocWorker {
|
|
private _comm: Comm;
|
|
constructor(private _dbManager: HomeDBManager, {comm}: AttachOptions) {
|
|
this._comm = comm;
|
|
}
|
|
|
|
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
|
|
try {
|
|
const docSession = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
|
|
integerParam(req.query.docFD, 'docFD'));
|
|
const activeDoc = docSession.activeDoc;
|
|
const ext = path.extname(stringParam(req.query.ident, 'ident'));
|
|
const type = mimeTypes.lookup(ext);
|
|
|
|
let inline = Boolean(req.query.inline);
|
|
// Serving up user-uploaded HTML files inline is an open door to XSS attacks.
|
|
if (type === "text/html") { inline = false; }
|
|
|
|
// Construct a content-disposition header of the form 'inline|attachment; filename="NAME"'
|
|
const contentDispType = inline ? "inline" : "attachment";
|
|
const contentDispHeader = contentDisposition(stringParam(req.query.name, 'name'), {type: contentDispType});
|
|
const data = await activeDoc.getAttachmentData(docSession, stringParam(req.query.ident, 'ident'));
|
|
res.status(200)
|
|
.type(ext)
|
|
.set('Content-Disposition', contentDispHeader)
|
|
.set('Cache-Control', 'private, max-age=3600')
|
|
.send(data);
|
|
} catch (err) {
|
|
res.status(404).send({error: err.toString()});
|
|
}
|
|
}
|
|
|
|
public async downloadDoc(req: express.Request, res: express.Response,
|
|
storageManager: IDocStorageManager): Promise<void> {
|
|
const mreq = req as RequestWithLogin;
|
|
const docId = getDocId(mreq);
|
|
|
|
// Query DB for doc metadata to get the doc title.
|
|
const doc = await this._dbManager.getDoc(req);
|
|
const docTitle = doc.name;
|
|
|
|
// Get a copy of document for downloading.
|
|
const tmpPath = await storageManager.getCopy(docId);
|
|
if (req.query.template === '1') {
|
|
// If template flag is on, remove data and history from the download.
|
|
await removeData(tmpPath);
|
|
}
|
|
await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath);
|
|
// NOTE: We may want to reconsider the mimeType used for Grist files.
|
|
return res.type('application/x-sqlite3')
|
|
.download(tmpPath, (optStringParam(req.query.title) || docTitle || 'document') + ".grist", async (err: any) => {
|
|
if (err) {
|
|
log.error(`Download failure for doc ${docId}`, err);
|
|
}
|
|
await fse.unlink(tmpPath);
|
|
});
|
|
}
|
|
|
|
// Register main methods related to documents.
|
|
public registerCommCore(): void {
|
|
const comm = this._comm;
|
|
comm.registerMethods({
|
|
closeDoc: activeDocMethod.bind(null, null, 'closeDoc'),
|
|
fetchTable: activeDocMethod.bind(null, 'viewers', 'fetchTable'),
|
|
fetchTableSchema: activeDocMethod.bind(null, 'viewers', 'fetchTableSchema'),
|
|
useQuerySet: activeDocMethod.bind(null, 'viewers', 'useQuerySet'),
|
|
disposeQuerySet: activeDocMethod.bind(null, 'viewers', 'disposeQuerySet'),
|
|
applyUserActions: activeDocMethod.bind(null, 'editors', 'applyUserActions'),
|
|
applyUserActionsById: activeDocMethod.bind(null, 'editors', 'applyUserActionsById'),
|
|
findColFromValues: activeDocMethod.bind(null, 'viewers', 'findColFromValues'),
|
|
getFormulaError: activeDocMethod.bind(null, 'viewers', 'getFormulaError'),
|
|
importFiles: activeDocMethod.bind(null, 'editors', 'importFiles'),
|
|
finishImportFiles: activeDocMethod.bind(null, 'editors', 'finishImportFiles'),
|
|
cancelImportFiles: activeDocMethod.bind(null, 'editors', 'cancelImportFiles'),
|
|
generateImportDiff: activeDocMethod.bind(null, 'editors', 'generateImportDiff'),
|
|
addAttachments: activeDocMethod.bind(null, 'editors', 'addAttachments'),
|
|
removeInstanceFromDoc: activeDocMethod.bind(null, 'editors', 'removeInstanceFromDoc'),
|
|
startBundleUserActions: activeDocMethod.bind(null, 'editors', 'startBundleUserActions'),
|
|
stopBundleUserActions: activeDocMethod.bind(null, 'editors', 'stopBundleUserActions'),
|
|
autocomplete: activeDocMethod.bind(null, 'viewers', 'autocomplete'),
|
|
fetchURL: activeDocMethod.bind(null, 'viewers', 'fetchURL'),
|
|
getActionSummaries: activeDocMethod.bind(null, 'viewers', 'getActionSummaries'),
|
|
reloadDoc: activeDocMethod.bind(null, 'editors', 'reloadDoc'),
|
|
fork: activeDocMethod.bind(null, 'viewers', 'fork'),
|
|
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
|
|
getAclResources: activeDocMethod.bind(null, 'viewers', 'getAclResources'),
|
|
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
|
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
|
});
|
|
}
|
|
|
|
// Register methods related to plugins.
|
|
public registerCommPlugin(): void {
|
|
this._comm.registerMethods({
|
|
forwardPluginRpc: activeDocMethod.bind(null, 'editors', 'forwardPluginRpc'),
|
|
// TODO: consider not providing reloadPlugins on hosted grist, since it affects the
|
|
// plugin manager shared across docs on a given doc worker, and seems useful only in
|
|
// standalone case.
|
|
reloadPlugins: activeDocMethod.bind(null, 'editors', 'reloadPlugins'),
|
|
});
|
|
}
|
|
|
|
// Checks that document is accessible, and adds docAuth information to request.
|
|
// Otherwise issues a 403 access denied.
|
|
// (This is used for endpoints like /download, /gen-csv, /attachment.)
|
|
public async assertDocAccess(
|
|
req: express.Request,
|
|
res: express.Response,
|
|
next: express.NextFunction
|
|
) {
|
|
const mreq = req as RequestWithLogin;
|
|
let urlId: string|undefined;
|
|
try {
|
|
if (optStringParam(req.query.clientId)) {
|
|
const activeDoc = this._getDocSession(stringParam(req.query.clientId, 'clientId'),
|
|
integerParam(req.query.docFD, 'docFD')).activeDoc;
|
|
// TODO: The docId should be stored in the ActiveDoc class. Currently docName is
|
|
// used instead, which will coincide with the docId for hosted grist but not for
|
|
// standalone grist.
|
|
urlId = activeDoc.docName;
|
|
} else {
|
|
// Otherwise, if being used without a client, expect the doc query parameter to
|
|
// be the docId.
|
|
urlId = stringParam(req.query.doc, 'doc');
|
|
}
|
|
if (!urlId) { return res.status(403).send({error: 'missing document id'}); }
|
|
|
|
const docAuth = await getOrSetDocAuth(mreq, this._dbManager, urlId);
|
|
assertAccess('viewers', docAuth);
|
|
next();
|
|
} catch (err) {
|
|
log.info(`DocWorker can't access document ${urlId} with userId ${mreq.userId}: ${err}`);
|
|
res.status(err.status || 404).send({error: err.toString()});
|
|
}
|
|
}
|
|
|
|
private _getDocSession(clientId: string, docFD: number): DocSession {
|
|
const client = this._comm.getClient(clientId);
|
|
return client.getDocSession(docFD);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
docFD: number, ...args: any[]): Promise<any> {
|
|
const docSession = client.getDocSession(docFD);
|
|
const activeDoc = docSession.activeDoc;
|
|
if (role) { await docSession.authorizer.assertAccess(role); }
|
|
// Include a basic log record for each ActiveDoc method call.
|
|
log.rawDebug('activeDocMethod', activeDoc.getLogMeta(docSession, methodName));
|
|
return (activeDoc as any)[methodName](docSession, ...args);
|
|
}
|
|
|
|
/**
|
|
* Remove rows from all user tables, and wipe as much history as we can.
|
|
*/
|
|
async function removeData(filename: string) {
|
|
const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);
|
|
const tableIds = (await db.all("SELECT name FROM sqlite_master WHERE type='table'"))
|
|
.map(row => row.name as string)
|
|
.filter(name => !name.startsWith('_grist'));
|
|
for (const tableId of tableIds) {
|
|
await db.run(`DELETE FROM ${quoteIdent(tableId)}`);
|
|
}
|
|
const history = new ActionHistoryImpl(db);
|
|
await history.deleteActions(1);
|
|
await db.vacuum();
|
|
await db.close();
|
|
}
|