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.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-BsIXahIW.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
  3. package/dist/client/assets/{GitDialog-CFzsF7ys.js → GitDialog-s54eAFX6.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-YPiAOGU_.js → HistoryDialog-DrUnIy0a.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-BX4WLdhS.js → LatexEditor-Y5KfVqQI.js} +20 -20
  6. package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-BZanedGt.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-DnH4c7Se.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-B5iMrEn5.js → index-CQG_gpuU.js} +1 -1
  13. package/dist/client/assets/{plus-B-DG5h4y.js → plus-Bp4_lUor.js} +1 -1
  14. package/dist/client/assets/{spellCheck-4xuNPF_M.js → spellCheck-0f5ZG5IV.js} +3 -3
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/app.js +889 -337
  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 +37 -9
  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 +535 -132
  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-FQ8oxyUx.js +0 -1
  42. package/dist/client/assets/binary-MtJxbY5b.js +0 -1
  43. package/dist/client/assets/index-CUa7L47p.css +0 -1
  44. package/dist/client/assets/index-DdFlmXqA.js +0 -17
@@ -3,14 +3,14 @@ import path from "node:path";
3
3
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
4
4
  import { spawn } from "node:child_process";
5
5
  import { assertGitAvailable } from "./environment.js";
6
- import { sourceRoot } from "./files.js";
6
+ import { assertNoSourceSymlinks, sourceRoot, symbolicLinkError } from "./files.js";
7
+ import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
7
8
  const MAX_COMMAND_OUTPUT = 8 * 1024 * 1024;
8
9
  const MAX_DIFF_OUTPUT = 1024 * 1024;
