texlite 0.5.5 → 0.6.5
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 +1 -0
- package/dist/client/assets/{BinaryModule-Aj1vLnwf-BsIXahIW.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
- package/dist/client/assets/{GitDialog-CFzsF7ys.js → GitDialog-s54eAFX6.js} +1 -1
- package/dist/client/assets/{HistoryDialog-YPiAOGU_.js → HistoryDialog-DrUnIy0a.js} +1 -1
- package/dist/client/assets/{LatexEditor-BX4WLdhS.js → LatexEditor-Y5KfVqQI.js} +20 -20
- package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-BZanedGt.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-DnH4c7Se.js → SystemMetricsDialog-B97Og0La.js} +1 -1
- package/dist/client/assets/binary-DCBFVo4B.js +1 -0
- package/dist/client/assets/index-5ajAy27z.css +1 -0
- package/dist/client/assets/index-CCitTPyj.js +17 -0
- package/dist/client/assets/{index-B5iMrEn5.js → index-CQG_gpuU.js} +1 -1
- package/dist/client/assets/{plus-B-DG5h4y.js → plus-Bp4_lUor.js} +1 -1
- package/dist/client/assets/{spellCheck-4xuNPF_M.js → spellCheck-0f5ZG5IV.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +889 -337
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +37 -9
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +535 -132
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-FQ8oxyUx.js +0 -1
- package/dist/client/assets/binary-MtJxbY5b.js +0 -1
- package/dist/client/assets/index-CUa7L47p.css +0 -1
- package/dist/client/assets/index-DdFlmXqA.js +0 -17
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { publishedCompileArtifacts } from "./compiler.js";
|
|
4
|
-
import { outputRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
4
|
+
import { outputRoot, resolveSourcePath, safeRelativePath, sourceRoot, texFileStem } from "./files.js";
|
|
5
5
|
import { hasDocumentClass } from "./zip.js";
|
|
6
6
|
export function availablePdf(config, projectId, mainFile, defaultMainFile) {
|
|
7
7
|
const isDefault = mainFile === defaultMainFile;
|
|
@@ -11,10 +11,20 @@ export function availablePdf(config, projectId, mainFile, defaultMainFile) {
|
|
|
11
11
|
if (!isDefault)
|
|
12
12
|
return null;
|
|
13
13
|
const retained = retainedPdfPath(config, projectId);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const retainedStat = regularFileStat(retained);
|
|
15
|
+
if (retainedStat)
|
|
16
|
+
return { path: retained, version: String(retainedStat.mtimeMs) };
|
|
17
|
+
const legacy = path.join(outputRoot(config, projectId), `${texFileStem(mainFile)}.pdf`);
|
|
18
|
+
const legacyStat = regularFileStat(legacy);
|
|
19
|
+
return legacyStat ? { path: legacy, version: String(legacyStat.mtimeMs) } : null;
|
|
20
|
+
}
|
|
21
|
+
/** Resolve the PDF bytes belonging to one successful compile run. */
|
|
22
|
+
export function compileRunPdf(config, projectId, mainFile, runId) {
|
|
23
|
+
if (!/^[a-f0-9-]{36}$/i.test(runId))
|
|
24
|
+
return null;
|
|
25
|
+
const output = path.join(outputRoot(config, projectId), ".texlite", "runs", runId, "output");
|
|
26
|
+
const pdf = path.join(output, `${texFileStem(mainFile)}.pdf`);
|
|
27
|
+
return regularFileStat(pdf) ? { path: pdf, version: runId } : null;
|
|
18
28
|
}
|
|
19
29
|
export function syncArtifacts(config, projectId, mainFile, defaultMainFile) {
|
|
20
30
|
const isDefault = mainFile === defaultMainFile;
|
|
@@ -30,7 +40,7 @@ export function syncArtifacts(config, projectId, mainFile, defaultMainFile) {
|
|
|
30
40
|
};
|
|
31
41
|
if (fs.existsSync(retained.pdf) && fs.existsSync(retained.synctex))
|
|
32
42
|
return retained;
|
|
33
|
-
const basename =
|
|
43
|
+
const basename = texFileStem(mainFile);
|
|
34
44
|
const legacy = {
|
|
35
45
|
source: sourceRoot(config, projectId),
|
|
36
46
|
pdf: path.join(outputRoot(config, projectId), `${basename}.pdf`),
|
|
@@ -49,10 +59,19 @@ export function compileMainFile(config, projectId, defaultMainFile, value) {
|
|
|
49
59
|
if (!mainFile.toLocaleLowerCase().endsWith(".tex"))
|
|
50
60
|
return null;
|
|
51
61
|
const absolute = resolveSourcePath(config, projectId, mainFile);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
try {
|
|
63
|
+
if (!fs.statSync(absolute).isFile())
|
|
64
|
+
return null;
|
|
65
|
+
// A project may intentionally contain only one fragmentary .tex file; the
|
|
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")))
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
55
73
|
return null;
|
|
74
|
+
}
|
|
56
75
|
return mainFile;
|
|
57
76
|
}
|
|
58
77
|
export function compilePdfUrl(projectId, mainFile, version) {
|
|
@@ -84,3 +103,12 @@ function retainedPdfPath(config, projectId) {
|
|
|
84
103
|
function retainedSynctexPath(config, projectId) {
|
|
85
104
|
return path.join(outputRoot(config, projectId), ".texlite", "latest.synctex.gz");
|
|
86
105
|
}
|
|
106
|
+
function regularFileStat(filePath) {
|
|
107
|
+
try {
|
|
108
|
+
const stat = fs.statSync(filePath);
|
|
109
|
+
return stat.isFile() ? stat : null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/server/compiler.js
CHANGED
|
@@ -6,8 +6,9 @@ import { performance } from "node:perf_hooks";
|
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
7
|
import { Transform } from "node:stream";
|
|
8
8
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
9
|
-
import { listProjectFilesAsync, outputRoot, safeRelativePath,
|
|
9
|
+
import { listProjectFilesAsync, outputRoot, resolveSourcePath, safeRelativePath, texFileStem } from "./files.js";
|
|
10
10
|
import { parseCompileDiagnostics } from "./compileDiagnostics.js";
|
|
11
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
11
12
|
export class CompileQueue {
|
|
12
13
|
concurrency;
|
|
13
14
|
running = 0;
|
|
@@ -44,7 +45,12 @@ export class CompileQueue {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Coalesces and serializes each project/main-document target while still
|
|
50
|
+
* respecting the server-wide LaTeX process limit. Different root documents
|
|
51
|
+
* may compile concurrently because their caches and published bundles are
|
|
52
|
+
* isolated by target.
|
|
53
|
+
*/
|
|
48
54
|
export class ProjectCompileCoordinator {
|
|
49
55
|
queue;
|
|
50
56
|
targets = new Map();
|
|
@@ -55,19 +61,23 @@ export class ProjectCompileCoordinator {
|
|
|
55
61
|
const targetKey = `${input.projectId}\0${input.target ?? ""}`;
|
|
56
62
|
const state = this.targets.get(targetKey) ?? { active: null, pending: null };
|
|
57
63
|
this.targets.set(targetKey, state);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const generation = input.generation ?? input.revision;
|
|
65
|
+
const active = state.active;
|
|
66
|
+
if (active && (active.input.generation ?? active.input.revision) === generation) {
|
|
67
|
+
const pending = state.pending;
|
|
68
|
+
if (pending) {
|
|
69
|
+
this.redirect(pending, active);
|
|
70
|
+
pending.input.onDiscarded("superseded");
|
|
62
71
|
state.pending = null;
|
|
63
72
|
}
|
|
64
73
|
input.onDiscarded("duplicate");
|
|
65
|
-
|
|
66
|
-
return
|
|
74
|
+
active.input.onSelected();
|
|
75
|
+
return active.promise;
|
|
67
76
|
}
|
|
68
|
-
|
|
77
|
+
const pending = state.pending;
|
|
78
|
+
if (pending && (pending.input.generation ?? pending.input.revision) === generation) {
|
|
69
79
|
input.onDiscarded("duplicate");
|
|
70
|
-
return
|
|
80
|
+
return pending.promise;
|
|
71
81
|
}
|
|
72
82
|
const managed = deferredJob(input);
|
|
73
83
|
if (state.active) {
|
|
@@ -123,7 +133,11 @@ export async function captureCompileSnapshot(config, projectId, runId, settings)
|
|
|
123
133
|
const hash = createHash("sha256");
|
|
124
134
|
const files = [];
|
|
125
135
|
const directories = [];
|
|
126
|
-
|
|
136
|
+
// The content revision remains independent from the cheap generation. A
|
|
137
|
+
// metadata-only project update should still be able to reuse an identical
|
|
138
|
+
// source snapshot after the generation check misses.
|
|
139
|
+
const { generation, ...contentSettings } = settings;
|
|
140
|
+
hash.update(JSON.stringify(contentSettings));
|
|
127
141
|
try {
|
|
128
142
|
const entries = (await listProjectFilesAsync(config, projectId)).sort((left, right) => left.path.localeCompare(right.path));
|
|
129
143
|
const fileEntries = entries.filter((entry) => entry.type === "file");
|
|
@@ -141,8 +155,10 @@ export async function captureCompileSnapshot(config, projectId, runId, settings)
|
|
|
141
155
|
const batch = fileEntries.slice(offset, offset + 4);
|
|
142
156
|
const settled = await Promise.allSettled(batch.map(async (entry) => {
|
|
143
157
|
const destination = path.join(snapshotSource, entry.path);
|
|
144
|
-
const liveFile =
|
|
145
|
-
const stat = await fs.promises.
|
|
158
|
+
const liveFile = resolveSourcePath(config, projectId, entry.path);
|
|
159
|
+
const stat = await fs.promises.lstat(liveFile);
|
|
160
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
161
|
+
throw new Error(`Compile source path is not a regular file: ${entry.path}`);
|
|
146
162
|
await fs.promises.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
147
163
|
const digest = await copyAndDigest(liveFile, destination);
|
|
148
164
|
await fs.promises.utimes(destination, stat.atime, stat.mtime);
|
|
@@ -156,7 +172,7 @@ export async function captureCompileSnapshot(config, projectId, runId, settings)
|
|
|
156
172
|
for (const file of files)
|
|
157
173
|
hash.update(file.digest);
|
|
158
174
|
return {
|
|
159
|
-
projectId, runId, mainFile: safeRelativePath(settings.mainFile), revision: hash.digest("hex"), root,
|
|
175
|
+
projectId, runId, mainFile: safeRelativePath(settings.mainFile), revision: hash.digest("hex"), generation, root,
|
|
160
176
|
sourceDir: snapshotSource, outputDir: snapshotOutput, files, directories
|
|
161
177
|
};
|
|
162
178
|
}
|
|
@@ -218,8 +234,8 @@ export function cleanCompileArtifacts(config, projectId, mainFile, defaultMainFi
|
|
|
218
234
|
path.join(output, ".texlite", "latest.json"),
|
|
219
235
|
path.join(output, ".texlite", "latest.pdf"),
|
|
220
236
|
path.join(output, ".texlite", "latest.synctex.gz"),
|
|
221
|
-
path.join(output, `${
|
|
222
|
-
path.join(output, `${
|
|
237
|
+
path.join(output, `${texFileStem(mainFile)}.pdf`),
|
|
238
|
+
path.join(output, `${texFileStem(mainFile)}.synctex.gz`)
|
|
223
239
|
])
|
|
224
240
|
fs.rmSync(generated, { force: true });
|
|
225
241
|
}
|
|
@@ -234,6 +250,7 @@ export function publishCompileArtifacts(config, projectId, snapshot, result) {
|
|
|
234
250
|
mainFile: snapshot.mainFile,
|
|
235
251
|
runId: snapshot.runId,
|
|
236
252
|
revision: snapshot.revision,
|
|
253
|
+
generation: snapshot.generation ?? null,
|
|
237
254
|
pdf: path.basename(result.pdfPath),
|
|
238
255
|
synctex: result.synctexPath ? path.basename(result.synctexPath) : null
|
|
239
256
|
};
|
|
@@ -283,6 +300,34 @@ export function listPublishedCompileArtifacts(config, projectId) {
|
|
|
283
300
|
result.push(legacy);
|
|
284
301
|
return result;
|
|
285
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Recover database run state after a crash between publishing an immutable
|
|
305
|
+
* bundle and updating compile_runs. A valid published manifest is evidence
|
|
306
|
+
* that latexmk produced a usable PDF, so the corresponding run must be
|
|
307
|
+
* visible to latest/PDF endpoints after restart.
|
|
308
|
+
*/
|
|
309
|
+
export function reconcilePublishedCompileRuns(config, db, projectId) {
|
|
310
|
+
const recover = db.prepare(`UPDATE compile_runs
|
|
311
|
+
SET status = 'succeeded', log = CASE WHEN log = '' THEN ? ELSE log END, finished_at = ?
|
|
312
|
+
WHERE id = ? AND project_id = ?`);
|
|
313
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO compile_runs
|
|
314
|
+
(id, project_id, requested_by, main_file, status, log, created_at, finished_at)
|
|
315
|
+
VALUES (?, ?, NULL, ?, 'succeeded', ?, ?, ?)`);
|
|
316
|
+
for (const artifact of listPublishedCompileArtifacts(config, projectId)) {
|
|
317
|
+
let finishedAt = new Date().toISOString();
|
|
318
|
+
try {
|
|
319
|
+
finishedAt = fs.statSync(artifact.pdf).mtime.toISOString();
|
|
320
|
+
}
|
|
321
|
+
catch { /* manifest validation already checked the file */ }
|
|
322
|
+
const message = "Recovered a published PDF after a server restart.";
|
|
323
|
+
const existing = db.prepare("SELECT status FROM compile_runs WHERE id = ? AND project_id = ?")
|
|
324
|
+
.get(artifact.runId, projectId);
|
|
325
|
+
if (!existing)
|
|
326
|
+
insert.run(artifact.runId, projectId, artifact.mainFile, message, finishedAt, finishedAt);
|
|
327
|
+
else if (existing.status !== "succeeded")
|
|
328
|
+
recover.run(message, finishedAt, artifact.runId, projectId);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
286
331
|
function readPublishedManifest(config, projectId, manifestPath, expectedMainFile, legacy = false) {
|
|
287
332
|
if (!fs.existsSync(manifestPath))
|
|
288
333
|
return null;
|
|
@@ -291,9 +336,12 @@ function readPublishedManifest(config, projectId, manifestPath, expectedMainFile
|
|
|
291
336
|
const mainFile = manifest.version === 2 && typeof manifest.mainFile === "string"
|
|
292
337
|
? safeRelativePath(manifest.mainFile)
|
|
293
338
|
: legacy && manifest.version === 1 ? expectedMainFile ?? "" : null;
|
|
339
|
+
const generation = manifest.generation === undefined || manifest.generation === null
|
|
340
|
+
? null : typeof manifest.generation === "string" ? manifest.generation : null;
|
|
294
341
|
if (mainFile === null || (expectedMainFile !== undefined && mainFile !== expectedMainFile)
|
|
295
342
|
|| typeof manifest.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(manifest.runId)
|
|
296
343
|
|| typeof manifest.revision !== "string" || typeof manifest.pdf !== "string"
|
|
344
|
+
|| (manifest.generation !== undefined && manifest.generation !== null && generation === null)
|
|
297
345
|
|| path.basename(manifest.pdf) !== manifest.pdf
|
|
298
346
|
|| (manifest.synctex !== null && (typeof manifest.synctex !== "string" || path.basename(manifest.synctex) !== manifest.synctex))) {
|
|
299
347
|
return null;
|
|
@@ -305,7 +353,7 @@ function readPublishedManifest(config, projectId, manifestPath, expectedMainFile
|
|
|
305
353
|
const synctex = manifest.synctex ? path.join(output, manifest.synctex) : null;
|
|
306
354
|
if (!fs.existsSync(source) || !fs.existsSync(output) || !fs.existsSync(pdf) || (synctex && !fs.existsSync(synctex)))
|
|
307
355
|
return null;
|
|
308
|
-
return { mainFile, runId: manifest.runId, revision: manifest.revision, source, output, pdf, synctex };
|
|
356
|
+
return { mainFile, runId: manifest.runId, revision: manifest.revision, generation, source, output, pdf, synctex };
|
|
309
357
|
}
|
|
310
358
|
catch {
|
|
311
359
|
return null;
|
|
@@ -486,7 +534,7 @@ function rewriteSynctexSource(synctexPath, cacheSource, snapshotSource) {
|
|
|
486
534
|
}
|
|
487
535
|
export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput) {
|
|
488
536
|
const mainFile = safeRelativePath(mainFileInput);
|
|
489
|
-
if (
|
|
537
|
+
if (!/\.tex$/i.test(mainFile))
|
|
490
538
|
throw new Error("主文件必须是 .tex 文件");
|
|
491
539
|
const startedAt = performance.now();
|
|
492
540
|
let latexmkrc = null;
|
|
@@ -520,6 +568,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
|
|
|
520
568
|
const child = spawn(config.latexmk, args, {
|
|
521
569
|
cwd,
|
|
522
570
|
shell: false,
|
|
571
|
+
detached: detachedProcessGroup(),
|
|
523
572
|
env: { ...process.env, max_print_line: "1000" },
|
|
524
573
|
stdio: ["ignore", "pipe", "pipe"]
|
|
525
574
|
});
|
|
@@ -534,7 +583,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
|
|
|
534
583
|
child.on("error", reject);
|
|
535
584
|
const timeout = setTimeout(() => {
|
|
536
585
|
log += `\n编译超过 ${config.compileTimeoutMs / 1000} 秒,已终止。\n`;
|
|
537
|
-
child
|
|
586
|
+
killProcessGroup(child);
|
|
538
587
|
}, config.compileTimeoutMs);
|
|
539
588
|
child.on("close", (code) => {
|
|
540
589
|
clearTimeout(timeout);
|
|
@@ -542,7 +591,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
|
|
|
542
591
|
});
|
|
543
592
|
});
|
|
544
593
|
const latexmkMs = performance.now() - latexmkStartedAt;
|
|
545
|
-
const basename =
|
|
594
|
+
const basename = texFileStem(mainFile);
|
|
546
595
|
const cachedPdf = path.join(outDir, `${basename}.pdf`);
|
|
547
596
|
const ok = processResult.code === 0 && fs.existsSync(cachedPdf);
|
|
548
597
|
const diagnostics = parseCompileDiagnostics(processResult.log, ok ? "succeeded" : "failed");
|
package/dist/server/db.js
CHANGED
|
@@ -29,6 +29,7 @@ function migrate(db) {
|
|
|
29
29
|
created_at TEXT NOT NULL
|
|
30
30
|
);
|
|
31
31
|
CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS sessions_expires_at ON sessions(expires_at);
|
|
32
33
|
|
|
33
34
|
CREATE TABLE IF NOT EXISTS projects (
|
|
34
35
|
id TEXT PRIMARY KEY,
|
|
@@ -125,6 +126,7 @@ function migrate(db) {
|
|
|
125
126
|
edited_at TEXT
|
|
126
127
|
);
|
|
127
128
|
CREATE INDEX IF NOT EXISTS comments_project_file ON comments(project_id, file_path);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS comments_project_resolved ON comments(project_id, resolved);
|
|
128
130
|
|
|
129
131
|
CREATE TABLE IF NOT EXISTS comment_replies (
|
|
130
132
|
id TEXT PRIMARY KEY,
|
|
@@ -274,6 +276,10 @@ function migrate(db) {
|
|
|
274
276
|
FROM project_tag_links link JOIN user_tags user_tag ON user_tag.id = link.tag_id;
|
|
275
277
|
`);
|
|
276
278
|
}
|
|
279
|
+
/** Remove sessions that can no longer authenticate any request. */
|
|
280
|
+
export function pruneExpiredSessions(db, asOf = new Date().toISOString()) {
|
|
281
|
+
return db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(asOf).changes;
|
|
282
|
+
}
|
|
277
283
|
export function activeAdminCount(db) {
|
|
278
284
|
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled = 0").get();
|
|
279
285
|
return Number(row.count);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
2
3
|
export async function assertEnvironment(config) {
|
|
3
4
|
const commands = [
|
|
4
5
|
{ name: "latexmk", command: config.latexmk },
|
|
@@ -33,7 +34,8 @@ export async function assertGitAvailable(config) {
|
|
|
33
34
|
function commandVersion(command, timeoutMs) {
|
|
34
35
|
return new Promise((resolve) => {
|
|
35
36
|
const child = spawn(command, ["--version"], {
|
|
36
|
-
env: { ...process.env, LC_ALL: "C" }, shell: false,
|
|
37
|
+
env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
|
|
38
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
37
39
|
});
|
|
38
40
|
const stdout = [];
|
|
39
41
|
const stderr = [];
|
|
@@ -46,7 +48,7 @@ function commandVersion(command, timeoutMs) {
|
|
|
46
48
|
clearTimeout(timer);
|
|
47
49
|
resolve({ ...result, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
48
50
|
};
|
|
49
|
-
const timer = setTimeout(() => { timedOut = true; child
|
|
51
|
+
const timer = setTimeout(() => { timedOut = true; killProcessGroup(child); }, timeoutMs);
|
|
50
52
|
child.stdout.on("data", (chunk) => { if (Buffer.concat(stdout).length < 64 * 1024)
|
|
51
53
|
stdout.push(chunk); });
|
|
52
54
|
child.stderr.on("data", (chunk) => { if (Buffer.concat(stderr).length < 64 * 1024)
|
package/dist/server/files.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
export function projectRoot(config, projectId) {
|
|
4
5
|
return path.join(config.projectsDir, projectId);
|
|
5
6
|
}
|
|
@@ -27,79 +28,203 @@ Start writing here.
|
|
|
27
28
|
\\end{document}
|
|
28
29
|
`, { encoding: "utf8", mode: 0o600 });
|
|
29
30
|
}
|
|
30
|
-
export function duplicateProjectFiles(config, sourceProjectId, targetProjectId) {
|
|
31
|
+
export async function duplicateProjectFiles(config, sourceProjectId, targetProjectId) {
|
|
31
32
|
const source = sourceRoot(config, sourceProjectId);
|
|
32
33
|
const target = sourceRoot(config, targetProjectId);
|
|
33
|
-
|
|
34
|
+
assertNoSourceSymlinks(config, sourceProjectId);
|
|
35
|
+
await fs.promises.mkdir(target, { recursive: true, mode: 0o700 });
|
|
34
36
|
if (fs.existsSync(source)) {
|
|
35
|
-
|
|
37
|
+
const entries = await fs.promises.readdir(source, { withFileTypes: true });
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.isSymbolicLink())
|
|
40
|
+
throw symbolicLinkError(entry.name);
|
|
36
41
|
if (entry.name === ".git")
|
|
37
42
|
continue;
|
|
38
|
-
fs.
|
|
39
|
-
recursive: true, errorOnExist: true, force: false, verbatimSymlinks:
|
|
43
|
+
await fs.promises.cp(path.join(source, entry.name), path.join(target, entry.name), {
|
|
44
|
+
recursive: true, errorOnExist: true, force: false, verbatimSymlinks: false
|
|
40
45
|
});
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
|
-
fs.
|
|
48
|
+
await fs.promises.mkdir(outputRoot(config, targetProjectId), { recursive: true, mode: 0o700 });
|
|
44
49
|
}
|
|
45
50
|
export function safeRelativePath(input) {
|
|
46
51
|
if (!input || input.includes("\0") || path.isAbsolute(input)) {
|
|
47
|
-
throw Object.assign(new Error("无效的文件路径"), { code: "INVALID_PATH" });
|
|
52
|
+
throw Object.assign(new Error("无效的文件路径"), { statusCode: 400, code: "INVALID_PATH" });
|
|
48
53
|
}
|
|
49
54
|
const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
|
|
50
55
|
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
51
|
-
throw Object.assign(new Error("无效的文件路径"), { code: "INVALID_PATH" });
|
|
56
|
+
throw Object.assign(new Error("无效的文件路径"), { statusCode: 400, code: "INVALID_PATH" });
|
|
52
57
|
}
|
|
53
58
|
if (normalized.split("/").some((segment) => segment.toLocaleLowerCase() === ".git")) {
|
|
54
|
-
throw Object.assign(new Error(".git 是系统保留目录"), { code: "RESERVED_PATH" });
|
|
59
|
+
throw Object.assign(new Error(".git 是系统保留目录"), { statusCode: 400, code: "RESERVED_PATH" });
|
|
55
60
|
}
|
|
56
61
|
return normalized;
|
|
57
62
|
}
|
|
58
|
-
export function
|
|
59
|
-
return
|
|
63
|
+
export function symbolicLinkError(relativePath) {
|
|
64
|
+
return Object.assign(new Error(`项目源文件包含不受支持的符号链接,已拒绝访问:${relativePath || "source"}`), { statusCode: 409, code: "SYMLINK_FORBIDDEN" });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Check every existing component below the project directory without
|
|
68
|
+
* resolving it. `stat()` and most file APIs follow links, so a lexical
|
|
69
|
+
* `safeRelativePath()` check alone is not sufficient after a Git checkout.
|
|
70
|
+
*/
|
|
71
|
+
function assertSourcePathComponents(config, projectId, relativePath, allowFinalSymlink = false) {
|
|
72
|
+
let current = projectRoot(config, projectId);
|
|
73
|
+
const segments = ["source", ...relativePath.split("/").filter(Boolean)];
|
|
74
|
+
const projectStat = lstatIfPresent(current);
|
|
75
|
+
if (projectStat?.isSymbolicLink())
|
|
76
|
+
throw symbolicLinkError("source");
|
|
77
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
78
|
+
current = path.join(current, segments[index]);
|
|
79
|
+
const stat = lstatIfPresent(current);
|
|
80
|
+
if (!stat)
|
|
81
|
+
return;
|
|
82
|
+
const isFinal = index === segments.length - 1;
|
|
83
|
+
if (stat.isSymbolicLink() && !(allowFinalSymlink && isFinal && relativePath.length > 0)) {
|
|
84
|
+
const displayed = relativePath || segments.slice(0, index + 1).join("/");
|
|
85
|
+
throw symbolicLinkError(displayed);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function lstatIfPresent(target) {
|
|
90
|
+
try {
|
|
91
|
+
return fs.lstatSync(target);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error.code === "ENOENT")
|
|
95
|
+
return null;
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Reject every link below an existing directory without following it. */
|
|
100
|
+
export function assertNoSymbolicLinks(root, ignoreGitDirectory = false) {
|
|
101
|
+
const rootStat = lstatIfPresent(root);
|
|
102
|
+
if (!rootStat)
|
|
103
|
+
return;
|
|
104
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
105
|
+
throw symbolicLinkError("source");
|
|
106
|
+
const visit = (directory, prefix) => {
|
|
107
|
+
const directoryStat = lstatIfPresent(directory);
|
|
108
|
+
if (!directoryStat || directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
109
|
+
throw symbolicLinkError(prefix || "source");
|
|
110
|
+
}
|
|
111
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
112
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
113
|
+
if (entry.isSymbolicLink())
|
|
114
|
+
throw symbolicLinkError(relative);
|
|
115
|
+
if (ignoreGitDirectory && entry.name === ".git")
|
|
116
|
+
continue;
|
|
117
|
+
if (entry.isDirectory())
|
|
118
|
+
visit(path.join(directory, entry.name), relative);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
visit(root, "");
|
|
122
|
+
}
|
|
123
|
+
/** Reject every link in a checked-out project source tree. */
|
|
124
|
+
export function assertNoSourceSymlinks(config, projectId) {
|
|
125
|
+
assertSourcePathComponents(config, projectId, "");
|
|
126
|
+
assertNoSymbolicLinks(sourceRoot(config, projectId), true);
|
|
127
|
+
}
|
|
128
|
+
/** Return the output stem for a TeX file, regardless of extension casing. */
|
|
129
|
+
export function texFileStem(input) {
|
|
130
|
+
return path.basename(input).replace(/\.tex$/i, "");
|
|
131
|
+
}
|
|
132
|
+
export function resolveSourcePath(config, projectId, input, options = {}) {
|
|
133
|
+
const relativePath = safeRelativePath(input);
|
|
134
|
+
assertSourcePathComponents(config, projectId, relativePath, options.allowFinalSymlink === true);
|
|
135
|
+
return path.join(sourceRoot(config, projectId), relativePath);
|
|
60
136
|
}
|
|
61
137
|
export function listProjectFiles(config, projectId) {
|
|
62
138
|
const root = sourceRoot(config, projectId);
|
|
63
139
|
const result = [];
|
|
64
140
|
const visit = (directory, prefix) => {
|
|
141
|
+
const directoryStat = fs.lstatSync(directory);
|
|
142
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
|
|
143
|
+
throw symbolicLinkError(prefix || "source");
|
|
65
144
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
66
|
-
if (entry.name === ".git")
|
|
67
|
-
continue;
|
|
68
145
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
69
146
|
const absolute = path.join(directory, entry.name);
|
|
147
|
+
if (entry.isSymbolicLink())
|
|
148
|
+
throw symbolicLinkError(relative);
|
|
149
|
+
if (entry.name === ".git")
|
|
150
|
+
continue;
|
|
70
151
|
if (entry.isDirectory()) {
|
|
71
152
|
result.push({ path: relative, type: "directory" });
|
|
72
153
|
visit(absolute, relative);
|
|
73
154
|
}
|
|
74
155
|
else if (entry.isFile()) {
|
|
75
|
-
|
|
156
|
+
const stat = fs.lstatSync(absolute);
|
|
157
|
+
if (stat.isSymbolicLink())
|
|
158
|
+
throw symbolicLinkError(relative);
|
|
159
|
+
if (stat.isFile())
|
|
160
|
+
result.push({ path: relative, type: "file", size: stat.size });
|
|
76
161
|
}
|
|
77
162
|
}
|
|
78
163
|
};
|
|
79
|
-
|
|
164
|
+
assertSourcePathComponents(config, projectId, "");
|
|
165
|
+
if (lstatIfPresent(root))
|
|
80
166
|
visit(root, "");
|
|
81
167
|
return result;
|
|
82
168
|
}
|
|
83
169
|
export async function listProjectFilesAsync(config, projectId) {
|
|
84
170
|
const root = sourceRoot(config, projectId);
|
|
85
171
|
const result = [];
|
|
172
|
+
let activeIo = 0;
|
|
173
|
+
const waiters = [];
|
|
174
|
+
const limited = async (operation) => {
|
|
175
|
+
if (activeIo >= 4)
|
|
176
|
+
await new Promise((resolve) => waiters.push(resolve));
|
|
177
|
+
activeIo += 1;
|
|
178
|
+
try {
|
|
179
|
+
return await operation();
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
activeIo -= 1;
|
|
183
|
+
waiters.shift()?.();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
86
186
|
const visit = async (directory, prefix) => {
|
|
87
|
-
|
|
187
|
+
let entries;
|
|
188
|
+
try {
|
|
189
|
+
const directoryStat = await limited(() => fs.promises.lstat(directory));
|
|
190
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory())
|
|
191
|
+
throw symbolicLinkError(prefix || "source");
|
|
192
|
+
entries = await limited(() => fs.promises.readdir(directory, { withFileTypes: true }));
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error.code === "ENOENT")
|
|
196
|
+
return;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
const children = [];
|
|
88
200
|
for (const entry of entries) {
|
|
89
|
-
if (entry.name === ".git")
|
|
90
|
-
continue;
|
|
91
201
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
92
202
|
const absolute = path.join(directory, entry.name);
|
|
203
|
+
if (entry.isSymbolicLink())
|
|
204
|
+
throw symbolicLinkError(relative);
|
|
205
|
+
if (entry.name === ".git")
|
|
206
|
+
continue;
|
|
93
207
|
if (entry.isDirectory()) {
|
|
94
208
|
result.push({ path: relative, type: "directory" });
|
|
95
|
-
|
|
209
|
+
children.push(visit(absolute, relative));
|
|
96
210
|
}
|
|
97
211
|
else if (entry.isFile()) {
|
|
98
|
-
|
|
99
|
-
|
|
212
|
+
try {
|
|
213
|
+
const stat = await limited(() => fs.promises.lstat(absolute));
|
|
214
|
+
if (stat.isSymbolicLink())
|
|
215
|
+
throw symbolicLinkError(relative);
|
|
216
|
+
if (stat.isFile())
|
|
217
|
+
result.push({ path: relative, type: "file", size: stat.size, mtimeMs: stat.mtimeMs });
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error.code !== "ENOENT")
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
100
223
|
}
|
|
101
224
|
}
|
|
225
|
+
await Promise.all(children);
|
|
102
226
|
};
|
|
227
|
+
assertSourcePathComponents(config, projectId, "");
|
|
103
228
|
try {
|
|
104
229
|
await visit(root, "");
|
|
105
230
|
}
|
|
@@ -107,7 +232,7 @@ export async function listProjectFilesAsync(config, projectId) {
|
|
|
107
232
|
if (error.code !== "ENOENT")
|
|
108
233
|
throw error;
|
|
109
234
|
}
|
|
110
|
-
return result;
|
|
235
|
+
return result.sort((left, right) => left.path.localeCompare(right.path));
|
|
111
236
|
}
|
|
112
237
|
export function removeProjectDirectory(config, projectId) {
|
|
113
238
|
const root = projectRoot(config, projectId);
|
|
@@ -115,7 +240,29 @@ export function removeProjectDirectory(config, projectId) {
|
|
|
115
240
|
return;
|
|
116
241
|
const trash = path.join(config.dataDir, "trash");
|
|
117
242
|
fs.mkdirSync(trash, { recursive: true, mode: 0o700 });
|
|
118
|
-
const target = path.join(trash, `${projectId}-${Date.now()}`);
|
|
119
|
-
|
|
120
|
-
|
|
243
|
+
const target = path.join(trash, `${projectId}-${Date.now()}-${randomUUID()}`);
|
|
244
|
+
try {
|
|
245
|
+
fs.renameSync(root, target);
|
|
246
|
+
void fs.promises.rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
250
|
+
if (fs.existsSync(root)) {
|
|
251
|
+
throw new Error(`无法清理项目目录: ${root}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
export async function pruneTrashDirectory(config) {
|
|
256
|
+
for (const folder of ["trash", "tmp"]) {
|
|
257
|
+
const dir = path.join(config.dataDir, folder);
|
|
258
|
+
try {
|
|
259
|
+
if (!fs.existsSync(dir))
|
|
260
|
+
continue;
|
|
261
|
+
const entries = await fs.promises.readdir(dir);
|
|
262
|
+
await Promise.allSettled(entries.map((entry) => fs.promises.rm(path.join(dir, entry), { recursive: true, force: true })));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Ignore error
|
|
266
|
+
}
|
|
267
|
+
}
|
|
121
268
|
}
|