Skip to content

Commit 9ede0cb

Browse files
committed
(core) updates from grist-core
2 parents 2d8cc12 + 08fee12 commit 9ede0cb

25 files changed

+817
-351
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ Grist can be configured in many ways. Here are the main environment variables it
303303
| GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN |
304304
| GRIST_SESSION_SECRET | a key used to encode sessions |
305305
| GRIST_SKIP_BUNDLED_WIDGETS | if set, Grist will ignore any bundled widgets included via NPM packages. |
306+
| GRIST_SQLITE_MODE | if set to `wal`, use SQLite in [WAL mode](https://www.sqlite.org/wal.html), if set to `sync`, use SQLite with [SYNCHRONOUS=full](https://www.sqlite.org/pragma.html#pragma_synchronous)
306307
| GRIST_ANON_PLAYGROUND | When set to `false` deny anonymous users access to the home page (but documents can still be shared to anonymous users). Defaults to `true`. |
307308
| GRIST_FORCE_LOGIN | Setting it to `true` is similar to setting `GRIST_ANON_PLAYGROUND: false` but it blocks any anonymous access (thus any document shared publicly actually requires the users to be authenticated before consulting them) |
308309
| GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org |

app/gen-server/lib/DocApiForwarder.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ export class DocApiForwarder {
5959
app.use('/api/docs/:docId/create-fork', withDoc);
6060
app.use('/api/docs/:docId/apply', withDoc);
6161
app.use('/api/docs/:docId/attachments', withDoc);
62+
app.use('/api/docs/:docId/attachments/download', withDoc);
63+
app.use('/api/docs/:docId/attachments/transferStatus', withDoc);
64+
app.use('/api/docs/:docId/attachments/transferAll', withDoc);
65+
app.use('/api/docs/:docId/attachments/store', withDoc);
66+
app.use('/api/docs/:docId/attachments/stores', withDoc);
6267
app.use('/api/docs/:docId/snapshots', withDoc);
6368
app.use('/api/docs/:docId/usersForViewAs', withDoc);
6469
app.use('/api/docs/:docId/replace', withDoc);
@@ -74,10 +79,6 @@ export class DocApiForwarder {
7479
app.use('/api/docs/:docId/timing/start', withDoc);
7580
app.use('/api/docs/:docId/timing/stop', withDoc);
7681
app.use('/api/docs/:docId/forms/:vsId', withDoc);
77-
app.use('/api/docs/:docId/attachments/transferStatus', withDoc);
78-
app.use('/api/docs/:docId/attachments/transferAll', withDoc);
79-
app.use('/api/docs/:docId/attachments/store', withDoc);
80-
app.use('/api/docs/:docId/attachments/stores', withDoc);
8182

8283
app.use('^/api/docs$', withoutDoc);
8384
}

app/server/lib/ActiveDoc.ts

+17-3
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ import {Share} from 'app/gen-server/entity/Share';
9090
import {RecordWithStringId} from 'app/plugin/DocApiTypes';
9191
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
9292
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
93-
import {Archive, ArchiveEntry, create_zip_archive} from 'app/server/lib/Archive';
93+
import {
94+
Archive,
95+
ArchiveEntry, CreatableArchiveFormats,
96+
create_tar_archive,
97+
create_zip_archive
98+
} from 'app/server/lib/Archive';
9499
import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance';
95100
import {AssistanceContext} from 'app/common/AssistancePrompts';
96101
import {AuditEventAction} from 'app/server/lib/AuditEvent';
@@ -960,7 +965,8 @@ export class ActiveDoc extends EventEmitter {
960965
return data;
961966
}
962967

963-
public async getAttachmentsArchive(docSession: OptDocSession): Promise<Archive> {
968+
public async getAttachmentsArchive(docSession: OptDocSession,
969+
format: CreatableArchiveFormats = 'zip'): Promise<Archive> {
964970
if (
965971
!await this._granularAccess.canReadEverything(docSession) &&
966972
!await this.canDownload(docSession)
@@ -990,12 +996,20 @@ export class ActiveDoc extends EventEmitter {
990996
filesAdded.add(name);
991997
yield({
992998
name,
999+
size: file.metadata.size,
9931000
data: file.contentStream,
9941001
});
9951002
}
9961003
}
9971004

998-
return create_zip_archive({ store: true }, fileGenerator());
1005+
if (format == 'tar') {
1006+
return create_tar_archive(fileGenerator());
1007+
}
1008+
if (format == 'zip') {
1009+
return create_zip_archive({ store: true }, fileGenerator());
1010+
}
1011+
// Generally this won't happen, as long as the above is exhaustive over the type of `format`
1012+
throw new ApiError(`Unsupported archive format ${format}`, 400);
9991013
}
10001014

10011015
@ActiveDoc.keepDocOpen

app/server/lib/AppSettings.ts

+7
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ export class AppSettings {
7272
} else if (query.defaultValue !== undefined) {
7373
this._value = query.defaultValue;
7474
}
75+
if (query.acceptedValues && this._value) {
76+
if (query.acceptedValues.every(v => v !== this._value)) {
77+
throw new Error(`value is not accepted: ${this._value}`);
78+
}
79+
}
7580
return this;
7681
}
7782

@@ -239,6 +244,8 @@ export interface AppSettingQuery {
239244
* When set to true, the value is obscured when printed.
240245
*/
241246
censor?: boolean;
247+
248+
acceptedValues?: Array<JSONValue>;
242249
}
243250

244251
export interface AppSettingQueryInt extends AppSettingQuery {

app/server/lib/Archive.ts

+46
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
import {StringUnion} from 'app/common/StringUnion';
12
import {ZipArchiveEntry} from 'compress-commons';
23
import stream from 'node:stream';
4+
import * as tar from 'tar-stream';
35
import ZipStream, {ZipStreamOptions} from 'zip-stream';
46

57
export interface ArchiveEntry {
68
name: string;
9+
size: number;
710
data: stream.Readable | Buffer;
811
}
912

1013
export interface Archive {
14+
mimeType: string;
15+
fileExtension: string;
1116
dataStream: stream.Readable;
1217
completed: Promise<void>;
1318
}
1419

20+
export const CreatableArchiveFormats = StringUnion('zip', 'tar');
21+
export type CreatableArchiveFormats = typeof CreatableArchiveFormats.type;
22+
1523
/**
1624
*
1725
* Creates a streamable zip archive, reading files on-demand from the entries iterator.
@@ -27,6 +35,8 @@ export async function create_zip_archive(
2735
const archive = new ZipStream(options);
2836

2937
return {
38+
mimeType: "application/zip",
39+
fileExtension: "zip",
3040
dataStream: archive,
3141
// Caller is responsible for error handling/awaiting on this promise.
3242
completed: (async () => {
@@ -57,3 +67,39 @@ function addEntryToZipArchive(archive: ZipStream, file: ArchiveEntry): Promise<Z
5767
});
5868
});
5969
}
70+
71+
/**
72+
*
73+
* Creates a streamable tar archive, reading files on-demand from the entries iterator.
74+
* Entries are provided as an async iterable, to ensure the archive is constructed
75+
* correctly. A generator can be used for convenience.
76+
* @param {AsyncIterable<ArchiveEntry>} entries - Entries to add.
77+
* @returns {Archive}
78+
*/
79+
export async function create_tar_archive(
80+
entries: AsyncIterable<ArchiveEntry>
81+
): Promise<Archive> {
82+
const archive = tar.pack();
83+
84+
return {
85+
mimeType: "application/x-tar",
86+
fileExtension: "tar",
87+
dataStream: archive,
88+
// Caller is responsible for error handling/awaiting on this promise.
89+
completed: (async () => {
90+
try {
91+
for await (const entry of entries) {
92+
const entryStream = archive.entry({ name: entry.name, size: entry.size });
93+
await stream.promises.pipeline(entry.data, entryStream);
94+
}
95+
archive.finalize();
96+
} catch (error) {
97+
archive.destroy(error);
98+
} finally {
99+
// If the stream was destroyed with an error, this will re-throw the error we caught above.
100+
// Without this, node will see the stream as having an uncaught error, and complain.
101+
await stream.promises.finished(archive);
102+
}
103+
})()
104+
};
105+
}

app/server/lib/DocApi.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
} from 'app/plugin/TableOperationsImpl';
4646
import {ActiveDoc, colIdToRef as colIdToReference, getRealTableId, tableIdToRef} from "app/server/lib/ActiveDoc";
4747
import {appSettings} from "app/server/lib/AppSettings";
48+
import {CreatableArchiveFormats} from 'app/server/lib/Archive';
4849
import {sendForCompletion} from 'app/server/lib/Assistance';
4950
import {getDocPoolIdFromDocInfo} from 'app/server/lib/AttachmentStore';
5051
import {
@@ -575,12 +576,19 @@ export class DocWorkerApi {
575576

576577
// Responds with an archive of all attachment contents, with suitable Content-Type and Content-Disposition.
577578
this._app.get('/api/docs/:docId/attachments/download', canView, withDoc(async (activeDoc, req, res) => {
578-
const archive = await activeDoc.getAttachmentsArchive(docSessionFromRequest(req));
579+
const archiveFormatStr = optStringParam(req.query.format, 'format', {
580+
allowed: CreatableArchiveFormats.values,
581+
allowEmpty: true,
582+
});
583+
584+
const archiveFormat = CreatableArchiveFormats.parse(archiveFormatStr) || 'zip';
585+
const archive = await activeDoc.getAttachmentsArchive(docSessionFromRequest(req), archiveFormat);
579586
const docName = await this._getDownloadFilename(req, "Attachments", activeDoc.doc);
580587
res.status(200)
581-
.type("application/zip")
588+
.type(archive.mimeType)
582589
// Construct a content-disposition header of the form 'attachment; filename="NAME"'
583-
.set('Content-Disposition', contentDisposition(`${docName}.zip`, {type: 'attachment'}))
590+
.set('Content-Disposition',
591+
contentDisposition(`${docName}.${archive.fileExtension}`, {type: 'attachment'}))
584592
// Avoid storing because this could be huge.
585593
.set('Cache-Control', 'no-store');
586594

app/server/lib/DocStorage.ts

+30-10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as marshal from 'app/common/marshal';
1515
import * as schema from 'app/common/schema';
1616
import {SingleCell} from 'app/common/TableData';
1717
import {GristObjCode} from "app/plugin/GristData";
18+
import {appSettings} from 'app/server/lib/AppSettings';
1819
import {ActionHistoryImpl} from 'app/server/lib/ActionHistoryImpl';
1920
import {combineExpr, ExpandedQuery} from 'app/server/lib/ExpandedQuery';
2021
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
@@ -48,6 +49,16 @@ export const ATTACHMENTS_EXPIRY_DAYS = 7;
4849
// Cleanup expired attachments every hour (also happens when shutting down).
4950
export const REMOVE_UNUSED_ATTACHMENTS_DELAY = {delayMs: 60 * 60 * 1000, varianceMs: 30 * 1000};
5051

52+
/**
53+
* Check what way we want to access SQLite files.
54+
*/
55+
export function getSqliteMode() {
56+
return appSettings.section('features')
57+
.section('sqlite').flag('mode').readString({
58+
envVar: 'GRIST_SQLITE_MODE',
59+
acceptedValues: ['wal', 'sync'],
60+
}) as 'wal'|'sync'|undefined;
61+
}
5162

5263
export class DocStorage implements ISQLiteDB, OnDemandStorage {
5364

@@ -711,16 +722,25 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
711722
// a database being corrupted if the computer it is running on crashes.
712723
// TODO: Switch setting to FULL, but don't wait for SQLite transactions to finish before
713724
// returning responses to the user. Instead send error messages on unexpected errors.
714-
return this._getDB().exec(
715-
// "PRAGMA wal_autochceckpoint = 1000;" +
716-
// "PRAGMA page_size = 4096;" +
717-
// "PRAGMA journal_size_limit = 0;" +
718-
// "PRAGMA journal_mode = WAL;" +
719-
// "PRAGMA auto_vacuum = 0;" +
720-
// "PRAGMA synchronous = NORMAL"
721-
"PRAGMA synchronous = OFF;" +
722-
"PRAGMA trusted_schema = OFF;" // mitigation suggested by https://www.sqlite.org/security.html#untrusted_sqlite_database_files
723-
);
725+
const settings = [
726+
'PRAGMA trusted_schema = OFF;', // mitigation suggested by https://www.sqlite.org/security.html#untrusted_sqlite_database_files
727+
];
728+
const sqliteMode = getSqliteMode();
729+
if (sqliteMode === undefined) {
730+
// Historically, Grist has used this setting.
731+
settings.push('PRAGMA synchronous = OFF;');
732+
} else if (sqliteMode === 'sync') {
733+
// This is a safer, but potentially slower, setting for general use.
734+
settings.push('PRAGMA synchronous = FULL;');
735+
} else if (sqliteMode === 'wal') {
736+
// This is a good modern setting for servers, but awkward
737+
// on a Desktop for users who interact with their documents
738+
// directly as files on the file system. With WAL, at any
739+
// time, changes may be stored in a companion file rather
740+
// than the .grist file.
741+
settings.push('PRAGMA journal_mode = WAL;');
742+
}
743+
return this._getDB().exec(settings.join('\n'));
724744
}
725745

726746
/**

app/server/lib/DocStorageManager.ts

+25-10
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {DocEntry, DocEntryTag} from 'app/common/DocListAPI';
88
import {DocSnapshots} from 'app/common/DocSnapshot';
99
import {DocumentUsage} from 'app/common/DocUsage';
1010
import * as gutil from 'app/common/gutil';
11+
import {backupUsingBestConnection} from 'app/server/lib/backupSqliteDatabase';
1112
import {Comm} from 'app/server/lib/Comm';
1213
import * as docUtils from 'app/server/lib/docUtils';
14+
import {GristServer} from 'app/server/lib/GristServer';
1315
import {EmptySnapshotProgress, IDocStorageManager, SnapshotProgress} from 'app/server/lib/IDocStorageManager';
1416
import {IShell} from 'app/server/lib/IShell';
1517
import log from 'app/server/lib/log';
@@ -36,7 +38,7 @@ export class DocStorageManager implements IDocStorageManager {
3638
* The file watcher is created if the optComm argument is given.
3739
*/
3840
constructor(private _docsRoot: string, private _samplesRoot?: string,
39-
private _comm?: Comm, shell?: IShell) {
41+
private _comm?: Comm, shell?: IShell, private _gristServer?: GristServer) {
4042
// If we have a way to communicate with clients, watch the docsRoot for changes.
4143
this._shell = shell ?? {
4244
trashItem() { throw new Error('Unable to move document to trash'); },
@@ -55,6 +57,10 @@ export class DocStorageManager implements IDocStorageManager {
5557
return path.resolve(this._docsRoot, docName);
5658
}
5759

60+
public getSQLiteDB(docName: string) {
61+
return this._gristServer?.getDocManager().getSQLiteDB(docName);
62+
}
63+
5864
/**
5965
* Returns the path to the given sample document.
6066
*/
@@ -86,8 +92,10 @@ export class DocStorageManager implements IDocStorageManager {
8692

8793
public async prepareFork(srcDocName: string, destDocName: string): Promise<string> {
8894
// This is implemented only to support old tests.
89-
await fse.copy(this.getPath(srcDocName), this.getPath(destDocName));
90-
return this.getPath(destDocName);
95+
const output = this.getPath(destDocName);
96+
return this._safeCopy(srcDocName, {
97+
output,
98+
});
9199
}
92100

93101
/**
@@ -175,9 +183,8 @@ export class DocStorageManager implements IDocStorageManager {
175183
);
176184
}).tap((numberedBakPathPrefix: string) => { // do the copying, but return bakPath anyway
177185
finalBakPath = numberedBakPathPrefix + ext;
178-
const docPath = this.getPath(docName);
179186
log.info(`Backing up ${docName} to ${finalBakPath}`);
180-
return docUtils.copyFile(docPath, finalBakPath);
187+
return this._safeCopy(docName, { output: finalBakPath });
181188
}).then(() => {
182189
log.debug("DocStorageManager: Backup made successfully at: %s", finalBakPath);
183190
return finalBakPath;
@@ -235,11 +242,9 @@ export class DocStorageManager implements IDocStorageManager {
235242
}
236243

237244
public async getCopy(docName: string): Promise<string> {
238-
const srcPath = this.getPath(docName);
239-
const postfix = uuidv4();
240-
const tmpPath = `${srcPath}-${postfix}`;
241-
await docUtils.copyFile(srcPath, tmpPath);
242-
return tmpPath;
245+
return this._safeCopy(docName, {
246+
postfix: uuidv4(),
247+
});
243248
}
244249

245250
public async getSnapshots(docName: string, skipMetadataCache?: boolean): Promise<DocSnapshots> {
@@ -316,6 +321,16 @@ export class DocStorageManager implements IDocStorageManager {
316321
this._comm.broadcastMessage('docListAction', { [actionType]: [data] });
317322
}
318323
}
324+
325+
private async _safeCopy(docName: string, options: {
326+
postfix?: string,
327+
output?: string,
328+
}): Promise<string> {
329+
return backupUsingBestConnection(this, docName, {
330+
...options,
331+
log: (err) => log.error("DocStorageManager: copy failed for %s: %s", docName, String(err)),
332+
});
333+
}
319334
}
320335

321336
/**

app/server/lib/FlexServer.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {create} from 'app/server/lib/create';
4444
import {addDiscourseConnectEndpoints} from 'app/server/lib/DiscourseConnect';
4545
import {addDocApiRoutes} from 'app/server/lib/DocApi';
4646
import {DocManager} from 'app/server/lib/DocManager';
47+
import {getSqliteMode} from 'app/server/lib/DocStorage';
4748
import {DocWorker} from 'app/server/lib/DocWorker';
4849
import {DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
4950
import {expressWrap, jsonErrorHandler, secureJsonErrorHandler} from 'app/server/lib/expressWrap';
@@ -1367,6 +1368,9 @@ export class FlexServer implements GristServer {
13671368
if (!this._docManager) { this.addCleanup(); }
13681369
await this.addLoginMiddleware();
13691370
this.addComm();
1371+
// Check SQLite mode so it shows up in initial configuration readout
1372+
// (even though we don't need it until opening documents).
1373+
getSqliteMode();
13701374

13711375
await this.create.configure?.();
13721376

@@ -1392,7 +1396,7 @@ export class FlexServer implements GristServer {
13921396
} else {
13931397
const samples = getAppPathTo(this.appRoot, 'public_samples');
13941398
const storageManager = await this.create.createLocalDocStorageManager(
1395-
this.docsRoot, samples, this._comm);
1399+
this.docsRoot, samples, this._comm, undefined, this);
13961400
this._storageManager = storageManager;
13971401
}
13981402

0 commit comments

Comments
 (0)