texlite 0.6.0 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-FnkCpIDV.js} +1 -1
  3. package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-BtWtaeZV.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DOzAQAQA.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-C5S1jijC.js} +20 -20
  6. package/dist/client/assets/PdfPreview-B3-kvd02.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-DeEMFRsp.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-DWh6yS0w.js} +1 -1
  9. package/dist/client/assets/binary-D_GeVl1T.js +1 -0
  10. package/dist/client/assets/index-C4KkkecE.js +17 -0
  11. package/dist/client/assets/index-DwzHKaKD.css +1 -0
  12. package/dist/client/assets/{index-CyEG4l54.js → index-nywS6bYC.js} +1 -1
  13. package/dist/client/assets/{plus-Bup73Wq6.js → plus-B_Bl12ZY.js} +1 -1
  14. package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-BynsoX26.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,45 +1,55 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { Transform } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
4
6
  import { monitorEventLoopDelay, performance } from "node:perf_hooks";
5
7
  import Fastify from "fastify";
6
8
  import cookie from "@fastify/cookie";
7
9
  import multipart from "@fastify/multipart";
8
10
  import staticPlugin from "@fastify/static";
9
11
  import websocket from "@fastify/websocket";
10
- import { activeAdminCount } from "./db.js";
11
- import { createSessionToken, hashPassword, verifyPassword, digestToken } from "./security.js";
12
+ import { activeAdminCount, pruneExpiredSessions } from "./db.js";
13
+ import { createSessionToken, hashPassword, verifyPassword, digestToken, LoginRateLimiter } from "./security.js";
12
14
  import { currentUser, publicUser, requireAdmin, requireUser } from "./auth.js";
13
15
  import { accessibleProject, canEdit } from "./projects.js";
14
- import { createProjectFiles, duplicateProjectFiles, listProjectFiles, listProjectFilesAsync, outputRoot, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
15
- import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns } from "./compiler.js";
16
+ import { createProjectFiles, duplicateProjectFiles, assertNoSourceSymlinks, listProjectFiles, listProjectFilesAsync, outputRoot, pruneTrashDirectory, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
17
+ import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns, reconcilePublishedCompileRuns } from "./compiler.js";
16
18
  import { createSourceAnchor, offsetToLine, reanchorFileComments } from "./anchors.js";
17
19
  import { extractProjectZip } from "./zip.js";
18
- import { createProjectArchive } from "./archive.js";
19
- import { CollaborationService } from "./collaboration.js";
20
+ import { writeProjectArchive } from "./archive.js";
21
+ import { CollaborationService, isCollaborativeTextFile, maxCollaborativeFileBytes } from "./collaboration.js";
22
+ import { ProjectMutationCoordinator } from "./projectMutations.js";
20
23
  import { ProjectGitService } from "./git.js";
21
24
  import { LatexCompletionService } from "./latexCompletion.js";
22
25
  import { ProjectHistoryService } from "./history.js";
23
26
  import { LatexFormatterService } from "./latexFormatter.js";
24
- import { buildProjectOutline } from "./projectOutline.js";
27
+ import { ProjectOutlineService } from "./projectOutline.js";
25
28
  import { replaceProject, searchProject } from "./projectSearch.js";
26
29
  import { MetricRegistry } from "./metrics.js";
27
30
  import { compileMainFile } from "./compileArtifacts.js";
28
- import { apiError, contentDisposition } from "./http.js";
31
+ import { apiError, contentDisposition, ValidationError } from "./http.js";
29
32
  import { registerCompileRoutes } from "./routes/compile.js";
30
33
  const now = () => new Date().toISOString();
34
+ const SESSION_CLEANUP_INTERVAL_MS = 15 * 60_000;
35
+ function escapeLikePattern(value) {
36
+ return value.replace(/[\\%_]/g, "\\$&");
37
+ }
38
+ export function escapeGlobPattern(value) {
39
+ return value.replace(/[*?[]/g, "[$&]");
40
+ }
31
41
  function text(value, name, max = 200) {
32
42
  if (typeof value !== "string" || !value.trim() || value.length > max) {
33
- throw new Error(`${name}格式不正确`);
43
+ throw new ValidationError(`${name}格式不正确`);
34
44
  }
35
45
  return value.trim();
36
46
  }
37
47
  function dictionaryWord(value) {
38
48
  if (typeof value !== "string")
39
- throw new Error("自定义词格式不正确");
49
+ throw new ValidationError("自定义词格式不正确");
40
50
  const word = value.trim();
41
51
  if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
42
- throw new Error("自定义词格式不正确");
52
+ throw new ValidationError("自定义词格式不正确");
43
53
  return word;
44
54
  }
45
55
  const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
@@ -63,7 +73,44 @@ function tagsForProjects(db, projectIds, userId) {
63
73
  }
64
74
  return result;
65
75
  }
