texlite 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/README.zh-CN.md +8 -3
- package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-Bm5yMpQK.js} +2 -2
- package/dist/client/assets/HistoryDialog-Po4KHT_2.js +5 -0
- package/dist/client/assets/LatexEditor-CnKCXaHr.js +59 -0
- package/dist/client/assets/PdfPreview-Cd20VIjT.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-BDOtIoTy.js} +1 -1
- package/dist/client/assets/SystemMetricsDialog-DdOlcqqK.js +1 -0
- package/dist/client/assets/index-MDwr9W9R.css +1 -0
- package/dist/client/assets/index-zIjCeZFK.js +64 -0
- package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
- package/dist/client/assets/minus-O6pK-6mA.js +1 -0
- package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-Ce3o_xO-.js +4 -0
- package/dist/client/index.html +3 -3
- package/dist/server/anchors.js +22 -1
- package/dist/server/app.js +103 -2335
- package/dist/server/auth.js +3 -2
- package/dist/server/cli.js +8 -2
- package/dist/server/collaboration.js +283 -11
- package/dist/server/compileArtifacts.js +10 -7
- package/dist/server/compiler.js +9 -4
- package/dist/server/config.js +25 -1
- package/dist/server/environment.js +5 -1
- package/dist/server/files.js +7 -6
- package/dist/server/git.js +28 -39
- package/dist/server/harper.js +144 -0
- package/dist/server/harperRuntime.js +38 -0
- package/dist/server/harperWorker.js +15 -0
- package/dist/server/history.js +11 -9
- package/dist/server/http.js +33 -13
- package/dist/server/i18n.js +276 -0
- package/dist/server/latexRoot.js +78 -0
- package/dist/server/limits.js +9 -0
- package/dist/server/projectSearch.js +3 -4
- package/dist/server/routes/auth.js +77 -0
- package/dist/server/routes/citations.js +331 -0
- package/dist/server/routes/collaboration.js +17 -0
- package/dist/server/routes/comments.js +176 -0
- package/dist/server/routes/compile.js +71 -50
- package/dist/server/routes/projectFiles.js +572 -0
- package/dist/server/routes/projectGit.js +145 -0
- package/dist/server/routes/projectHistory.js +150 -0
- package/dist/server/routes/projectMembers.js +112 -0
- package/dist/server/routes/projectShared.js +238 -0
- package/dist/server/routes/projects.js +473 -0
- package/dist/server/routes/system.js +37 -0
- package/dist/server/routes/users.js +163 -0
- package/dist/server/security.js +2 -4
- package/dist/server/synctex.js +7 -4
- package/dist/server/zip.js +29 -13
- package/package.json +3 -3
- package/texlite.config.example.json +5 -1
- package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
- package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
- package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
- package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
- package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/binary-BoldOm0z.js +0 -1
- package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
- package/dist/client/assets/index-BQCB3z5H.js +0 -64
- package/dist/client/assets/index-BnvpaWsa.css +0 -1
- package/dist/client/assets/index-Byn2Miy9.js +0 -3456
- package/dist/client/assets/index-rBWMBTI-.js +0 -5
- package/dist/client/assets/minus-K-5IK4nL.js +0 -1
- package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
- package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
package/dist/server/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { apiError } from "./http.js";
|
|
1
2
|
import { digestToken } from "./security.js";
|
|
2
3
|
export function publicUser(user) {
|
|
3
4
|
return {
|
|
@@ -36,7 +37,7 @@ export function currentUser(request, db) {
|
|
|
36
37
|
export function requireUser(request, reply, db) {
|
|
37
38
|
const user = currentUser(request, db);
|
|
38
39
|
if (!user) {
|
|
39
|
-
void reply
|
|
40
|
+
void apiError(reply, 401, "AUTH_REQUIRED");
|
|
40
41
|
return null;
|
|
41
42
|
}
|
|
42
43
|
return user;
|
|
@@ -46,7 +47,7 @@ export function requireAdmin(request, reply, db) {
|
|
|
46
47
|
if (!user)
|
|
47
48
|
return null;
|
|
48
49
|
if (user.role !== "admin") {
|
|
49
|
-
void reply
|
|
50
|
+
void apiError(reply, 403, "ADMIN_REQUIRED");
|
|
50
51
|
return null;
|
|
51
52
|
}
|
|
52
53
|
return user;
|
package/dist/server/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { stdin, stdout } from "node:process";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { CONFIG_DEFAULTS, loadConfig } from "./config.js";
|
|
9
9
|
import { activeAdminCount, openDatabase } from "./db.js";
|
|
10
|
-
import { hashPassword } from "./security.js";
|
|
10
|
+
import { hashPassword, MIN_PASSWORD_LENGTH } from "./security.js";
|
|
11
11
|
import { assertEnvironment, assertGitAvailable } from "./environment.js";
|
|
12
12
|
import { serve } from "./index.js";
|
|
13
13
|
import { processStatus, restartManaged, startManaged, stopManaged, streamLogs, waitForOnline } from "./pm2.js";
|
|
@@ -110,6 +110,10 @@ function writeInitialConfig(configPath, siteName, adminEmail) {
|
|
|
110
110
|
server: { host: CONFIG_DEFAULTS.host, port: CONFIG_DEFAULTS.port },
|
|
111
111
|
storage: { dataDir: dataDirectory },
|
|
112
112
|
uploads: { maxFileSizeMB: CONFIG_DEFAULTS.maxFileSizeMB },
|
|
113
|
+
pdf: {
|
|
114
|
+
loadingStrategy: CONFIG_DEFAULTS.pdfLoadingStrategy,
|
|
115
|
+
rangeThresholdMB: CONFIG_DEFAULTS.pdfRangeThresholdMB
|
|
116
|
+
},
|
|
113
117
|
history: {
|
|
114
118
|
maxVersions: CONFIG_DEFAULTS.historyMaxVersions,
|
|
115
119
|
maxStorageMB: CONFIG_DEFAULTS.historyMaxStorageMB
|
|
@@ -172,7 +176,7 @@ async function initialize(options) {
|
|
|
172
176
|
const displayName = process.env.TEXLITE_INIT_DISPLAY_NAME
|
|
173
177
|
?? (rl ? (await rl.question("Administrator display name [Administrator]: ")).trim() || "Administrator" : "Administrator");
|
|
174
178
|
const password = process.env.TEXLITE_INIT_PASSWORD
|
|
175
|
-
?? (rl ? await rl.question(
|
|
179
|
+
?? (rl ? await rl.question(`Administrator password (at least ${MIN_PASSWORD_LENGTH} characters; input is visible): `) : "");
|
|
176
180
|
if (!password)
|
|
177
181
|
throw new Error("Non-interactive initialization requires TEXLITE_INIT_PASSWORD to be set.");
|
|
178
182
|
const timestamp = new Date().toISOString();
|
|
@@ -234,6 +238,8 @@ async function printConfig(options) {
|
|
|
234
238
|
defaultEngine: config.defaultEngine,
|
|
235
239
|
allowedEngines: config.allowedEngines,
|
|
236
240
|
maxUploadBytes: config.maxUploadBytes,
|
|
241
|
+
pdfLoadingStrategy: config.pdfLoadingStrategy,
|
|
242
|
+
pdfRangeThresholdMB: config.pdfRangeThresholdBytes / (1024 * 1024),
|
|
237
243
|
historyMaxVersions: config.historyMaxVersions,
|
|
238
244
|
historyMaxStorageBytes: config.historyMaxStorageBytes,
|
|
239
245
|
git: config.git
|
|
@@ -22,12 +22,15 @@ const MESSAGE_PERMISSION = 7;
|
|
|
22
22
|
// Source text still uses the normal Yjs sync protocol; this message prevents
|
|
23
23
|
// an old IndexedDB metadata entry from being treated as an active compile.
|
|
24
24
|
const MESSAGE_COMPILE_STATES = 8;
|
|
25
|
+
// Ephemeral, per-file formatter leases. These are deliberately kept out of
|
|
26
|
+
// the Yjs document and source persistence state.
|
|
27
|
+
const MESSAGE_FORMAT_LEASE = 9;
|
|
25
28
|
/**
|
|
26
29
|
* Increment this when a wire-level collaboration change cannot be decoded by
|
|
27
30
|
* an older browser. The epoch marker is rotated at the same time, which makes
|
|
28
31
|
* already-open older pages discard their local draft and reload safely.
|
|
29
32
|
*/
|
|
30
|
-
export const COLLABORATION_PROTOCOL_VERSION =
|
|
33
|
+
export const COLLABORATION_PROTOCOL_VERSION = 3;
|
|
31
34
|
const VERSIONED_EPOCH_PREFIX = `${COLLABORATION_PROTOCOL_VERSION}:`;
|
|
32
35
|
const SOURCE_PREFIX = "source:";
|
|
33
36
|
const MAX_PROJECT_SESSIONS = 10;
|
|
@@ -38,6 +41,12 @@ const META_ORIGIN = Symbol("meta");
|
|
|
38
41
|
const SAVE_DELAY_MS = 750;
|
|
39
42
|
const STATE_SAVE_DELAY_MS = 750;
|
|
40
43
|
const ROOM_IDLE_MS = 30_000;
|
|
44
|
+
const FORMAT_LEASE_TTL_MS = 45_000;
|
|
45
|
+
const MAX_FORMAT_LEASE_WAITERS = MAX_PROJECT_SESSIONS * 2;
|
|
46
|
+
// Completion notifications are useful only to browsers that were already
|
|
47
|
+
// present when the operation finished. The database and retained manifests
|
|
48
|
+
// are the authority for a later workspace open.
|
|
49
|
+
const COMPLETED_COMPILE_STATE_TTL_MS = 60_000;
|
|
41
50
|
const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
|
|
42
51
|
const COLORS = [
|
|
43
52
|
["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
|
|
@@ -262,6 +271,11 @@ export class CollaborationService {
|
|
|
262
271
|
const room = this.rooms.get(projectId);
|
|
263
272
|
if (!room)
|
|
264
273
|
return;
|
|
274
|
+
if (permission === "read" || permission === "revoked") {
|
|
275
|
+
const connectionIds = new Set([...room.connections].filter((connection) => connection.user.id === userId));
|
|
276
|
+
for (const connection of connectionIds)
|
|
277
|
+
this.releaseFormatLeasesForConnection(room, connection);
|
|
278
|
+
}
|
|
265
279
|
const encoder = encoding.createEncoder();
|
|
266
280
|
encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
|
|
267
281
|
encoding.writeVarString(encoder, userId);
|
|
@@ -403,13 +417,10 @@ export class CollaborationService {
|
|
|
403
417
|
room.doc.transact(() => room.meta.set("compileStates", retained), META_ORIGIN);
|
|
404
418
|
}
|
|
405
419
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* server must not let an old `queued`/`running` value become authoritative
|
|
411
|
-
* again after a restart. Completed states are retained for the current room
|
|
412
|
-
* because they are useful to the UI and cannot disable a new compile.
|
|
420
|
+
* Keep collaboration compile metadata strictly ephemeral and database
|
|
421
|
+
* backed. A browser can replay old IndexedDB updates after reconnecting;
|
|
422
|
+
* completed states must therefore not override the retained-PDF lookup for
|
|
423
|
+
* a newly opened workspace.
|
|
413
424
|
*/
|
|
414
425
|
sanitizeCompileStates(room) {
|
|
415
426
|
const current = room.meta.get("compileStates");
|
|
@@ -420,19 +431,42 @@ export class CollaborationService {
|
|
|
420
431
|
}
|
|
421
432
|
const retained = {};
|
|
422
433
|
let changed = false;
|
|
434
|
+
const checkedAt = Date.now();
|
|
435
|
+
const findRun = this.db.prepare(`SELECT id, status, main_file FROM compile_runs
|
|
436
|
+
WHERE id = ? AND project_id = ?`);
|
|
437
|
+
const findLatestRun = this.db.prepare(`SELECT id, status FROM compile_runs
|
|
438
|
+
WHERE project_id = ? AND main_file = ? ORDER BY created_at DESC LIMIT 1`);
|
|
423
439
|
for (const [mainFile, value] of Object.entries(current)) {
|
|
424
440
|
if (!isSharedCompileState(mainFile, value)) {
|
|
425
441
|
changed = true;
|
|
426
442
|
continue;
|
|
427
443
|
}
|
|
428
444
|
if (value.status === "queued" || value.status === "running") {
|
|
429
|
-
const run =
|
|
430
|
-
WHERE id = ? AND project_id = ?`).get(value.runId, room.projectId);
|
|
445
|
+
const run = findRun.get(value.runId, room.projectId);
|
|
431
446
|
if (!run || run.main_file !== mainFile || (run.status !== "queued" && run.status !== "running")) {
|
|
432
447
|
changed = true;
|
|
433
448
|
continue;
|
|
434
449
|
}
|
|
435
450
|
}
|
|
451
|
+
else {
|
|
452
|
+
const updatedAt = Date.parse(value.updatedAt);
|
|
453
|
+
const fresh = Number.isFinite(updatedAt)
|
|
454
|
+
&& updatedAt <= checkedAt + COMPLETED_COMPILE_STATE_TTL_MS
|
|
455
|
+
&& checkedAt - updatedAt <= COMPLETED_COMPILE_STATE_TTL_MS;
|
|
456
|
+
if (!fresh) {
|
|
457
|
+
changed = true;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (value.status !== "cleaned") {
|
|
461
|
+
const run = findRun.get(value.runId, room.projectId);
|
|
462
|
+
const latest = findLatestRun.get(room.projectId, mainFile);
|
|
463
|
+
if (!run || run.main_file !== mainFile || run.status !== value.status
|
|
464
|
+
|| !latest || latest.id !== value.runId || latest.status !== value.status) {
|
|
465
|
+
changed = true;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
436
470
|
retained[mainFile] = value;
|
|
437
471
|
}
|
|
438
472
|
if (!changed && Object.keys(retained).length === Object.keys(current).length)
|
|
@@ -657,7 +691,9 @@ export class CollaborationService {
|
|
|
657
691
|
snapshotFlushPending: false,
|
|
658
692
|
pendingFlushes: [],
|
|
659
693
|
rejectedPaths: new Set(),
|
|
660
|
-
compileMetaValidationPending: false
|
|
694
|
+
compileMetaValidationPending: false,
|
|
695
|
+
formatLeases: new Map(),
|
|
696
|
+
formatLeaseWaiters: new Map()
|
|
661
697
|
};
|
|
662
698
|
room.awareness.setLocalState(null);
|
|
663
699
|
// A recovered Yjs document may contain metadata from a process that no
|
|
@@ -859,6 +895,7 @@ export class CollaborationService {
|
|
|
859
895
|
this.sendSyncStep1(room, connection.socket);
|
|
860
896
|
this.sendCompileStates(room, connection.socket);
|
|
861
897
|
this.sendAwareness(room, connection.socket);
|
|
898
|
+
this.sendFormatLeaseStates(room, connection);
|
|
862
899
|
}
|
|
863
900
|
return;
|
|
864
901
|
}
|
|
@@ -916,6 +953,10 @@ export class CollaborationService {
|
|
|
916
953
|
this.sendFlushReceipt(connection, requestId, receipt);
|
|
917
954
|
return;
|
|
918
955
|
}
|
|
956
|
+
if (messageType === MESSAGE_FORMAT_LEASE) {
|
|
957
|
+
this.handleFormatLeaseMessage(room, connection, decoder, current);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
919
960
|
if (messageType !== MESSAGE_AWARENESS)
|
|
920
961
|
return;
|
|
921
962
|
const update = decoding.readVarUint8Array(decoder);
|
|
@@ -946,6 +987,7 @@ export class CollaborationService {
|
|
|
946
987
|
disconnect(room, connection) {
|
|
947
988
|
if (!room.connections.delete(connection))
|
|
948
989
|
return;
|
|
990
|
+
this.releaseFormatLeasesForConnection(room, connection);
|
|
949
991
|
room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
|
|
950
992
|
if (connection.protocolTimer)
|
|
951
993
|
clearTimeout(connection.protocolTimer);
|
|
@@ -972,6 +1014,219 @@ export class CollaborationService {
|
|
|
972
1014
|
if (connection !== except)
|
|
973
1015
|
send(connection.socket, message);
|
|
974
1016
|
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Serialize access to one source file while a browser-side formatter is
|
|
1019
|
+
* calculating and applying its replacement. This is intentionally an
|
|
1020
|
+
* in-memory room primitive: the lease is advisory to the live collaboration
|
|
1021
|
+
* room, while the Yjs update + flush ordering remains the authoritative
|
|
1022
|
+
* durability boundary. A disconnected holder can never keep a lease alive.
|
|
1023
|
+
*/
|
|
1024
|
+
handleFormatLeaseMessage(room, connection, decoder, currentProject) {
|
|
1025
|
+
const operation = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 16) : "";
|
|
1026
|
+
const requestId = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
|
|
1027
|
+
const rawPath = decoding.hasContent(decoder) ? decoding.readVarString(decoder) : "";
|
|
1028
|
+
const token = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
|
|
1029
|
+
let filePath;
|
|
1030
|
+
try {
|
|
1031
|
+
filePath = safeRelativePath(rawPath);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, rawPath, "invalid path");
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (!requestId || !isCollaborativeTextFile(filePath) || !room.allowedPaths.has(filePath)) {
|
|
1038
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "file is not format-able");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (!canEdit(currentProject)) {
|
|
1042
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "write permission is required");
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (operation === "acquire") {
|
|
1046
|
+
const current = this.activeFormatLease(room, filePath);
|
|
1047
|
+
if (!current) {
|
|
1048
|
+
// Expiring a lease may have immediately granted the next queued
|
|
1049
|
+
// request. Do not overwrite that grant with this new request.
|
|
1050
|
+
if (room.formatLeases.has(filePath)) {
|
|
1051
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1052
|
+
if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
|
|
1053
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
waiters.push({ path: filePath, requestId, connection });
|
|
1057
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
this.grantFormatLease(room, { path: filePath, requestId, connection });
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (current.connection === connection) {
|
|
1064
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "this session already holds the lease");
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1068
|
+
if (waiters.some((waiter) => waiter.connection === connection && waiter.requestId === requestId))
|
|
1069
|
+
return;
|
|
1070
|
+
if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
|
|
1071
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
waiters.push({ path: filePath, requestId, connection });
|
|
1075
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (operation === "renew") {
|
|
1079
|
+
const lease = this.activeFormatLease(room, filePath);
|
|
1080
|
+
if (!lease || lease.connection !== connection || lease.token !== token) {
|
|
1081
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
this.extendFormatLease(room, lease);
|
|
1085
|
+
this.sendFormatLeaseResponse(connection, "renewed", requestId, filePath, "", lease.expiresAt, lease.token);
|
|
1086
|
+
this.broadcastFormatLeaseState(room, lease);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (operation === "release") {
|
|
1090
|
+
const lease = this.activeFormatLease(room, filePath);
|
|
1091
|
+
if (!lease || lease.connection !== connection || lease.token !== token) {
|
|
1092
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
this.releaseFormatLease(room, lease);
|
|
1096
|
+
this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (operation === "cancel") {
|
|
1100
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1101
|
+
const remaining = waiters.filter((waiter) => !(waiter.connection === connection && waiter.requestId === requestId));
|
|
1102
|
+
if (remaining.length)
|
|
1103
|
+
room.formatLeaseWaiters.set(filePath, remaining);
|
|
1104
|
+
else
|
|
1105
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1106
|
+
// A timeout may race with the server granting the request. In that
|
|
1107
|
+
// window the request is no longer in the waiter queue, so also revoke a
|
|
1108
|
+
// matching active lease instead of leaving it until the TTL expires.
|
|
1109
|
+
const active = this.activeFormatLease(room, filePath);
|
|
1110
|
+
if (active?.connection === connection && active.requestId === requestId) {
|
|
1111
|
+
this.releaseFormatLease(room, active);
|
|
1112
|
+
}
|
|
1113
|
+
this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "unknown format lease operation");
|
|
1117
|
+
}
|
|
1118
|
+
activeFormatLease(room, filePath) {
|
|
1119
|
+
const lease = room.formatLeases.get(filePath);
|
|
1120
|
+
if (!lease)
|
|
1121
|
+
return null;
|
|
1122
|
+
if (lease.expiresAt > Date.now())
|
|
1123
|
+
return lease;
|
|
1124
|
+
this.releaseFormatLease(room, lease);
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
grantFormatLease(room, waiter) {
|
|
1128
|
+
if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
|
|
1129
|
+
return false;
|
|
1130
|
+
const lease = {
|
|
1131
|
+
path: waiter.path,
|
|
1132
|
+
token: randomUUID(),
|
|
1133
|
+
requestId: waiter.requestId,
|
|
1134
|
+
connection: waiter.connection,
|
|
1135
|
+
expiresAt: Date.now() + FORMAT_LEASE_TTL_MS,
|
|
1136
|
+
timer: setTimeout(() => this.expireFormatLease(room, waiter.path), FORMAT_LEASE_TTL_MS + 25)
|
|
1137
|
+
};
|
|
1138
|
+
room.formatLeases.set(waiter.path, lease);
|
|
1139
|
+
this.sendFormatLeaseResponse(waiter.connection, "grant", waiter.requestId, lease.path, "", lease.expiresAt, lease.token);
|
|
1140
|
+
this.broadcastFormatLeaseState(room, lease);
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
extendFormatLease(room, lease) {
|
|
1144
|
+
clearTimeout(lease.timer);
|
|
1145
|
+
lease.expiresAt = Date.now() + FORMAT_LEASE_TTL_MS;
|
|
1146
|
+
lease.timer = setTimeout(() => this.expireFormatLease(room, lease.path), FORMAT_LEASE_TTL_MS + 25);
|
|
1147
|
+
}
|
|
1148
|
+
expireFormatLease(room, filePath) {
|
|
1149
|
+
const lease = room.formatLeases.get(filePath);
|
|
1150
|
+
if (!lease)
|
|
1151
|
+
return;
|
|
1152
|
+
if (lease.expiresAt > Date.now()) {
|
|
1153
|
+
lease.timer = setTimeout(() => this.expireFormatLease(room, filePath), lease.expiresAt - Date.now() + 25);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
this.releaseFormatLease(room, lease);
|
|
1157
|
+
}
|
|
1158
|
+
releaseFormatLease(room, lease) {
|
|
1159
|
+
if (room.formatLeases.get(lease.path) !== lease)
|
|
1160
|
+
return;
|
|
1161
|
+
clearTimeout(lease.timer);
|
|
1162
|
+
room.formatLeases.delete(lease.path);
|
|
1163
|
+
this.broadcastFormatLeaseState(room, null, lease.path);
|
|
1164
|
+
this.grantNextFormatLease(room, lease.path);
|
|
1165
|
+
}
|
|
1166
|
+
grantNextFormatLease(room, filePath) {
|
|
1167
|
+
if (room.formatLeases.has(filePath))
|
|
1168
|
+
return;
|
|
1169
|
+
const waiters = room.formatLeaseWaiters.get(filePath);
|
|
1170
|
+
if (!waiters?.length) {
|
|
1171
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
while (waiters.length) {
|
|
1175
|
+
const waiter = waiters.shift();
|
|
1176
|
+
if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
|
|
1177
|
+
continue;
|
|
1178
|
+
// Keep all later requests in the room queue while the first valid
|
|
1179
|
+
// waiter owns the lease. The previous implementation deleted the map
|
|
1180
|
+
// before granting and silently dropped every later waiter.
|
|
1181
|
+
if (waiters.length)
|
|
1182
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1183
|
+
else
|
|
1184
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1185
|
+
if (this.grantFormatLease(room, waiter))
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1189
|
+
}
|
|
1190
|
+
releaseFormatLeasesForConnection(room, connection) {
|
|
1191
|
+
const pathsToGrant = new Set();
|
|
1192
|
+
for (const [filePath, lease] of room.formatLeases) {
|
|
1193
|
+
if (lease.connection !== connection)
|
|
1194
|
+
continue;
|
|
1195
|
+
pathsToGrant.add(filePath);
|
|
1196
|
+
clearTimeout(lease.timer);
|
|
1197
|
+
room.formatLeases.delete(filePath);
|
|
1198
|
+
this.broadcastFormatLeaseState(room, null, filePath);
|
|
1199
|
+
}
|
|
1200
|
+
for (const [filePath, waiters] of room.formatLeaseWaiters) {
|
|
1201
|
+
const remaining = waiters.filter((waiter) => waiter.connection !== connection);
|
|
1202
|
+
if (remaining.length)
|
|
1203
|
+
room.formatLeaseWaiters.set(filePath, remaining);
|
|
1204
|
+
else
|
|
1205
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1206
|
+
}
|
|
1207
|
+
for (const filePath of pathsToGrant)
|
|
1208
|
+
this.grantNextFormatLease(room, filePath);
|
|
1209
|
+
}
|
|
1210
|
+
sendFormatLeaseResponse(connection, status, requestId, filePath, reason = "", expiresAt = 0, token = "") {
|
|
1211
|
+
const encoder = encoding.createEncoder();
|
|
1212
|
+
encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
|
|
1213
|
+
encoding.writeVarString(encoder, status);
|
|
1214
|
+
encoding.writeVarString(encoder, requestId);
|
|
1215
|
+
encoding.writeVarString(encoder, filePath);
|
|
1216
|
+
encoding.writeVarString(encoder, token);
|
|
1217
|
+
encoding.writeVarString(encoder, expiresAt > 0 ? String(expiresAt) : "");
|
|
1218
|
+
encoding.writeVarString(encoder, reason.slice(0, 256));
|
|
1219
|
+
send(connection.socket, encoding.toUint8Array(encoder));
|
|
1220
|
+
}
|
|
1221
|
+
broadcastFormatLeaseState(room, lease, filePath = lease?.path ?? "") {
|
|
1222
|
+
this.broadcast(room, formatLeaseStateMessage(lease, filePath), null);
|
|
1223
|
+
}
|
|
1224
|
+
sendFormatLeaseStates(room, connection) {
|
|
1225
|
+
for (const lease of room.formatLeases.values()) {
|
|
1226
|
+
if (lease.expiresAt > Date.now())
|
|
1227
|
+
send(connection.socket, formatLeaseStateMessage(lease, lease.path));
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
975
1230
|
scheduleRoomCleanup(room) {
|
|
976
1231
|
if (room.connections.size === 0 && !room.cleanupTimer) {
|
|
977
1232
|
room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
|
|
@@ -1147,6 +1402,10 @@ export class CollaborationService {
|
|
|
1147
1402
|
connection.protocolTimer = null;
|
|
1148
1403
|
}
|
|
1149
1404
|
room.connections.clear();
|
|
1405
|
+
for (const lease of room.formatLeases.values())
|
|
1406
|
+
clearTimeout(lease.timer);
|
|
1407
|
+
room.formatLeases.clear();
|
|
1408
|
+
room.formatLeaseWaiters.clear();
|
|
1150
1409
|
room.pendingFlushes.splice(0);
|
|
1151
1410
|
room.rejectedPaths.clear();
|
|
1152
1411
|
room.awarenessOwners.clear();
|
|
@@ -1302,6 +1561,19 @@ function maintenanceMessage(reason) {
|
|
|
1302
1561
|
encoding.writeVarString(encoder, reason);
|
|
1303
1562
|
return encoding.toUint8Array(encoder);
|
|
1304
1563
|
}
|
|
1564
|
+
function formatLeaseStateMessage(lease, filePath) {
|
|
1565
|
+
const encoder = encoding.createEncoder();
|
|
1566
|
+
encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
|
|
1567
|
+
encoding.writeVarString(encoder, "state");
|
|
1568
|
+
encoding.writeVarString(encoder, "");
|
|
1569
|
+
encoding.writeVarString(encoder, filePath);
|
|
1570
|
+
encoding.writeVarString(encoder, "");
|
|
1571
|
+
encoding.writeVarString(encoder, lease ? String(lease.expiresAt) : "");
|
|
1572
|
+
encoding.writeVarString(encoder, "");
|
|
1573
|
+
encoding.writeVarString(encoder, lease?.connection.user.id ?? "");
|
|
1574
|
+
encoding.writeVarString(encoder, lease?.connection.user.display_name ?? "");
|
|
1575
|
+
return encoding.toUint8Array(encoder);
|
|
1576
|
+
}
|
|
1305
1577
|
function awarenessClientIds(update) {
|
|
1306
1578
|
const decoder = decoding.createDecoder(update);
|
|
1307
1579
|
const count = decoding.readVarUint(decoder);
|
|
@@ -2,7 +2,14 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { publishedCompileArtifacts } from "./compiler.js";
|
|
4
4
|
import { outputRoot, resolveSourcePath, safeRelativePath, sourceRoot, texFileStem } from "./files.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isMainDocumentCandidateSync } from "./latexRoot.js";
|
|
6
|
+
import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
7
|
+
export function pdfLoadingMode(config, pdfSizeBytes) {
|
|
8
|
+
if (config.pdfLoadingStrategy === "full" || config.pdfLoadingStrategy === "range") {
|
|
9
|
+
return config.pdfLoadingStrategy;
|
|
10
|
+
}
|
|
11
|
+
return pdfSizeBytes > config.pdfRangeThresholdBytes ? "range" : "full";
|
|
12
|
+
}
|
|
6
13
|
export function availablePdf(config, projectId, mainFile, defaultMainFile) {
|
|
7
14
|
const isDefault = mainFile === defaultMainFile;
|
|
8
15
|
const published = publishedCompileArtifacts(config, projectId, mainFile, isDefault);
|
|
@@ -62,11 +69,7 @@ export function compileMainFile(config, projectId, defaultMainFile, value) {
|
|
|
62
69
|
try {
|
|
63
70
|
if (!fs.statSync(absolute).isFile())
|
|
64
71
|
return null;
|
|
65
|
-
|
|
66
|
-
// upload flow keeps it as the configured root and latexmk reports the
|
|
67
|
-
// resulting compile error. Session-selected alternate roots are stricter:
|
|
68
|
-
// they must identify an actual document rather than an included fragment.
|
|
69
|
-
if (mainFile !== defaultMainFile && !hasDocumentClass(fs.readFileSync(absolute, "utf8")))
|
|
72
|
+
if (!isMainDocumentCandidateSync(config, projectId, mainFile))
|
|
70
73
|
return null;
|
|
71
74
|
}
|
|
72
75
|
catch {
|
|
@@ -87,7 +90,7 @@ export function listCompileArtifacts(directory) {
|
|
|
87
90
|
visit(absolute, relative);
|
|
88
91
|
else if (entry.isFile()) {
|
|
89
92
|
const size = fs.statSync(absolute).size;
|
|
90
|
-
artifacts.push({ path: relative, size, viewable: size <=
|
|
93
|
+
artifacts.push({ path: relative, size, viewable: size <= MAX_TEXT_PREVIEW_BYTES && isTextCompileArtifact(relative) });
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
};
|
package/dist/server/compiler.js
CHANGED
|
@@ -242,7 +242,7 @@ export function cleanCompileArtifacts(config, projectId, mainFile, defaultMainFi
|
|
|
242
242
|
}
|
|
243
243
|
export function publishCompileArtifacts(config, projectId, snapshot, result) {
|
|
244
244
|
if (!result.ok || !result.pdfPath)
|
|
245
|
-
throw new Error("
|
|
245
|
+
throw new Error("Cannot publish a failed compilation result");
|
|
246
246
|
const manifestDirectory = compileTargetRoot(config, projectId, snapshot.mainFile);
|
|
247
247
|
const previous = publishedCompileArtifacts(config, projectId, snapshot.mainFile);
|
|
248
248
|
const manifest = {
|
|
@@ -535,14 +535,14 @@ function rewriteSynctexSource(synctexPath, cacheSource, snapshotSource) {
|
|
|
535
535
|
export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput) {
|
|
536
536
|
const mainFile = safeRelativePath(mainFileInput);
|
|
537
537
|
if (!/\.tex$/i.test(mainFile))
|
|
538
|
-
throw new Error("
|
|
538
|
+
throw new Error("The main file must have a .tex extension");
|
|
539
539
|
const startedAt = performance.now();
|
|
540
540
|
let latexmkrc = null;
|
|
541
541
|
if (config.allowProjectLatexmkrc && latexmkrcInput) {
|
|
542
542
|
latexmkrc = safeRelativePath(latexmkrcInput);
|
|
543
543
|
const absoluteRc = path.join(snapshot.sourceDir, latexmkrc);
|
|
544
544
|
if (!fs.existsSync(absoluteRc) || !fs.statSync(absoluteRc).isFile()) {
|
|
545
|
-
throw new Error(
|
|
545
|
+
throw new Error(`The configured project latexmkrc does not exist: ${latexmkrc}`);
|
|
546
546
|
}
|
|
547
547
|
}
|
|
548
548
|
const cacheStartedAt = performance.now();
|
|
@@ -553,6 +553,11 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
|
|
|
553
553
|
const engineFlag = engine === "xelatex" ? "-xelatex" : engine === "lualatex" ? "-lualatex" : "-pdf";
|
|
554
554
|
const args = [
|
|
555
555
|
engineFlag,
|
|
556
|
+
// latexmk otherwise reads a .latexmkrc from the compile working
|
|
557
|
+
// directory automatically. Project sources may contain one after a ZIP
|
|
558
|
+
// import or a Git checkout, so only an rc file explicitly selected in the
|
|
559
|
+
// project settings may be loaded below with -r.
|
|
560
|
+
"-norc",
|
|
556
561
|
"-interaction=nonstopmode",
|
|
557
562
|
"-file-line-error",
|
|
558
563
|
"-halt-on-error",
|
|
@@ -582,7 +587,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
|
|
|
582
587
|
child.stderr.on("data", append);
|
|
583
588
|
child.on("error", reject);
|
|
584
589
|
const timeout = setTimeout(() => {
|
|
585
|
-
log += `\
|
|
590
|
+
log += `\nCompilation exceeded ${config.compileTimeoutMs / 1000} seconds and was terminated.\n`;
|
|
586
591
|
killProcessGroup(child);
|
|
587
592
|
}, config.compileTimeoutMs);
|
|
588
593
|
child.on("close", (code) => {
|
package/dist/server/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { defaultDataDirectory, packageClientDirectory, resolveConfigPath } from "./runtimePaths.js";
|
|
4
4
|
export const LATEX_ENGINES = ["pdflatex", "xelatex", "lualatex"];
|
|
5
|
+
export const PDF_LOADING_STRATEGIES = ["auto", "full", "range"];
|
|
5
6
|
/** Effective values used when the corresponding file/env setting is omitted. */
|
|
6
7
|
export const CONFIG_DEFAULTS = {
|
|
7
8
|
siteName: "TexLite",
|
|
@@ -19,8 +20,10 @@ export const CONFIG_DEFAULTS = {
|
|
|
19
20
|
extraArgs: [],
|
|
20
21
|
allowProjectLatexmkrc: true,
|
|
21
22
|
maxFileSizeMB: 50,
|
|
23
|
+
pdfLoadingStrategy: "auto",
|
|
24
|
+
pdfRangeThresholdMB: 5,
|
|
22
25
|
historyMaxVersions: 200,
|
|
23
|
-
historyMaxStorageMB:
|
|
26
|
+
historyMaxStorageMB: 128,
|
|
24
27
|
git: "git",
|
|
25
28
|
gitOperationTimeoutSeconds: 120,
|
|
26
29
|
githubApiBaseUrl: "https://api.github.com"
|
|
@@ -31,6 +34,7 @@ const CONFIG_LIMITS = {
|
|
|
31
34
|
compileTimeoutSeconds: [1, 3_600],
|
|
32
35
|
maxCompileJobs: [1, 32],
|
|
33
36
|
maxFileSizeMB: [1, 2_048],
|
|
37
|
+
pdfRangeThresholdMB: [1, 2_048],
|
|
34
38
|
historyMaxVersions: [10, 5_000],
|
|
35
39
|
historyMaxStorageMB: [16, 102_400],
|
|
36
40
|
gitOperationTimeoutSeconds: [1, 3_600]
|
|
@@ -59,6 +63,11 @@ export function loadConfig(configPathOverride) {
|
|
|
59
63
|
const compileTimeoutSeconds = integerSetting("latex.compileTimeoutSeconds", process.env.TEXLITE_COMPILE_TIMEOUT, fileConfig.latex?.compileTimeoutSeconds, CONFIG_DEFAULTS.compileTimeoutSeconds, CONFIG_LIMITS.compileTimeoutSeconds);
|
|
60
64
|
const maxCompileJobs = integerSetting("latex.maxCompileJobs", process.env.TEXLITE_MAX_COMPILE_JOBS, fileConfig.latex?.maxCompileJobs, CONFIG_DEFAULTS.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
|
|
61
65
|
const maxFileSizeMB = integerSetting("uploads.maxFileSizeMB", process.env.TEXLITE_MAX_UPLOAD_SIZE_MB, fileConfig.uploads?.maxFileSizeMB, CONFIG_DEFAULTS.maxFileSizeMB, CONFIG_LIMITS.maxFileSizeMB);
|
|
66
|
+
const configuredPdfLoadingStrategy = setting("pdf.loadingStrategy", process.env.TEXLITE_PDF_LOADING_STRATEGY, fileConfig.pdf?.loadingStrategy, CONFIG_DEFAULTS.pdfLoadingStrategy);
|
|
67
|
+
if (!isPdfLoadingStrategy(configuredPdfLoadingStrategy)) {
|
|
68
|
+
throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}; received ${displayValue(configuredPdfLoadingStrategy)}`);
|
|
69
|
+
}
|
|
70
|
+
const pdfRangeThresholdMB = integerSetting("pdf.rangeThresholdMB", process.env.TEXLITE_PDF_RANGE_THRESHOLD_MB, fileConfig.pdf?.rangeThresholdMB, CONFIG_DEFAULTS.pdfRangeThresholdMB, CONFIG_LIMITS.pdfRangeThresholdMB);
|
|
62
71
|
const historyMaxVersions = integerSetting("history.maxVersions", process.env.TEXLITE_HISTORY_MAX_VERSIONS, fileConfig.history?.maxVersions, CONFIG_DEFAULTS.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
63
72
|
const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
|
|
64
73
|
const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
@@ -81,6 +90,8 @@ export function loadConfig(configPathOverride) {
|
|
|
81
90
|
extraArgs: fileConfig.latex?.extraArgs === undefined ? [] : [...fileConfig.latex.extraArgs],
|
|
82
91
|
allowProjectLatexmkrc: fileConfig.latex?.allowProjectLatexmkrc ?? CONFIG_DEFAULTS.allowProjectLatexmkrc,
|
|
83
92
|
maxUploadBytes: maxFileSizeMB * 1024 * 1024,
|
|
93
|
+
pdfLoadingStrategy: configuredPdfLoadingStrategy,
|
|
94
|
+
pdfRangeThresholdBytes: pdfRangeThresholdMB * 1024 * 1024,
|
|
84
95
|
historyMaxVersions,
|
|
85
96
|
historyMaxStorageBytes: historyMaxStorageMB * 1024 * 1024,
|
|
86
97
|
git: stringSetting("git.binary", process.env.TEXLITE_GIT, fileConfig.git?.binary, CONFIG_DEFAULTS.git, { min: 1, max: 256 }),
|
|
@@ -115,6 +126,10 @@ export function validateConfig(config) {
|
|
|
115
126
|
validateInteger("latex.compileTimeoutSeconds", config.compileTimeoutMs / 1000, CONFIG_LIMITS.compileTimeoutSeconds);
|
|
116
127
|
validateInteger("latex.maxCompileJobs", config.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
|
|
117
128
|
validateInteger("uploads.maxFileSizeMB", config.maxUploadBytes / (1024 * 1024), CONFIG_LIMITS.maxFileSizeMB);
|
|
129
|
+
if (!isPdfLoadingStrategy(config.pdfLoadingStrategy)) {
|
|
130
|
+
throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
validateInteger("pdf.rangeThresholdMB", config.pdfRangeThresholdBytes / (1024 * 1024), CONFIG_LIMITS.pdfRangeThresholdMB);
|
|
118
133
|
validateInteger("history.maxVersions", config.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
119
134
|
validateInteger("history.maxStorageMB", config.historyMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.historyMaxStorageMB);
|
|
120
135
|
validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
@@ -122,6 +137,9 @@ export function validateConfig(config) {
|
|
|
122
137
|
function isEngine(value) {
|
|
123
138
|
return typeof value === "string" && LATEX_ENGINES.includes(value);
|
|
124
139
|
}
|
|
140
|
+
function isPdfLoadingStrategy(value) {
|
|
141
|
+
return typeof value === "string" && PDF_LOADING_STRATEGIES.includes(value);
|
|
142
|
+
}
|
|
125
143
|
function readConfigFile(configPath) {
|
|
126
144
|
if (!fs.existsSync(configPath))
|
|
127
145
|
return {};
|
|
@@ -149,6 +167,12 @@ function validateFileConfig(config) {
|
|
|
149
167
|
optionalString(storage?.dataDir, "storage.dataDir", { min: 1, max: 4_096 });
|
|
150
168
|
const uploads = optionalSection(config.uploads, "uploads");
|
|
151
169
|
optionalInteger(uploads?.maxFileSizeMB, "uploads.maxFileSizeMB", CONFIG_LIMITS.maxFileSizeMB);
|
|
170
|
+
const pdf = optionalSection(config.pdf, "pdf");
|
|
171
|
+
if (pdf && Object.prototype.hasOwnProperty.call(pdf, "loadingStrategy")
|
|
172
|
+
&& !isPdfLoadingStrategy(pdf.loadingStrategy)) {
|
|
173
|
+
throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}; received ${displayValue(pdf.loadingStrategy)}`);
|
|
174
|
+
}
|
|
175
|
+
optionalInteger(pdf?.rangeThresholdMB, "pdf.rangeThresholdMB", CONFIG_LIMITS.pdfRangeThresholdMB);
|
|
152
176
|
const history = optionalSection(config.history, "history");
|
|
153
177
|
optionalInteger(history?.maxVersions, "history.maxVersions", CONFIG_LIMITS.historyMaxVersions);
|
|
154
178
|
optionalInteger(history?.maxStorageMB, "history.maxStorageMB", CONFIG_LIMITS.historyMaxStorageMB);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { httpError } from "./http.js";
|
|
2
3
|
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
3
4
|
export async function assertEnvironment(config) {
|
|
4
5
|
const commands = [
|
|
@@ -26,7 +27,10 @@ export async function assertGitAvailable(config) {
|
|
|
26
27
|
const result = await commandVersion(item.command, Math.min(config.gitOperationTimeoutMs, 10_000));
|
|
27
28
|
if (result.error || result.status !== 0) {
|
|
28
29
|
const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
29
|
-
|
|
30
|
+
// Command output can reveal host paths; it remains a startup diagnostic,
|
|
31
|
+
// while API clients receive the localized, stable error code.
|
|
32
|
+
void detail;
|
|
33
|
+
throw httpError(503, "GIT_UNAVAILABLE");
|
|
30
34
|
}
|
|
31
35
|
const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
|
|
32
36
|
return { ...item, version };
|