9
10
  export class ProjectGitService {
10
11
  config;
11
12
  db;
12
13
  githubFetch;
13
- locks = new Map();
14
14
  gitAvailability = null;
15
15
  constructor(config, db, githubFetch = fetch) {
16
16
  this.config = config;
@@ -19,8 +19,20 @@ export class ProjectGitService {
19
19
  }
20
20
  async status(project) {
21
21
  await this.ensureGitAvailable();
22
+ assertNoSourceSymlinks(this.config, project.id);
22
23
  const settings = this.settings(project.id);
23
- const initialized = fs.existsSync(path.join(sourceRoot(this.config, project.id), ".git"));
24
+ const gitDirectory = path.join(sourceRoot(this.config, project.id), ".git");
25
+ let gitStat = null;
26
+ try {
27
+ gitStat = fs.lstatSync(gitDirectory);
28
+ }
29
+ catch (error) {
30
+ if (error.code !== "ENOENT")
31
+ throw error;
32
+ }
33
+ if (gitStat && !gitStat.isDirectory())
34
+ throw symbolicLinkError(".git");
35
+ const initialized = Boolean(gitStat);
24
36
  if (!initialized)
25
37
  return {
26
38
  initialized: false,
@@ -65,102 +77,94 @@ export class ProjectGitService {
65
77
  if (token.length < 20 || token.length > 500)
66
78
  throw httpError(400, "GitHub token 格式不正确");
67
79
  await this.ensureGitAvailable();
68
- return this.exclusive(project.id, async () => {
69
- const account = await this.githubRequest(token, "/user", { method: "GET" });
70
- if (typeof account.login !== "string" || !account.login)
71
- throw httpError(502, "GitHub 没有返回有效的用户信息");
72
- await this.ensureRepository(project);
73
- const timestamp = new Date().toISOString();
74
- this.db.prepare(`INSERT INTO project_git_settings
75
- (project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
76
- VALUES (?, ?, ?, 'main', ?, ?)
77
- ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
78
- github_login = excluded.github_login, updated_at = excluded.updated_at`)
79
- .run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
80
- return this.status(project);
81
- });
80
+ const account = await this.githubRequest(token, "/user", { method: "GET" });
81
+ if (typeof account.login !== "string" || !account.login)
82
+ throw httpError(502, "GitHub 没有返回有效的用户信息");
83
+ await this.ensureRepository(project);
84
+ const timestamp = new Date().toISOString();
85
+ this.db.prepare(`INSERT INTO project_git_settings
86
+ (project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
87
+ VALUES (?, ?, ?, 'main', ?, ?)
88
+ ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
89
+ github_login = excluded.github_login, updated_at = excluded.updated_at`)
90
+ .run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
91
+ return this.status(project);
82
92
  }
83
93
  async removeToken(project) {
84
- return this.exclusive(project.id, async () => {
85
- this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
86
- .run(new Date().toISOString(), project.id);
87
- return this.status(project);
88
- });
94
+ this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
95
+ .run(new Date().toISOString(), project.id);
96
+ return this.status(project);
89
97
  }
90
98
  async createGitHubRepository(project, name, isPrivate) {
91
99
  if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
92
100
  throw httpError(400, "GitHub 仓库名称只能包含字母、数字、点、横线或下划线");
93
101
  await this.ensureGitAvailable();
94
- return this.exclusive(project.id, async () => {
95
- const settings = this.requireTokenSettings(project.id);
96
- if (settings.remote_url)
97
- throw httpError(409, "该项目已经配置远程仓库");
98
- const token = decryptToken(this.config, settings.token_ciphertext);
99
- const repository = await this.githubRequest(token, "/user/repos", {
100
- method: "POST",
101
- body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
102
- });
103
- if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
104
- throw httpError(502, "GitHub 没有返回有效的仓库地址");
105
- }
106
- await this.ensureRepository(project);
107
- const root = sourceRoot(this.config, project.id);
108
- const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
109
- if (currentRemote.code === 0)
110
- await this.git(root, ["remote", "set-url", "origin", repository.clone_url]);
111
- else
112
- await this.git(root, ["remote", "add", "origin", repository.clone_url]);
113
- const defaultBranch = typeof repository.default_branch === "string" && repository.default_branch
114
- ? repository.default_branch : "main";
115
- this.db.prepare(`UPDATE project_git_settings SET remote_url = ?, repository_name = ?,
116
- repository_html_url = ?, default_branch = ?, updated_at = ? WHERE project_id = ?`)
117
- .run(repository.clone_url, typeof repository.name === "string" ? repository.name : name, repository.html_url, defaultBranch, new Date().toISOString(), project.id);
118
- return this.status(project);
102
+ const settings = this.requireTokenSettings(project.id);
103
+ if (settings.remote_url)
104
+ throw httpError(409, "该项目已经配置远程仓库");
105
+ const token = decryptToken(this.config, settings.token_ciphertext);
106
+ const repository = await this.githubRequest(token, "/user/repos", {
107
+ method: "POST",
108
+ body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
119
109
  });
110
+ if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
111
+ throw httpError(502, "GitHub 没有返回有效的仓库地址");
112
+ }
113
+ await this.ensureRepository(project);
114
+ const root = sourceRoot(this.config, project.id);
115
+ const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
116
+ if (currentRemote.code === 0)
117
+ await this.git(root, ["remote", "set-url", "origin", repository.clone_url]);
118
+ else
119
+ await this.git(root, ["remote", "add", "origin", repository.clone_url]);
120
+ const defaultBranch = typeof repository.default_branch === "string" && repository.default_branch
121
+ ? repository.default_branch : "main";
122
+ this.db.prepare(`UPDATE project_git_settings SET remote_url = ?, repository_name = ?,
123
+ repository_html_url = ?, default_branch = ?, updated_at = ? WHERE project_id = ?`)
124
+ .run(repository.clone_url, typeof repository.name === "string" ? repository.name : name, repository.html_url, defaultBranch, new Date().toISOString(), project.id);
125
+ return this.status(project);
120
126
  }
121
127
  async commit(project, owner, messageInput) {
122
128
  const message = messageInput.trim();
123
129
  if (!message || message.length > 300)
124
130
  throw httpError(400, "提交说明不能为空且不能超过 300 个字符");
125
- return this.exclusive(project.id, async () => {
126
- const root = this.requireRepository(project.id);
127
- const branch = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
128
- if (branch.code !== 0)
129
- throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再提交");
130
- await this.git(root, ["add", "--all"]);
131
- const staged = await this.git(root, ["diff", "--cached", "--quiet"], [1]);
132
- if (staged.code === 0)
133
- throw httpError(409, "当前没有可以提交的修改");
134
- await this.git(root, [
135
- "-c", `user.name=${owner.username}`,
136
- "-c", `user.email=${owner.username}@texlite.com`,
137
- "-c", "commit.gpgSign=false",
138
- "-c", "core.hooksPath=/dev/null",
139
- "commit", "-m", message
140
- ]);
141
- const result = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"]);
142
- return parseCommit(result.stdout.trim());
143
- });
131
+ const root = this.requireRepository(project.id);
132
+ assertNoSourceSymlinks(this.config, project.id);
133
+ const branch = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
134
+ if (branch.code !== 0)
135
+ throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再提交");
136
+ await this.git(root, ["add", "--all"]);
137
+ const staged = await this.git(root, ["diff", "--cached", "--quiet"], [1]);
138
+ if (staged.code === 0)
139
+ throw httpError(409, "当前没有可以提交的修改");
140
+ await this.git(root, [
141
+ "-c", `user.name=${owner.username}`,
142
+ "-c", `user.email=${owner.username}@texlite.com`,
143
+ "-c", "commit.gpgSign=false",
144
+ "-c", "core.hooksPath=/dev/null",
145
+ "commit", "-m", message
146
+ ]);
147
+ const result = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"]);
148
+ return parseCommit(result.stdout.trim());
144
149
  }
145
150
  async push(project) {
146
- return this.exclusive(project.id, async () => {
147
- const settings = this.requireTokenSettings(project.id);
148
- if (!settings.remote_url)
149
- throw httpError(409, "请先创建 GitHub 仓库");
150
- const root = this.requireRepository(project.id);
151
- const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
152
- if (head.code !== 0)
153
- throw httpError(409, "请先提交项目修改");
154
- const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
155
- if (branchResult.code !== 0)
156
- throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再推送");
157
- const branch = branchResult.stdout.trim();
158
- if (!/^[A-Za-z0-9._/-]+$/.test(branch))
159
- throw httpError(400, "本地分支名称无效");
160
- const token = decryptToken(this.config, settings.token_ciphertext);
161
- await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
162
- return this.status(project);
163
- });
151
+ const settings = this.requireTokenSettings(project.id);
152
+ if (!settings.remote_url)
153
+ throw httpError(409, "请先创建 GitHub 仓库");
154
+ const root = this.requireRepository(project.id);
155
+ assertNoSourceSymlinks(this.config, project.id);
156
+ const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
157
+ if (head.code !== 0)
158
+ throw httpError(409, "请先提交项目修改");
159
+ const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
160
+ if (branchResult.code !== 0)
161
+ throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再推送");
162
+ const branch = branchResult.stdout.trim();
163
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch))
164
+ throw httpError(400, "本地分支名称无效");
165
+ const token = decryptToken(this.config, settings.token_ciphertext);
166
+ await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
167
+ return this.status(project);
164
168
  }
165
169
  async history(project) {
166
170
  const root = this.requireRepository(project.id);
@@ -200,40 +204,65 @@ export class ProjectGitService {
200
204
  };
201
205
  }
202
206
  async checkout(project, revisionInput, force) {
203
- return this.exclusive(project.id, async () => {
204
- const root = this.requireRepository(project.id);
205
- const settings = this.settings(project.id);
206
- const revision = revisionInput === null ? settings?.default_branch ?? "main" : validateRevision(revisionInput);
207
- const target = revisionInput === null ? revision : await this.verifyCommit(root, revision);
208
- if (force) {
209
- await this.git(root, ["reset", "--hard", "HEAD"]);
210
- await this.git(root, ["clean", "-fdx"]);
211
- }
212
- await this.git(root, ["-c", "core.hooksPath=/dev/null", "checkout", ...(revisionInput === null ? [] : ["--detach"]), ...(force ? ["--force"] : []), target]);
213
- return target;
214
- });
207
+ const root = this.requireRepository(project.id);
208
+ const settings = this.settings(project.id);
209
+ const revision = revisionInput === null ? settings?.default_branch ?? "main" : validateRevision(revisionInput);
210
+ const target = revisionInput === null ? revision : await this.verifyCommit(root, revision);
211
+ await this.assertRevisionHasNoSymlinks(root, target);
212
+ if (force) {
213
+ await this.git(root, ["reset", "--hard", "HEAD"]);
214
+ await this.git(root, ["clean", "-fdx"]);
215
+ }
216
+ await this.git(root, ["-c", "core.hooksPath=/dev/null", "checkout", ...(revisionInput === null ? [] : ["--detach"]), ...(force ? ["--force"] : []), target]);
217
+ assertNoSourceSymlinks(this.config, project.id);
218
+ return target;
215
219
  }
216
220
  async discardChanges(project) {
217
- return this.exclusive(project.id, async () => {
218
- const root = this.requireRepository(project.id);
219
- const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
220
- if (head.code !== 0)
221
- throw httpError(409, "该项目尚无可恢复的提交");
222
- await this.git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."]);
223
- });
221
+ const root = this.requireRepository(project.id);
222
+ const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
223
+ if (head.code !== 0)
224
+ throw httpError(409, "该项目尚无可恢复的提交");
225
+ await this.assertRevisionHasNoSymlinks(root, head.stdout.trim());
226
+ await this.git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."]);
227
+ assertNoSourceSymlinks(this.config, project.id);
224
228
  }
225
229
  async ensureRepository(project) {
226
230
  const root = sourceRoot(this.config, project.id);
231
+ assertNoSourceSymlinks(this.config, project.id);
227
232
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
228
- if (fs.existsSync(path.join(root, ".git")))
233
+ const gitDirectory = path.join(root, ".git");
234
+ let gitStat = null;
235
+ try {
236
+ gitStat = fs.lstatSync(gitDirectory);
237
+ }
238
+ catch (error) {
239
+ if (error.code !== "ENOENT")
240
+ throw error;
241
+ }
242
+ if (gitStat) {
243
+ if (gitStat.isSymbolicLink() || !gitStat.isDirectory())
244
+ throw symbolicLinkError(".git");
229
245
  return;
246
+ }
230
247
  await this.git(root, ["init"]);
231
248
  await this.git(root, ["symbolic-ref", "HEAD", "refs/heads/main"]);
232
249
  }
233
250
  requireRepository(projectId) {
234
251
  const root = sourceRoot(this.config, projectId);
235
- if (!fs.existsSync(path.join(root, ".git")))
252
+ assertNoSourceSymlinks(this.config, projectId);
253
+ const gitDirectory = path.join(root, ".git");
254
+ let gitStat = null;
255
+ try {
256
+ gitStat = fs.lstatSync(gitDirectory);
257
+ }
258
+ catch (error) {
259
+ if (error.code !== "ENOENT")
260
+ throw error;
261
+ }
262
+ if (!gitStat)
236
263
  throw httpError(409, "该项目尚未初始化 Git");
264
+ if (gitStat.isSymbolicLink() || !gitStat.isDirectory())
265
+ throw symbolicLinkError(".git");
237
266
  return root;
238
267
  }
239
268
  settings(projectId) {
@@ -251,6 +280,12 @@ export class ProjectGitService {
251
280
  throw httpError(404, "Git 版本不存在");
252
281
  return result.stdout.trim();
253
282
  }
283
+ async assertRevisionHasNoSymlinks(root, revision) {
284
+ const tree = await this.git(root, ["ls-tree", "-r", "-z", "--full-tree", revision]);
285
+ if (tree.stdout.split("\0").some((entry) => entry.startsWith("120000 "))) {
286
+ throw symbolicLinkError(`Git revision ${revision}`);
287
+ }
288
+ }
254
289
  async githubRequest(token, route, init) {
255
290
  const controller = new AbortController();
256
291
  const timer = setTimeout(() => controller.abort(), this.config.gitOperationTimeoutMs);
@@ -309,16 +344,25 @@ export class ProjectGitService {
309
344
  async git(cwd, args, allowedCodes = [], env = process.env) {
310
345
  await this.ensureGitAvailable();
311
346
  return new Promise((resolve, reject) => {
312
- const child = spawn(this.config.git, args, { cwd, env, shell: false, stdio: ["ignore", "pipe", "pipe"] });
347
+ const child = spawn(this.config.git, args, {
348
+ cwd, env, shell: false, detached: detachedProcessGroup(), stdio: ["ignore", "pipe", "pipe"]
349
+ });
313
350
  const stdout = [];
314
351
  const stderr = [];
315
352
  let size = 0;
316
353
  let timedOut = false;
317
- const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, this.config.gitOperationTimeoutMs);
354
+ let terminated = false;
355
+ const terminate = () => {
356
+ if (terminated)
357
+ return;
358
+ terminated = true;
359
+ killProcessGroup(child);
360
+ };
361
+ const timer = setTimeout(() => { timedOut = true; terminate(); }, this.config.gitOperationTimeoutMs);
318
362
  const collect = (target) => (chunk) => {
319
363
  size += chunk.length;
320
364
  if (size > MAX_COMMAND_OUTPUT)
321
- child.kill("SIGKILL");
365
+ terminate();
322
366
  else
323
367
  target.push(chunk);
324
368
  };
@@ -340,22 +384,6 @@ export class ProjectGitService {
340
384
  });
341
385
  });
342
386
  }
343
- async exclusive(projectId, operation) {
344
- const previous = this.locks.get(projectId) ?? Promise.resolve();
345
- let release = () => undefined;
346
- const gate = new Promise((resolve) => { release = resolve; });
347
- const tail = previous.catch(() => undefined).then(() => gate);
348
- this.locks.set(projectId, tail);
349
- await previous.catch(() => undefined);
350
- try {
351
- return await operation();
352
- }
353
- finally {
354
- release();
355
- if (this.locks.get(projectId) === tail)
356
- this.locks.delete(projectId);
357
- }
358
- }
359
387
  }
360
388
  function parseCommit(line) {
361
389
  const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
- import { listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
4
+ import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
5
5
  const AUTOSAVE_COALESCE_MS = 2 * 60_000;
6
6
  export class ProjectHistoryService {
7
7
  config;
@@ -240,23 +240,90 @@ export class ProjectHistoryService {
240
240
  return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
241
241
  }
242
242
  pruneVersions(projectId) {
243
- const obsolete = this.db.prepare(`SELECT id, manifest_json FROM project_history_versions
244
- WHERE project_id = ? AND label IS NULL AND reason <> 'initial'
245
- ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?`)
246
- .all(projectId, this.config.historyMaxVersions);
247
- this.deleteVersions(projectId, obsolete);
248
- // The latest version is always retained as the baseline for subsequent
249
- // incremental snapshots. Initial and labeled versions are also protected,
250
- // so explicitly preserved work can make the project exceed the soft cap.
251
- while (this.stats(projectId).objectBytes > this.config.historyMaxStorageBytes) {
252
- const candidate = this.db.prepare(`SELECT id, manifest_json FROM project_history_versions
253
- WHERE project_id = ? AND label IS NULL AND reason <> 'initial'
254
- AND id <> (SELECT id FROM project_history_versions WHERE project_id = ?
255
- ORDER BY created_at DESC, rowid DESC LIMIT 1)
256
- ORDER BY created_at ASC, rowid ASC LIMIT 1`).get(projectId, projectId);
257
- if (!candidate)
258
- break;
259
- this.deleteVersions(projectId, [candidate]);
243
+ const rows = this.db.prepare(`SELECT id, reason, label, manifest_json, created_at
244
+ FROM project_history_versions WHERE project_id = ?
245
+ ORDER BY created_at DESC, rowid DESC`).all(projectId);
246
+ if (!rows.length)
247
+ return;
248
+ // Parse manifests and build reference counts & object size tracking in a single pass
249
+ const manifests = new Map();
250
+ const refCounts = new Map();
251
+ const sizeMap = new Map();
252
+ for (const row of rows) {
253
+ const manifest = parseManifest(row.manifest_json);
254
+ manifests.set(row.id, manifest);
255
+ for (const file of Object.values(manifest.files)) {
256
+ refCounts.set(file.digest, (refCounts.get(file.digest) ?? 0) + 1);
257
+ if (!sizeMap.has(file.digest))
258
+ sizeMap.set(file.digest, file.size);
259
+ }
260
+ }
261
+ const baseline = this.baseline(projectId);
262
+ if (baseline) {
263
+ for (const file of Object.values(baseline.files)) {
264
+ refCounts.set(file.digest, (refCounts.get(file.digest) ?? 0) + 1);
265
+ if (!sizeMap.has(file.digest))
266
+ sizeMap.set(file.digest, file.size);
267
+ }
268
+ }
269
+ let currentObjectBytes = 0;
270
+ for (const [digest, count] of refCounts.entries()) {
271
+ if (count > 0)
272
+ currentObjectBytes += sizeMap.get(digest) ?? 0;
273
+ }
274
+ const latestId = rows[0]?.id;
275
+ // Ordinary versions eligible for version count capping (ordered newest to oldest)
276
+ const ordinaryDesc = rows.filter((row) => row.label === null && row.reason !== "initial");
277
+ const toDeleteIds = new Set();
278
+ const removeVersionRefs = (versionId) => {
279
+ if (toDeleteIds.has(versionId))
280
+ return;
281
+ toDeleteIds.add(versionId);
282
+ const manifest = manifests.get(versionId);
283
+ if (!manifest)
284
+ return;
285
+ for (const file of Object.values(manifest.files)) {
286
+ const count = refCounts.get(file.digest);
287
+ if (count !== undefined) {
288
+ if (count === 1) {
289
+ refCounts.set(file.digest, 0);
290
+ currentObjectBytes -= sizeMap.get(file.digest) ?? 0;
291
+ }
292
+ else if (count > 1) {
293
+ refCounts.set(file.digest, count - 1);
294
+ }
295
+ }
296
+ }
297
+ };
298
+ // 1. Cap by historyMaxVersions (prune excess ordinary versions from newest to oldest offset)
299
+ if (ordinaryDesc.length > this.config.historyMaxVersions) {
300
+ const obsolete = ordinaryDesc.slice(this.config.historyMaxVersions);
301
+ for (const row of obsolete)
302
+ removeVersionRefs(row.id);
303
+ }
304
+ // 2. Cap by historyMaxStorageBytes (prune remaining eligible versions from oldest to newest)
305
+ if (currentObjectBytes > this.config.historyMaxStorageBytes) {
306
+ const remainingEligibleAsc = ordinaryDesc
307
+ .filter((row) => row.id !== latestId && !toDeleteIds.has(row.id))
308
+ .reverse();
309
+ for (const row of remainingEligibleAsc) {
310
+ if (currentObjectBytes <= this.config.historyMaxStorageBytes)
311
+ break;
312
+ removeVersionRefs(row.id);
313
+ }
314
+ }
315
+ if (!toDeleteIds.size)
316
+ return;
317
+ // 3. Batch delete rows in a single transaction
318
+ const remove = this.db.prepare("DELETE FROM project_history_versions WHERE id = ? AND project_id = ?");
319
+ this.db.transaction(() => {
320
+ for (const id of toDeleteIds)
321
+ remove.run(id, projectId);
322
+ })();
323
+ // 4. Remove unreferenced CAS objects on disk
324
+ for (const [digest, count] of refCounts.entries()) {
325
+ if (count === 0)
326
+ fs.rmSync(this.objectPath(projectId, digest), { force: true });
260
327
  }
261
328
  }
262
329
  deleteVersions(projectId, rows) {
@@ -292,6 +359,7 @@ export class ProjectHistoryService {
292
359
  fs.rmSync(this.objectPath(projectId, digest), { force: true });
293
360
  }
294
361
  restoreProjectTree(projectId, manifest) {
362
+ assertNoSourceSymlinks(this.config, projectId);
295
363
  const root = projectRoot(this.config, projectId);
296
364
  const live = sourceRoot(this.config, projectId);
297
365
  const temporary = path.join(root, `.history-restore-${randomUUID()}`);
@@ -1,3 +1,29 @@
1
+ export class ValidationError extends Error {
2
+ statusCode = 400;
3
+ code = "REQUEST_INVALID";
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "ValidationError";
7
+ }
8
+ }
9
+ /**
10
+ * A collaborative draft could not be made durable on disk. Mutations that
11
+ * replace or remove source files must stop at this boundary instead of
12
+ * continuing with a stale filesystem snapshot.
13
+ */
14
+ export class SourceFlushError extends Error {
15
+ statusCode = 409;
16
+ code = "SOURCE_FLUSH_FAILED";
17
+ failedPaths;
18
+ constructor(failedPaths = []) {
19
+ const paths = [...new Set(failedPaths)].slice(0, 100);
20
+ super(paths.length
21
+ ? `部分源码未能保存至磁盘,无法继续项目操作:${paths.join(", ")}`
22
+ : "部分源码未能保存至磁盘,无法继续项目操作");
23
+ this.name = "SourceFlushError";
24
+ this.failedPaths = paths;
25
+ }
26
+ }
1
27
  export function apiError(reply, status, code, message, details = {}) {
2
28
  return reply.code(status).send({ code, error: message, ...details });
3
29
  }
@@ -4,44 +4,57 @@ import { loadConfig } from "./config.js";
4
4
  import { activeAdminCount, openDatabase } from "./db.js";
5
5
  import { buildApp } from "./app.js";
6
6
  import { assertEnvironment } from "./environment.js";
7
+ import { acquireDataDirectoryLock } from "./instanceLock.js";
7
8
  export async function startServer(configPath) {
8
9
  const config = loadConfig(configPath);
9
- const environment = await assertEnvironment(config);
10
- const db = openDatabase(config);
11
- if (activeAdminCount(db) === 0) {
12
- db.close();
13
- throw new Error("No active administrator found. Run `texlite init` first (or `npm run init` from a source checkout); the server will not start.");
14
- }
15
- let app;
10
+ const lock = acquireDataDirectoryLock(config);
11
+ let db = null;
12
+ let app = null;
16
13
  try {
14
+ const environment = await assertEnvironment(config);
15
+ db = openDatabase(config);
16
+ if (activeAdminCount(db) === 0) {
17
+ throw new Error("No active administrator found. Run `texlite init` first (or `npm run init` from a source checkout); the server will not start.");
18
+ }
17
19
  app = await buildApp(config, db);
18
- }
19
- catch (error) {
20
- db.close();
21
- throw error;
22
- }
23
- try {
24
20
  await app.listen({ host: config.host, port: config.port });
21
+ app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
22
+ app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
23
+ if (typeof process.send === "function")
24
+ process.send("ready");
25
+ return {
26
+ address: `http://${config.host}:${config.port}`,
27
+ close: async () => {
28
+ try {
29
+ if (app)
30
+ await app.close();
31
+ }
32
+ finally {
33
+ try {
34
+ db?.close();
35
+ }
36
+ finally {
37
+ lock.release();
38
+ }
39
+ }
40
+ }
41
+ };
25
42
  }
26
43
  catch (error) {
27
- db.close();
28
- throw error;
29
- }
30
- app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
31
- app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
32
- if (typeof process.send === "function")
33
- process.send("ready");
34
- return {
35
- address: `http://${config.host}:${config.port}`,
36
- close: async () => {
37
- try {
44
+ try {
45
+ if (app)
38
46
  await app.close();
47
+ }
48
+ finally {
49
+ try {
50
+ db?.close();
39
51
  }
40
52
  finally {
41
- db.close();
53
+ lock.release();
42
54
  }
43
55
  }
44
- };
56
+ throw error;
57
+ }
45
58
  }
46
59
  export async function serve(configPath) {
47
60
  const running = await startServer(configPath);