2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* 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, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
|
|
|
import {Client} from 'app/server/lib/Client';
|
|
|
|
import * as Comm from 'app/server/lib/Comm';
|
2020-09-02 18:17:17 +00:00
|
|
|
import {DocSession} from 'app/server/lib/DocSession';
|
2020-07-21 13:20:51 +00:00
|
|
|
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
|
|
|
|
import * as log from 'app/server/lib/log';
|
|
|
|
import {integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils';
|
|
|
|
import {OpenMode, quoteIdent, SQLiteDB} from 'app/server/lib/SQLiteDB';
|
|
|
|
import {generateCSV} from 'app/server/serverMethods';
|
|
|
|
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 getCSV(req: express.Request, res: express.Response): void {
|
|
|
|
return generateCSV(req, res, this._comm);
|
|
|
|
}
|
|
|
|
|
|
|
|
public async getAttachment(req: express.Request, res: express.Response): Promise<void> {
|
|
|
|
try {
|
2020-09-02 18:17:17 +00:00
|
|
|
const docSession = this._getDocSession(stringParam(req.query.clientId),
|
|
|
|
integerParam(req.query.docFD));
|
|
|
|
const activeDoc = docSession.activeDoc;
|
2020-07-21 13:20:51 +00:00
|
|
|
const ext = path.extname(stringParam(req.query.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), {type: contentDispType});
|
2020-09-02 18:17:17 +00:00
|
|
|
const data = await activeDoc.getAttachmentData(docSession, stringParam(req.query.ident));
|
2020-07-21 13:20:51 +00:00
|
|
|
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;
|
|
|
|
if (!mreq.docAuth || !mreq.docAuth.docId) { throw new Error('Cannot find document'); }
|
|
|
|
const docId = mreq.docAuth.docId;
|
|
|
|
|
|
|
|
// Query DB for doc metadata to get the doc title.
|
|
|
|
const doc = await this._dbManager.getDoc({userId: getUserId(req), org: mreq.org, urlId: docId});
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
// 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'),
|
|
|
|
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'),
|
2020-12-15 04:19:38 +00:00
|
|
|
checkAclFormula: activeDocMethod.bind(null, 'viewers', 'checkAclFormula'),
|
2020-07-21 13:20:51 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)) {
|
2020-09-02 18:17:17 +00:00
|
|
|
const activeDoc = this._getDocSession(stringParam(req.query.clientId),
|
|
|
|
integerParam(req.query.docFD)).activeDoc;
|
2020-07-21 13:20:51 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
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()});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-02 18:17:17 +00:00
|
|
|
private _getDocSession(clientId: string, docFD: number): DocSession {
|
2020-07-21 13:20:51 +00:00
|
|
|
const client = this._comm.getClient(clientId);
|
2020-09-02 18:17:17 +00:00
|
|
|
return client.getDocSession(docFD);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2020-09-02 18:17:17 +00:00
|
|
|
log.rawDebug('activeDocMethod', activeDoc.getLogMeta(docSession, methodName));
|
2020-07-21 13:20:51 +00:00
|
|
|
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.run('VACUUM');
|
|
|
|
await db.close();
|
|
|
|
}
|