texlite 0.6.0 → 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.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
  3. package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-s54eAFX6.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DrUnIy0a.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-Y5KfVqQI.js} +20 -20
  6. package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-B97Og0La.js} +1 -1
  9. package/dist/client/assets/binary-DCBFVo4B.js +1 -0
  10. package/dist/client/assets/index-5ajAy27z.css +1 -0
  11. package/dist/client/assets/index-CCitTPyj.js +17 -0
  12. package/dist/client/assets/{index-CyEG4l54.js → index-CQG_gpuU.js} +1 -1
  13. package/dist/client/assets/{plus-Bup73Wq6.js → plus-Bp4_lUor.js} +1 -1
  14. package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-0f5ZG5IV.js} +3 -3
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/app.js +888 -339
  17. package/dist/server/archive.js +20 -3
  18. package/dist/server/auth.js +13 -2
  19. package/dist/server/collaboration.js +815 -124
  20. package/dist/server/compileArtifacts.js +31 -13
  21. package/dist/server/compiler.js +69 -20
  22. package/dist/server/db.js +6 -0
  23. package/dist/server/environment.js +4 -2
  24. package/dist/server/files.js +172 -25
  25. package/dist/server/git.js +152 -124
  26. package/dist/server/history.js +86 -18
  27. package/dist/server/http.js +26 -0
  28. package/dist/server/index.js +39 -26
  29. package/dist/server/instanceLock.js +137 -0
  30. package/dist/server/latexCompletion.js +23 -1
  31. package/dist/server/latexFormatter.js +4 -2
  32. package/dist/server/processTree.js +31 -0
  33. package/dist/server/projectMutations.js +255 -0
  34. package/dist/server/projectOutline.js +113 -1
  35. package/dist/server/projectSearch.js +6 -0
  36. package/dist/server/routes/compile.js +505 -128
  37. package/dist/server/security.js +61 -1
  38. package/dist/server/synctex.js +11 -2
  39. package/dist/server/zip.js +2 -1
  40. package/package.json +1 -1
  41. package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
  42. package/dist/client/assets/binary-DTriWQzf.js +0 -1
  43. package/dist/client/assets/index-CI87C5Wy.js +0 -17
  44. package/dist/client/assets/index-DB6GC33g.css +0 -1
@@ -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,20 +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
- if (fs.existsSync(retained))
15
- return { path: retained, version: String(fs.statSync(retained).mtimeMs) };
16
- const legacy = path.join(outputRoot(config, projectId), `${path.basename(mainFile, ".tex")}.pdf`);
17
- return fs.existsSync(legacy) ? { path: legacy, version: String(fs.statSync(legacy).mtimeMs) } : null;
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;
18
20
  }
19
21
  /** Resolve the PDF bytes belonging to one successful compile run. */
20
22
  export function compileRunPdf(config, projectId, mainFile, runId) {
21
23
  if (!/^[a-f0-9-]{36}$/i.test(runId))
22
24
  return null;
23
25
  const output = path.join(outputRoot(config, projectId), ".texlite", "runs", runId, "output");
24
- const pdf = path.join(output, `${path.basename(mainFile, ".tex")}.pdf`);
25
- if (!fs.existsSync(pdf) || !fs.statSync(pdf).isFile())
26
- return null;
27
- return { path: pdf, version: runId };
26
+ const pdf = path.join(output, `${texFileStem(mainFile)}.pdf`);
27
+ return regularFileStat(pdf) ? { path: pdf, version: runId } : null;
28
28
  }
29
29
  export function syncArtifacts(config, projectId, mainFile, defaultMainFile) {
30
30
  const isDefault = mainFile === defaultMainFile;
@@ -40,7 +40,7 @@ export function syncArtifacts(config, projectId, mainFile, defaultMainFile) {
40
40
  };
41
41
  if (fs.existsSync(retained.pdf) && fs.existsSync(retained.synctex))
42
42
  return retained;
43
- const basename = path.basename(mainFile, ".tex");
43
+ const basename = texFileStem(mainFile);
44
44
  const legacy = {
45
45
  source: sourceRoot(config, projectId),
46
46
  pdf: path.join(outputRoot(config, projectId), `${basename}.pdf`),
@@ -59,10 +59,19 @@ export function compileMainFile(config, projectId, defaultMainFile, value) {
59
59
  if (!mainFile.toLocaleLowerCase().endsWith(".tex"))
60
60
  return null;
61
61
  const absolute = resolveSourcePath(config, projectId, mainFile);
62
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
63
- return null;
64
- if (mainFile !== defaultMainFile && !hasDocumentClass(fs.readFileSync(absolute, "utf8")))
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 {
65
73
  return null;
74
+ }
66
75
  return mainFile;
67
76
  }
68
77
  export function compilePdfUrl(projectId, mainFile, version) {
@@ -94,3 +103,12 @@ function retainedPdfPath(config, projectId) {
94
103
  function retainedSynctexPath(config, projectId) {
95
104
  return path.join(outputRoot(config, projectId), ".texlite", "latest.synctex.gz");
96
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
+ }
@@ -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, sourceRoot } from "./files.js";
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
- /** Serializes each project while still respecting the server-wide LaTeX process limit. */
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
- if (state.active?.input.revision === input.revision) {
59
- if (state.pending) {
60
- this.redirect(state.pending, state.active);
61
- state.pending.input.onDiscarded("superseded");
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
- state.active.input.onSelected();
66
- return state.active.promise;
74
+ active.input.onSelected();
75
+ return active.promise;
67
76
  }
68
- if (state.pending?.input.revision === input.revision) {
77
+ const pending = state.pending;
78
+ if (pending && (pending.input.generation ?? pending.input.revision) === generation) {
69
79
  input.onDiscarded("duplicate");
70
- return state.pending.promise;
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
- hash.update(JSON.stringify(settings));
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 = path.join(sourceRoot(config, projectId), entry.path);
145
- const stat = await fs.promises.stat(liveFile);
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, `${path.basename(mainFile, ".tex")}.pdf`),
222
- path.join(output, `${path.basename(mainFile, ".tex")}.synctex.gz`)
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 (!mainFile.endsWith(".tex"))
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.kill("SIGKILL");
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 = path.basename(mainFile, ".tex");
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, stdio: ["ignore", "pipe", "pipe"]
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.kill("SIGKILL"); }, timeoutMs);
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)
@@ -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
- fs.mkdirSync(target, { recursive: true, mode: 0o700 });
34
+ assertNoSourceSymlinks(config, sourceProjectId);
35
+ await fs.promises.mkdir(target, { recursive: true, mode: 0o700 });
34
36
  if (fs.existsSync(source)) {
35
- for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
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.cpSync(path.join(source, entry.name), path.join(target, entry.name), {
39
- recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true
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.mkdirSync(outputRoot(config, targetProjectId), { recursive: true, mode: 0o700 });
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 resolveSourcePath(config, projectId, input) {
59
- return path.join(sourceRoot(config, projectId), safeRelativePath(input));
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
- result.push({ path: relative, type: "file", size: fs.statSync(absolute).size });
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
- if (fs.existsSync(root))
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
- const entries = await fs.promises.readdir(directory, { withFileTypes: true });
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
- await visit(absolute, relative);
209
+ children.push(visit(absolute, relative));
96
210
  }
97
211
  else if (entry.isFile()) {
98
- const stat = await fs.promises.stat(absolute);
99
- result.push({ path: relative, type: "file", size: stat.size, mtimeMs: stat.mtimeMs });
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
- fs.renameSync(root, target);
120
- fs.rmSync(target, { recursive: true, force: true });
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
  }