mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
229 lines
10 KiB
TypeScript
229 lines
10 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 {isAffirmative} from 'app/common/gutil';
|
|
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 {Comm} from 'app/server/lib/Comm';
|
|
import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession';
|
|
import {filterDocumentInPlace} from 'app/server/lib/filterUtils';
|
|
import {GristServer} from 'app/server/lib/GristServer';
|
|
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
|
import 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 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
|
|
gristServer: GristServer;
|
|
}
|
|
|
|
export class DocWorker {
|
|
private _comm: Comm;
|
|
private _gristServer: GristServer;
|
|
constructor(private _dbManager: HomeDBManager, options: AttachOptions) {
|
|
this._comm = options.comm;
|
|
this._gristServer = options.gristServer;
|
|
}
|
|
|
|
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 colId = stringParam(req.query.colId, 'colId');
|
|
const tableId = stringParam(req.query.tableId, 'tableId');
|
|
const rowId = integerParam(req.query.rowId, 'rowId');
|
|
const cell = {colId, tableId, rowId};
|
|
const maybeNew = isAffirmative(req.query.maybeNew);
|
|
const attId = integerParam(req.query.attId, 'attId');
|
|
const attRecord = activeDoc.getAttachmentMetadata(attId);
|
|
const ext = path.extname(attRecord.fileIdent);
|
|
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, attRecord, {cell, maybeNew});
|
|
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 (isAffirmative(req.query.template)) {
|
|
await removeData(tmpPath);
|
|
await removeHistory(tmpPath);
|
|
} else if (isAffirmative(req.query.nohistory)) {
|
|
await removeHistory(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, 'title') || docTitle || 'document') + ".grist",
|
|
async (err: any) => {
|
|
if (err) {
|
|
if (err.message && /Request aborted/.test(err.message)) {
|
|
log.warn(`Download request aborted for doc ${docId}`, err);
|
|
} else {
|
|
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'),
|
|
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
|
|
});
|
|
}
|
|
|
|
// 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, '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, this._gristServer, 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.
|
|
*/
|
|
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)}`);
|
|
}
|
|
await db.close();
|
|
}
|
|
|
|
/**
|
|
* Wipe as much history as we can.
|
|
*/
|
|
async function removeHistory(filename: string) {
|
|
const db = await SQLiteDB.openDBRaw(filename, OpenMode.OPEN_EXISTING);
|
|
const history = new ActionHistoryImpl(db);
|
|
await history.deleteActions(1);
|
|
await db.close();
|
|
}
|