texlite 0.7.5 → 0.7.6
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-W3hLOWrF.js} +2 -2
- package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
- package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
- package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
- package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
- package/dist/client/assets/index-DRShM2ob.js +64 -0
- package/dist/client/assets/index-DzcH7hBu.css +1 -0
- package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
- package/dist/client/assets/minus-BfqMIeUg.js +1 -0
- package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-BBnCnxmQ.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 +250 -2
- 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,8 @@ 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;
|
|
41
46
|
const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
|
|
42
47
|
const COLORS = [
|
|
43
48
|
["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
|
|
@@ -262,6 +267,11 @@ export class CollaborationService {
|
|
|
262
267
|
const room = this.rooms.get(projectId);
|
|
263
268
|
if (!room)
|
|
264
269
|
return;
|
|
270
|
+
if (permission === "read" || permission === "revoked") {
|
|
271
|
+
const connectionIds = new Set([...room.connections].filter((connection) => connection.user.id === userId));
|
|
272
|
+
for (const connection of connectionIds)
|
|
273
|
+
this.releaseFormatLeasesForConnection(room, connection);
|
|
274
|
+
}
|
|
265
275
|
const encoder = encoding.createEncoder();
|
|
266
276
|
encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
|
|
267
277
|
encoding.writeVarString(encoder, userId);
|
|
@@ -657,7 +667,9 @@ export class CollaborationService {
|
|
|
657
667
|
snapshotFlushPending: false,
|
|
658
668
|
pendingFlushes: [],
|
|
659
669
|
rejectedPaths: new Set(),
|
|
660
|
-
compileMetaValidationPending: false
|
|
670
|
+
compileMetaValidationPending: false,
|
|
671
|
+
formatLeases: new Map(),
|
|
672
|
+
formatLeaseWaiters: new Map()
|
|
661
673
|
};
|
|
662
674
|
room.awareness.setLocalState(null);
|
|
663
675
|
// A recovered Yjs document may contain metadata from a process that no
|
|
@@ -859,6 +871,7 @@ export class CollaborationService {
|
|
|
859
871
|
this.sendSyncStep1(room, connection.socket);
|
|
860
872
|
this.sendCompileStates(room, connection.socket);
|
|
861
873
|
this.sendAwareness(room, connection.socket);
|
|
874
|
+
this.sendFormatLeaseStates(room, connection);
|
|
862
875
|
}
|
|
863
876
|
return;
|
|
864
877
|
}
|
|
@@ -916,6 +929,10 @@ export class CollaborationService {
|
|
|
916
929
|
this.sendFlushReceipt(connection, requestId, receipt);
|
|
917
930
|
return;
|
|
918
931
|
}
|
|
932
|
+
if (messageType === MESSAGE_FORMAT_LEASE) {
|
|
933
|
+
this.handleFormatLeaseMessage(room, connection, decoder, current);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
919
936
|
if (messageType !== MESSAGE_AWARENESS)
|
|
920
937
|
return;
|
|
921
938
|
const update = decoding.readVarUint8Array(decoder);
|
|
@@ -946,6 +963,7 @@ export class CollaborationService {
|
|
|
946
963
|
disconnect(room, connection) {
|
|
947
964
|
if (!room.connections.delete(connection))
|
|
948
965
|
return;
|
|
966
|
+
this.releaseFormatLeasesForConnection(room, connection);
|
|
949
967
|
room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
|
|
950
968
|
if (connection.protocolTimer)
|
|
951
969
|
clearTimeout(connection.protocolTimer);
|
|
@@ -972,6 +990,219 @@ export class CollaborationService {
|
|
|
972
990
|
if (connection !== except)
|
|
973
991
|
send(connection.socket, message);
|
|
974
992
|
}
|
|
993
|
+
/**
|
|
994
|
+
* Serialize access to one source file while a browser-side formatter is
|
|
995
|
+
* calculating and applying its replacement. This is intentionally an
|
|
996
|
+
* in-memory room primitive: the lease is advisory to the live collaboration
|
|
997
|
+
* room, while the Yjs update + flush ordering remains the authoritative
|
|
998
|
+
* durability boundary. A disconnected holder can never keep a lease alive.
|
|
999
|
+
*/
|
|
1000
|
+
handleFormatLeaseMessage(room, connection, decoder, currentProject) {
|
|
1001
|
+
const operation = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 16) : "";
|
|
1002
|
+
const requestId = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
|
|
1003
|
+
const rawPath = decoding.hasContent(decoder) ? decoding.readVarString(decoder) : "";
|
|
1004
|
+
const token = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
|
|
1005
|
+
let filePath;
|
|
1006
|
+
try {
|
|
1007
|
+
filePath = safeRelativePath(rawPath);
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, rawPath, "invalid path");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!requestId || !isCollaborativeTextFile(filePath) || !room.allowedPaths.has(filePath)) {
|
|
1014
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "file is not format-able");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!canEdit(currentProject)) {
|
|
1018
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "write permission is required");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (operation === "acquire") {
|
|
1022
|
+
const current = this.activeFormatLease(room, filePath);
|
|
1023
|
+
if (!current) {
|
|
1024
|
+
// Expiring a lease may have immediately granted the next queued
|
|
1025
|
+
// request. Do not overwrite that grant with this new request.
|
|
1026
|
+
if (room.formatLeases.has(filePath)) {
|
|
1027
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1028
|
+
if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
|
|
1029
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
waiters.push({ path: filePath, requestId, connection });
|
|
1033
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
this.grantFormatLease(room, { path: filePath, requestId, connection });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (current.connection === connection) {
|
|
1040
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "this session already holds the lease");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1044
|
+
if (waiters.some((waiter) => waiter.connection === connection && waiter.requestId === requestId))
|
|
1045
|
+
return;
|
|
1046
|
+
if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
|
|
1047
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
waiters.push({ path: filePath, requestId, connection });
|
|
1051
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (operation === "renew") {
|
|
1055
|
+
const lease = this.activeFormatLease(room, filePath);
|
|
1056
|
+
if (!lease || lease.connection !== connection || lease.token !== token) {
|
|
1057
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
this.extendFormatLease(room, lease);
|
|
1061
|
+
this.sendFormatLeaseResponse(connection, "renewed", requestId, filePath, "", lease.expiresAt, lease.token);
|
|
1062
|
+
this.broadcastFormatLeaseState(room, lease);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (operation === "release") {
|
|
1066
|
+
const lease = this.activeFormatLease(room, filePath);
|
|
1067
|
+
if (!lease || lease.connection !== connection || lease.token !== token) {
|
|
1068
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
this.releaseFormatLease(room, lease);
|
|
1072
|
+
this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (operation === "cancel") {
|
|
1076
|
+
const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
|
|
1077
|
+
const remaining = waiters.filter((waiter) => !(waiter.connection === connection && waiter.requestId === requestId));
|
|
1078
|
+
if (remaining.length)
|
|
1079
|
+
room.formatLeaseWaiters.set(filePath, remaining);
|
|
1080
|
+
else
|
|
1081
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1082
|
+
// A timeout may race with the server granting the request. In that
|
|
1083
|
+
// window the request is no longer in the waiter queue, so also revoke a
|
|
1084
|
+
// matching active lease instead of leaving it until the TTL expires.
|
|
1085
|
+
const active = this.activeFormatLease(room, filePath);
|
|
1086
|
+
if (active?.connection === connection && active.requestId === requestId) {
|
|
1087
|
+
this.releaseFormatLease(room, active);
|
|
1088
|
+
}
|
|
1089
|
+
this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "unknown format lease operation");
|
|
1093
|
+
}
|
|
1094
|
+
activeFormatLease(room, filePath) {
|
|
1095
|
+
const lease = room.formatLeases.get(filePath);
|
|
1096
|
+
if (!lease)
|
|
1097
|
+
return null;
|
|
1098
|
+
if (lease.expiresAt > Date.now())
|
|
1099
|
+
return lease;
|
|
1100
|
+
this.releaseFormatLease(room, lease);
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
grantFormatLease(room, waiter) {
|
|
1104
|
+
if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
|
|
1105
|
+
return false;
|
|
1106
|
+
const lease = {
|
|
1107
|
+
path: waiter.path,
|
|
1108
|
+
token: randomUUID(),
|
|
1109
|
+
requestId: waiter.requestId,
|
|
1110
|
+
connection: waiter.connection,
|
|
1111
|
+
expiresAt: Date.now() + FORMAT_LEASE_TTL_MS,
|
|
1112
|
+
timer: setTimeout(() => this.expireFormatLease(room, waiter.path), FORMAT_LEASE_TTL_MS + 25)
|
|
1113
|
+
};
|
|
1114
|
+
room.formatLeases.set(waiter.path, lease);
|
|
1115
|
+
this.sendFormatLeaseResponse(waiter.connection, "grant", waiter.requestId, lease.path, "", lease.expiresAt, lease.token);
|
|
1116
|
+
this.broadcastFormatLeaseState(room, lease);
|
|
1117
|
+
return true;
|
|
1118
|
+
}
|
|
1119
|
+
extendFormatLease(room, lease) {
|
|
1120
|
+
clearTimeout(lease.timer);
|
|
1121
|
+
lease.expiresAt = Date.now() + FORMAT_LEASE_TTL_MS;
|
|
1122
|
+
lease.timer = setTimeout(() => this.expireFormatLease(room, lease.path), FORMAT_LEASE_TTL_MS + 25);
|
|
1123
|
+
}
|
|
1124
|
+
expireFormatLease(room, filePath) {
|
|
1125
|
+
const lease = room.formatLeases.get(filePath);
|
|
1126
|
+
if (!lease)
|
|
1127
|
+
return;
|
|
1128
|
+
if (lease.expiresAt > Date.now()) {
|
|
1129
|
+
lease.timer = setTimeout(() => this.expireFormatLease(room, filePath), lease.expiresAt - Date.now() + 25);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
this.releaseFormatLease(room, lease);
|
|
1133
|
+
}
|
|
1134
|
+
releaseFormatLease(room, lease) {
|
|
1135
|
+
if (room.formatLeases.get(lease.path) !== lease)
|
|
1136
|
+
return;
|
|
1137
|
+
clearTimeout(lease.timer);
|
|
1138
|
+
room.formatLeases.delete(lease.path);
|
|
1139
|
+
this.broadcastFormatLeaseState(room, null, lease.path);
|
|
1140
|
+
this.grantNextFormatLease(room, lease.path);
|
|
1141
|
+
}
|
|
1142
|
+
grantNextFormatLease(room, filePath) {
|
|
1143
|
+
if (room.formatLeases.has(filePath))
|
|
1144
|
+
return;
|
|
1145
|
+
const waiters = room.formatLeaseWaiters.get(filePath);
|
|
1146
|
+
if (!waiters?.length) {
|
|
1147
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
while (waiters.length) {
|
|
1151
|
+
const waiter = waiters.shift();
|
|
1152
|
+
if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
|
|
1153
|
+
continue;
|
|
1154
|
+
// Keep all later requests in the room queue while the first valid
|
|
1155
|
+
// waiter owns the lease. The previous implementation deleted the map
|
|
1156
|
+
// before granting and silently dropped every later waiter.
|
|
1157
|
+
if (waiters.length)
|
|
1158
|
+
room.formatLeaseWaiters.set(filePath, waiters);
|
|
1159
|
+
else
|
|
1160
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1161
|
+
if (this.grantFormatLease(room, waiter))
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1165
|
+
}
|
|
1166
|
+
releaseFormatLeasesForConnection(room, connection) {
|
|
1167
|
+
const pathsToGrant = new Set();
|
|
1168
|
+
for (const [filePath, lease] of room.formatLeases) {
|
|
1169
|
+
if (lease.connection !== connection)
|
|
1170
|
+
continue;
|
|
1171
|
+
pathsToGrant.add(filePath);
|
|
1172
|
+
clearTimeout(lease.timer);
|
|
1173
|
+
room.formatLeases.delete(filePath);
|
|
1174
|
+
this.broadcastFormatLeaseState(room, null, filePath);
|
|
1175
|
+
}
|
|
1176
|
+
for (const [filePath, waiters] of room.formatLeaseWaiters) {
|
|
1177
|
+
const remaining = waiters.filter((waiter) => waiter.connection !== connection);
|
|
1178
|
+
if (remaining.length)
|
|
1179
|
+
room.formatLeaseWaiters.set(filePath, remaining);
|
|
1180
|
+
else
|
|
1181
|
+
room.formatLeaseWaiters.delete(filePath);
|
|
1182
|
+
}
|
|
1183
|
+
for (const filePath of pathsToGrant)
|
|
1184
|
+
this.grantNextFormatLease(room, filePath);
|
|
1185
|
+
}
|
|
1186
|
+
sendFormatLeaseResponse(connection, status, requestId, filePath, reason = "", expiresAt = 0, token = "") {
|
|
1187
|
+
const encoder = encoding.createEncoder();
|
|
1188
|
+
encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
|
|
1189
|
+
encoding.writeVarString(encoder, status);
|
|
1190
|
+
encoding.writeVarString(encoder, requestId);
|
|
1191
|
+
encoding.writeVarString(encoder, filePath);
|
|
1192
|
+
encoding.writeVarString(encoder, token);
|
|
1193
|
+
encoding.writeVarString(encoder, expiresAt > 0 ? String(expiresAt) : "");
|
|
1194
|
+
encoding.writeVarString(encoder, reason.slice(0, 256));
|
|
1195
|
+
send(connection.socket, encoding.toUint8Array(encoder));
|
|
1196
|
+
}
|
|
1197
|
+
broadcastFormatLeaseState(room, lease, filePath = lease?.path ?? "") {
|
|
1198
|
+
this.broadcast(room, formatLeaseStateMessage(lease, filePath), null);
|
|
1199
|
+
}
|
|
1200
|
+
sendFormatLeaseStates(room, connection) {
|
|
1201
|
+
for (const lease of room.formatLeases.values()) {
|
|
1202
|
+
if (lease.expiresAt > Date.now())
|
|
1203
|
+
send(connection.socket, formatLeaseStateMessage(lease, lease.path));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
975
1206
|
scheduleRoomCleanup(room) {
|
|
976
1207
|
if (room.connections.size === 0 && !room.cleanupTimer) {
|
|
977
1208
|
room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
|
|
@@ -1147,6 +1378,10 @@ export class CollaborationService {
|
|
|
1147
1378
|
connection.protocolTimer = null;
|
|
1148
1379
|
}
|
|
1149
1380
|
room.connections.clear();
|
|
1381
|
+
for (const lease of room.formatLeases.values())
|
|
1382
|
+
clearTimeout(lease.timer);
|
|
1383
|
+
room.formatLeases.clear();
|
|
1384
|
+
room.formatLeaseWaiters.clear();
|
|
1150
1385
|
room.pendingFlushes.splice(0);
|
|
1151
1386
|
room.rejectedPaths.clear();
|
|
1152
1387
|
room.awarenessOwners.clear();
|
|
@@ -1302,6 +1537,19 @@ function maintenanceMessage(reason) {
|
|
|
1302
1537
|
encoding.writeVarString(encoder, reason);
|
|
1303
1538
|
return encoding.toUint8Array(encoder);
|
|
1304
1539
|
}
|
|
1540
|
+
function formatLeaseStateMessage(lease, filePath) {
|
|
1541
|
+
const encoder = encoding.createEncoder();
|
|
1542
|
+
encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
|
|
1543
|
+
encoding.writeVarString(encoder, "state");
|
|
1544
|
+
encoding.writeVarString(encoder, "");
|
|
1545
|
+
encoding.writeVarString(encoder, filePath);
|
|
1546
|
+
encoding.writeVarString(encoder, "");
|
|
1547
|
+
encoding.writeVarString(encoder, lease ? String(lease.expiresAt) : "");
|
|
1548
|
+
encoding.writeVarString(encoder, "");
|
|
1549
|
+
encoding.writeVarString(encoder, lease?.connection.user.id ?? "");
|
|
1550
|
+
encoding.writeVarString(encoder, lease?.connection.user.display_name ?? "");
|
|
1551
|
+
return encoding.toUint8Array(encoder);
|
|
1552
|
+
}
|
|
1305
1553
|
function awarenessClientIds(update) {
|
|
1306
1554
|
const decoder = decoding.createDecoder(update);
|
|
1307
1555
|
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 };
|
package/dist/server/files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { httpError } from "./http.js";
|
|
4
5
|
export function projectRoot(config, projectId) {
|
|
5
6
|
return path.join(config.projectsDir, projectId);
|
|
6
7
|
}
|
|
@@ -49,14 +50,14 @@ export async function duplicateProjectFiles(config, sourceProjectId, targetProje
|
|
|
49
50
|
}
|
|
50
51
|
export function safeRelativePath(input) {
|
|
51
52
|
if (!input || input.includes("\0") || path.isAbsolute(input)) {
|
|
52
|
-
throw
|
|
53
|
+
throw httpError(400, "INVALID_PATH");
|
|
53
54
|
}
|
|
54
55
|
const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
|
|
55
56
|
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
56
|
-
throw
|
|
57
|
+
throw httpError(400, "INVALID_PATH");
|
|
57
58
|
}
|
|
58
59
|
if (normalized.split("/").some((segment) => segment.toLocaleLowerCase() === ".git")) {
|
|
59
|
-
throw
|
|
60
|
+
throw httpError(400, "RESERVED_PATH");
|
|
60
61
|
}
|
|
61
62
|
return normalized;
|
|
62
63
|
}
|
|
@@ -64,12 +65,12 @@ export function safeRelativePath(input) {
|
|
|
64
65
|
export function safePathSegment(input) {
|
|
65
66
|
const name = input.trim();
|
|
66
67
|
if (!name || name.includes("\0") || name.includes("/") || name.includes("\\")) {
|
|
67
|
-
throw
|
|
68
|
+
throw httpError(400, "INVALID_PATH");
|
|
68
69
|
}
|
|
69
70
|
return safeRelativePath(name);
|
|
70
71
|
}
|
|
71
72
|
export function symbolicLinkError(relativePath) {
|
|
72
|
-
return
|
|
73
|
+
return httpError(409, "SYMLINK_FORBIDDEN", { path: relativePath || "source" });
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
75
76
|
* Check every existing component below the project directory without
|
|
@@ -256,7 +257,7 @@ export function removeProjectDirectory(config, projectId) {
|
|
|
256
257
|
catch {
|
|
257
258
|
fs.rmSync(root, { recursive: true, force: true });
|
|
258
259
|
if (fs.existsSync(root)) {
|
|
259
|
-
throw new Error(
|
|
260
|
+
throw new Error(`Unable to remove project directory: ${root}`);
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
263
|
}
|