66
- function projectJson(project, tags = []) {
76
+ function commentsSummaryForProjects(db, projectIds) {
77
+ const result = new Map();
78
+ if (!projectIds.length)
79
+ return result;
80
+ for (let index = 0; index < projectIds.length; index += 100) {
81
+ const chunk = projectIds.slice(index, index + 100);
82
+ const placeholders = chunk.map(() => "?").join(", ");
83
+ const rows = db.prepare(`
84
+ SELECT project_id,
85
+ COUNT(*) AS total_count,
86
+ SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
87
+ FROM comments
88
+ WHERE project_id IN (${placeholders})
89
+ GROUP BY project_id
90
+ `).all(...chunk);
91
+ for (const row of rows) {
92
+ result.set(row.project_id, {
93
+ totalCount: Number(row.total_count) || 0,
94
+ unresolvedCount: Number(row.unresolved_count) || 0
95
+ });
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+ function commentsSummaryForProject(db, projectId) {
101
+ const row = db.prepare(`
102
+ SELECT
103
+ COUNT(*) AS total_count,
104
+ SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
105
+ FROM comments
106
+ WHERE project_id = ?
107
+ `).get(projectId);
108
+ return {
109
+ totalCount: Number(row?.total_count) || 0,
110
+ unresolvedCount: Number(row?.unresolved_count) || 0
111
+ };
112
+ }
113
+ function projectJson(project, tags = [], commentsSummary) {
67
114
  return {
68
115
  id: project.id,
69
116
  ownerId: project.owner_id,
@@ -78,6 +125,8 @@ function projectJson(project, tags = []) {
78
125
  engine: project.engine,
79
126
  permission: project.permission,
80
127
  tags,
128
+ unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
129
+ commentCount: commentsSummary?.totalCount ?? 0,
81
130
  archived: Boolean(project.archived),
82
131
  createdAt: project.created_at,
83
132
  updatedAt: project.updated_at
@@ -87,11 +136,53 @@ function touchProject(db, projectId, userId) {
87
136
  db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
88
137
  .run(now(), userId, projectId);
89
138
  }
139
+ function requireActiveUser(db, user) {
140
+ const current = db.prepare("SELECT disabled FROM users WHERE id = ?").get(user.id);
141
+ if (!current || current.disabled) {
142
+ throw Object.assign(new Error("用户已被禁用或删除"), { statusCode: 401, code: "AUTH_REQUIRED" });
143
+ }
144
+ }
145
+ /**
146
+ * Authorization must be checked again after a queued mutation acquires its
147
+ * project lock. A member can be revoked, or ownership can be transferred,
148
+ * while the request is waiting behind another filesystem operation.
149
+ */
150
+ function requireEditableProject(db, projectId, user) {
151
+ requireActiveUser(db, user);
152
+ const project = accessibleProject(db, projectId, user);
153
+ if (!project)
154
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
155
+ if (!canEdit(project))
156
+ throw Object.assign(new Error("没有编辑权限"), { statusCode: 403, code: "PROJECT_EDIT_FORBIDDEN" });
157
+ return project;
158
+ }
159
+ /** Owner permission includes an administrator's effective owner access. */
160
+ function requireProjectOwnerPermission(db, projectId, user) {
161
+ requireActiveUser(db, user);
162
+ const project = accessibleProject(db, projectId, user);
163
+ if (!project)
164
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
165
+ if (project.permission !== "owner") {
166
+ throw Object.assign(new Error("只有项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
167
+ }
168
+ return project;
169
+ }
170
+ /** Operations such as ownership transfer require the actual stored owner. */
171
+ function requireActualProjectOwner(db, projectId, user) {
172
+ requireActiveUser(db, user);
173
+ const project = accessibleProject(db, projectId, user);
174
+ if (!project)
175
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
176
+ if (project.owner_id !== user.id) {
177
+ throw Object.assign(new Error("只有当前项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
178
+ }
179
+ return project;
180
+ }
90
181
  function projectTextSnapshot(config, projectId) {
91
182
  const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
92
183
  return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
93
184
  const absolute = resolveSourcePath(config, projectId, entry.path);
94
- return [entry.path, fs.statSync(absolute).size <= 5 * 1024 * 1024 ? fs.readFileSync(absolute, "utf8") : ""];
185
+ return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
95
186
  }));
96
187
  }
97
188
  function reanchorProjectSnapshot(db, projectId, before, after) {
@@ -129,6 +220,30 @@ function commentsForFile(db, config, projectId, filePath) {
129
220
  const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
130
221
  LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
131
222
  .all(projectId, filePath);
223
+ if (!rows.length)
224
+ return [];
225
+ const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
226
+ FROM comment_replies reply
227
+ JOIN comments c ON c.id = reply.comment_id
228
+ LEFT JOIN users user ON user.id = reply.author_id
229
+ WHERE c.project_id = ? AND c.file_path = ?
230
+ ORDER BY reply.created_at`)
231
+ .all(projectId, filePath);
232
+ const replyMap = new Map();
233
+ for (const reply of replies) {
234
+ const list = replyMap.get(reply.comment_id) ?? [];
235
+ list.push({
236
+ id: reply.id,
237
+ authorId: reply.author_id,
238
+ authorUsername: reply.author_username,
239
+ authorDisplayName: reply.author_display_name,
240
+ content: reply.content,
241
+ createdAt: reply.created_at,
242
+ updatedAt: reply.updated_at,
243
+ editedAt: reply.edited_at
244
+ });
245
+ replyMap.set(reply.comment_id, list);
246
+ }
132
247
  return rows.map((comment) => ({
133
248
  id: comment.id,
134
249
  authorId: comment.author_id,
@@ -145,7 +260,7 @@ function commentsForFile(db, config, projectId, filePath) {
145
260
  createdAt: comment.created_at,
146
261
  updatedAt: comment.updated_at,
147
262
  editedAt: comment.edited_at,
148
- replies: repliesForComment(db, comment.id)
263
+ replies: replyMap.get(comment.id) ?? []
149
264
  }));
150
265
  }
151
266
  export async function buildApp(config, db, options = {}) {
@@ -160,6 +275,7 @@ export async function buildApp(config, db, options = {}) {
160
275
  eventLoopDelay.enable();
161
276
  const history = new ProjectHistoryService(config, db);
162
277
  const latexCompletions = new LatexCompletionService(config);
278
+ const projectOutlines = new ProjectOutlineService(config);
163
279
  const latexFormatter = new LatexFormatterService();
164
280
  const recordHistory = (projectId, userId, reason, paths) => {
165
281
  try {
@@ -174,7 +290,12 @@ export async function buildApp(config, db, options = {}) {
174
290
  metrics.record("collaboration.persist", durationMs);
175
291
  recordHistory(projectId, userId, "autosave", paths);
176
292
  });
293
+ const projectMutations = new ProjectMutationCoordinator(collaboration);
177
294
  const projectGit = new ProjectGitService(config, db, options.githubFetch);
295
+ const loginLimiter = new LoginRateLimiter();
296
+ for (const row of db.prepare("SELECT id FROM projects").all()) {
297
+ reconcilePublishedCompileRuns(config, db, row.id);
298
+ }
178
299
  db.prepare(`UPDATE compile_runs SET status = 'failed',
179
300
  log = CASE WHEN log = '' THEN 'Server restarted before compilation finished.' ELSE log END,
180
301
  finished_at = ? WHERE status IN ('queued', 'running')`).run(now());
@@ -191,10 +312,16 @@ export async function buildApp(config, db, options = {}) {
191
312
  }
192
313
  }
193
314
  const remove = db.prepare("DELETE FROM compile_runs WHERE id = ?");
194
- for (const run of completed)
195
- if (!keep.has(run.id))
196
- remove.run(run.id);
315
+ db.transaction(() => {
316
+ for (const run of completed)
317
+ if (!keep.has(run.id))
318
+ remove.run(run.id);
319
+ })();
197
320
  };
321
+ // No second TexLite instance can mutate the data directory while the
322
+ // instance lock is held. Finish cleanup before accepting requests so a
323
+ // freshly started server never races a stale trash/tmp removal.
324
+ await pruneTrashDirectory(config);
198
325
  for (const row of db.prepare("SELECT id FROM projects").all()) {
199
326
  history.enforceRetention(row.id);
200
327
  pruneCompileRuns(row.id);
@@ -225,18 +352,39 @@ export async function buildApp(config, db, options = {}) {
225
352
  });
226
353
  app.setErrorHandler((error, _request, reply) => {
227
354
  app.log.error(error);
228
- const status = typeof error === "object" && error !== null && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : 400;
229
- const message = error instanceof Error ? error.message : "请求格式不正确";
230
- const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
231
- ? error.code : status >= 500 ? "SERVER_ERROR" : "REQUEST_INVALID";
232
- void reply.code(status >= 500 ? 500 : status).send({
233
- code, error: status >= 500 ? "服务器内部错误" : message
234
- });
355
+ const rawStatus = typeof error === "object" && error !== null && "statusCode" in error && typeof error.statusCode === "number"
356
+ ? error.statusCode
357
+ : undefined;
358
+ const isClientError = rawStatus !== undefined && rawStatus >= 400 && rawStatus < 500;
359
+ const status = isClientError ? rawStatus : 500;
360
+ if (isClientError) {
361
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
362
+ ? error.code
363
+ : "REQUEST_INVALID";
364
+ const message = error instanceof Error ? error.message : "请求格式不正确";
365
+ const failedPaths = typeof error === "object" && error !== null && "failedPaths" in error
366
+ && Array.isArray(error.failedPaths)
367
+ && error.failedPaths.every((path) => typeof path === "string")
368
+ ? error.failedPaths.slice(0, 100)
369
+ : undefined;
370
+ void reply.code(status).send({
371
+ code,
372
+ error: message,
373
+ ...(failedPaths ? { failedPaths } : {})
374
+ });
375
+ }
376
+ else {
377
+ void reply.code(500).send({
378
+ code: "SERVER_ERROR",
379
+ error: "服务器内部错误"
380
+ });
381
+ }
235
382
  });
236
383
  app.get("/api/config", async () => ({
237
384
  siteName: config.siteName,
238
385
  adminEmail: config.adminEmail,
239
386
  maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
387
+ maxCollaborativeFileSizeMB: Math.floor(maxCollaborativeFileBytes(config) / 1024 / 1024),
240
388
  allowedEngines: config.allowedEngines,
241
389
  allowProjectLatexmkrc: config.allowProjectLatexmkrc
242
390
  }));
@@ -255,6 +403,7 @@ export async function buildApp(config, db, options = {}) {
255
403
  },
256
404
  compileQueue: queue.stats(),
257
405
  collaboration: collaboration.stats(),
406
+ caches: { completions: latexCompletions.stats(), outlines: projectOutlines.stats() },
258
407
  durationsMs: metrics.summaries()
259
408
  };
260
409
  });
@@ -266,31 +415,38 @@ export async function buildApp(config, db, options = {}) {
266
415
  }
267
416
  const { id } = request.params;
268
417
  const startedAt = performance.now();
269
- try {
270
- collaboration.connect(socket, id, user);
271
- }
272
- finally {
273
- metrics.record("collaboration.connect", performance.now() - startedAt);
274
- }
418
+ void collaboration.connect(socket, id, user)
419
+ .finally(() => metrics.record("collaboration.connect", performance.now() - startedAt));
275
420
  });
276
421
  app.post("/api/auth/login", async (request, reply) => {
277
422
  const body = request.body;
278
423
  const username = text(body?.username, "用户名", 64);
424
+ const ip = request.ip || "127.0.0.1";
425
+ const rateLimitKey = `${ip}:${username.toLowerCase()}`;
426
+ if (loginLimiter.isLocked(rateLimitKey)) {
427
+ return apiError(reply, 429, "AUTH_RATE_LIMITED", "登录尝试过于频繁,账号已临时锁定,请在 15 分钟后再试");
428
+ }
279
429
  const password = typeof body?.password === "string" ? body.password : "";
280
430
  const user = db.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE").get(username);
281
431
  if (!user || user.disabled || !(await verifyPassword(password, user.password_hash))) {
432
+ const result = loginLimiter.recordFailure(rateLimitKey);
433
+ if (result.locked) {
434
+ return apiError(reply, 429, "AUTH_RATE_LIMITED", "密码错误次数过多,账号已临时锁定,请在 15 分钟后再试");
435
+ }
282
436
  return apiError(reply, 401, "AUTH_INVALID", "用户名或密码错误");
283
437
  }
438
+ loginLimiter.reset(rateLimitKey);
284
439
  const session = createSessionToken();
285
440
  const createdAt = now();
286
441
  const expires = new Date(Date.now() + config.sessionDays * 86_400_000);
287
442
  db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
288
443
  .run(session.digest, user.id, expires.toISOString(), createdAt);
444
+ const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
289
445
  reply.setCookie("texlite_session", session.token, {
290
446
  path: "/",
291
447
  httpOnly: true,
292
448
  sameSite: "strict",
293
- secure: false,
449
+ secure: isSecure,
294
450
  expires
295
451
  });
296
452
  return { user: publicUser(user) };
@@ -341,7 +497,7 @@ export async function buildApp(config, db, options = {}) {
341
497
  const body = request.body;
342
498
  const username = text(body?.username, "用户名", 64);
343
499
  if (!/^[\p{L}\p{N}_.-]+$/u.test(username))
344
- throw new Error("用户名只能包含字母、数字、点、横线或下划线");
500
+ return apiError(reply, 400, "USERNAME_INVALID", "用户名只能包含字母、数字、点、横线或下划线");
345
501
  const displayName = text(body?.displayName ?? username, "显示名称", 100);
346
502
  const password = typeof body?.password === "string" ? body.password : "";
347
503
  const role = body?.role === "admin" ? "admin" : "user";
@@ -382,6 +538,14 @@ export async function buildApp(config, db, options = {}) {
382
538
  }
383
539
  db.prepare(`UPDATE users SET display_name = ?, role = ?, disabled = ?, password_hash = ?, must_change_password = ?, can_create_projects = ? WHERE id = ?`)
384
540
  .run(displayName, role, disabled, passwordHash, mustChange, canCreateProjects, id);
541
+ // A disabled account must not regain access by being re-enabled while an
542
+ // old cookie is still within its normal lifetime. Password resets already
543
+ // revoke sessions above; disabling does the same for every active token.
544
+ if (disabled === 1)
545
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
546
+ if (disabled === 1 || (typeof body.password === "string" && body.password)) {
547
+ collaboration.disconnectUser(id, "用户已被禁用或密码已重置");
548
+ }
385
549
  const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
386
550
  return { user: publicUser(updated) };
387
551
  });
@@ -399,15 +563,27 @@ export async function buildApp(config, db, options = {}) {
399
563
  if (target.role === "admin" && activeAdminCount(db) <= 1) {
400
564
  return apiError(reply, 400, "LAST_ADMIN", "不能删除最后一个管理员");
401
565
  }
402
- const owned = db.prepare("SELECT id FROM projects WHERE owner_id = ?").all(id);
403
- for (const project of owned) {
404
- if (body.deleteProjects)
405
- collaboration.closeProject(project.id);
406
- else
407
- collaboration.flushProject(project.id);
408
- }
566
+ let owned = [];
567
+ // Capture and mutate the complete owned-project set in the same synchronous
568
+ // transaction. There is deliberately no await before COMMIT: another
569
+ // request cannot transfer or create a project for this user between the
570
+ // snapshot and the owner/delete statements.
409
571
  db.exec("BEGIN IMMEDIATE");
410
572
  try {
573
+ const currentTarget = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
574
+ if (!currentTarget)
575
+ throw Object.assign(new Error("用户不存在"), { statusCode: 404, code: "USER_NOT_FOUND" });
576
+ if (currentTarget.role === "admin" && activeAdminCount(db) <= 1) {
577
+ throw Object.assign(new Error("不能删除最后一个管理员"), { statusCode: 400, code: "LAST_ADMIN" });
578
+ }
579
+ owned = db.prepare("SELECT id FROM projects WHERE owner_id = ?").all(id);
580
+ if (!body.deleteProjects) {
581
+ // Persist any active drafts while the old owner row still exists. This
582
+ // is synchronous, so no edit can arrive between this flush and the
583
+ // ownership update below, and last_modified_by remains FK-safe.
584
+ for (const project of owned)
585
+ projectMutations.flushProject(project.id);
586
+ }
411
587
  if (body.deleteProjects) {
412
588
  db.prepare("DELETE FROM projects WHERE owner_id = ?").run(id);
413
589
  }
@@ -426,14 +602,23 @@ export async function buildApp(config, db, options = {}) {
426
602
  db.exec("ROLLBACK");
427
603
  throw error;
428
604
  }
429
- if (body.deleteProjects) {
430
- for (const project of owned)
431
- removeProjectDirectory(config, project.id);
432
- }
433
- else {
434
- for (const project of owned)
435
- collaboration.resetProject(project.id);
436
- }
605
+ // Requests already queued for the removed user fail their lock-time
606
+ // preflight. Cleanup is then serialized after them and invalidates any
607
+ // room initialization that started before the transaction committed.
608
+ for (const project of owned) {
609
+ await projectMutations.runExclusive(project.id, "admin user deletion cleanup", () => {
610
+ if (body.deleteProjects) {
611
+ collaboration.resetProject(project.id);
612
+ removeProjectDirectory(config, project.id);
613
+ latexCompletions.invalidate(project.id);
614
+ projectOutlines.invalidate(project.id);
615
+ }
616
+ else {
617
+ collaboration.resetProject(project.id);
618
+ }
619
+ }, { flush: false });
620
+ }
621
+ collaboration.disconnectUser(id, "用户已被删除");
437
622
  return { ok: true, deletedProjects: body.deleteProjects ? owned.length : 0 };
438
623
  });
439
624
  app.get("/api/users", async (request, reply) => {
@@ -491,8 +676,8 @@ export async function buildApp(config, db, options = {}) {
491
676
  ];
492
677
  const params = { userId: user.id };
493
678
  if (search) {
494
- conditions.push("(p.name LIKE :search OR owner.username LIKE :search OR owner.display_name LIKE :search)");
495
- params.search = `%${search}%`;
679
+ conditions.push("(p.name LIKE :search ESCAPE '\\' OR owner.username LIKE :search ESCAPE '\\' OR owner.display_name LIKE :search ESCAPE '\\')");
680
+ params.search = `%${escapeLikePattern(search)}%`;
496
681
  }
497
682
  if (tagId) {
498
683
  conditions.push(`EXISTS (
@@ -520,9 +705,11 @@ export async function buildApp(config, db, options = {}) {
520
705
  ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
521
706
  LIMIT :limit OFFSET :offset`).all(rowsParams);
522
707
  const projects = rows;
523
- const projectTags = tagsForProjects(db, projects.map((project) => project.id), user.id);
708
+ const projectIds = projects.map((project) => project.id);
709
+ const projectTags = tagsForProjects(db, projectIds, user.id);
710
+ const commentsSummaries = commentsSummaryForProjects(db, projectIds);
524
711
  return {
525
- projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [])),
712
+ projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id))),
526
713
  pagination: { page: currentPage, pageSize, total, totalPages }
527
714
  };
528
715
  });
@@ -581,7 +768,7 @@ export async function buildApp(config, db, options = {}) {
581
768
  }
582
769
  catch (error) {
583
770
  removeProjectDirectory(config, project.id);
584
- throw error;
771
+ return apiError(reply, 400, "ZIP_INVALID", error instanceof Error ? error.message : "无法解压 ZIP 文件");
585
772
  }
586
773
  recordHistory(project.id, user.id, "initial");
587
774
  return reply.code(201).send({ project: projectJson({
@@ -607,7 +794,17 @@ export async function buildApp(config, db, options = {}) {
607
794
  main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
608
795
  };
609
796
  try {
610
- duplicateProjectFiles(config, source.id, project.id);
797
+ // Duplicate the source tree only after flushing the live Yjs room and
798
+ // while a short source barrier prevents autosave from changing files
799
+ // between directory entries. The copy is asynchronous, so a large
800
+ // project does not block the Node.js event loop for its entire duration.
801
+ await projectMutations.runConsistentRead(source.id, () => duplicateProjectFiles(config, source.id, project.id), {
802
+ preflight: () => {
803
+ if (!accessibleProject(db, source.id, user)) {
804
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
805
+ }
806
+ }
807
+ });
611
808
  db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
612
809
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
613
810
  .run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.latexmkrc, project.engine, project.created_at, project.updated_at);
@@ -630,7 +827,9 @@ export async function buildApp(config, db, options = {}) {
630
827
  const project = accessibleProject(db, id, user);
631
828
  if (!project)
632
829
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
633
- return { project: projectJson(project, tagsForProject(db, id, user.id)) };
830
+ return {
831
+ project: projectJson(project, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
832
+ };
634
833
  });
635
834
  app.get("/api/projects/:id/history", async (request, reply) => {
636
835
  const user = requireUser(request, reply, db);
@@ -673,20 +872,25 @@ export async function buildApp(config, db, options = {}) {
673
872
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
674
873
  const query = request.query;
675
874
  const filePath = safeRelativePath(query.path ?? "");
676
- const historical = history.readTextFile(id, versionId, filePath);
677
- if (historical === null)
678
- return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", "该历史文件不存在或不能作为文本比较", { path: filePath });
679
- let comparison = "";
680
- if (query.against) {
681
- comparison = history.readTextFile(id, query.against, filePath) ?? "";
682
- }
683
- else {
684
- const current = resolveSourcePath(config, id, filePath);
685
- if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
686
- comparison = fs.readFileSync(current, "utf8");
875
+ return await projectMutations.runConsistentRead(id, () => {
876
+ const historical = history.readTextFile(id, versionId, filePath);
877
+ if (historical === null)
878
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", "该历史文件不存在或不能作为文本比较", { path: filePath });
879
+ let comparison = "";
880
+ if (query.against) {
881
+ comparison = history.readTextFile(id, query.against, filePath) ?? "";
687
882
  }
688
- }
689
- return { path: filePath, historical, comparison, against: query.against ?? "current" };
883
+ else {
884
+ const current = resolveSourcePath(config, id, filePath);
885
+ if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
886
+ comparison = fs.readFileSync(current, "utf8");
887
+ }
888
+ }
889
+ return { path: filePath, historical, comparison, against: query.against ?? "current" };
890
+ }, { preflight: () => {
891
+ if (!accessibleProject(db, id, user))
892
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
893
+ } });
690
894
  });
691
895
  app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
692
896
  const user = requireUser(request, reply, db);
@@ -711,10 +915,16 @@ export async function buildApp(config, db, options = {}) {
711
915
  const project = accessibleProject(db, id, user);
712
916
  if (!project || project.permission !== "owner")
713
917
  return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以删除历史版本");
714
- collaboration.flushProject(id);
715
- if (!history.deleteVersion(id, versionId))
716
- return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
717
- return { ok: true, stats: history.stats(id) };
918
+ return await projectMutations.runWrite(id, () => {
919
+ if (!history.deleteVersion(id, versionId))
920
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
921
+ return { ok: true, stats: history.stats(id) };
922
+ }, { preflight: () => {
923
+ requireProjectOwnerPermission(db, id, user);
924
+ if (!history.version(versionId, id)) {
925
+ throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
926
+ }
927
+ } });
718
928
  });
719
929
  app.delete("/api/projects/:id/history", async (request, reply) => {
720
930
  const user = requireUser(request, reply, db);
@@ -724,9 +934,10 @@ export async function buildApp(config, db, options = {}) {
724
934
  const project = accessibleProject(db, id, user);
725
935
  if (!project || project.permission !== "owner")
726
936
  return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以清空历史版本");
727
- collaboration.flushProject(id);
728
- history.clear(id);
729
- return { ok: true, stats: history.stats(id) };
937
+ return await projectMutations.runWrite(id, () => {
938
+ history.clear(id);
939
+ return { ok: true, stats: history.stats(id) };
940
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
730
941
  });
731
942
  app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
732
943
  const user = requireUser(request, reply, db);
@@ -738,15 +949,25 @@ export async function buildApp(config, db, options = {}) {
738
949
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
739
950
  const body = request.body;
740
951
  const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
741
- collaboration.flushProject(id);
742
- recordHistory(id, user.id, "checkpoint");
743
- const before = projectTextSnapshot(config, id);
744
- const restored = history.restore(id, versionId, filePath);
745
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
746
- touchProject(db, id, user.id);
747
- recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
748
- collaboration.resetProject(id);
749
- return { ok: true, restoredPaths: restored.restoredPaths, project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id)) };
952
+ return await projectMutations.runExclusive(id, "history restore", () => {
953
+ const currentProject = requireEditableProject(db, id, user);
954
+ recordHistory(id, user.id, "checkpoint");
955
+ const before = projectTextSnapshot(config, id);
956
+ const restored = history.restore(id, versionId, filePath);
957
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
958
+ touchProject(db, id, user.id);
959
+ recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
960
+ return {
961
+ ok: true,
962
+ restoredPaths: restored.restoredPaths,
963
+ project: projectJson(accessibleProject(db, id, user) ?? currentProject, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
964
+ };
965
+ }, { preflight: () => {
966
+ requireEditableProject(db, id, user);
967
+ if (!history.version(versionId, id)) {
968
+ throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
969
+ }
970
+ } });
750
971
  });
751
972
  app.put("/api/projects/:id/archive", async (request, reply) => {
752
973
  const user = requireUser(request, reply, db);
@@ -821,27 +1042,72 @@ export async function buildApp(config, db, options = {}) {
821
1042
  if (!user)
822
1043
  return;
823
1044
  const { id } = request.params;
1045
+ if (collaboration.isMaintaining(id))
1046
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
824
1047
  const project = accessibleProject(db, id, user);
825
1048
  if (!project || project.permission !== "owner")
826
1049
  return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
827
1050
  const body = request.body;
828
- const name = typeof body.name === "string" ? text(body.name, "项目名称", 120) : project.name;
829
- const mainFile = typeof body.mainFile === "string" ? safeRelativePath(body.mainFile) : project.main_file;
830
- const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
831
- ? body.engine : project.engine;
832
- const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
833
- ? null
834
- : typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : project.latexmkrc;
835
- if (!fs.existsSync(resolveSourcePath(config, id, mainFile)))
836
- return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", "主文件不存在", { path: mainFile });
837
- if (latexmkrc && !config.allowProjectLatexmkrc)
838
- return apiError(reply, 400, "LATEXMKRC_DISABLED", "管理员已禁用项目级 latexmkrc");
839
- if (latexmkrc && !fs.existsSync(resolveSourcePath(config, id, latexmkrc)))
840
- return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", "latexmkrc 文件不存在", { path: latexmkrc });
841
- db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
842
- .run(name, mainFile, latexmkrc, engine, now(), user.id, id);
843
- recordHistory(id, user.id, "settings", []);
844
- return { project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id)) };
1051
+ return await projectMutations.runWrite(id, () => {
1052
+ const currentProject = accessibleProject(db, id, user);
1053
+ if (!currentProject || currentProject.permission !== "owner") {
1054
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
1055
+ }
1056
+ const name = typeof body.name === "string" ? text(body.name, "项目名称", 120) : currentProject.name;
1057
+ let mainFile = currentProject.main_file;
1058
+ if (body.mainFile !== undefined) {
1059
+ if (typeof body.mainFile !== "string" || !body.mainFile.trim()) {
1060
+ return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是存在的 .tex 文件");
1061
+ }
1062
+ mainFile = safeRelativePath(body.mainFile);
1063
+ }
1064
+ const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
1065
+ ? body.engine : currentProject.engine;
1066
+ const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
1067
+ ? null
1068
+ : typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : currentProject.latexmkrc;
1069
+ if (!mainFile.toLocaleLowerCase().endsWith(".tex")) {
1070
+ return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是 .tex 文件", { path: mainFile });
1071
+ }
1072
+ const mainFileAbsolute = resolveSourcePath(config, id, mainFile);
1073
+ let stat = null;
1074
+ try {
1075
+ stat = fs.statSync(mainFileAbsolute);
1076
+ }
1077
+ catch (error) {
1078
+ if (error.code === "ENOENT") {
1079
+ return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", "主文件不存在", { path: mainFile });
1080
+ }
1081
+ throw error;
1082
+ }
1083
+ if (!stat.isFile()) {
1084
+ return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是常规文件,不能是目录", { path: mainFile });
1085
+ }
1086
+ if (latexmkrc && !config.allowProjectLatexmkrc)
1087
+ return apiError(reply, 400, "LATEXMKRC_DISABLED", "管理员已禁用项目级 latexmkrc");
1088
+ if (latexmkrc) {
1089
+ const rcAbsolute = resolveSourcePath(config, id, latexmkrc);
1090
+ let rcStat = null;
1091
+ try {
1092
+ rcStat = fs.statSync(rcAbsolute);
1093
+ }
1094
+ catch (error) {
1095
+ if (error.code === "ENOENT") {
1096
+ return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", "latexmkrc 文件不存在", { path: latexmkrc });
1097
+ }
1098
+ throw error;
1099
+ }
1100
+ if (!rcStat.isFile()) {
1101
+ return apiError(reply, 400, "LATEXMKRC_INVALID", "latexmkrc 必须是常规文件,不能是目录", { path: latexmkrc });
1102
+ }
1103
+ }
1104
+ db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
1105
+ .run(name, mainFile, latexmkrc, engine, now(), user.id, id);
1106
+ recordHistory(id, user.id, "settings", []);
1107
+ return {
1108
+ project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
1109
+ };
1110
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
845
1111
  });
846
1112
  app.post("/api/projects/:id/tags", async (request, reply) => {
847
1113
  const user = requireUser(request, reply, db);
@@ -858,7 +1124,10 @@ export async function buildApp(config, db, options = {}) {
858
1124
  db.prepare("INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at) VALUES (?, ?, ?)")
859
1125
  .run(id, body.tagId, now());
860
1126
  const tags = tagsForProject(db, id, user.id);
861
- return reply.code(201).send({ tags, project: projectJson(accessibleProject(db, id, user), tags) });
1127
+ return reply.code(201).send({
1128
+ tags,
1129
+ project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
1130
+ });
862
1131
  });
863
1132
  app.delete("/api/projects/:id/tags/:tagId", async (request, reply) => {
864
1133
  const user = requireUser(request, reply, db);
@@ -872,9 +1141,13 @@ export async function buildApp(config, db, options = {}) {
872
1141
  AND EXISTS (SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?)`)
873
1142
  .run(tagId, id, tagId, user.id);
874
1143
  const tags = tagsForProject(db, id, user.id);
875
- return { tags, project: projectJson(accessibleProject(db, id, user), tags) };
1144
+ return {
1145
+ tags,
1146
+ project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
1147
+ };
876
1148
  });
877
1149
  const requireGitOwner = (projectId, user) => {
1150
+ requireActiveUser(db, user);
878
1151
  const project = accessibleProject(db, projectId, user);
879
1152
  if (!project)
880
1153
  throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
@@ -887,7 +1160,9 @@ export async function buildApp(config, db, options = {}) {
887
1160
  if (!user)
888
1161
  return;
889
1162
  const { id } = request.params;
890
- return { status: await projectGit.status(requireGitOwner(id, user)) };
1163
+ return await projectMutations.runConsistentRead(id, async () => {
1164
+ return { status: await projectGit.status(requireGitOwner(id, user)) };
1165
+ }, { preflight: () => { requireGitOwner(id, user); } });
891
1166
  });
892
1167
  app.put("/api/projects/:id/git/token", async (request, reply) => {
893
1168
  const user = requireUser(request, reply, db);
@@ -897,14 +1172,19 @@ export async function buildApp(config, db, options = {}) {
897
1172
  const body = request.body;
898
1173
  if (typeof body.token !== "string")
899
1174
  return apiError(reply, 400, "GIT_TOKEN_INVALID", "请输入 GitHub token");
900
- return { status: await projectGit.configureToken(requireGitOwner(id, user), body.token) };
1175
+ const token = body.token;
1176
+ return await projectMutations.runSerialized(id, async () => {
1177
+ return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
1178
+ }, { preflight: () => { requireGitOwner(id, user); } });
901
1179
  });
902
1180
  app.delete("/api/projects/:id/git/token", async (request, reply) => {
903
1181
  const user = requireUser(request, reply, db);
904
1182
  if (!user)
905
1183
  return;
906
1184
  const { id } = request.params;
907
- return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
1185
+ return await projectMutations.runSerialized(id, async () => {
1186
+ return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
1187
+ }, { preflight: () => { requireGitOwner(id, user); } });
908
1188
  });
909
1189
  app.post("/api/projects/:id/git/repository", async (request, reply) => {
910
1190
  const user = requireUser(request, reply, db);
@@ -914,34 +1194,45 @@ export async function buildApp(config, db, options = {}) {
914
1194
  const body = request.body;
915
1195
  if (typeof body.name !== "string")
916
1196
  return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID", "请输入 GitHub 仓库名称");
917
- return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), body.name.trim(), body.private !== false) };
1197
+ const repositoryName = body.name.trim();
1198
+ return await projectMutations.runSerialized(id, async () => {
1199
+ return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), repositoryName, body.private !== false) };
1200
+ }, { preflight: () => { requireGitOwner(id, user); } });
918
1201
  });
919
1202
  app.post("/api/projects/:id/git/commit", async (request, reply) => {
920
1203
  const user = requireUser(request, reply, db);
921
1204
  if (!user)
922
1205
  return;
923
1206
  const { id } = request.params;
924
- const project = requireGitOwner(id, user);
1207
+ if (collaboration.isMaintaining(id))
1208
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
925
1209
  const body = request.body;
926
- collaboration.flushProject(id);
927
- const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
928
- return reply.code(201).send({ commit, status: await projectGit.status(project) });
1210
+ return await projectMutations.runExclusive(id, "Git commit", async () => {
1211
+ const project = requireGitOwner(id, user);
1212
+ const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
1213
+ return reply.code(201).send({ commit, status: await projectGit.status(project) });
1214
+ }, { preflight: () => { requireGitOwner(id, user); } });
929
1215
  });
930
1216
  app.post("/api/projects/:id/git/push", async (request, reply) => {
931
1217
  const user = requireUser(request, reply, db);
932
1218
  if (!user)
933
1219
  return;
934
1220
  const { id } = request.params;
935
- const project = requireGitOwner(id, user);
936
- collaboration.flushProject(id);
937
- return { status: await projectGit.push(project) };
1221
+ if (collaboration.isMaintaining(id))
1222
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1223
+ return await projectMutations.runSerialized(id, async () => {
1224
+ const project = requireGitOwner(id, user);
1225
+ return { status: await projectGit.push(project) };
1226
+ }, { flush: false, preflight: () => { requireGitOwner(id, user); } });
938
1227
  });
939
1228
  app.get("/api/projects/:id/git/history", async (request, reply) => {
940
1229
  const user = requireUser(request, reply, db);
941
1230
  if (!user)
942
1231
  return;
943
1232
  const { id } = request.params;
944
- return { commits: await projectGit.history(requireGitOwner(id, user)) };
1233
+ return await projectMutations.runConsistentRead(id, async () => {
1234
+ return { commits: await projectGit.history(requireGitOwner(id, user)) };
1235
+ }, { preflight: () => { requireGitOwner(id, user); } });
945
1236
  });
946
1237
  app.get("/api/projects/:id/git/diff", async (request, reply) => {
947
1238
  const user = requireUser(request, reply, db);
@@ -949,44 +1240,48 @@ export async function buildApp(config, db, options = {}) {
949
1240
  return;
950
1241
  const { id } = request.params;
951
1242
  const { revision } = request.query;
952
- const project = requireGitOwner(id, user);
953
- collaboration.flushProject(id);
954
- return projectGit.diff(project, revision);
1243
+ return await projectMutations.runConsistentRead(id, async () => {
1244
+ const project = requireGitOwner(id, user);
1245
+ return projectGit.diff(project, revision);
1246
+ }, { preflight: () => { requireGitOwner(id, user); } });
955
1247
  });
956
1248
  app.post("/api/projects/:id/git/checkout", async (request, reply) => {
957
1249
  const user = requireUser(request, reply, db);
958
1250
  if (!user)
959
1251
  return;
960
1252
  const { id } = request.params;
961
- const project = requireGitOwner(id, user);
1253
+ requireGitOwner(id, user);
962
1254
  const body = request.body;
963
1255
  if (body.revision !== null && typeof body.revision !== "string")
964
1256
  return apiError(reply, 400, "GIT_REVISION_INVALID", "请选择要 checkout 的 Git 版本");
965
- collaboration.flushProject(id);
966
- recordHistory(id, user.id, "checkpoint");
967
- const before = projectTextSnapshot(config, id);
968
- const revision = await projectGit.checkout(project, body.revision, body.force === true);
969
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
970
- touchProject(db, id, user.id);
971
- recordHistory(id, user.id, "git");
972
- collaboration.resetProject(id);
973
- return { revision, status: await projectGit.status(project) };
1257
+ const revisionInput = body.revision === null ? null : body.revision;
1258
+ return await projectMutations.runExclusive(id, "Git checkout", async () => {
1259
+ const currentProject = requireGitOwner(id, user);
1260
+ recordHistory(id, user.id, "checkpoint");
1261
+ const before = projectTextSnapshot(config, id);
1262
+ const revision = await projectGit.checkout(currentProject, revisionInput, body.force === true);
1263
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
1264
+ touchProject(db, id, user.id);
1265
+ recordHistory(id, user.id, "git");
1266
+ return { revision, status: await projectGit.status(currentProject) };
1267
+ }, { preflight: () => { requireGitOwner(id, user); } });
974
1268
  });
975
1269
  app.post("/api/projects/:id/git/discard", async (request, reply) => {
976
1270
  const user = requireUser(request, reply, db);
977
1271
  if (!user)
978
1272
  return;
979
1273
  const { id } = request.params;
980
- const project = requireGitOwner(id, user);
981
- collaboration.flushProject(id);
982
- recordHistory(id, user.id, "checkpoint");
983
- const before = projectTextSnapshot(config, id);
984
- await projectGit.discardChanges(project);
985
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
986
- touchProject(db, id, user.id);
987
- recordHistory(id, user.id, "git");
988
- collaboration.resetProject(id);
989
- return { status: await projectGit.status(project) };
1274
+ requireGitOwner(id, user);
1275
+ return await projectMutations.runExclusive(id, "Git restore", async () => {
1276
+ const currentProject = requireGitOwner(id, user);
1277
+ recordHistory(id, user.id, "checkpoint");
1278
+ const before = projectTextSnapshot(config, id);
1279
+ await projectGit.discardChanges(currentProject);
1280
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
1281
+ touchProject(db, id, user.id);
1282
+ recordHistory(id, user.id, "git");
1283
+ return { status: await projectGit.status(currentProject) };
1284
+ }, { preflight: () => { requireGitOwner(id, user); } });
990
1285
  });
991
1286
  app.get("/api/projects/:id/download", async (request, reply) => {
992
1287
  const user = requireUser(request, reply, db);
@@ -996,12 +1291,30 @@ export async function buildApp(config, db, options = {}) {
996
1291
  const project = accessibleProject(db, id, user);
997
1292
  if (!project)
998
1293
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
999
- collaboration.flushProject(id);
1000
- const archive = createProjectArchive(config, id);
1294
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
1295
+ const temporaryArchive = path.join(temporaryDirectory, `project-${id}-${randomUUID()}.zip`);
1296
+ try {
1297
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
1298
+ await projectMutations.runConsistentRead(id, () => writeProjectArchive(config, id, temporaryArchive), {
1299
+ preflight: () => {
1300
+ const current = accessibleProject(db, id, user);
1301
+ if (!current)
1302
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1303
+ }
1304
+ });
1305
+ }
1306
+ catch (error) {
1307
+ await fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined);
1308
+ throw error;
1309
+ }
1001
1310
  const filename = `${project.name}.zip`;
1002
1311
  reply.header("Content-Type", "application/zip");
1003
1312
  reply.header("Content-Disposition", contentDisposition(filename, "attachment"));
1004
- return reply.send(archive.outputStream);
1313
+ const stream = fs.createReadStream(temporaryArchive);
1314
+ const cleanup = () => { void fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined); };
1315
+ stream.once("close", cleanup);
1316
+ stream.once("error", cleanup);
1317
+ return reply.send(stream);
1005
1318
  });
1006
1319
  app.delete("/api/projects/:id", async (request, reply) => {
1007
1320
  const user = requireUser(request, reply, db);
@@ -1011,10 +1324,14 @@ export async function buildApp(config, db, options = {}) {
1011
1324
  const project = accessibleProject(db, id, user);
1012
1325
  if (!project || project.permission !== "owner")
1013
1326
  return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN", "只有项目所有者可以删除项目");
1014
- collaboration.closeProject(id);
1015
- db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1016
- removeProjectDirectory(config, id);
1017
- return { ok: true };
1327
+ return await projectMutations.runExclusive(id, "project deletion", () => {
1328
+ collaboration.resetProject(id);
1329
+ removeProjectDirectory(config, id);
1330
+ db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1331
+ latexCompletions.invalidate(id);
1332
+ projectOutlines.invalidate(id);
1333
+ return { ok: true };
1334
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
1018
1335
  });
1019
1336
  app.get("/api/projects/:id/files", async (request, reply) => {
1020
1337
  const user = requireUser(request, reply, db);
@@ -1024,9 +1341,18 @@ export async function buildApp(config, db, options = {}) {
1024
1341
  if (!accessibleProject(db, id, user))
1025
1342
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1026
1343
  // Directory walks can be slow on external/project-mounted storage. Keep
1027
- // this request off the event loop so the retained-PDF metadata and PDF
1028
- // stream can be served while the file tree is being collected.
1029
- return { files: await listProjectFilesAsync(config, id) };
1344
+ // this request off the event loop and unblocked by Yjs room initialization
1345
+ // so the file tree and retained-PDF stream can be served concurrently, but
1346
+ // still serialize it with source-tree mutations and collaborative flushes.
1347
+ return await projectMutations.runFilesystemRead(id, async () => ({
1348
+ files: await listProjectFilesAsync(config, id)
1349
+ }), {
1350
+ preflight: () => {
1351
+ if (!accessibleProject(db, id, user)) {
1352
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1353
+ }
1354
+ }
1355
+ });
1030
1356
  });
1031
1357
  app.post("/api/projects/:id/format", async (request, reply) => {
1032
1358
  const user = requireUser(request, reply, db);
@@ -1045,11 +1371,17 @@ export async function buildApp(config, db, options = {}) {
1045
1371
  }
1046
1372
  if (typeof body?.source !== "string")
1047
1373
  return apiError(reply, 400, "FORMAT_SOURCE_INVALID", "待格式化内容格式不正确");
1048
- if (Buffer.byteLength(body.source, "utf8") > config.maxUploadBytes) {
1374
+ const source = body.source;
1375
+ if (Buffer.byteLength(source, "utf8") > config.maxUploadBytes) {
1049
1376
  return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath });
1050
1377
  }
1051
1378
  try {
1052
- return await latexFormatter.format(body.source, sourceRoot(config, id));
1379
+ return await projectMutations.runConsistentRead(id, () => {
1380
+ assertNoSourceSymlinks(config, id);
1381
+ return latexFormatter.format(source, sourceRoot(config, id));
1382
+ }, {
1383
+ preflight: () => { requireEditableProject(db, id, user); }
1384
+ });
1053
1385
  }
1054
1386
  catch (formatError) {
1055
1387
  const statusCode = typeof formatError === "object" && formatError !== null && "statusCode" in formatError && typeof formatError.statusCode === "number"
@@ -1072,10 +1404,20 @@ export async function buildApp(config, db, options = {}) {
1072
1404
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
1073
1405
  if (!mainFile)
1074
1406
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
1075
- collaboration.flushProject(id);
1076
1407
  const startedAt = performance.now();
1077
1408
  try {
1078
- return { outline: buildProjectOutline(config, id, mainFile), mainFile };
1409
+ return await projectMutations.runConsistentRead(id, async () => ({
1410
+ outline: await projectOutlines.build(id, mainFile), mainFile
1411
+ }), {
1412
+ preflight: () => {
1413
+ const current = accessibleProject(db, id, user);
1414
+ if (!current)
1415
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1416
+ const selected = compileMainFile(config, id, current.main_file, query.mainFile);
1417
+ if (!selected || selected !== mainFile)
1418
+ throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
1419
+ }
1420
+ });
1079
1421
  }
1080
1422
  finally {
1081
1423
  metrics.record("outline.build", performance.now() - startedAt);
@@ -1089,13 +1431,17 @@ export async function buildApp(config, db, options = {}) {
1089
1431
  if (!accessibleProject(db, id, user))
1090
1432
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1091
1433
  const query = request.query;
1092
- collaboration.flushProject(id);
1093
1434
  const startedAt = performance.now();
1094
1435
  try {
1095
- return await searchProject(config, id, {
1436
+ return await projectMutations.runConsistentRead(id, () => searchProject(config, id, {
1096
1437
  query: query.q ?? "",
1097
1438
  caseSensitive: query.caseSensitive === "1",
1098
1439
  wholeWord: query.wholeWord === "1"
1440
+ }), {
1441
+ preflight: () => {
1442
+ if (!accessibleProject(db, id, user))
1443
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1444
+ }
1099
1445
  });
1100
1446
  }
1101
1447
  finally {
@@ -1114,23 +1460,25 @@ export async function buildApp(config, db, options = {}) {
1114
1460
  if (typeof body.query !== "string" || typeof body.replacement !== "string" || body.replacement.length > 100_000) {
1115
1461
  return apiError(reply, 400, "SEARCH_QUERY_INVALID", "搜索或替换内容格式不正确");
1116
1462
  }
1117
- collaboration.flushProject(id);
1118
- const changed = await replaceProject(config, id, {
1119
- query: body.query,
1120
- caseSensitive: body.caseSensitive === true,
1121
- wholeWord: body.wholeWord === true
1122
- }, body.replacement);
1123
- let replacements = 0;
1124
- for (const file of changed) {
1125
- reanchorFileComments(db, id, file.path, file.previous, file.content);
1126
- collaboration.updateFile(id, file.path, file.content, user.id);
1127
- replacements += file.count;
1128
- }
1129
- if (changed.length) {
1130
- touchProject(db, id, user.id);
1131
- recordHistory(id, user.id, "file", changed.map((file) => file.path));
1132
- }
1133
- return { ok: true, replacements, files: changed.map((file) => file.path) };
1463
+ return await projectMutations.runExclusive(id, "project-wide replace", async () => {
1464
+ const changed = await replaceProject(config, id, {
1465
+ query: body.query,
1466
+ caseSensitive: body.caseSensitive === true,
1467
+ wholeWord: body.wholeWord === true,
1468
+ maxFileBytes: maxCollaborativeFileBytes(config)
1469
+ }, body.replacement);
1470
+ let replacements = 0;
1471
+ for (const file of changed) {
1472
+ reanchorFileComments(db, id, file.path, file.previous, file.content);
1473
+ collaboration.updateFile(id, file.path, file.content, user.id);
1474
+ replacements += file.count;
1475
+ }
1476
+ if (changed.length) {
1477
+ touchProject(db, id, user.id);
1478
+ recordHistory(id, user.id, "file", changed.map((file) => file.path));
1479
+ }
1480
+ return { ok: true, replacements, files: changed.map((file) => file.path) };
1481
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1134
1482
  });
1135
1483
  app.get("/api/projects/:id/completions", async (request, reply) => {
1136
1484
  const user = requireUser(request, reply, db);
@@ -1139,10 +1487,14 @@ export async function buildApp(config, db, options = {}) {
1139
1487
  const { id } = request.params;
1140
1488
  if (!accessibleProject(db, id, user))
1141
1489
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1142
- collaboration.flushProject(id);
1143
1490
  const startedAt = performance.now();
1144
1491
  try {
1145
- return { index: await latexCompletions.build(id) };
1492
+ return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
1493
+ preflight: () => {
1494
+ if (!accessibleProject(db, id, user))
1495
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1496
+ }
1497
+ });
1146
1498
  }
1147
1499
  finally {
1148
1500
  metrics.record("completions.build", performance.now() - startedAt);
@@ -1153,23 +1505,33 @@ export async function buildApp(config, db, options = {}) {
1153
1505
  if (!user)
1154
1506
  return;
1155
1507
  const { id } = request.params;
1508
+ if (collaboration.isMaintaining(id))
1509
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1156
1510
  const project = accessibleProject(db, id, user);
1157
1511
  if (!project || !canEdit(project))
1158
1512
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1159
1513
  const body = request.body;
1160
1514
  const folderPath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1161
- const absolute = resolveSourcePath(config, id, folderPath);
1162
- if (fs.existsSync(absolute))
1163
- return apiError(reply, 409, "PATH_EXISTS", "同名文件或目录已存在", { path: folderPath });
1164
- fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
1165
- touchProject(db, id, user.id);
1166
- return reply.code(201).send({ ok: true, path: folderPath });
1515
+ return await projectMutations.runWrite(id, () => {
1516
+ const absolute = resolveSourcePath(config, id, folderPath);
1517
+ if (fs.existsSync(absolute))
1518
+ return apiError(reply, 409, "PATH_EXISTS", "同名文件或目录已存在", { path: folderPath });
1519
+ fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
1520
+ touchProject(db, id, user.id);
1521
+ // Empty folders have no file entry to trigger a refresh on their own.
1522
+ // Publish a source-tree event so other open sessions see the folder
1523
+ // immediately instead of waiting for a later file operation.
1524
+ collaboration.invalidateSourceTree(id, folderPath);
1525
+ return reply.code(201).send({ ok: true, path: folderPath });
1526
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1167
1527
  });
1168
1528
  app.patch("/api/projects/:id/path", async (request, reply) => {
1169
1529
  const user = requireUser(request, reply, db);
1170
1530
  if (!user)
1171
1531
  return;
1172
1532
  const { id } = request.params;
1533
+ if (collaboration.isMaintaining(id))
1534
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1173
1535
  const project = accessibleProject(db, id, user);
1174
1536
  if (!project || !canEdit(project))
1175
1537
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
@@ -1177,11 +1539,16 @@ export async function buildApp(config, db, options = {}) {
1177
1539
  const source = safeRelativePath(typeof body.source === "string" ? body.source : "");
1178
1540
  const destinationDirectory = body.destinationDirectory === "" ? ""
1179
1541
  : safeRelativePath(typeof body.destinationDirectory === "string" ? body.destinationDirectory : "");
1542
+ const destination = destinationDirectory
1543
+ ? `${destinationDirectory}/${path.posix.basename(source)}`
1544
+ : path.posix.basename(source);
1180
1545
  const sourceAbsolute = resolveSourcePath(config, id, source);
1181
1546
  if (!fs.existsSync(sourceAbsolute))
1182
1547
  return apiError(reply, 404, "PATH_NOT_FOUND", "要移动的文件或目录不存在", { path: source });
1183
- const sourceStat = fs.statSync(sourceAbsolute);
1184
- if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1548
+ if (destination === source)
1549
+ return { ok: true, path: source };
1550
+ const initialSourceStat = fs.statSync(sourceAbsolute);
1551
+ if (initialSourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1185
1552
  return apiError(reply, 400, "MOVE_INTO_SELF", "不能把目录移动到自身内部", { path: source });
1186
1553
  }
1187
1554
  const destinationRoot = destinationDirectory
@@ -1190,40 +1557,59 @@ export async function buildApp(config, db, options = {}) {
1190
1557
  if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
1191
1558
  return apiError(reply, 404, "DIRECTORY_NOT_FOUND", "目标目录不存在", { path: destinationDirectory });
1192
1559
  }
1193
- const destination = destinationDirectory
1194
- ? `${destinationDirectory}/${path.posix.basename(source)}`
1195
- : path.posix.basename(source);
1196
- if (destination === source)
1197
- return { ok: true, path: source };
1198
1560
  const destinationAbsolute = resolveSourcePath(config, id, destination);
1199
1561
  if (fs.existsSync(destinationAbsolute))
1200
1562
  return apiError(reply, 409, "PATH_EXISTS", "目标目录中存在同名文件或目录", { path: destination });
1201
- collaboration.flushProject(id);
1202
- fs.renameSync(sourceAbsolute, destinationAbsolute);
1203
- db.exec("BEGIN IMMEDIATE");
1204
- try {
1205
- const mainFile = movedProjectPath(project.main_file, source, destination);
1206
- const latexmkrc = movedProjectPath(project.latexmkrc, source, destination);
1207
- const changedAt = now();
1208
- db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
1209
- .run(mainFile, latexmkrc, changedAt, user.id, id);
1210
- const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
1211
- const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
1212
- for (const comment of comments) {
1213
- const nextPath = movedProjectPath(comment.file_path, source, destination);
1214
- if (nextPath !== comment.file_path)
1215
- updateComment.run(nextPath, changedAt, comment.id);
1563
+ let currentProject = project;
1564
+ const validateMove = () => {
1565
+ currentProject = requireEditableProject(db, id, user);
1566
+ assertNoSourceSymlinks(config, id);
1567
+ if (!fs.existsSync(sourceAbsolute)) {
1568
+ throw Object.assign(new Error("要移动的文件或目录不存在"), { statusCode: 404, code: "PATH_NOT_FOUND" });
1216
1569
  }
1217
- db.exec("COMMIT");
1218
- }
1219
- catch (error) {
1220
- db.exec("ROLLBACK");
1221
- fs.renameSync(destinationAbsolute, sourceAbsolute);
1222
- throw error;
1223
- }
1224
- collaboration.movePath(id, source, destination, user.id);
1225
- recordHistory(id, user.id, "file", [source, destination]);
1226
- return { ok: true, path: destination };
1570
+ const sourceStat = fs.statSync(sourceAbsolute);
1571
+ if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1572
+ throw Object.assign(new Error("不能把目录移动到自身内部"), { statusCode: 400, code: "MOVE_INTO_SELF" });
1573
+ }
1574
+ if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
1575
+ throw Object.assign(new Error("目标目录不存在"), { statusCode: 404, code: "DIRECTORY_NOT_FOUND" });
1576
+ }
1577
+ if (fs.existsSync(destinationAbsolute)) {
1578
+ throw Object.assign(new Error("目标目录中存在同名文件或目录"), { statusCode: 409, code: "PATH_EXISTS" });
1579
+ }
1580
+ };
1581
+ return await projectMutations.runExclusive(id, "move project path", () => {
1582
+ fs.renameSync(sourceAbsolute, destinationAbsolute);
1583
+ let hasCommentUpdates = false;
1584
+ db.exec("BEGIN IMMEDIATE");
1585
+ try {
1586
+ const mainFile = movedProjectPath(currentProject.main_file, source, destination);
1587
+ const latexmkrc = movedProjectPath(currentProject.latexmkrc, source, destination);
1588
+ const changedAt = now();
1589
+ db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
1590
+ .run(mainFile, latexmkrc, changedAt, user.id, id);
1591
+ const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
1592
+ const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
1593
+ for (const comment of comments) {
1594
+ const nextPath = movedProjectPath(comment.file_path, source, destination);
1595
+ if (nextPath !== comment.file_path) {
1596
+ updateComment.run(nextPath, changedAt, comment.id);
1597
+ hasCommentUpdates = true;
1598
+ }
1599
+ }
1600
+ db.exec("COMMIT");
1601
+ }
1602
+ catch (error) {
1603
+ db.exec("ROLLBACK");
1604
+ fs.renameSync(destinationAbsolute, sourceAbsolute);
1605
+ throw error;
1606
+ }
1607
+ collaboration.movePath(id, source, destination, user.id);
1608
+ if (hasCommentUpdates)
1609
+ collaboration.signalComments(id);
1610
+ recordHistory(id, user.id, "file", [source, destination]);
1611
+ return { ok: true, path: destination };
1612
+ }, { preflight: validateMove });
1227
1613
  });
1228
1614
  app.get("/api/projects/:id/file/raw", async (request, reply) => {
1229
1615
  const user = requireUser(request, reply, db);
@@ -1234,9 +1620,25 @@ export async function buildApp(config, db, options = {}) {
1234
1620
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1235
1621
  const query = request.query;
1236
1622
  const filePath = safeRelativePath(query.path ?? "");
1237
- const absolute = resolveSourcePath(config, id, filePath);
1238
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1239
- return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: filePath });
1623
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
1624
+ const temporaryFile = path.join(temporaryDirectory, `file-${id}-${randomUUID()}`);
1625
+ try {
1626
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
1627
+ await projectMutations.runConsistentRead(id, async () => {
1628
+ const absolute = resolveSourcePath(config, id, filePath);
1629
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
1630
+ throw Object.assign(new Error("文件不存在"), { statusCode: 404, code: "FILE_NOT_FOUND" });
1631
+ }
1632
+ await fs.promises.copyFile(absolute, temporaryFile);
1633
+ }, { preflight: () => {
1634
+ if (!accessibleProject(db, id, user))
1635
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1636
+ } });
1637
+ }
1638
+ catch (error) {
1639
+ await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
1640
+ throw error;
1641
+ }
1240
1642
  const extension = path.extname(filePath).toLocaleLowerCase();
1241
1643
  const contentTypes = {
1242
1644
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
@@ -1248,8 +1650,12 @@ export async function buildApp(config, db, options = {}) {
1248
1650
  reply.header("Content-Type", contentType);
1249
1651
  reply.header("Content-Disposition", contentDisposition(path.basename(filePath), downloading ? "attachment" : "inline"));
1250
1652
  reply.header("Cache-Control", "private, no-cache");
1251
- reply.header("Content-Length", fs.statSync(absolute).size);
1252
- return reply.send(fs.createReadStream(absolute));
1653
+ reply.header("Content-Length", fs.statSync(temporaryFile).size);
1654
+ const stream = fs.createReadStream(temporaryFile);
1655
+ const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
1656
+ stream.once("close", cleanup);
1657
+ stream.once("error", cleanup);
1658
+ return reply.send(stream);
1253
1659
  });
1254
1660
  app.get("/api/projects/:id/file", async (request, reply) => {
1255
1661
  const user = requireUser(request, reply, db);
@@ -1259,12 +1665,19 @@ export async function buildApp(config, db, options = {}) {
1259
1665
  const { path: filePath } = request.query;
1260
1666
  if (!accessibleProject(db, id, user))
1261
1667
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1262
- const absolute = resolveSourcePath(config, id, filePath ?? "");
1263
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1264
- return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: filePath });
1265
- if (fs.statSync(absolute).size > 5 * 1024 * 1024)
1266
- return apiError(reply, 413, "FILE_TOO_LARGE", "文件过大,不能作为文本打开", { path: filePath });
1267
- return { path: safeRelativePath(filePath ?? ""), content: fs.readFileSync(absolute, "utf8") };
1668
+ const relative = safeRelativePath(filePath ?? "");
1669
+ return await projectMutations.runConsistentRead(id, () => {
1670
+ const absolute = resolveSourcePath(config, id, relative);
1671
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1672
+ return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: relative });
1673
+ if (isCollaborativeTextFile(relative) && fs.statSync(absolute).size > maxCollaborativeFileBytes(config)) {
1674
+ return apiError(reply, 413, "FILE_TOO_LARGE", "协作文本文件过大,不能作为编辑器内容打开", { path: relative });
1675
+ }
1676
+ return { path: relative, content: fs.readFileSync(absolute, "utf8") };
1677
+ }, { preflight: () => {
1678
+ if (!accessibleProject(db, id, user))
1679
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1680
+ } });
1268
1681
  });
1269
1682
  // Creating a file is intentionally separate from updating one. The editor
1270
1683
  // uses PUT for autosaves, but a user action such as "New file" must never
@@ -1274,91 +1687,122 @@ export async function buildApp(config, db, options = {}) {
1274
1687
  if (!user)
1275
1688
  return;
1276
1689
  const { id } = request.params;
1690
+ if (collaboration.isMaintaining(id))
1691
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1277
1692
  const project = accessibleProject(db, id, user);
1278
1693
  if (!project || !canEdit(project))
1279
1694
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1280
1695
  const body = request.body;
1281
1696
  const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1282
1697
  if (typeof body.content !== "string")
1283
- throw new Error("文件内容格式不正确");
1284
- if (Buffer.byteLength(body.content, "utf8") > config.maxUploadBytes) {
1285
- return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(config.maxUploadBytes / 1024 / 1024) });
1286
- }
1287
- const absolute = resolveSourcePath(config, id, filePath);
1288
- try {
1289
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1290
- }
1291
- catch (error) {
1292
- if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1293
- return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: filePath });
1698
+ return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
1699
+ const content = body.content;
1700
+ const byteLength = Buffer.byteLength(content, "utf8");
1701
+ const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
1702
+ if (byteLength > limit) {
1703
+ return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
1704
+ }
1705
+ return await projectMutations.runWrite(id, () => {
1706
+ const absolute = resolveSourcePath(config, id, filePath);
1707
+ try {
1708
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1294
1709
  }
1295
- throw error;
1296
- }
1297
- try {
1298
- fs.writeFileSync(absolute, body.content, { encoding: "utf8", mode: 0o600, flag: "wx" });
1299
- }
1300
- catch (error) {
1301
- if (error.code === "EEXIST") {
1302
- return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
1710
+ catch (error) {
1711
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1712
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: filePath });
1713
+ }
1714
+ throw error;
1303
1715
  }
1304
- throw error;
1305
- }
1306
- touchProject(db, id, user.id);
1307
- collaboration.updateFile(id, filePath, body.content, user.id);
1308
- recordHistory(id, user.id, "file", [filePath]);
1309
- return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
1716
+ try {
1717
+ fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
1718
+ }
1719
+ catch (error) {
1720
+ if (error.code === "EEXIST") {
1721
+ return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
1722
+ }
1723
+ throw error;
1724
+ }
1725
+ touchProject(db, id, user.id);
1726
+ collaboration.updateFile(id, filePath, content, user.id);
1727
+ recordHistory(id, user.id, "file", [filePath]);
1728
+ return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
1729
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1310
1730
  });
1311
1731
  app.put("/api/projects/:id/file", async (request, reply) => {
1312
1732
  const user = requireUser(request, reply, db);
1313
1733
  if (!user)
1314
1734
  return;
1315
1735
  const { id } = request.params;
1736
+ if (collaboration.isMaintaining(id))
1737
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1316
1738
  const project = accessibleProject(db, id, user);
1317
1739
  if (!project || !canEdit(project))
1318
1740
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1319
1741
  const body = request.body;
1320
1742
  const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1321
1743
  if (typeof body.content !== "string")
1322
- throw new Error("文件内容格式不正确");
1323
- if (Buffer.byteLength(body.content, "utf8") > config.maxUploadBytes) {
1324
- return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(config.maxUploadBytes / 1024 / 1024) });
1325
- }
1326
- const absolute = resolveSourcePath(config, id, filePath);
1327
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1328
- const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
1329
- reanchorFileComments(db, id, filePath, previousContent, body.content);
1330
- fs.writeFileSync(absolute, body.content, { encoding: "utf8", mode: 0o600 });
1331
- touchProject(db, id, user.id);
1332
- collaboration.updateFile(id, filePath, body.content, user.id);
1333
- recordHistory(id, user.id, "file", [filePath]);
1334
- return { ok: true, comments: commentsForFile(db, config, id, filePath) };
1744
+ return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
1745
+ const content = body.content;
1746
+ const byteLength = Buffer.byteLength(content, "utf8");
1747
+ const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
1748
+ if (byteLength > limit) {
1749
+ return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
1750
+ }
1751
+ return await projectMutations.runWrite(id, () => {
1752
+ const absolute = resolveSourcePath(config, id, filePath);
1753
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1754
+ const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
1755
+ reanchorFileComments(db, id, filePath, previousContent, content);
1756
+ fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600 });
1757
+ touchProject(db, id, user.id);
1758
+ collaboration.updateFile(id, filePath, content, user.id);
1759
+ recordHistory(id, user.id, "file", [filePath]);
1760
+ return { ok: true, comments: commentsForFile(db, config, id, filePath) };
1761
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1335
1762
  });
1336
1763
  app.delete("/api/projects/:id/file", async (request, reply) => {
1337
1764
  const user = requireUser(request, reply, db);
1338
1765
  if (!user)
1339
1766
  return;
1340
1767
  const { id } = request.params;
1768
+ if (collaboration.isMaintaining(id))
1769
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1341
1770
  const { path: filePath } = request.query;
1771
+ const relative = safeRelativePath(filePath ?? "");
1342
1772
  const project = accessibleProject(db, id, user);
1343
1773
  if (!project || !canEdit(project))
1344
1774
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1345
- const relative = safeRelativePath(filePath ?? "");
1346
- if (relative === project.main_file || project.main_file.startsWith(`${relative}/`)) {
1347
- return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", "不能删除当前主文件或包含主文件的目录", { path: relative });
1348
- }
1349
- if (!fs.existsSync(resolveSourcePath(config, id, relative)))
1350
- return apiError(reply, 404, "PATH_NOT_FOUND", "文件或目录不存在", { path: relative });
1351
- fs.rmSync(resolveSourcePath(config, id, relative), { recursive: true, force: true });
1352
- touchProject(db, id, user.id);
1353
- collaboration.removePath(id, relative);
1354
- recordHistory(id, user.id, "file", [relative]);
1355
- return { ok: true };
1775
+ return await projectMutations.runWrite(id, () => {
1776
+ const currentProject = requireEditableProject(db, id, user);
1777
+ if (relative === currentProject.main_file || currentProject.main_file.startsWith(`${relative}/`)) {
1778
+ return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", "不能删除当前主文件或包含主文件的目录", { path: relative });
1779
+ }
1780
+ const absolute = resolveSourcePath(config, id, relative, { allowFinalSymlink: true });
1781
+ try {
1782
+ fs.lstatSync(absolute);
1783
+ }
1784
+ catch (error) {
1785
+ if (error.code === "ENOENT")
1786
+ return apiError(reply, 404, "PATH_NOT_FOUND", "文件或目录不存在", { path: relative });
1787
+ throw error;
1788
+ }
1789
+ fs.rmSync(absolute, { recursive: true, force: true });
1790
+ const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
1791
+ touchProject(db, id, user.id);
1792
+ collaboration.removePath(id, relative);
1793
+ if (deleteResult.changes > 0)
1794
+ collaboration.signalComments(id);
1795
+ recordHistory(id, user.id, "file", [relative]);
1796
+ return { ok: true };
1797
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1356
1798
  });
1357
1799
  app.post("/api/projects/:id/upload", async (request, reply) => {
1358
1800
  const user = requireUser(request, reply, db);
1359
1801
  if (!user)
1360
1802
  return;
1361
1803
  const { id } = request.params;
1804
+ if (collaboration.isMaintaining(id))
1805
+ return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1362
1806
  const project = accessibleProject(db, id, user);
1363
1807
  if (!project || !canEdit(project))
1364
1808
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
@@ -1367,34 +1811,98 @@ export async function buildApp(config, db, options = {}) {
1367
1811
  return apiError(reply, 400, "UPLOAD_EMPTY", "没有收到上传文件");
1368
1812
  const { directory, overwrite } = request.query;
1369
1813
  const relative = safeRelativePath(directory ? `${safeRelativePath(directory)}/${part.filename}` : part.filename);
1370
- const absolute = resolveSourcePath(config, id, relative);
1814
+ const uploadLimit = isCollaborativeTextFile(relative) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
1815
+ const tmpDir = path.join(config.dataDir, "tmp");
1816
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
1817
+ const tmpPath = path.join(tmpDir, `upload-${randomUUID()}.tmp`);
1818
+ let byteLength = 0;
1371
1819
  try {
1372
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1820
+ const limiter = new Transform({
1821
+ transform(chunk, _encoding, callback) {
1822
+ byteLength += chunk.length;
1823
+ if (byteLength > uploadLimit) {
1824
+ callback(new Error("FILE_TOO_LARGE"));
1825
+ }
1826
+ else {
1827
+ callback(null, chunk);
1828
+ }
1829
+ }
1830
+ });
1831
+ await pipeline(part.file, limiter, fs.createWriteStream(tmpPath, { mode: 0o600 }));
1373
1832
  }
1374
1833
  catch (error) {
1375
- if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1376
- return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: relative });
1834
+ if (fs.existsSync(tmpPath)) {
1835
+ try {
1836
+ fs.unlinkSync(tmpPath);
1837
+ }
1838
+ catch { /* ignore */ }
1839
+ }
1840
+ if (error instanceof Error && error.message === "FILE_TOO_LARGE") {
1841
+ return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(uploadLimit / 1024 / 1024)} MB`, { path: relative, size: Math.floor(uploadLimit / 1024 / 1024) });
1377
1842
  }
1378
1843
  throw error;
1379
1844
  }
1380
- const uploaded = await part.toBuffer();
1381
- const replacing = overwrite === "1";
1382
- if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
1383
- return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
1384
- }
1385
1845
  try {
1386
- fs.writeFileSync(absolute, uploaded, { mode: 0o600, flag: replacing ? "w" : "wx" });
1846
+ return await projectMutations.runWrite(id, () => {
1847
+ const absolute = resolveSourcePath(config, id, relative);
1848
+ try {
1849
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1850
+ }
1851
+ catch (error) {
1852
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1853
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: relative });
1854
+ }
1855
+ throw error;
1856
+ }
1857
+ const replacing = overwrite === "1";
1858
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
1859
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
1860
+ }
1861
+ if (!replacing && fs.existsSync(absolute)) {
1862
+ return apiError(reply, 409, "FILE_EXISTS", "同名文件已存在", { path: relative });
1863
+ }
1864
+ const collaborativeText = isCollaborativeTextFile(relative);
1865
+ let previousContent = null;
1866
+ if (replacing && collaborativeText && fs.existsSync(absolute)) {
1867
+ // runWrite() has flushed any live Yjs edits before entering this
1868
+ // callback. Prefer the room's text so comment anchors are based on
1869
+ // exactly what collaborators last saved, then fall back to disk for
1870
+ // projects without an active collaboration room.
1871
+ previousContent = collaboration.fileContent(id, relative);
1872
+ if (previousContent === null)
1873
+ previousContent = fs.readFileSync(absolute, "utf8");
1874
+ }
1875
+ try {
1876
+ fs.renameSync(tmpPath, absolute);
1877
+ }
1878
+ catch {
1879
+ fs.copyFileSync(tmpPath, absolute);
1880
+ fs.unlinkSync(tmpPath);
1881
+ }
1882
+ touchProject(db, id, user.id);
1883
+ if (collaborativeText) {
1884
+ const content = fs.readFileSync(absolute, "utf8");
1885
+ if (previousContent !== null && previousContent !== content) {
1886
+ reanchorFileComments(db, id, relative, previousContent, content);
1887
+ collaboration.signalComments(id);
1888
+ }
1889
+ collaboration.updateFile(id, relative, content, user.id);
1890
+ }
1891
+ else {
1892
+ collaboration.invalidateSourceTree(id, relative);
1893
+ }
1894
+ recordHistory(id, user.id, "file", [relative]);
1895
+ return reply.code(201).send({ ok: true, path: relative });
1896
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
1387
1897
  }
1388
- catch (error) {
1389
- if (error.code === "EEXIST") {
1390
- return apiError(reply, 409, "FILE_EXISTS", "同名文件已存在", { path: relative });
1898
+ finally {
1899
+ if (fs.existsSync(tmpPath)) {
1900
+ try {
1901
+ fs.unlinkSync(tmpPath);
1902
+ }
1903
+ catch { /* ignore */ }
1391
1904
  }
1392
- throw error;
1393
1905
  }
1394
- touchProject(db, id, user.id);
1395
- collaboration.updateFile(id, relative, uploaded.toString("utf8"), user.id);
1396
- recordHistory(id, user.id, "file", [relative]);
1397
- return reply.code(201).send({ ok: true, path: relative });
1398
1906
  });
1399
1907
  app.get("/api/projects/:id/members", async (request, reply) => {
1400
1908
  const user = requireUser(request, reply, db);
@@ -1426,34 +1934,44 @@ export async function buildApp(config, db, options = {}) {
1426
1934
  const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
1427
1935
  if (!target)
1428
1936
  return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
1429
- collaboration.flushProject(id);
1430
- const changedAt = now();
1431
- db.exec("BEGIN IMMEDIATE");
1432
- try {
1433
- // The new owner may already be a shared member. The previous owner keeps
1434
- // edit access so a transfer does not unexpectedly lock them out.
1435
- db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, target.id);
1436
- db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
1437
- VALUES (?, ?, 'edit', ?)
1438
- ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
1439
- .run(id, user.id, changedAt);
1440
- db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
1441
- .run(target.id, user.id, changedAt, id);
1442
- // A project token belongs to the account that supplied it. Preserve the
1443
- // local repository/remote metadata, but require the new owner to add
1444
- // their own credential before the next GitHub operation.
1445
- db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
1446
- .run(changedAt, id);
1447
- db.exec("COMMIT");
1448
- }
1449
- catch (error) {
1450
- db.exec("ROLLBACK");
1451
- throw error;
1452
- }
1453
- collaboration.resetProject(id);
1454
- return {
1455
- project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id))
1456
- };
1937
+ return await projectMutations.runExclusive(id, "project transfer", () => {
1938
+ const currentTarget = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
1939
+ // The synchronous preflight immediately precedes maintenance, so this
1940
+ // lookup cannot change before the operation starts.
1941
+ if (!currentTarget)
1942
+ throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
1943
+ const changedAt = now();
1944
+ db.exec("BEGIN IMMEDIATE");
1945
+ try {
1946
+ // The new owner may already be a shared member. The previous owner keeps
1947
+ // edit access so a transfer does not unexpectedly lock them out.
1948
+ db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, currentTarget.id);
1949
+ db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
1950
+ VALUES (?, ?, 'edit', ?)
1951
+ ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
1952
+ .run(id, user.id, changedAt);
1953
+ db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
1954
+ .run(currentTarget.id, user.id, changedAt, id);
1955
+ // A project token belongs to the account that supplied it. Preserve the
1956
+ // local repository/remote metadata, but require the new owner to add
1957
+ // their own credential before the next GitHub operation.
1958
+ db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
1959
+ .run(changedAt, id);
1960
+ db.exec("COMMIT");
1961
+ }
1962
+ catch (error) {
1963
+ db.exec("ROLLBACK");
1964
+ throw error;
1965
+ }
1966
+ return {
1967
+ project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
1968
+ };
1969
+ }, { preflight: () => {
1970
+ requireActualProjectOwner(db, id, user);
1971
+ if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(body.userId)) {
1972
+ throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
1973
+ }
1974
+ } });
1457
1975
  });
1458
1976
  app.put("/api/projects/:id/members/:userId", async (request, reply) => {
1459
1977
  const user = requireUser(request, reply, db);
@@ -1465,12 +1983,16 @@ export async function buildApp(config, db, options = {}) {
1465
1983
  return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
1466
1984
  if (userId === project.owner_id)
1467
1985
  return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN", "项目所有者不能作为成员添加");
1986
+ if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(userId)) {
1987
+ return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
1988
+ }
1468
1989
  const body = request.body;
1469
1990
  const permission = body.permission === "edit" ? "edit" : "read";
1470
1991
  db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
1471
1992
  ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
1472
1993
  .run(id, userId, permission, now());
1473
1994
  touchProject(db, id, user.id);
1995
+ collaboration.notifyPermissionChanged(id, userId, permission);
1474
1996
  return { ok: true };
1475
1997
  });
1476
1998
  app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
@@ -1483,6 +2005,7 @@ export async function buildApp(config, db, options = {}) {
1483
2005
  return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
1484
2006
  db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
1485
2007
  touchProject(db, id, user.id);
2008
+ collaboration.notifyPermissionChanged(id, userId, "revoked");
1486
2009
  return { ok: true };
1487
2010
  });
1488
2011
  app.get("/api/projects/:id/comments", async (request, reply) => {
@@ -1494,7 +2017,14 @@ export async function buildApp(config, db, options = {}) {
1494
2017
  if (!accessibleProject(db, id, user))
1495
2018
  return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1496
2019
  const relative = safeRelativePath(filePath ?? "");
1497
- return { comments: commentsForFile(db, config, id, relative) };
2020
+ return {
2021
+ comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
2022
+ preflight: () => {
2023
+ if (!accessibleProject(db, id, user))
2024
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
2025
+ }
2026
+ })
2027
+ };
1498
2028
  });
1499
2029
  app.post("/api/projects/:id/comments", async (request, reply) => {
1500
2030
  const user = requireUser(request, reply, db);
@@ -1506,27 +2036,31 @@ export async function buildApp(config, db, options = {}) {
1506
2036
  const body = request.body;
1507
2037
  const createdAt = now();
1508
2038
  const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1509
- collaboration.flushProject(id);
1510
- const absolute = resolveSourcePath(config, id, filePath);
1511
- if (!fs.existsSync(absolute))
1512
- return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND", "批注文件不存在");
1513
- const source = fs.readFileSync(absolute, "utf8");
1514
- if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
1515
- return apiError(reply, 400, "COMMENT_RANGE_INVALID", "批注缺少有效的源码区间");
1516
- }
1517
- const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
1518
- const comment = {
1519
- id: randomUUID(), filePath,
1520
- content: text(body.content, "批注", 5000)
1521
- };
1522
- db.prepare(`INSERT INTO comments
1523
- (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
1524
- context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
1525
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
1526
- .run(comment.id, id, comment.filePath, user.id, anchor.selectedText, anchor.startOffset, anchor.endOffset, anchor.contextBefore, anchor.contextAfter, offsetToLine(source, anchor.startOffset), offsetToLine(source, anchor.endOffset), comment.content, createdAt, createdAt);
1527
- const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
1528
- collaboration.signalComments(id);
1529
- return reply.code(201).send({ comment: created });
2039
+ return await projectMutations.runWrite(id, () => {
2040
+ const absolute = resolveSourcePath(config, id, filePath);
2041
+ if (!fs.existsSync(absolute))
2042
+ return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND", "批注文件不存在");
2043
+ const source = fs.readFileSync(absolute, "utf8");
2044
+ if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
2045
+ return apiError(reply, 400, "COMMENT_RANGE_INVALID", "批注缺少有效的源码区间");
2046
+ }
2047
+ const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
2048
+ const comment = {
2049
+ id: randomUUID(), filePath,
2050
+ content: text(body.content, "批注", 5000)
2051
+ };
2052
+ db.prepare(`INSERT INTO comments
2053
+ (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
2054
+ context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
2055
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
2056
+ .run(comment.id, id, comment.filePath, user.id, anchor.selectedText, anchor.startOffset, anchor.endOffset, anchor.contextBefore, anchor.contextAfter, offsetToLine(source, anchor.startOffset), offsetToLine(source, anchor.endOffset), comment.content, createdAt, createdAt);
2057
+ const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
2058
+ collaboration.signalComments(id);
2059
+ return reply.code(201).send({ comment: created });
2060
+ }, { preflight: () => {
2061
+ if (!accessibleProject(db, id, user))
2062
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
2063
+ } });
1530
2064
  });
1531
2065
  app.post("/api/projects/:id/comments/:commentId/replies", async (request, reply) => {
1532
2066
  const user = requireUser(request, reply, db);
@@ -1638,7 +2172,7 @@ export async function buildApp(config, db, options = {}) {
1638
2172
  collaboration.signalComments(id);
1639
2173
  return { ok: true };
1640
2174
  });
1641
- registerCompileRoutes(app, { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns });
2175
+ registerCompileRoutes(app, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
1642
2176
  app.addHook("onRequest", async (request) => {
1643
2177
  if (request.url.startsWith("/api/") && request.url !== "/api/auth/login" && request.url !== "/api/health" && request.url !== "/api/config") {
1644
2178
  const token = request.cookies.texlite_session;
@@ -1657,5 +2191,20 @@ export async function buildApp(config, db, options = {}) {
1657
2191
  return reply.sendFile("index.html");
1658
2192
  });
1659
2193
  }
2194
+ const cleanupExpiredSessions = () => {
2195
+ try {
2196
+ pruneExpiredSessions(db, now());
2197
+ loginLimiter.prune();
2198
+ }
2199
+ catch (error) {
2200
+ app.log.error({ err: error }, "Failed to prune expired sessions");
2201
+ }
2202
+ };
2203
+ // Clean up at startup as well as periodically so long-running deployments do
2204
+ // not retain one row per historical login indefinitely.
2205
+ cleanupExpiredSessions();
2206
+ const sessionCleanupTimer = setInterval(cleanupExpiredSessions, SESSION_CLEANUP_INTERVAL_MS);
2207
+ sessionCleanupTimer.unref();
2208
+ app.addHook("onClose", async () => { clearInterval(sessionCleanupTimer); });
1660
2209
  return app;
1661
2210
  }