texlite 0.7.5 → 0.7.7

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 (68) hide show
  1. package/README.md +19 -3
  2. package/README.zh-CN.md +8 -3
  3. package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-Bm5yMpQK.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-Po4KHT_2.js +5 -0
  5. package/dist/client/assets/LatexEditor-CnKCXaHr.js +59 -0
  6. package/dist/client/assets/PdfPreview-Cd20VIjT.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-BDOtIoTy.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-DdOlcqqK.js +1 -0
  9. package/dist/client/assets/index-MDwr9W9R.css +1 -0
  10. package/dist/client/assets/index-zIjCeZFK.js +64 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-O6pK-6mA.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-Ce3o_xO-.js +4 -0
  15. package/dist/client/index.html +3 -3
  16. package/dist/server/anchors.js +22 -1
  17. package/dist/server/app.js +103 -2335
  18. package/dist/server/auth.js +3 -2
  19. package/dist/server/cli.js +8 -2
  20. package/dist/server/collaboration.js +283 -11
  21. package/dist/server/compileArtifacts.js +10 -7
  22. package/dist/server/compiler.js +9 -4
  23. package/dist/server/config.js +25 -1
  24. package/dist/server/environment.js +5 -1
  25. package/dist/server/files.js +7 -6
  26. package/dist/server/git.js +28 -39
  27. package/dist/server/harper.js +144 -0
  28. package/dist/server/harperRuntime.js +38 -0
  29. package/dist/server/harperWorker.js +15 -0
  30. package/dist/server/history.js +11 -9
  31. package/dist/server/http.js +33 -13
  32. package/dist/server/i18n.js +276 -0
  33. package/dist/server/latexRoot.js +78 -0
  34. package/dist/server/limits.js +9 -0
  35. package/dist/server/projectSearch.js +3 -4
  36. package/dist/server/routes/auth.js +77 -0
  37. package/dist/server/routes/citations.js +331 -0
  38. package/dist/server/routes/collaboration.js +17 -0
  39. package/dist/server/routes/comments.js +176 -0
  40. package/dist/server/routes/compile.js +71 -50
  41. package/dist/server/routes/projectFiles.js +572 -0
  42. package/dist/server/routes/projectGit.js +145 -0
  43. package/dist/server/routes/projectHistory.js +150 -0
  44. package/dist/server/routes/projectMembers.js +112 -0
  45. package/dist/server/routes/projectShared.js +238 -0
  46. package/dist/server/routes/projects.js +473 -0
  47. package/dist/server/routes/system.js +37 -0
  48. package/dist/server/routes/users.js +163 -0
  49. package/dist/server/security.js +2 -4
  50. package/dist/server/synctex.js +7 -4
  51. package/dist/server/zip.js +29 -13
  52. package/package.json +3 -3
  53. package/texlite.config.example.json +5 -1
  54. package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
  55. package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
  56. package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
  57. package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
  58. package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
  59. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  60. package/dist/client/assets/binary-BoldOm0z.js +0 -1
  61. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  62. package/dist/client/assets/index-BQCB3z5H.js +0 -64
  63. package/dist/client/assets/index-BnvpaWsa.css +0 -1
  64. package/dist/client/assets/index-Byn2Miy9.js +0 -3456
  65. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  66. package/dist/client/assets/minus-K-5IK4nL.js +0 -1
  67. package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
  68. package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
@@ -1,359 +1,41 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { randomUUID } from "node:crypto";
4
- import { Transform } from "node:stream";
5
- import { pipeline } from "node:stream/promises";
6
3
  import { monitorEventLoopDelay, performance } from "node:perf_hooks";
7
4
  import Fastify from "fastify";
8
5
  import cookie from "@fastify/cookie";
9
6
  import multipart from "@fastify/multipart";
10
7
  import staticPlugin from "@fastify/static";
11
8
  import websocket from "@fastify/websocket";
12
- import { activeAdminCount, pruneExpiredSessions } from "./db.js";
13
- import { createSessionToken, hashPassword, verifyPassword, digestToken, LoginRateLimiter } from "./security.js";
14
- import { currentUser, publicUser, requireAdmin, requireUser } from "./auth.js";
15
- import { accessibleProject, canEdit } from "./projects.js";
16
- import { createProjectFiles, duplicateProjectFiles, assertNoSourceSymlinks, listProjectFiles, listProjectFilesAsync, outputRoot, pruneTrashDirectory, removeProjectDirectory, resolveSourcePath, safePathSegment, safeRelativePath, sourceRoot } from "./files.js";
9
+ import { pruneExpiredSessions } from "./db.js";
10
+ import { digestToken, LoginRateLimiter } from "./security.js";
11
+ import { currentUser } from "./auth.js";
12
+ import { pruneTrashDirectory } from "./files.js";
17
13
  import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns, reconcilePublishedCompileRuns } from "./compiler.js";
18
- import { createSourceAnchor, offsetToLine, reanchorFileComments } from "./anchors.js";
19
- import { extractProjectZip } from "./zip.js";
20
- import { writeProjectArchive } from "./archive.js";
21
- import { CollaborationService, isCollaborativeTextFile, maxCollaborativeFileBytes } from "./collaboration.js";
14
+ import { CollaborationService } from "./collaboration.js";
22
15
  import { ProjectMutationCoordinator } from "./projectMutations.js";
23
16
  import { ProjectGitService } from "./git.js";
24
17
  import { LatexCompletionService } from "./latexCompletion.js";
25
18
  import { ProjectHistoryService } from "./history.js";
26
19
  import { ProjectOutlineService } from "./projectOutline.js";
27
- import { replaceProject, searchProject } from "./projectSearch.js";
28
20
  import { MetricRegistry } from "./metrics.js";
29
- import { compileMainFile } from "./compileArtifacts.js";
30
- import { apiError, contentDisposition, ValidationError } from "./http.js";
21
+ import { apiError, HttpError } from "./http.js";
31
22
  import { registerCompileRoutes } from "./routes/compile.js";
32
- const now = () => new Date().toISOString();
33
- const MAX_CITATION_BIBTEX_BYTES = 512 * 1024;
23
+ import { registerAuthRoutes } from "./routes/auth.js";
24
+ import { registerCollaborationRoutes } from "./routes/collaboration.js";
25
+ import { registerCitationRoutes } from "./routes/citations.js";
26
+ import { registerCommentRoutes } from "./routes/comments.js";
27
+ import { registerProjectMemberRoutes } from "./routes/projectMembers.js";
28
+ import { registerProjectFileRoutes } from "./routes/projectFiles.js";
29
+ import { registerProjectHistoryRoutes } from "./routes/projectHistory.js";
30
+ import { registerProjectGitRoutes } from "./routes/projectGit.js";
31
+ import { registerProjectCatalogRoutes } from "./routes/projects.js";
32
+ import { registerSystemRoutes } from "./routes/system.js";
33
+ import { registerUserManagementRoutes } from "./routes/users.js";
34
+ import { HarperService } from "./harper.js";
35
+ // Retained public helper for callers and tests; implementation lives with the file routes.
36
+ export { escapeGlobPattern } from "./routes/projectShared.js";
34
37
  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
- }
41
- function text(value, name, max = 200) {
42
- if (typeof value !== "string" || !value.trim() || value.length > max) {
43
- throw new ValidationError(`${name}格式不正确`);
44
- }
45
- return value.trim();
46
- }
47
- function dictionaryWord(value) {
48
- if (typeof value !== "string")
49
- throw new ValidationError("自定义词格式不正确");
50
- const word = value.trim();
51
- if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
52
- throw new ValidationError("自定义词格式不正确");
53
- return word;
54
- }
55
- function citationJson(row, tags = []) {
56
- return {
57
- id: row.id,
58
- citationKey: row.citation_key,
59
- entryType: row.entry_type,
60
- bibtex: row.bibtex,
61
- title: row.title,
62
- authors: row.authors,
63
- year: row.year,
64
- revision: row.revision,
65
- tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.user_id })),
66
- ownerId: row.user_id,
67
- ownerUsername: row.owner_username ?? null,
68
- ownerDisplayName: row.owner_display_name ?? null,
69
- createdAt: row.created_at,
70
- updatedAt: row.updated_at
71
- };
72
- }
73
- function citationTagsForEntries(db, entryIds) {
74
- const result = new Map(entryIds.map((entryId) => [entryId, []]));
75
- if (!entryIds.length)
76
- return result;
77
- const placeholders = entryIds.map(() => "?").join(", ");
78
- const rows = db.prepare(`SELECT link.entry_id, tag.id, tag.name, tag.color, tag.user_id
79
- FROM citation_library_entry_tags link JOIN citation_library_tags tag ON tag.id = link.tag_id
80
- JOIN citation_library_entries entry ON entry.id = link.entry_id AND entry.user_id = tag.user_id
81
- WHERE link.entry_id IN (${placeholders})
82
- ORDER BY tag.name COLLATE NOCASE`).all(...entryIds);
83
- for (const row of rows)
84
- result.get(row.entry_id)?.push({ id: row.id, name: row.name, color: row.color, user_id: row.user_id });
85
- return result;
86
- }
87
- function citationTagIds(db, userId, value) {
88
- if (value === undefined)
89
- return null;
90
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
91
- throw new ValidationError("引用标签格式不正确");
92
- const ids = [...new Set(value.map((item) => item.trim()).filter(Boolean))];
93
- if (ids.length > 100)
94
- throw new ValidationError("单个引用最多只能设置 100 个标签");
95
- if (!ids.length)
96
- return [];
97
- const placeholders = ids.map(() => "?").join(", ");
98
- const rows = db.prepare(`SELECT id FROM citation_library_tags WHERE user_id = ? AND id IN (${placeholders})`)
99
- .all(userId, ...ids);
100
- if (rows.length !== ids.length)
101
- throw Object.assign(new Error("引用标签不存在"), { statusCode: 404, code: "CITATION_TAG_NOT_FOUND" });
102
- return ids;
103
- }
104
- function citationTagName(value) {
105
- return text(value, "引用标签名称", 32);
106
- }
107
- function citationExpectedRevision(value) {
108
- if (!Number.isInteger(value) || Number(value) < 1) {
109
- throw new ValidationError("引用版本格式不正确");
110
- }
111
- return Number(value);
112
- }
113
- function citationNullableText(value, name, max) {
114
- if (value === undefined || value === null || value === "")
115
- return null;
116
- if (typeof value !== "string" || value.length > max)
117
- throw new ValidationError(`${name}格式不正确`);
118
- const trimmed = value.trim();
119
- return trimmed || null;
120
- }
121
- function citationInput(value) {
122
- if (typeof value !== "object" || value === null)
123
- throw new ValidationError("引用条目格式不正确");
124
- const body = value;
125
- if (typeof body.bibtex !== "string" || !body.bibtex.trim()) {
126
- throw new ValidationError("引用条目不能为空");
127
- }
128
- if (Buffer.byteLength(body.bibtex, "utf8") > MAX_CITATION_BIBTEX_BYTES) {
129
- throw Object.assign(new Error("引用条目过大"), { statusCode: 413, code: "CITATION_TOO_LARGE" });
130
- }
131
- if (typeof body.citationKey !== "string" || !body.citationKey.trim() || body.citationKey.length > 512) {
132
- throw new ValidationError("引用 key 格式不正确");
133
- }
134
- if (typeof body.entryType !== "string" || !body.entryType.trim() || body.entryType.length > 128) {
135
- throw new ValidationError("引用类型格式不正确");
136
- }
137
- return {
138
- bibtex: body.bibtex.trim(),
139
- citationKey: body.citationKey.trim(),
140
- entryType: body.entryType.trim(),
141
- title: citationNullableText(body.title, "引用标题", 2048),
142
- authors: citationNullableText(body.authors, "引用作者", 2048),
143
- year: citationNullableText(body.year, "引用年份", 128)
144
- };
145
- }
146
- const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
147
- function tagsForProject(db, projectId, userId) {
148
- return db.prepare(`SELECT tag.id, tag.name, tag.color
149
- FROM user_tags tag JOIN user_project_tag_links link ON link.tag_id = tag.id
150
- WHERE link.project_id = ? AND tag.user_id = ? ORDER BY tag.name COLLATE NOCASE`)
151
- .all(projectId, userId);
152
- }
153
- function tagsForProjects(db, projectIds, userId) {
154
- const result = new Map(projectIds.map((projectId) => [projectId, []]));
155
- for (let offset = 0; offset < projectIds.length; offset += 500) {
156
- const chunk = projectIds.slice(offset, offset + 500);
157
- const placeholders = chunk.map(() => "?").join(", ");
158
- const rows = db.prepare(`SELECT link.project_id, tag.id, tag.name, tag.color
159
- FROM user_project_tag_links link JOIN user_tags tag ON tag.id = link.tag_id
160
- WHERE tag.user_id = ? AND link.project_id IN (${placeholders})
161
- ORDER BY tag.name COLLATE NOCASE`).all(userId, ...chunk);
162
- for (const row of rows)
163
- result.get(row.project_id)?.push({ id: row.id, name: row.name, color: row.color });
164
- }
165
- return result;
166
- }
167
- function commentsSummaryForProjects(db, projectIds) {
168
- const result = new Map();
169
- if (!projectIds.length)
170
- return result;
171
- for (let index = 0; index < projectIds.length; index += 100) {
172
- const chunk = projectIds.slice(index, index + 100);
173
- const placeholders = chunk.map(() => "?").join(", ");
174
- const rows = db.prepare(`
175
- SELECT project_id,
176
- COUNT(*) AS total_count,
177
- SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
178
- FROM comments
179
- WHERE project_id IN (${placeholders})
180
- GROUP BY project_id
181
- `).all(...chunk);
182
- for (const row of rows) {
183
- result.set(row.project_id, {
184
- totalCount: Number(row.total_count) || 0,
185
- unresolvedCount: Number(row.unresolved_count) || 0
186
- });
187
- }
188
- }
189
- return result;
190
- }
191
- function commentsSummaryForProject(db, projectId) {
192
- const row = db.prepare(`
193
- SELECT
194
- COUNT(*) AS total_count,
195
- SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
196
- FROM comments
197
- WHERE project_id = ?
198
- `).get(projectId);
199
- return {
200
- totalCount: Number(row?.total_count) || 0,
201
- unresolvedCount: Number(row?.unresolved_count) || 0
202
- };
203
- }
204
- function projectJson(project, tags = [], commentsSummary) {
205
- return {
206
- id: project.id,
207
- ownerId: project.owner_id,
208
- ownerUsername: project.owner_username,
209
- ownerDisplayName: project.owner_display_name,
210
- lastModifiedBy: project.last_modified_by,
211
- lastModifiedUsername: project.last_modified_username,
212
- lastModifiedDisplayName: project.last_modified_display_name,
213
- name: project.name,
214
- mainFile: project.main_file,
215
- latexmkrc: project.latexmkrc,
216
- engine: project.engine,
217
- permission: project.permission,
218
- tags,
219
- unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
220
- commentCount: commentsSummary?.totalCount ?? 0,
221
- archived: Boolean(project.archived),
222
- createdAt: project.created_at,
223
- updatedAt: project.updated_at
224
- };
225
- }
226
- function touchProject(db, projectId, userId) {
227
- db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
228
- .run(now(), userId, projectId);
229
- }
230
- function requireActiveUser(db, user) {
231
- const current = db.prepare("SELECT disabled FROM users WHERE id = ?").get(user.id);
232
- if (!current || current.disabled) {
233
- throw Object.assign(new Error("用户已被禁用或删除"), { statusCode: 401, code: "AUTH_REQUIRED" });
234
- }
235
- }
236
- /**
237
- * Authorization must be checked again after a queued mutation acquires its
238
- * project lock. A member can be revoked, or ownership can be transferred,
239
- * while the request is waiting behind another filesystem operation.
240
- */
241
- function requireEditableProject(db, projectId, user) {
242
- requireActiveUser(db, user);
243
- const project = accessibleProject(db, projectId, user);
244
- if (!project)
245
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
246
- if (!canEdit(project))
247
- throw Object.assign(new Error("没有编辑权限"), { statusCode: 403, code: "PROJECT_EDIT_FORBIDDEN" });
248
- return project;
249
- }
250
- /** Owner permission includes an administrator's effective owner access. */
251
- function requireProjectOwnerPermission(db, projectId, user) {
252
- requireActiveUser(db, user);
253
- const project = accessibleProject(db, projectId, user);
254
- if (!project)
255
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
256
- if (project.permission !== "owner") {
257
- throw Object.assign(new Error("只有项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
258
- }
259
- return project;
260
- }
261
- /** Operations such as ownership transfer require the actual stored owner. */
262
- function requireActualProjectOwner(db, projectId, user) {
263
- requireActiveUser(db, user);
264
- const project = accessibleProject(db, projectId, user);
265
- if (!project)
266
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
267
- if (project.owner_id !== user.id) {
268
- throw Object.assign(new Error("只有当前项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
269
- }
270
- return project;
271
- }
272
- function projectTextSnapshot(config, projectId) {
273
- const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
274
- return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
275
- const absolute = resolveSourcePath(config, projectId, entry.path);
276
- return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
277
- }));
278
- }
279
- function reanchorProjectSnapshot(db, projectId, before, after) {
280
- for (const filePath of new Set([...before.keys(), ...after.keys()])) {
281
- reanchorFileComments(db, projectId, filePath, before.get(filePath) ?? "", after.get(filePath) ?? "");
282
- }
283
- }
284
- function movedProjectPath(value, source, destination) {
285
- if (value === null)
286
- return null;
287
- if (value === source)
288
- return destination;
289
- return value.startsWith(`${source}/`) ? `${destination}${value.slice(source.length)}` : value;
290
- }
291
- function repliesForComment(db, commentId) {
292
- const rows = db.prepare(`SELECT reply.*, user.username AS author_username,
293
- user.display_name AS author_display_name
294
- FROM comment_replies reply LEFT JOIN users user ON user.id = reply.author_id
295
- WHERE reply.comment_id = ? ORDER BY reply.created_at`)
296
- .all(commentId);
297
- return rows.map((reply) => ({
298
- id: reply.id,
299
- authorId: reply.author_id,
300
- authorUsername: reply.author_username,
301
- authorDisplayName: reply.author_display_name,
302
- content: reply.content,
303
- createdAt: reply.created_at,
304
- updatedAt: reply.updated_at,
305
- editedAt: reply.edited_at
306
- }));
307
- }
308
- function commentsForFile(db, config, projectId, filePath) {
309
- const absolute = resolveSourcePath(config, projectId, filePath);
310
- const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
311
- const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
312
- LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
313
- .all(projectId, filePath);
314
- if (!rows.length)
315
- return [];
316
- const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
317
- FROM comment_replies reply
318
- JOIN comments c ON c.id = reply.comment_id
319
- LEFT JOIN users user ON user.id = reply.author_id
320
- WHERE c.project_id = ? AND c.file_path = ?
321
- ORDER BY reply.created_at`)
322
- .all(projectId, filePath);
323
- const replyMap = new Map();
324
- for (const reply of replies) {
325
- const list = replyMap.get(reply.comment_id) ?? [];
326
- list.push({
327
- id: reply.id,
328
- authorId: reply.author_id,
329
- authorUsername: reply.author_username,
330
- authorDisplayName: reply.author_display_name,
331
- content: reply.content,
332
- createdAt: reply.created_at,
333
- updatedAt: reply.updated_at,
334
- editedAt: reply.edited_at
335
- });
336
- replyMap.set(reply.comment_id, list);
337
- }
338
- return rows.map((comment) => ({
339
- id: comment.id,
340
- authorId: comment.author_id,
341
- authorUsername: comment.author_username,
342
- authorDisplayName: comment.author_display_name,
343
- selectedText: comment.selected_text,
344
- startOffset: comment.start_offset,
345
- endOffset: comment.end_offset,
346
- startLine: offsetToLine(source, comment.start_offset),
347
- endLine: offsetToLine(source, comment.end_offset),
348
- content: comment.content,
349
- resolved: Boolean(comment.resolved),
350
- orphaned: Boolean(comment.orphaned),
351
- createdAt: comment.created_at,
352
- updatedAt: comment.updated_at,
353
- editedAt: comment.edited_at,
354
- replies: replyMap.get(comment.id) ?? []
355
- }));
356
- }
38
+ const now = () => new Date().toISOString();
357
39
  export async function buildApp(config, db, options = {}) {
358
40
  const app = Fastify({
359
41
  logger: options.logger ?? true,
@@ -367,6 +49,10 @@ export async function buildApp(config, db, options = {}) {
367
49
  const history = new ProjectHistoryService(config, db);
368
50
  const latexCompletions = new LatexCompletionService(config);
369
51
  const projectOutlines = new ProjectOutlineService(config);
52
+ const harper = new HarperService();
53
+ // Warm the single server-side runtime without delaying startup. The first
54
+ // request shares this promise if initialization is still in progress.
55
+ void harper.preload().catch((error) => app.log.warn({ err: error }, "Harper could not be initialized"));
370
56
  const recordHistory = (projectId, userId, reason, paths) => {
371
57
  try {
372
58
  return history.record(projectId, userId, reason, paths);
@@ -417,7 +103,10 @@ export async function buildApp(config, db, options = {}) {
417
103
  pruneCompileRuns(row.id);
418
104
  pruneOrphanedCompileRuns(config, row.id);
419
105
  }
420
- app.addHook("onClose", async () => { eventLoopDelay.disable(); });
106
+ app.addHook("onClose", async () => {
107
+ eventLoopDelay.disable();
108
+ await harper.dispose();
109
+ });
421
110
  await app.register(cookie, { hook: "onRequest" });
422
111
  await app.register(websocket, { options: { maxPayload: 6 * 1024 * 1024 } });
423
112
  await app.register(multipart, {
@@ -451,2003 +140,63 @@ export async function buildApp(config, db, options = {}) {
451
140
  const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
452
141
  ? error.code
453
142
  : "REQUEST_INVALID";
454
- const message = error instanceof Error ? error.message : "请求格式不正确";
455
- const failedPaths = typeof error === "object" && error !== null && "failedPaths" in error
456
- && Array.isArray(error.failedPaths)
457
- && error.failedPaths.every((path) => typeof path === "string")
458
- ? error.failedPaths.slice(0, 100)
459
- : undefined;
460
- void reply.code(status).send({
461
- code,
462
- error: message,
463
- ...(failedPaths ? { failedPaths } : {})
464
- });
143
+ // Only structured HttpError details are included in the response. This
144
+ // prevents operational Error messages (paths, command output, etc.) from
145
+ // leaking while still allowing safe interpolation such as minLength.
146
+ const details = error instanceof HttpError ? error.details : {};
147
+ void apiError(reply, status, code, details);
465
148
  }
466
149
  else {
467
- void reply.code(500).send({
468
- code: "SERVER_ERROR",
469
- error: "服务器内部错误"
470
- });
471
- }
472
- });
473
- app.get("/api/config", async () => ({
474
- siteName: config.siteName,
475
- adminEmail: config.adminEmail,
476
- maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
477
- maxCollaborativeFileSizeMB: Math.floor(maxCollaborativeFileBytes(config) / 1024 / 1024),
478
- allowedEngines: config.allowedEngines,
479
- allowProjectLatexmkrc: config.allowProjectLatexmkrc
480
- }));
481
- app.get("/api/health", async () => ({ ok: true, pid: process.pid, latexmk: config.latexmk }));
482
- app.get("/api/health/metrics", async (request, reply) => {
483
- if (!requireAdmin(request, reply, db))
484
- return;
485
- const memory = process.memoryUsage();
486
- return {
487
- uptimeSeconds: Math.round(process.uptime()),
488
- memory: { rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, heapTotalBytes: memory.heapTotal },
489
- eventLoopDelay: {
490
- p50Ms: Math.round(eventLoopDelay.percentile(50) / 100_000) / 10,
491
- p95Ms: Math.round(eventLoopDelay.percentile(95) / 100_000) / 10,
492
- maxMs: Math.round(eventLoopDelay.max / 100_000) / 10
493
- },
494
- compileQueue: queue.stats(),
495
- collaboration: collaboration.stats(),
496
- caches: { completions: latexCompletions.stats(), outlines: projectOutlines.stats() },
497
- durationsMs: metrics.summaries()
498
- };
499
- });
500
- app.get("/api/collaboration/:id", { websocket: true }, (socket, request) => {
501
- const user = currentUser(request, db);
502
- if (!user) {
503
- socket.close(1008, "Authentication required");
504
- return;
505
- }
506
- const { id } = request.params;
507
- const startedAt = performance.now();
508
- void collaboration.connect(socket, id, user)
509
- .finally(() => metrics.record("collaboration.connect", performance.now() - startedAt));
510
- });
511
- app.post("/api/auth/login", async (request, reply) => {
512
- const body = request.body;
513
- const username = text(body?.username, "用户名", 64);
514
- const ip = request.ip || "127.0.0.1";
515
- const rateLimitKey = `${ip}:${username.toLowerCase()}`;
516
- if (loginLimiter.isLocked(rateLimitKey)) {
517
- return apiError(reply, 429, "AUTH_RATE_LIMITED", "登录尝试过于频繁,账号已临时锁定,请在 15 分钟后再试");
518
- }
519
- const password = typeof body?.password === "string" ? body.password : "";
520
- const user = db.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE").get(username);
521
- if (!user || user.disabled || !(await verifyPassword(password, user.password_hash))) {
522
- const result = loginLimiter.recordFailure(rateLimitKey);
523
- if (result.locked) {
524
- return apiError(reply, 429, "AUTH_RATE_LIMITED", "密码错误次数过多,账号已临时锁定,请在 15 分钟后再试");
525
- }
526
- return apiError(reply, 401, "AUTH_INVALID", "用户名或密码错误");
527
- }
528
- loginLimiter.reset(rateLimitKey);
529
- const session = createSessionToken();
530
- const createdAt = now();
531
- const expires = new Date(Date.now() + config.sessionDays * 86_400_000);
532
- db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
533
- .run(session.digest, user.id, expires.toISOString(), createdAt);
534
- const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
535
- reply.setCookie("texlite_session", session.token, {
536
- path: "/",
537
- httpOnly: true,
538
- sameSite: "strict",
539
- secure: isSecure,
540
- expires
541
- });
542
- return { user: publicUser(user) };
543
- });
544
- app.post("/api/auth/logout", async (request, reply) => {
545
- const token = request.cookies.texlite_session;
546
- if (token)
547
- db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
548
- reply.clearCookie("texlite_session", { path: "/" });
549
- return { ok: true };
550
- });
551
- app.get("/api/me", async (request, reply) => {
552
- const user = requireUser(request, reply, db);
553
- if (!user)
554
- return;
555
- return { user: publicUser(user) };
556
- });
557
- app.put("/api/me/password", async (request, reply) => {
558
- const user = requireUser(request, reply, db);
559
- if (!user)
560
- return;
561
- const body = request.body;
562
- const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
563
- const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
564
- if (!(await verifyPassword(currentPassword, user.password_hash))) {
565
- return apiError(reply, 400, "CURRENT_PASSWORD_INVALID", "当前密码错误");
566
- }
567
- const passwordHash = await hashPassword(newPassword);
568
- db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?")
569
- .run(passwordHash, user.id);
570
- db.prepare("DELETE FROM sessions WHERE user_id = ? AND id != ?")
571
- .run(user.id, digestToken(request.cookies.texlite_session ?? ""));
572
- return { ok: true };
573
- });
574
- app.get("/api/citations", async (request, reply) => {
575
- const user = requireUser(request, reply, db);
576
- if (!user)
577
- return;
578
- const query = request.query;
579
- const search = typeof query.q === "string" ? query.q.trim() : "";
580
- const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
581
- const requestedPage = Number.parseInt(query.page ?? "1", 10);
582
- const requestedPageSize = Number.parseInt(query.pageSize ?? query.limit ?? "60", 10);
583
- const pageSize = Math.min(200, Math.max(1, Number.isFinite(requestedPageSize) ? requestedPageSize : 60));
584
- const where = ["entry.user_id = ?"];
585
- const params = [user.id];
586
- if (search) {
587
- where.push(`(
588
- citation_key LIKE ? ESCAPE '\\' OR entry_type LIKE ? ESCAPE '\\'
589
- OR COALESCE(title, '') LIKE ? ESCAPE '\\'
590
- OR COALESCE(authors, '') LIKE ? ESCAPE '\\'
591
- OR COALESCE(year, '') LIKE ? ESCAPE '\\'
592
- )`);
593
- params.push(...Array.from({ length: 5 }, () => `%${escapeLikePattern(search)}%`));
594
- }
595
- if (tagId) {
596
- where.push(`EXISTS (SELECT 1 FROM citation_library_entry_tags filter_link
597
- JOIN citation_library_tags filter_tag ON filter_tag.id = filter_link.tag_id
598
- WHERE filter_link.entry_id = entry.id AND filter_link.tag_id = ? AND filter_tag.user_id = ?)`);
599
- params.push(tagId, user.id);
600
- }
601
- const countRow = db.prepare(`SELECT COUNT(*) AS count
602
- FROM citation_library_entries entry
603
- WHERE ${where.join(" AND ")}`).get(...params);
604
- const total = Number(countRow.count) || 0;
605
- const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0;
606
- const page = totalPages > 0 ? Math.min(Math.max(1, Number.isFinite(requestedPage) ? requestedPage : 1), totalPages) : 1;
607
- const offset = (page - 1) * pageSize;
608
- params.push(pageSize, offset);
609
- const rows = db.prepare(`SELECT entry.*, owner.username AS owner_username, owner.display_name AS owner_display_name
610
- FROM citation_library_entries entry
611
- JOIN users owner ON owner.id = entry.user_id
612
- WHERE ${where.join(" AND ")} ORDER BY entry.updated_at DESC, entry.citation_key COLLATE NOCASE LIMIT ? OFFSET ?`).all(...params);
613
- const tags = citationTagsForEntries(db, rows.map((row) => row.id));
614
- return {
615
- entries: rows.map((row) => citationJson(row, tags.get(row.id) ?? [])),
616
- pagination: { page, pageSize, total, totalPages }
617
- };
618
- });
619
- app.get("/api/citations/tags", async (request, reply) => {
620
- const user = requireUser(request, reply, db);
621
- if (!user)
622
- return;
623
- const tags = db.prepare(`SELECT tag.id, tag.name, tag.color, tag.user_id AS owner_id
624
- FROM citation_library_tags tag
625
- WHERE tag.user_id = ?
626
- ORDER BY tag.name COLLATE NOCASE`).all(user.id);
627
- return { tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.owner_id })) };
628
- });
629
- app.post("/api/citations/tags", async (request, reply) => {
630
- const user = requireUser(request, reply, db);
631
- if (!user)
632
- return;
633
- const body = request.body;
634
- const name = citationTagName(body?.name);
635
- const color = tagColors.includes(body?.color) ? body?.color : "gray";
636
- const existing = db.prepare("SELECT id, name, color, user_id FROM citation_library_tags WHERE user_id = ? AND name = ? COLLATE NOCASE").get(user.id, name);
637
- if (existing)
638
- return { tag: { id: existing.id, name: existing.name, color: existing.color, ownerId: existing.user_id }, created: false };
639
- const tag = { id: randomUUID(), name, color, ownerId: user.id };
640
- db.prepare("INSERT INTO citation_library_tags (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)")
641
- .run(tag.id, user.id, tag.name, tag.color, now());
642
- return reply.code(201).send({ tag, created: true });
643
- });
644
- app.delete("/api/citations/tags/:tagId", async (request, reply) => {
645
- const user = requireUser(request, reply, db);
646
- if (!user)
647
- return;
648
- const { tagId } = request.params;
649
- const result = db.prepare("DELETE FROM citation_library_tags WHERE id = ? AND user_id = ?").run(tagId, user.id);
650
- if (!result.changes)
651
- return apiError(reply, 404, "CITATION_TAG_NOT_FOUND", "引用标签不存在");
652
- return { ok: true };
653
- });
654
- // Keep the old settings endpoint explicit so stale clients cannot accidentally
655
- // reinterpret "settings" as a citation id. Citation libraries are always private.
656
- app.patch("/api/citations/settings", async (request, reply) => {
657
- if (!requireUser(request, reply, db))
658
- return;
659
- return apiError(reply, 403, "CITATION_LIBRARY_PRIVATE", "引用库始终为私有,仅当前用户可访问");
660
- });
661
- app.post("/api/citations/lookup", async (request, reply) => {
662
- const user = requireUser(request, reply, db);
663
- if (!user)
664
- return;
665
- const body = request.body;
666
- if (!Array.isArray(body?.keys) || body.keys.length > 5000
667
- || body.keys.some((key) => typeof key !== "string" || !key.trim() || key.length > 512)) {
668
- throw new ValidationError("引用 key 列表格式不正确");
669
- }
670
- const keys = [...new Map(body.keys.map((key) => [key.trim().toLowerCase(), key.trim()])).values()];
671
- const matches = [];
672
- for (let offset = 0; offset < keys.length; offset += 500) {
673
- const chunk = keys.slice(offset, offset + 500);
674
- const placeholders = chunk.map(() => "?").join(", ");
675
- matches.push(...db.prepare(`SELECT id, citation_key, revision FROM citation_library_entries
676
- WHERE user_id = ? AND citation_key COLLATE NOCASE IN (${placeholders})`)
677
- .all(user.id, ...chunk));
678
- }
679
- return { matches: matches.map((match) => ({ id: match.id, citationKey: match.citation_key, revision: match.revision })) };
680
- });
681
- app.post("/api/citations", async (request, reply) => {
682
- const user = requireUser(request, reply, db);
683
- if (!user)
684
- return;
685
- const body = request.body;
686
- const citation = citationInput(body);
687
- const tagIds = citationTagIds(db, user.id, body?.tagIds);
688
- const overwrite = body?.overwrite === true;
689
- const existing = db.prepare("SELECT id, revision FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE")
690
- .get(user.id, citation.citationKey);
691
- if (existing && !overwrite)
692
- return apiError(reply, 409, "CITATION_KEY_EXISTS", "引用 key 已经存在");
693
- const expectedRevision = existing ? citationExpectedRevision(body?.expectedRevision) : null;
694
- if (existing && existing.revision !== expectedRevision) {
695
- return apiError(reply, 409, "CITATION_CONFLICT", "引用已在其他位置发生修改,请刷新后重试");
696
- }
697
- const id = existing?.id ?? randomUUID();
698
- const timestamp = now();
699
- db.transaction(() => {
700
- if (existing) {
701
- const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
702
- title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
703
- WHERE id = ? AND user_id = ? AND revision = ?`)
704
- .run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, id, user.id, expectedRevision);
705
- if (!result.changes)
706
- throw Object.assign(new Error("引用已在其他位置发生修改,请刷新后重试"), { statusCode: 409, code: "CITATION_CONFLICT" });
707
- }
708
- else {
709
- db.prepare(`INSERT INTO citation_library_entries
710
- (id, user_id, citation_key, entry_type, bibtex, title, authors, year, revision, created_at, updated_at)
711
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`)
712
- .run(id, user.id, citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, timestamp);
713
- }
714
- if (tagIds !== null) {
715
- db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(id);
716
- for (const tagId of tagIds)
717
- db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(id, tagId, timestamp);
718
- }
719
- })();
720
- const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
721
- .get(id, user.id);
722
- const tags = citationTagsForEntries(db, [id]);
723
- return reply.code(existing ? 200 : 201).send({ entry: citationJson(row, tags.get(id) ?? []), updated: Boolean(existing) });
724
- });
725
- app.patch("/api/citations/:citationId/tags", async (request, reply) => {
726
- const user = requireUser(request, reply, db);
727
- if (!user)
728
- return;
729
- const { citationId } = request.params;
730
- const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
731
- .get(citationId, user.id);
732
- if (!existing)
733
- return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
734
- const body = request.body;
735
- const tagIds = citationTagIds(db, user.id, body?.tagIds);
736
- if (tagIds === null)
737
- throw new ValidationError("引用标签格式不正确");
738
- const expectedRevision = citationExpectedRevision(body?.expectedRevision);
739
- const timestamp = now();
740
- db.transaction(() => {
741
- const result = db.prepare(`UPDATE citation_library_entries SET revision = revision + 1, updated_at = ?
742
- WHERE id = ? AND user_id = ? AND revision = ?`).run(timestamp, citationId, user.id, expectedRevision);
743
- if (!result.changes) {
744
- const exists = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?").get(citationId, user.id);
745
- if (!exists)
746
- throw Object.assign(new Error("引用条目不存在"), { statusCode: 404, code: "CITATION_NOT_FOUND" });
747
- throw Object.assign(new Error("引用已在其他位置发生修改,请刷新后重试"), { statusCode: 409, code: "CITATION_CONFLICT" });
748
- }
749
- db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(citationId);
750
- for (const tagId of tagIds)
751
- db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(citationId, tagId, timestamp);
752
- })();
753
- const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
754
- .get(citationId, user.id);
755
- const tags = citationTagsForEntries(db, [citationId]);
756
- return { entry: citationJson(row, tags.get(citationId) ?? []) };
757
- });
758
- app.patch("/api/citations/:citationId", async (request, reply) => {
759
- const user = requireUser(request, reply, db);
760
- if (!user)
761
- return;
762
- const { citationId } = request.params;
763
- const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
764
- .get(citationId, user.id);
765
- if (!existing)
766
- return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
767
- const body = request.body;
768
- const citation = citationInput(body);
769
- const expectedRevision = citationExpectedRevision(body?.expectedRevision);
770
- const duplicate = db.prepare("SELECT id FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE AND id != ?")
771
- .get(user.id, citation.citationKey, citationId);
772
- if (duplicate)
773
- return apiError(reply, 409, "CITATION_KEY_EXISTS", "引用 key 已经存在");
774
- const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
775
- title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
776
- WHERE id = ? AND user_id = ? AND revision = ?`)
777
- .run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, now(), citationId, user.id, expectedRevision);
778
- if (!result.changes)
779
- return apiError(reply, 409, "CITATION_CONFLICT", "引用已在其他位置发生修改,请刷新后重试");
780
- const updated = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
781
- .get(citationId, user.id);
782
- const tags = citationTagsForEntries(db, [citationId]);
783
- return { entry: citationJson(updated, tags.get(citationId) ?? []) };
784
- });
785
- app.delete("/api/citations/:citationId", async (request, reply) => {
786
- const user = requireUser(request, reply, db);
787
- if (!user)
788
- return;
789
- const { citationId } = request.params;
790
- const result = db.prepare("DELETE FROM citation_library_entries WHERE id = ? AND user_id = ?").run(citationId, user.id);
791
- if (!result.changes)
792
- return apiError(reply, 404, "CITATION_NOT_FOUND", "引用条目不存在");
793
- return { ok: true };
794
- });
795
- app.get("/api/admin/users", async (request, reply) => {
796
- if (!requireAdmin(request, reply, db))
797
- return;
798
- const users = db.prepare(`
799
- SELECT u.*,
800
- (SELECT COUNT(*) FROM projects p WHERE p.owner_id = u.id) AS owned_projects
801
- FROM users u ORDER BY u.created_at
802
- `).all();
803
- return { users: users.map((user) => ({ ...publicUser(user), ownedProjects: user.owned_projects })) };
804
- });
805
- app.post("/api/admin/users", async (request, reply) => {
806
- if (!requireAdmin(request, reply, db))
807
- return;
808
- const body = request.body;
809
- const username = text(body?.username, "用户名", 64);
810
- if (!/^[\p{L}\p{N}_.-]+$/u.test(username))
811
- return apiError(reply, 400, "USERNAME_INVALID", "用户名只能包含字母、数字、点、横线或下划线");
812
- const displayName = text(body?.displayName ?? username, "显示名称", 100);
813
- const password = typeof body?.password === "string" ? body.password : "";
814
- const role = body?.role === "admin" ? "admin" : "user";
815
- const user = {
816
- id: randomUUID(), username, display_name: displayName,
817
- password_hash: await hashPassword(password), role, disabled: 0,
818
- must_change_password: 0, can_create_projects: body?.canCreateProjects === true ? 1 : 0, created_at: now()
819
- };
820
- db.prepare(`INSERT INTO users
821
- (id, username, display_name, password_hash, role, disabled, must_change_password, can_create_projects, created_at)
822
- VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`)
823
- .run(user.id, user.username, user.display_name, user.password_hash, user.role, user.can_create_projects, user.created_at);
824
- return reply.code(201).send({ user: publicUser(user) });
825
- });
826
- app.patch("/api/admin/users/:id", async (request, reply) => {
827
- const admin = requireAdmin(request, reply, db);
828
- if (!admin)
829
- return;
830
- const { id } = request.params;
831
- const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
832
- if (!target)
833
- return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在");
834
- const body = request.body;
835
- const role = body.role === "admin" ? "admin" : body.role === "user" ? "user" : target.role;
836
- const disabled = typeof body.disabled === "boolean" ? Number(body.disabled) : target.disabled;
837
- const canCreateProjects = typeof body.canCreateProjects === "boolean"
838
- ? Number(body.canCreateProjects) : target.can_create_projects;
839
- if (target.role === "admin" && (!role || role !== "admin" || disabled) && activeAdminCount(db) <= 1) {
840
- return apiError(reply, 400, "LAST_ADMIN", "不能禁用或降级最后一个管理员");
841
- }
842
- const displayName = typeof body.displayName === "string" ? text(body.displayName, "显示名称", 100) : target.display_name;
843
- let passwordHash = target.password_hash;
844
- let mustChange = target.must_change_password;
845
- if (typeof body.password === "string" && body.password) {
846
- passwordHash = await hashPassword(body.password);
847
- mustChange = 1;
848
- db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
849
- }
850
- db.prepare(`UPDATE users SET display_name = ?, role = ?, disabled = ?, password_hash = ?, must_change_password = ?, can_create_projects = ? WHERE id = ?`)
851
- .run(displayName, role, disabled, passwordHash, mustChange, canCreateProjects, id);
852
- // A disabled account must not regain access by being re-enabled while an
853
- // old cookie is still within its normal lifetime. Password resets already
854
- // revoke sessions above; disabling does the same for every active token.
855
- if (disabled === 1)
856
- db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
857
- if (disabled === 1 || (typeof body.password === "string" && body.password)) {
858
- collaboration.disconnectUser(id, "用户已被禁用或密码已重置");
859
- }
860
- const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
861
- return { user: publicUser(updated) };
862
- });
863
- app.delete("/api/admin/users/:id", async (request, reply) => {
864
- const admin = requireAdmin(request, reply, db);
865
- if (!admin)
866
- return;
867
- const { id } = request.params;
868
- const body = (request.body ?? {});
869
- const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
870
- if (!target)
871
- return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在");
872
- if (target.id === admin.id)
873
- return apiError(reply, 400, "SELF_DELETE_FORBIDDEN", "不能删除当前登录的管理员");
874
- if (target.role === "admin" && activeAdminCount(db) <= 1) {
875
- return apiError(reply, 400, "LAST_ADMIN", "不能删除最后一个管理员");
876
- }
877
- let owned = [];
878
- // Capture and mutate the complete owned-project set in the same synchronous
879
- // transaction. There is deliberately no await before COMMIT: another
880
- // request cannot transfer or create a project for this user between the
881
- // snapshot and the owner/delete statements.
882
- db.exec("BEGIN IMMEDIATE");
883
- try {
884
- const currentTarget = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
885
- if (!currentTarget)
886
- throw Object.assign(new Error("用户不存在"), { statusCode: 404, code: "USER_NOT_FOUND" });
887
- if (currentTarget.role === "admin" && activeAdminCount(db) <= 1) {
888
- throw Object.assign(new Error("不能删除最后一个管理员"), { statusCode: 400, code: "LAST_ADMIN" });
889
- }
890
- owned = db.prepare("SELECT id FROM projects WHERE owner_id = ?").all(id);
891
- if (!body.deleteProjects) {
892
- // Persist any active drafts while the old owner row still exists. This
893
- // is synchronous, so no edit can arrive between this flush and the
894
- // ownership update below, and last_modified_by remains FK-safe.
895
- for (const project of owned)
896
- projectMutations.flushProject(project.id);
897
- }
898
- if (body.deleteProjects) {
899
- db.prepare("DELETE FROM projects WHERE owner_id = ?").run(id);
900
- }
901
- else {
902
- db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id IN (SELECT id FROM projects WHERE owner_id = ?)")
903
- .run(now(), id);
904
- db.prepare("DELETE FROM project_members WHERE user_id = ? AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)")
905
- .run(admin.id, id);
906
- db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE owner_id = ?")
907
- .run(admin.id, admin.id, now(), id);
908
- }
909
- db.prepare("DELETE FROM users WHERE id = ?").run(id);
910
- db.exec("COMMIT");
911
- }
912
- catch (error) {
913
- db.exec("ROLLBACK");
914
- throw error;
915
- }
916
- // Requests already queued for the removed user fail their lock-time
917
- // preflight. Cleanup is then serialized after them and invalidates any
918
- // room initialization that started before the transaction committed.
919
- for (const project of owned) {
920
- await projectMutations.runExclusive(project.id, "admin user deletion cleanup", () => {
921
- if (body.deleteProjects) {
922
- collaboration.resetProject(project.id);
923
- removeProjectDirectory(config, project.id);
924
- latexCompletions.invalidate(project.id);
925
- projectOutlines.invalidate(project.id);
926
- }
927
- else {
928
- collaboration.resetProject(project.id);
929
- }
930
- }, { flush: false });
931
- }
932
- collaboration.disconnectUser(id, "用户已被删除");
933
- return { ok: true, deletedProjects: body.deleteProjects ? owned.length : 0 };
934
- });
935
- app.get("/api/users", async (request, reply) => {
936
- if (!requireUser(request, reply, db))
937
- return;
938
- const rows = db.prepare("SELECT id, username, display_name AS displayName FROM users WHERE disabled = 0 ORDER BY username").all();
939
- return { users: rows };
940
- });
941
- app.get("/api/tags", async (request, reply) => {
942
- const user = requireUser(request, reply, db);
943
- if (!user)
944
- return;
945
- const tags = db.prepare("SELECT id, name, color FROM user_tags WHERE user_id = ? ORDER BY name COLLATE NOCASE").all(user.id);
946
- return { tags };
947
- });
948
- app.post("/api/tags", async (request, reply) => {
949
- const user = requireUser(request, reply, db);
950
- if (!user)
951
- return;
952
- const body = request.body;
953
- const name = text(body.name, "标签名称", 32);
954
- const color = tagColors.includes(body.color)
955
- ? body.color : "gray";
956
- const tag = { id: randomUUID(), name, color };
957
- const createdAt = now();
958
- db.prepare("INSERT INTO user_tags (id, name, color, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
959
- .run(tag.id, tag.name, tag.color, user.id, createdAt, createdAt);
960
- return reply.code(201).send({ tag });
961
- });
962
- app.get("/api/projects", async (request, reply) => {
963
- const user = requireUser(request, reply, db);
964
- if (!user)
965
- return;
966
- const query = request.query;
967
- const archivedOnly = query.archived === "1" || query.archived === "true";
968
- const requestedPage = Number.parseInt(query.page ?? "1", 10);
969
- const page = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
970
- const requestedPageSize = Number.parseInt(query.pageSize ?? "20", 10);
971
- const pageSize = Math.min(100, Math.max(1, Number.isFinite(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 20));
972
- const search = typeof query.search === "string" ? query.search.trim() : "";
973
- const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
974
- const sortColumn = query.sort === "created" ? "p.created_at" : "p.updated_at";
975
- const archiveCondition = archivedOnly
976
- ? "EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)"
977
- : "NOT EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)";
978
- const from = user.role === "admin"
979
- ? `FROM projects p JOIN users owner ON owner.id = p.owner_id
980
- LEFT JOIN users modifier ON modifier.id = p.last_modified_by`
981
- : `FROM projects p JOIN users owner ON owner.id = p.owner_id
982
- LEFT JOIN users modifier ON modifier.id = p.last_modified_by
983
- LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
984
- const conditions = [
985
- ...(user.role === "admin" ? [] : ["(p.owner_id = :userId OR pm.user_id = :userId)"]),
986
- archiveCondition
987
- ];
988
- const params = { userId: user.id };
989
- if (search) {
990
- conditions.push("(p.name LIKE :search ESCAPE '\\' OR owner.username LIKE :search ESCAPE '\\' OR owner.display_name LIKE :search ESCAPE '\\')");
991
- params.search = `%${escapeLikePattern(search)}%`;
992
- }
993
- if (tagId) {
994
- conditions.push(`EXISTS (
995
- SELECT 1 FROM user_project_tag_links tag_link
996
- JOIN user_tags tag ON tag.id = tag_link.tag_id
997
- WHERE tag_link.project_id = p.id AND tag.user_id = :userId AND tag.id = :tagId
998
- )`);
999
- params.tagId = tagId;
1000
- }
1001
- const where = `WHERE ${conditions.join(" AND ")}`;
1002
- const countRow = db.prepare(`SELECT COUNT(DISTINCT p.id) AS total ${from} ${where}`).get(params);
1003
- const total = Number(countRow.total);
1004
- const totalPages = Math.ceil(total / pageSize);
1005
- const currentPage = totalPages === 0 ? 1 : Math.min(page, totalPages);
1006
- const rowsParams = { ...params, limit: pageSize, offset: (currentPage - 1) * pageSize };
1007
- const select = user.role === "admin"
1008
- ? `SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
1009
- owner.display_name AS owner_display_name,
1010
- modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`
1011
- : `SELECT DISTINCT p.*,
1012
- CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
1013
- owner.username AS owner_username, owner.display_name AS owner_display_name,
1014
- modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
1015
- const rows = db.prepare(`${select} ${from} ${where}
1016
- ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
1017
- LIMIT :limit OFFSET :offset`).all(rowsParams);
1018
- const projects = rows;
1019
- const projectIds = projects.map((project) => project.id);
1020
- const projectTags = tagsForProjects(db, projectIds, user.id);
1021
- const commentsSummaries = commentsSummaryForProjects(db, projectIds);
1022
- return {
1023
- projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id))),
1024
- pagination: { page: currentPage, pageSize, total, totalPages }
1025
- };
1026
- });
1027
- app.post("/api/projects", async (request, reply) => {
1028
- const user = requireUser(request, reply, db);
1029
- if (!user)
1030
- return;
1031
- if (user.role !== "admin" && !user.can_create_projects) {
1032
- return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
1033
- }
1034
- const body = request.body;
1035
- const project = {
1036
- id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(body?.name, "项目名称", 120),
1037
- main_file: "main.tex", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
1038
- };
1039
- createProjectFiles(config, project.id);
1040
- try {
1041
- db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1042
- .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);
1043
- }
1044
- catch (error) {
1045
- removeProjectDirectory(config, project.id);
1046
- throw error;
1047
- }
1048
- recordHistory(project.id, user.id, "initial");
1049
- return reply.code(201).send({ project: projectJson({
1050
- ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
1051
- last_modified_username: user.username, last_modified_display_name: user.display_name
1052
- }) });
1053
- });
1054
- app.post("/api/projects/import", async (request, reply) => {
1055
- const user = requireUser(request, reply, db);
1056
- if (!user)
1057
- return;
1058
- if (user.role !== "admin" && !user.can_create_projects) {
1059
- return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
1060
- }
1061
- const part = await request.file();
1062
- if (!part || !part.filename.toLowerCase().endsWith(".zip")) {
1063
- return apiError(reply, 400, "ZIP_ONLY", "请选择 ZIP 压缩包");
1064
- }
1065
- const query = request.query;
1066
- const fallbackName = path.basename(part.filename, path.extname(part.filename));
1067
- const project = {
1068
- id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(query.name || fallbackName, "项目名称", 120),
1069
- main_file: "", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
1070
- };
1071
- fs.mkdirSync(sourceRoot(config, project.id), { recursive: true, mode: 0o700 });
1072
- fs.mkdirSync(outputRoot(config, project.id), { recursive: true, mode: 0o700 });
1073
- try {
1074
- const extracted = await extractProjectZip(await part.toBuffer(), sourceRoot(config, project.id), config.maxUploadBytes);
1075
- project.main_file = extracted.mainFile;
1076
- db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
1077
- VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
1078
- .run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.engine, project.created_at, project.updated_at);
1079
- }
1080
- catch (error) {
1081
- removeProjectDirectory(config, project.id);
1082
- return apiError(reply, 400, "ZIP_INVALID", error instanceof Error ? error.message : "无法解压 ZIP 文件");
1083
- }
1084
- recordHistory(project.id, user.id, "initial");
1085
- return reply.code(201).send({ project: projectJson({
1086
- ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
1087
- last_modified_username: user.username, last_modified_display_name: user.display_name
1088
- }) });
1089
- });
1090
- app.post("/api/projects/:id/duplicate", async (request, reply) => {
1091
- const user = requireUser(request, reply, db);
1092
- if (!user)
1093
- return;
1094
- if (user.role !== "admin" && !user.can_create_projects) {
1095
- return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
1096
- }
1097
- const { id } = request.params;
1098
- const source = accessibleProject(db, id, user);
1099
- if (!source)
1100
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1101
- const body = request.body;
1102
- const requestedName = typeof body?.name === "string" && body.name.trim() ? body.name : `${source.name.slice(0, 115)} (1)`;
1103
- const project = {
1104
- id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(requestedName, "项目名称", 120),
1105
- main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
1106
- };
1107
- try {
1108
- // Duplicate the source tree only after flushing the live Yjs room and
1109
- // while a short source barrier prevents autosave from changing files
1110
- // between directory entries. The copy is asynchronous, so a large
1111
- // project does not block the Node.js event loop for its entire duration.
1112
- await projectMutations.runConsistentRead(source.id, () => duplicateProjectFiles(config, source.id, project.id), {
1113
- preflight: () => {
1114
- if (!accessibleProject(db, source.id, user)) {
1115
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1116
- }
1117
- }
1118
- });
1119
- db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
1120
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1121
- .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);
1122
- }
1123
- catch (error) {
1124
- removeProjectDirectory(config, project.id);
1125
- throw error;
1126
- }
1127
- recordHistory(project.id, user.id, "initial");
1128
- return reply.code(201).send({ project: projectJson({
1129
- ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
1130
- last_modified_username: user.username, last_modified_display_name: user.display_name
1131
- }) });
1132
- });
1133
- app.get("/api/projects/:id", async (request, reply) => {
1134
- const user = requireUser(request, reply, db);
1135
- if (!user)
1136
- return;
1137
- const { id } = request.params;
1138
- const project = accessibleProject(db, id, user);
1139
- if (!project)
1140
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1141
- return {
1142
- project: projectJson(project, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
1143
- };
1144
- });
1145
- app.get("/api/projects/:id/history", async (request, reply) => {
1146
- const user = requireUser(request, reply, db);
1147
- if (!user)
1148
- return;
1149
- const { id } = request.params;
1150
- const project = accessibleProject(db, id, user);
1151
- if (!project)
1152
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1153
- const { limit: limitInput } = request.query;
1154
- const limit = Number.parseInt(limitInput ?? "100", 10);
1155
- return {
1156
- versions: history.list(id, Number.isFinite(limit) ? limit : 100),
1157
- stats: project.permission === "owner" ? history.stats(id) : null
1158
- };
1159
- });
1160
- app.get("/api/projects/:id/history/:versionId", async (request, reply) => {
1161
- const user = requireUser(request, reply, db);
1162
- if (!user)
1163
- return;
1164
- const { id, versionId } = request.params;
1165
- if (!accessibleProject(db, id, user))
1166
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1167
- const version = history.version(versionId, id);
1168
- const manifest = history.manifest(id, versionId);
1169
- if (!version || !manifest)
1170
- return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
1171
- return {
1172
- version,
1173
- settings: manifest.settings,
1174
- files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
1175
- };
1176
- });
1177
- app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
1178
- const user = requireUser(request, reply, db);
1179
- if (!user)
1180
- return;
1181
- const { id, versionId } = request.params;
1182
- if (!accessibleProject(db, id, user))
1183
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1184
- const query = request.query;
1185
- const filePath = safeRelativePath(query.path ?? "");
1186
- return await projectMutations.runConsistentRead(id, () => {
1187
- const historical = history.readTextFile(id, versionId, filePath);
1188
- if (historical === null)
1189
- return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", "该历史文件不存在或不能作为文本比较", { path: filePath });
1190
- let comparison = "";
1191
- if (query.against) {
1192
- comparison = history.readTextFile(id, query.against, filePath) ?? "";
1193
- }
1194
- else {
1195
- const current = resolveSourcePath(config, id, filePath);
1196
- if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
1197
- comparison = fs.readFileSync(current, "utf8");
1198
- }
1199
- }
1200
- return { path: filePath, historical, comparison, against: query.against ?? "current" };
1201
- }, { preflight: () => {
1202
- if (!accessibleProject(db, id, user))
1203
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1204
- } });
1205
- });
1206
- app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
1207
- const user = requireUser(request, reply, db);
1208
- if (!user)
1209
- return;
1210
- const { id, versionId } = request.params;
1211
- const project = accessibleProject(db, id, user);
1212
- if (!project || !canEdit(project))
1213
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1214
- const body = request.body;
1215
- const label = body.label === null || body.label === "" ? null : text(body.label, "版本标签", 80);
1216
- const version = history.setLabel(id, versionId, label);
1217
- if (!version)
1218
- return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
1219
- return { version, stats: project.permission === "owner" ? history.stats(id) : null };
1220
- });
1221
- app.delete("/api/projects/:id/history/:versionId", async (request, reply) => {
1222
- const user = requireUser(request, reply, db);
1223
- if (!user)
1224
- return;
1225
- const { id, versionId } = request.params;
1226
- const project = accessibleProject(db, id, user);
1227
- if (!project || project.permission !== "owner")
1228
- return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以删除历史版本");
1229
- return await projectMutations.runWrite(id, () => {
1230
- if (!history.deleteVersion(id, versionId))
1231
- return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
1232
- return { ok: true, stats: history.stats(id) };
1233
- }, { preflight: () => {
1234
- requireProjectOwnerPermission(db, id, user);
1235
- if (!history.version(versionId, id)) {
1236
- throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
1237
- }
1238
- } });
1239
- });
1240
- app.delete("/api/projects/:id/history", async (request, reply) => {
1241
- const user = requireUser(request, reply, db);
1242
- if (!user)
1243
- return;
1244
- const { id } = request.params;
1245
- const project = accessibleProject(db, id, user);
1246
- if (!project || project.permission !== "owner")
1247
- return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以清空历史版本");
1248
- return await projectMutations.runWrite(id, () => {
1249
- history.clear(id);
1250
- return { ok: true, stats: history.stats(id) };
1251
- }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
1252
- });
1253
- app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
1254
- const user = requireUser(request, reply, db);
1255
- if (!user)
1256
- return;
1257
- const { id, versionId } = request.params;
1258
- const project = accessibleProject(db, id, user);
1259
- if (!project || !canEdit(project))
1260
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1261
- const body = request.body;
1262
- const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
1263
- return await projectMutations.runExclusive(id, "history restore", () => {
1264
- const currentProject = requireEditableProject(db, id, user);
1265
- recordHistory(id, user.id, "checkpoint");
1266
- const before = projectTextSnapshot(config, id);
1267
- const restored = history.restore(id, versionId, filePath);
1268
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
1269
- touchProject(db, id, user.id);
1270
- recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
1271
- return {
1272
- ok: true,
1273
- restoredPaths: restored.restoredPaths,
1274
- project: projectJson(accessibleProject(db, id, user) ?? currentProject, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
1275
- };
1276
- }, { preflight: () => {
1277
- requireEditableProject(db, id, user);
1278
- if (!history.version(versionId, id)) {
1279
- throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
1280
- }
1281
- history.validateRestoreTarget(id, versionId, filePath);
1282
- } });
1283
- });
1284
- app.put("/api/projects/:id/archive", async (request, reply) => {
1285
- const user = requireUser(request, reply, db);
1286
- if (!user)
1287
- return;
1288
- const { id } = request.params;
1289
- if (!accessibleProject(db, id, user))
1290
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1291
- db.prepare(`INSERT OR IGNORE INTO user_project_archives (user_id, project_id, archived_at) VALUES (?, ?, ?)`)
1292
- .run(user.id, id, now());
1293
- return { ok: true, archived: true };
1294
- });
1295
- app.delete("/api/projects/:id/archive", async (request, reply) => {
1296
- const user = requireUser(request, reply, db);
1297
- if (!user)
1298
- return;
1299
- const { id } = request.params;
1300
- if (!accessibleProject(db, id, user))
1301
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1302
- db.prepare("DELETE FROM user_project_archives WHERE user_id = ? AND project_id = ?").run(user.id, id);
1303
- return { ok: true, archived: false };
1304
- });
1305
- app.get("/api/projects/:id/dictionary", async (request, reply) => {
1306
- const user = requireUser(request, reply, db);
1307
- if (!user)
1308
- return;
1309
- const { id } = request.params;
1310
- if (!accessibleProject(db, id, user))
1311
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1312
- const rows = db.prepare(`SELECT word FROM project_dictionary_words
1313
- WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
1314
- return { words: rows.map((row) => row.word) };
1315
- });
1316
- app.post("/api/projects/:id/dictionary", async (request, reply) => {
1317
- const user = requireUser(request, reply, db);
1318
- if (!user)
1319
- return;
1320
- const { id } = request.params;
1321
- const project = accessibleProject(db, id, user);
1322
- if (!project)
1323
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1324
- if (!canEdit(project))
1325
- return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN", "没有维护项目词典的权限");
1326
- const body = request.body;
1327
- const word = dictionaryWord(body?.word);
1328
- db.prepare(`INSERT OR IGNORE INTO project_dictionary_words (project_id, word, created_by, created_at)
1329
- VALUES (?, ?, ?, ?)`).run(id, word, user.id, now());
1330
- collaboration.signalDictionary(id);
1331
- const words = db.prepare(`SELECT word FROM project_dictionary_words
1332
- WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
1333
- return reply.code(201).send({ word, words: words.map((row) => row.word) });
1334
- });
1335
- app.delete("/api/projects/:id/dictionary/:word", async (request, reply) => {
1336
- const user = requireUser(request, reply, db);
1337
- if (!user)
1338
- return;
1339
- const { id, word: rawWord } = request.params;
1340
- const project = accessibleProject(db, id, user);
1341
- if (!project)
1342
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1343
- if (!canEdit(project))
1344
- return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN", "没有维护项目词典的权限");
1345
- const word = dictionaryWord(rawWord);
1346
- db.prepare("DELETE FROM project_dictionary_words WHERE project_id = ? AND word = ?").run(id, word);
1347
- collaboration.signalDictionary(id);
1348
- const words = db.prepare(`SELECT word FROM project_dictionary_words
1349
- WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
1350
- return { words: words.map((row) => row.word) };
1351
- });
1352
- app.patch("/api/projects/:id", async (request, reply) => {
1353
- const user = requireUser(request, reply, db);
1354
- if (!user)
1355
- return;
1356
- const { id } = request.params;
1357
- if (collaboration.isMaintaining(id))
1358
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1359
- const project = accessibleProject(db, id, user);
1360
- if (!project || project.permission !== "owner")
1361
- return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
1362
- const body = request.body;
1363
- return await projectMutations.runWrite(id, () => {
1364
- const currentProject = accessibleProject(db, id, user);
1365
- if (!currentProject || currentProject.permission !== "owner") {
1366
- return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
1367
- }
1368
- const name = typeof body.name === "string" ? text(body.name, "项目名称", 120) : currentProject.name;
1369
- let mainFile = currentProject.main_file;
1370
- if (body.mainFile !== undefined) {
1371
- if (typeof body.mainFile !== "string" || !body.mainFile.trim()) {
1372
- return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是存在的 .tex 文件");
1373
- }
1374
- mainFile = safeRelativePath(body.mainFile);
1375
- }
1376
- const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
1377
- ? body.engine : currentProject.engine;
1378
- const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
1379
- ? null
1380
- : typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : currentProject.latexmkrc;
1381
- if (!mainFile.toLocaleLowerCase().endsWith(".tex")) {
1382
- return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是 .tex 文件", { path: mainFile });
1383
- }
1384
- const mainFileAbsolute = resolveSourcePath(config, id, mainFile);
1385
- let stat = null;
1386
- try {
1387
- stat = fs.statSync(mainFileAbsolute);
1388
- }
1389
- catch (error) {
1390
- if (error.code === "ENOENT") {
1391
- return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", "主文件不存在", { path: mainFile });
1392
- }
1393
- throw error;
1394
- }
1395
- if (!stat.isFile()) {
1396
- return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是常规文件,不能是目录", { path: mainFile });
1397
- }
1398
- if (latexmkrc && !config.allowProjectLatexmkrc)
1399
- return apiError(reply, 400, "LATEXMKRC_DISABLED", "管理员已禁用项目级 latexmkrc");
1400
- if (latexmkrc) {
1401
- const rcAbsolute = resolveSourcePath(config, id, latexmkrc);
1402
- let rcStat = null;
1403
- try {
1404
- rcStat = fs.statSync(rcAbsolute);
1405
- }
1406
- catch (error) {
1407
- if (error.code === "ENOENT") {
1408
- return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", "latexmkrc 文件不存在", { path: latexmkrc });
1409
- }
1410
- throw error;
1411
- }
1412
- if (!rcStat.isFile()) {
1413
- return apiError(reply, 400, "LATEXMKRC_INVALID", "latexmkrc 必须是常规文件,不能是目录", { path: latexmkrc });
1414
- }
1415
- }
1416
- db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
1417
- .run(name, mainFile, latexmkrc, engine, now(), user.id, id);
1418
- recordHistory(id, user.id, "settings", []);
1419
- return {
1420
- project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
1421
- };
1422
- }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
1423
- });
1424
- app.post("/api/projects/:id/tags", async (request, reply) => {
1425
- const user = requireUser(request, reply, db);
1426
- if (!user)
1427
- return;
1428
- const { id } = request.params;
1429
- const project = accessibleProject(db, id, user);
1430
- if (!project)
1431
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1432
- const body = request.body;
1433
- if (typeof body.tagId !== "string" || !db.prepare("SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?").get(body.tagId, user.id)) {
1434
- return apiError(reply, 404, "TAG_NOT_FOUND", "标签不存在");
1435
- }
1436
- db.prepare("INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at) VALUES (?, ?, ?)")
1437
- .run(id, body.tagId, now());
1438
- const tags = tagsForProject(db, id, user.id);
1439
- return reply.code(201).send({
1440
- tags,
1441
- project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
1442
- });
1443
- });
1444
- app.delete("/api/projects/:id/tags/:tagId", async (request, reply) => {
1445
- const user = requireUser(request, reply, db);
1446
- if (!user)
1447
- return;
1448
- const { id, tagId } = request.params;
1449
- const project = accessibleProject(db, id, user);
1450
- if (!project)
1451
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1452
- db.prepare(`DELETE FROM user_project_tag_links WHERE tag_id = ? AND project_id = ?
1453
- AND EXISTS (SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?)`)
1454
- .run(tagId, id, tagId, user.id);
1455
- const tags = tagsForProject(db, id, user.id);
1456
- return {
1457
- tags,
1458
- project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
1459
- };
1460
- });
1461
- const requireGitOwner = (projectId, user) => {
1462
- requireActiveUser(db, user);
1463
- const project = accessibleProject(db, projectId, user);
1464
- if (!project)
1465
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1466
- if (project.owner_id !== user.id)
1467
- throw Object.assign(new Error("只有项目创建者可以执行 Git 操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
1468
- return project;
1469
- };
1470
- app.get("/api/projects/:id/git", async (request, reply) => {
1471
- const user = requireUser(request, reply, db);
1472
- if (!user)
1473
- return;
1474
- const { id } = request.params;
1475
- return await projectMutations.runConsistentRead(id, async () => {
1476
- return { status: await projectGit.status(requireGitOwner(id, user)) };
1477
- }, { preflight: () => { requireGitOwner(id, user); } });
1478
- });
1479
- app.put("/api/projects/:id/git/token", async (request, reply) => {
1480
- const user = requireUser(request, reply, db);
1481
- if (!user)
1482
- return;
1483
- const { id } = request.params;
1484
- const body = request.body;
1485
- if (typeof body.token !== "string")
1486
- return apiError(reply, 400, "GIT_TOKEN_INVALID", "请输入 GitHub token");
1487
- const token = body.token;
1488
- return await projectMutations.runSerialized(id, async () => {
1489
- return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
1490
- }, { preflight: () => { requireGitOwner(id, user); } });
1491
- });
1492
- app.delete("/api/projects/:id/git/token", async (request, reply) => {
1493
- const user = requireUser(request, reply, db);
1494
- if (!user)
1495
- return;
1496
- const { id } = request.params;
1497
- return await projectMutations.runSerialized(id, async () => {
1498
- return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
1499
- }, { preflight: () => { requireGitOwner(id, user); } });
1500
- });
1501
- app.post("/api/projects/:id/git/repository", async (request, reply) => {
1502
- const user = requireUser(request, reply, db);
1503
- if (!user)
1504
- return;
1505
- const { id } = request.params;
1506
- const body = request.body;
1507
- if (typeof body.name !== "string")
1508
- return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID", "请输入 GitHub 仓库名称");
1509
- const repositoryName = body.name.trim();
1510
- return await projectMutations.runSerialized(id, async () => {
1511
- return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), repositoryName, body.private !== false) };
1512
- }, { preflight: () => { requireGitOwner(id, user); } });
1513
- });
1514
- app.post("/api/projects/:id/git/commit", async (request, reply) => {
1515
- const user = requireUser(request, reply, db);
1516
- if (!user)
1517
- return;
1518
- const { id } = request.params;
1519
- if (collaboration.isMaintaining(id))
1520
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1521
- const body = request.body;
1522
- return await projectMutations.runExclusive(id, "Git commit", async () => {
1523
- const project = requireGitOwner(id, user);
1524
- const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
1525
- return reply.code(201).send({ commit, status: await projectGit.status(project) });
1526
- }, { preflight: () => { requireGitOwner(id, user); } });
1527
- });
1528
- app.post("/api/projects/:id/git/push", async (request, reply) => {
1529
- const user = requireUser(request, reply, db);
1530
- if (!user)
1531
- return;
1532
- const { id } = request.params;
1533
- if (collaboration.isMaintaining(id))
1534
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1535
- return await projectMutations.runSerialized(id, async () => {
1536
- const project = requireGitOwner(id, user);
1537
- return { status: await projectGit.push(project) };
1538
- }, { flush: false, preflight: () => { requireGitOwner(id, user); } });
1539
- });
1540
- app.get("/api/projects/:id/git/history", async (request, reply) => {
1541
- const user = requireUser(request, reply, db);
1542
- if (!user)
1543
- return;
1544
- const { id } = request.params;
1545
- return await projectMutations.runConsistentRead(id, async () => {
1546
- return { commits: await projectGit.history(requireGitOwner(id, user)) };
1547
- }, { preflight: () => { requireGitOwner(id, user); } });
1548
- });
1549
- app.get("/api/projects/:id/git/diff", async (request, reply) => {
1550
- const user = requireUser(request, reply, db);
1551
- if (!user)
1552
- return;
1553
- const { id } = request.params;
1554
- const { revision } = request.query;
1555
- return await projectMutations.runConsistentRead(id, async () => {
1556
- const project = requireGitOwner(id, user);
1557
- return projectGit.diff(project, revision);
1558
- }, { preflight: () => { requireGitOwner(id, user); } });
1559
- });
1560
- app.post("/api/projects/:id/git/checkout", async (request, reply) => {
1561
- const user = requireUser(request, reply, db);
1562
- if (!user)
1563
- return;
1564
- const { id } = request.params;
1565
- requireGitOwner(id, user);
1566
- const body = request.body;
1567
- if (body.revision !== null && typeof body.revision !== "string")
1568
- return apiError(reply, 400, "GIT_REVISION_INVALID", "请选择要 checkout 的 Git 版本");
1569
- const revisionInput = body.revision === null ? null : body.revision;
1570
- return await projectMutations.runExclusive(id, "Git checkout", async () => {
1571
- const currentProject = requireGitOwner(id, user);
1572
- recordHistory(id, user.id, "checkpoint");
1573
- const before = projectTextSnapshot(config, id);
1574
- const revision = await projectGit.checkout(currentProject, revisionInput, body.force === true);
1575
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
1576
- touchProject(db, id, user.id);
1577
- recordHistory(id, user.id, "git");
1578
- return { revision, status: await projectGit.status(currentProject) };
1579
- }, { preflight: () => { requireGitOwner(id, user); } });
1580
- });
1581
- app.post("/api/projects/:id/git/discard", async (request, reply) => {
1582
- const user = requireUser(request, reply, db);
1583
- if (!user)
1584
- return;
1585
- const { id } = request.params;
1586
- requireGitOwner(id, user);
1587
- return await projectMutations.runExclusive(id, "Git restore", async () => {
1588
- const currentProject = requireGitOwner(id, user);
1589
- recordHistory(id, user.id, "checkpoint");
1590
- const before = projectTextSnapshot(config, id);
1591
- await projectGit.discardChanges(currentProject);
1592
- reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
1593
- touchProject(db, id, user.id);
1594
- recordHistory(id, user.id, "git");
1595
- return { status: await projectGit.status(currentProject) };
1596
- }, { preflight: () => { requireGitOwner(id, user); } });
1597
- });
1598
- app.get("/api/projects/:id/download", async (request, reply) => {
1599
- const user = requireUser(request, reply, db);
1600
- if (!user)
1601
- return;
1602
- const { id } = request.params;
1603
- const project = accessibleProject(db, id, user);
1604
- if (!project)
1605
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1606
- const temporaryDirectory = path.join(config.dataDir, "tmp");
1607
- const temporaryArchive = path.join(temporaryDirectory, `project-${id}-${randomUUID()}.zip`);
1608
- try {
1609
- await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
1610
- await projectMutations.runConsistentRead(id, () => writeProjectArchive(config, id, temporaryArchive), {
1611
- preflight: () => {
1612
- const current = accessibleProject(db, id, user);
1613
- if (!current)
1614
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1615
- }
1616
- });
1617
- }
1618
- catch (error) {
1619
- await fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined);
1620
- throw error;
1621
- }
1622
- const filename = `${project.name}.zip`;
1623
- reply.header("Content-Type", "application/zip");
1624
- reply.header("Content-Disposition", contentDisposition(filename, "attachment"));
1625
- const stream = fs.createReadStream(temporaryArchive);
1626
- const cleanup = () => { void fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined); };
1627
- stream.once("close", cleanup);
1628
- stream.once("error", cleanup);
1629
- return reply.send(stream);
1630
- });
1631
- app.delete("/api/projects/:id", async (request, reply) => {
1632
- const user = requireUser(request, reply, db);
1633
- if (!user)
1634
- return;
1635
- const { id } = request.params;
1636
- const project = accessibleProject(db, id, user);
1637
- if (!project || project.permission !== "owner")
1638
- return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN", "只有项目所有者可以删除项目");
1639
- return await projectMutations.runExclusive(id, "project deletion", () => {
1640
- collaboration.resetProject(id);
1641
- removeProjectDirectory(config, id);
1642
- db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1643
- latexCompletions.invalidate(id);
1644
- projectOutlines.invalidate(id);
1645
- return { ok: true };
1646
- }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
1647
- });
1648
- app.get("/api/projects/:id/files", async (request, reply) => {
1649
- const user = requireUser(request, reply, db);
1650
- if (!user)
1651
- return;
1652
- const { id } = request.params;
1653
- if (!accessibleProject(db, id, user))
1654
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1655
- // Directory walks can be slow on external/project-mounted storage. Keep
1656
- // this request off the event loop and unblocked by Yjs room initialization
1657
- // so the file tree and retained-PDF stream can be served concurrently, but
1658
- // still serialize it with source-tree mutations and collaborative flushes.
1659
- return await projectMutations.runFilesystemRead(id, async () => ({
1660
- files: await listProjectFilesAsync(config, id)
1661
- }), {
1662
- preflight: () => {
1663
- if (!accessibleProject(db, id, user)) {
1664
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1665
- }
1666
- }
1667
- });
1668
- });
1669
- app.get("/api/projects/:id/outline", async (request, reply) => {
1670
- const user = requireUser(request, reply, db);
1671
- if (!user)
1672
- return;
1673
- const { id } = request.params;
1674
- const project = accessibleProject(db, id, user);
1675
- if (!project)
1676
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1677
- const query = request.query;
1678
- const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
1679
- if (!mainFile)
1680
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
1681
- const startedAt = performance.now();
1682
- try {
1683
- return await projectMutations.runConsistentRead(id, async () => ({
1684
- outline: await projectOutlines.build(id, mainFile), mainFile
1685
- }), {
1686
- preflight: () => {
1687
- const current = accessibleProject(db, id, user);
1688
- if (!current)
1689
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1690
- const selected = compileMainFile(config, id, current.main_file, query.mainFile);
1691
- if (!selected || selected !== mainFile)
1692
- throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
1693
- }
1694
- });
1695
- }
1696
- finally {
1697
- metrics.record("outline.build", performance.now() - startedAt);
1698
- }
1699
- });
1700
- app.get("/api/projects/:id/search", async (request, reply) => {
1701
- const user = requireUser(request, reply, db);
1702
- if (!user)
1703
- return;
1704
- const { id } = request.params;
1705
- if (!accessibleProject(db, id, user))
1706
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1707
- const query = request.query;
1708
- const startedAt = performance.now();
1709
- try {
1710
- return await projectMutations.runConsistentRead(id, () => searchProject(config, id, {
1711
- query: query.q ?? "",
1712
- caseSensitive: query.caseSensitive === "1",
1713
- wholeWord: query.wholeWord === "1"
1714
- }), {
1715
- preflight: () => {
1716
- if (!accessibleProject(db, id, user))
1717
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1718
- }
1719
- });
1720
- }
1721
- finally {
1722
- metrics.record("search.project", performance.now() - startedAt);
1723
- }
1724
- });
1725
- app.post("/api/projects/:id/search/replace", async (request, reply) => {
1726
- const user = requireUser(request, reply, db);
1727
- if (!user)
1728
- return;
1729
- const { id } = request.params;
1730
- const project = accessibleProject(db, id, user);
1731
- if (!project || !canEdit(project))
1732
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1733
- const body = request.body;
1734
- if (typeof body.query !== "string" || typeof body.replacement !== "string" || body.replacement.length > 100_000) {
1735
- return apiError(reply, 400, "SEARCH_QUERY_INVALID", "搜索或替换内容格式不正确");
1736
- }
1737
- return await projectMutations.runExclusive(id, "project-wide replace", async () => {
1738
- const changed = await replaceProject(config, id, {
1739
- query: body.query,
1740
- caseSensitive: body.caseSensitive === true,
1741
- wholeWord: body.wholeWord === true,
1742
- maxFileBytes: maxCollaborativeFileBytes(config)
1743
- }, body.replacement);
1744
- let replacements = 0;
1745
- for (const file of changed) {
1746
- reanchorFileComments(db, id, file.path, file.previous, file.content);
1747
- collaboration.updateFile(id, file.path, file.content, user.id);
1748
- replacements += file.count;
1749
- }
1750
- if (changed.length) {
1751
- touchProject(db, id, user.id);
1752
- recordHistory(id, user.id, "file", changed.map((file) => file.path));
1753
- }
1754
- return { ok: true, replacements, files: changed.map((file) => file.path) };
1755
- }, { preflight: () => { requireEditableProject(db, id, user); } });
1756
- });
1757
- app.get("/api/projects/:id/completions", async (request, reply) => {
1758
- const user = requireUser(request, reply, db);
1759
- if (!user)
1760
- return;
1761
- const { id } = request.params;
1762
- if (!accessibleProject(db, id, user))
1763
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1764
- const startedAt = performance.now();
1765
- try {
1766
- return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
1767
- preflight: () => {
1768
- if (!accessibleProject(db, id, user))
1769
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1770
- }
1771
- });
1772
- }
1773
- finally {
1774
- metrics.record("completions.build", performance.now() - startedAt);
1775
- }
1776
- });
1777
- app.post("/api/projects/:id/folders", async (request, reply) => {
1778
- const user = requireUser(request, reply, db);
1779
- if (!user)
1780
- return;
1781
- const { id } = request.params;
1782
- if (collaboration.isMaintaining(id))
1783
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1784
- const project = accessibleProject(db, id, user);
1785
- if (!project || !canEdit(project))
1786
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1787
- const body = request.body;
1788
- const folderPath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1789
- return await projectMutations.runWrite(id, () => {
1790
- const absolute = resolveSourcePath(config, id, folderPath);
1791
- if (fs.existsSync(absolute))
1792
- return apiError(reply, 409, "PATH_EXISTS", "同名文件或目录已存在", { path: folderPath });
1793
- fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
1794
- touchProject(db, id, user.id);
1795
- // Empty folders have no file entry to trigger a refresh on their own.
1796
- // Publish a source-tree event so other open sessions see the folder
1797
- // immediately instead of waiting for a later file operation.
1798
- collaboration.invalidateSourceTree(id, folderPath);
1799
- return reply.code(201).send({ ok: true, path: folderPath });
1800
- }, { preflight: () => { requireEditableProject(db, id, user); } });
1801
- });
1802
- app.patch("/api/projects/:id/path", async (request, reply) => {
1803
- const user = requireUser(request, reply, db);
1804
- if (!user)
1805
- return;
1806
- const { id } = request.params;
1807
- if (collaboration.isMaintaining(id))
1808
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1809
- const project = accessibleProject(db, id, user);
1810
- if (!project || !canEdit(project))
1811
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1812
- const body = request.body;
1813
- const source = safeRelativePath(typeof body.source === "string" ? body.source : "");
1814
- const destinationDirectory = body.destinationDirectory === "" ? ""
1815
- : safeRelativePath(typeof body.destinationDirectory === "string" ? body.destinationDirectory : "");
1816
- const destinationName = body.destinationName === undefined
1817
- ? path.posix.basename(source)
1818
- : safePathSegment(typeof body.destinationName === "string" ? body.destinationName : "");
1819
- const destination = destinationDirectory ? `${destinationDirectory}/${destinationName}` : destinationName;
1820
- const sourceAbsolute = resolveSourcePath(config, id, source);
1821
- if (!fs.existsSync(sourceAbsolute))
1822
- return apiError(reply, 404, "PATH_NOT_FOUND", "要移动的文件或目录不存在", { path: source });
1823
- if (destination === source)
1824
- return { ok: true, path: source };
1825
- const initialSourceStat = fs.statSync(sourceAbsolute);
1826
- if (initialSourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1827
- return apiError(reply, 400, "MOVE_INTO_SELF", "不能把目录移动到自身内部", { path: source });
1828
- }
1829
- const destinationRoot = destinationDirectory
1830
- ? resolveSourcePath(config, id, destinationDirectory)
1831
- : sourceRoot(config, id);
1832
- if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
1833
- return apiError(reply, 404, "DIRECTORY_NOT_FOUND", "目标目录不存在", { path: destinationDirectory });
1834
- }
1835
- const destinationAbsolute = resolveSourcePath(config, id, destination);
1836
- if (fs.existsSync(destinationAbsolute))
1837
- return apiError(reply, 409, "PATH_EXISTS", "目标目录中存在同名文件或目录", { path: destination });
1838
- let currentProject = project;
1839
- const validateMove = () => {
1840
- currentProject = requireEditableProject(db, id, user);
1841
- assertNoSourceSymlinks(config, id);
1842
- if (!fs.existsSync(sourceAbsolute)) {
1843
- throw Object.assign(new Error("要移动的文件或目录不存在"), { statusCode: 404, code: "PATH_NOT_FOUND" });
1844
- }
1845
- const sourceStat = fs.statSync(sourceAbsolute);
1846
- if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1847
- throw Object.assign(new Error("不能把目录移动到自身内部"), { statusCode: 400, code: "MOVE_INTO_SELF" });
1848
- }
1849
- if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
1850
- throw Object.assign(new Error("目标目录不存在"), { statusCode: 404, code: "DIRECTORY_NOT_FOUND" });
1851
- }
1852
- if (fs.existsSync(destinationAbsolute)) {
1853
- throw Object.assign(new Error("目标目录中存在同名文件或目录"), { statusCode: 409, code: "PATH_EXISTS" });
1854
- }
1855
- };
1856
- return await projectMutations.runExclusive(id, "move project path", () => {
1857
- fs.renameSync(sourceAbsolute, destinationAbsolute);
1858
- let hasCommentUpdates = false;
1859
- db.exec("BEGIN IMMEDIATE");
1860
- try {
1861
- const mainFile = movedProjectPath(currentProject.main_file, source, destination);
1862
- const latexmkrc = movedProjectPath(currentProject.latexmkrc, source, destination);
1863
- const changedAt = now();
1864
- db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
1865
- .run(mainFile, latexmkrc, changedAt, user.id, id);
1866
- const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
1867
- const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
1868
- for (const comment of comments) {
1869
- const nextPath = movedProjectPath(comment.file_path, source, destination);
1870
- if (nextPath !== comment.file_path) {
1871
- updateComment.run(nextPath, changedAt, comment.id);
1872
- hasCommentUpdates = true;
1873
- }
1874
- }
1875
- db.exec("COMMIT");
1876
- }
1877
- catch (error) {
1878
- db.exec("ROLLBACK");
1879
- fs.renameSync(destinationAbsolute, sourceAbsolute);
1880
- throw error;
1881
- }
1882
- collaboration.movePath(id, source, destination, user.id);
1883
- if (hasCommentUpdates)
1884
- collaboration.signalComments(id);
1885
- recordHistory(id, user.id, "file", [source, destination]);
1886
- return { ok: true, path: destination };
1887
- }, { preflight: validateMove });
1888
- });
1889
- app.get("/api/projects/:id/file/raw", async (request, reply) => {
1890
- const user = requireUser(request, reply, db);
1891
- if (!user)
1892
- return;
1893
- const { id } = request.params;
1894
- if (!accessibleProject(db, id, user))
1895
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1896
- const query = request.query;
1897
- const filePath = safeRelativePath(query.path ?? "");
1898
- const temporaryDirectory = path.join(config.dataDir, "tmp");
1899
- const temporaryFile = path.join(temporaryDirectory, `file-${id}-${randomUUID()}`);
1900
- try {
1901
- await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
1902
- await projectMutations.runConsistentRead(id, async () => {
1903
- const absolute = resolveSourcePath(config, id, filePath);
1904
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
1905
- throw Object.assign(new Error("文件不存在"), { statusCode: 404, code: "FILE_NOT_FOUND" });
1906
- }
1907
- await fs.promises.copyFile(absolute, temporaryFile);
1908
- }, { preflight: () => {
1909
- if (!accessibleProject(db, id, user))
1910
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1911
- } });
1912
- }
1913
- catch (error) {
1914
- await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
1915
- throw error;
1916
- }
1917
- const extension = path.extname(filePath).toLocaleLowerCase();
1918
- const contentTypes = {
1919
- ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
1920
- ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
1921
- ".pdf": "application/pdf"
1922
- };
1923
- const contentType = contentTypes[extension] ?? "application/octet-stream";
1924
- const downloading = query.download === "1";
1925
- reply.header("Content-Type", contentType);
1926
- reply.header("Content-Disposition", contentDisposition(path.basename(filePath), downloading ? "attachment" : "inline"));
1927
- reply.header("Cache-Control", "private, no-cache");
1928
- reply.header("Content-Length", fs.statSync(temporaryFile).size);
1929
- const stream = fs.createReadStream(temporaryFile);
1930
- const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
1931
- stream.once("close", cleanup);
1932
- stream.once("error", cleanup);
1933
- return reply.send(stream);
1934
- });
1935
- app.get("/api/projects/:id/file", async (request, reply) => {
1936
- const user = requireUser(request, reply, db);
1937
- if (!user)
1938
- return;
1939
- const { id } = request.params;
1940
- const { path: filePath } = request.query;
1941
- if (!accessibleProject(db, id, user))
1942
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1943
- const relative = safeRelativePath(filePath ?? "");
1944
- return await projectMutations.runConsistentRead(id, () => {
1945
- const absolute = resolveSourcePath(config, id, relative);
1946
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1947
- return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: relative });
1948
- if (isCollaborativeTextFile(relative) && fs.statSync(absolute).size > maxCollaborativeFileBytes(config)) {
1949
- return apiError(reply, 413, "FILE_TOO_LARGE", "协作文本文件过大,不能作为编辑器内容打开", { path: relative });
1950
- }
1951
- return { path: relative, content: fs.readFileSync(absolute, "utf8") };
1952
- }, { preflight: () => {
1953
- if (!accessibleProject(db, id, user))
1954
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
1955
- } });
1956
- });
1957
- // Creating a file is intentionally separate from updating one. The editor
1958
- // uses PUT for autosaves, but a user action such as "New file" must never
1959
- // silently replace an existing file.
1960
- app.post("/api/projects/:id/file", async (request, reply) => {
1961
- const user = requireUser(request, reply, db);
1962
- if (!user)
1963
- return;
1964
- const { id } = request.params;
1965
- if (collaboration.isMaintaining(id))
1966
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
1967
- const project = accessibleProject(db, id, user);
1968
- if (!project || !canEdit(project))
1969
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1970
- const body = request.body;
1971
- const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1972
- if (typeof body.content !== "string")
1973
- return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
1974
- const content = body.content;
1975
- const byteLength = Buffer.byteLength(content, "utf8");
1976
- const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
1977
- if (byteLength > limit) {
1978
- return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
1979
- }
1980
- return await projectMutations.runWrite(id, () => {
1981
- const absolute = resolveSourcePath(config, id, filePath);
1982
- try {
1983
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1984
- }
1985
- catch (error) {
1986
- if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1987
- return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: filePath });
1988
- }
1989
- throw error;
1990
- }
1991
- try {
1992
- fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
1993
- }
1994
- catch (error) {
1995
- if (error.code === "EEXIST") {
1996
- return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
1997
- }
1998
- throw error;
1999
- }
2000
- touchProject(db, id, user.id);
2001
- collaboration.updateFile(id, filePath, content, user.id);
2002
- recordHistory(id, user.id, "file", [filePath]);
2003
- return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
2004
- }, { preflight: () => { requireEditableProject(db, id, user); } });
2005
- });
2006
- app.put("/api/projects/:id/file", async (request, reply) => {
2007
- const user = requireUser(request, reply, db);
2008
- if (!user)
2009
- return;
2010
- const { id } = request.params;
2011
- if (collaboration.isMaintaining(id))
2012
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
2013
- const project = accessibleProject(db, id, user);
2014
- if (!project || !canEdit(project))
2015
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
2016
- const body = request.body;
2017
- const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
2018
- if (typeof body.content !== "string")
2019
- return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
2020
- const content = body.content;
2021
- const byteLength = Buffer.byteLength(content, "utf8");
2022
- const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
2023
- if (byteLength > limit) {
2024
- return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
2025
- }
2026
- return await projectMutations.runWrite(id, () => {
2027
- const absolute = resolveSourcePath(config, id, filePath);
2028
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
2029
- const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
2030
- reanchorFileComments(db, id, filePath, previousContent, content);
2031
- fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600 });
2032
- touchProject(db, id, user.id);
2033
- collaboration.updateFile(id, filePath, content, user.id);
2034
- recordHistory(id, user.id, "file", [filePath]);
2035
- return { ok: true, comments: commentsForFile(db, config, id, filePath) };
2036
- }, { preflight: () => { requireEditableProject(db, id, user); } });
2037
- });
2038
- app.delete("/api/projects/:id/file", async (request, reply) => {
2039
- const user = requireUser(request, reply, db);
2040
- if (!user)
2041
- return;
2042
- const { id } = request.params;
2043
- if (collaboration.isMaintaining(id))
2044
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
2045
- const { path: filePath } = request.query;
2046
- const relative = safeRelativePath(filePath ?? "");
2047
- const project = accessibleProject(db, id, user);
2048
- if (!project || !canEdit(project))
2049
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
2050
- return await projectMutations.runWrite(id, () => {
2051
- const currentProject = requireEditableProject(db, id, user);
2052
- if (relative === currentProject.main_file || currentProject.main_file.startsWith(`${relative}/`)) {
2053
- return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", "不能删除当前主文件或包含主文件的目录", { path: relative });
2054
- }
2055
- const absolute = resolveSourcePath(config, id, relative, { allowFinalSymlink: true });
2056
- try {
2057
- fs.lstatSync(absolute);
2058
- }
2059
- catch (error) {
2060
- if (error.code === "ENOENT")
2061
- return apiError(reply, 404, "PATH_NOT_FOUND", "文件或目录不存在", { path: relative });
2062
- throw error;
2063
- }
2064
- fs.rmSync(absolute, { recursive: true, force: true });
2065
- const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
2066
- touchProject(db, id, user.id);
2067
- collaboration.removePath(id, relative);
2068
- if (deleteResult.changes > 0)
2069
- collaboration.signalComments(id);
2070
- recordHistory(id, user.id, "file", [relative]);
2071
- return { ok: true };
2072
- }, { preflight: () => { requireEditableProject(db, id, user); } });
2073
- });
2074
- app.post("/api/projects/:id/upload", async (request, reply) => {
2075
- const user = requireUser(request, reply, db);
2076
- if (!user)
2077
- return;
2078
- const { id } = request.params;
2079
- if (collaboration.isMaintaining(id))
2080
- return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
2081
- const project = accessibleProject(db, id, user);
2082
- if (!project || !canEdit(project))
2083
- return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
2084
- const part = await request.file();
2085
- if (!part)
2086
- return apiError(reply, 400, "UPLOAD_EMPTY", "没有收到上传文件");
2087
- const { directory, overwrite } = request.query;
2088
- const relative = safeRelativePath(directory ? `${safeRelativePath(directory)}/${part.filename}` : part.filename);
2089
- const uploadLimit = isCollaborativeTextFile(relative) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
2090
- const tmpDir = path.join(config.dataDir, "tmp");
2091
- fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
2092
- const tmpPath = path.join(tmpDir, `upload-${randomUUID()}.tmp`);
2093
- let byteLength = 0;
2094
- try {
2095
- const limiter = new Transform({
2096
- transform(chunk, _encoding, callback) {
2097
- byteLength += chunk.length;
2098
- if (byteLength > uploadLimit) {
2099
- callback(new Error("FILE_TOO_LARGE"));
2100
- }
2101
- else {
2102
- callback(null, chunk);
2103
- }
2104
- }
2105
- });
2106
- await pipeline(part.file, limiter, fs.createWriteStream(tmpPath, { mode: 0o600 }));
2107
- }
2108
- catch (error) {
2109
- if (fs.existsSync(tmpPath)) {
2110
- try {
2111
- fs.unlinkSync(tmpPath);
2112
- }
2113
- catch { /* ignore */ }
2114
- }
2115
- if (error instanceof Error && error.message === "FILE_TOO_LARGE") {
2116
- return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(uploadLimit / 1024 / 1024)} MB`, { path: relative, size: Math.floor(uploadLimit / 1024 / 1024) });
2117
- }
2118
- throw error;
2119
- }
2120
- try {
2121
- return await projectMutations.runWrite(id, () => {
2122
- const absolute = resolveSourcePath(config, id, relative);
2123
- try {
2124
- fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
2125
- }
2126
- catch (error) {
2127
- if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
2128
- return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: relative });
2129
- }
2130
- throw error;
2131
- }
2132
- const replacing = overwrite === "1";
2133
- if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
2134
- return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
2135
- }
2136
- if (!replacing && fs.existsSync(absolute)) {
2137
- return apiError(reply, 409, "FILE_EXISTS", "同名文件已存在", { path: relative });
2138
- }
2139
- const collaborativeText = isCollaborativeTextFile(relative);
2140
- let previousContent = null;
2141
- if (replacing && collaborativeText && fs.existsSync(absolute)) {
2142
- // runWrite() has flushed any live Yjs edits before entering this
2143
- // callback. Prefer the room's text so comment anchors are based on
2144
- // exactly what collaborators last saved, then fall back to disk for
2145
- // projects without an active collaboration room.
2146
- previousContent = collaboration.fileContent(id, relative);
2147
- if (previousContent === null)
2148
- previousContent = fs.readFileSync(absolute, "utf8");
2149
- }
2150
- try {
2151
- fs.renameSync(tmpPath, absolute);
2152
- }
2153
- catch {
2154
- fs.copyFileSync(tmpPath, absolute);
2155
- fs.unlinkSync(tmpPath);
2156
- }
2157
- touchProject(db, id, user.id);
2158
- if (collaborativeText) {
2159
- const content = fs.readFileSync(absolute, "utf8");
2160
- if (previousContent !== null && previousContent !== content) {
2161
- reanchorFileComments(db, id, relative, previousContent, content);
2162
- collaboration.signalComments(id);
2163
- }
2164
- collaboration.updateFile(id, relative, content, user.id);
2165
- }
2166
- else {
2167
- collaboration.invalidateSourceTree(id, relative);
2168
- }
2169
- recordHistory(id, user.id, "file", [relative]);
2170
- return reply.code(201).send({ ok: true, path: relative });
2171
- }, { preflight: () => { requireEditableProject(db, id, user); } });
2172
- }
2173
- finally {
2174
- if (fs.existsSync(tmpPath)) {
2175
- try {
2176
- fs.unlinkSync(tmpPath);
2177
- }
2178
- catch { /* ignore */ }
2179
- }
2180
- }
2181
- });
2182
- app.get("/api/projects/:id/members", async (request, reply) => {
2183
- const user = requireUser(request, reply, db);
2184
- if (!user)
2185
- return;
2186
- const { id } = request.params;
2187
- if (!accessibleProject(db, id, user))
2188
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2189
- const members = db.prepare(`SELECT pm.user_id AS id, u.username, u.display_name AS displayName, pm.permission
2190
- FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = ? ORDER BY u.username`).all(id);
2191
- return { members };
2192
- });
2193
- app.put("/api/projects/:id/owner", async (request, reply) => {
2194
- const user = requireUser(request, reply, db);
2195
- if (!user)
2196
- return;
2197
- const { id } = request.params;
2198
- const project = accessibleProject(db, id, user);
2199
- if (!project || project.owner_id !== user.id) {
2200
- return apiError(reply, 403, "PROJECT_TRANSFER_FORBIDDEN", "只有当前项目所有者可以转让项目");
2201
- }
2202
- const body = (request.body ?? {});
2203
- if (typeof body.userId !== "string" || !body.userId) {
2204
- return apiError(reply, 400, "PROJECT_TRANSFER_TARGET_INVALID", "请选择新的项目所有者");
2205
- }
2206
- if (body.userId === user.id) {
2207
- return apiError(reply, 400, "PROJECT_TRANSFER_SELF", "当前用户已经是项目所有者");
2208
- }
2209
- const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
2210
- if (!target)
2211
- return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
2212
- return await projectMutations.runExclusive(id, "project transfer", () => {
2213
- const currentTarget = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
2214
- // The synchronous preflight immediately precedes maintenance, so this
2215
- // lookup cannot change before the operation starts.
2216
- if (!currentTarget)
2217
- throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
2218
- const changedAt = now();
2219
- db.exec("BEGIN IMMEDIATE");
2220
- try {
2221
- // The new owner may already be a shared member. The previous owner keeps
2222
- // edit access so a transfer does not unexpectedly lock them out.
2223
- db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, currentTarget.id);
2224
- db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
2225
- VALUES (?, ?, 'edit', ?)
2226
- ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
2227
- .run(id, user.id, changedAt);
2228
- db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
2229
- .run(currentTarget.id, user.id, changedAt, id);
2230
- // A project token belongs to the account that supplied it. Preserve the
2231
- // local repository/remote metadata, but require the new owner to add
2232
- // their own credential before the next GitHub operation.
2233
- db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
2234
- .run(changedAt, id);
2235
- db.exec("COMMIT");
2236
- }
2237
- catch (error) {
2238
- db.exec("ROLLBACK");
2239
- throw error;
2240
- }
2241
- return {
2242
- project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
2243
- };
2244
- }, { preflight: () => {
2245
- requireActualProjectOwner(db, id, user);
2246
- if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(body.userId)) {
2247
- throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
2248
- }
2249
- } });
2250
- });
2251
- app.put("/api/projects/:id/members/:userId", async (request, reply) => {
2252
- const user = requireUser(request, reply, db);
2253
- if (!user)
2254
- return;
2255
- const { id, userId } = request.params;
2256
- const project = accessibleProject(db, id, user);
2257
- if (!project || project.permission !== "owner")
2258
- return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
2259
- if (userId === project.owner_id)
2260
- return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN", "项目所有者不能作为成员添加");
2261
- if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(userId)) {
2262
- return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
2263
- }
2264
- const body = request.body;
2265
- const permission = body.permission === "edit" ? "edit" : "read";
2266
- db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
2267
- ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
2268
- .run(id, userId, permission, now());
2269
- touchProject(db, id, user.id);
2270
- collaboration.notifyPermissionChanged(id, userId, permission);
2271
- return { ok: true };
2272
- });
2273
- app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
2274
- const user = requireUser(request, reply, db);
2275
- if (!user)
2276
- return;
2277
- const { id, userId } = request.params;
2278
- const project = accessibleProject(db, id, user);
2279
- if (!project || project.permission !== "owner")
2280
- return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
2281
- db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
2282
- touchProject(db, id, user.id);
2283
- collaboration.notifyPermissionChanged(id, userId, "revoked");
2284
- return { ok: true };
2285
- });
2286
- app.get("/api/projects/:id/comments", async (request, reply) => {
2287
- const user = requireUser(request, reply, db);
2288
- if (!user)
2289
- return;
2290
- const { id } = request.params;
2291
- const { path: filePath } = request.query;
2292
- if (!accessibleProject(db, id, user))
2293
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2294
- const relative = safeRelativePath(filePath ?? "");
2295
- return {
2296
- comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
2297
- preflight: () => {
2298
- if (!accessibleProject(db, id, user))
2299
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
2300
- }
2301
- })
2302
- };
2303
- });
2304
- app.post("/api/projects/:id/comments", async (request, reply) => {
2305
- const user = requireUser(request, reply, db);
2306
- if (!user)
2307
- return;
2308
- const { id } = request.params;
2309
- if (!accessibleProject(db, id, user))
2310
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2311
- const body = request.body;
2312
- const createdAt = now();
2313
- const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
2314
- return await projectMutations.runWrite(id, () => {
2315
- const absolute = resolveSourcePath(config, id, filePath);
2316
- if (!fs.existsSync(absolute))
2317
- return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND", "批注文件不存在");
2318
- const source = fs.readFileSync(absolute, "utf8");
2319
- if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
2320
- return apiError(reply, 400, "COMMENT_RANGE_INVALID", "批注缺少有效的源码区间");
2321
- }
2322
- const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
2323
- const comment = {
2324
- id: randomUUID(), filePath,
2325
- content: text(body.content, "批注", 5000)
2326
- };
2327
- db.prepare(`INSERT INTO comments
2328
- (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
2329
- context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
2330
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
2331
- .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);
2332
- const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
2333
- collaboration.signalComments(id);
2334
- return reply.code(201).send({ comment: created });
2335
- }, { preflight: () => {
2336
- if (!accessibleProject(db, id, user))
2337
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
2338
- } });
2339
- });
2340
- app.post("/api/projects/:id/comments/:commentId/replies", async (request, reply) => {
2341
- const user = requireUser(request, reply, db);
2342
- if (!user)
2343
- return;
2344
- const { id, commentId } = request.params;
2345
- if (!accessibleProject(db, id, user))
2346
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2347
- if (!db.prepare("SELECT 1 FROM comments WHERE id = ? AND project_id = ?").get(commentId, id)) {
2348
- return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
2349
- }
2350
- const body = request.body;
2351
- const createdAt = now();
2352
- const replyId = randomUUID();
2353
- db.prepare(`INSERT INTO comment_replies (id, comment_id, author_id, content, created_at, updated_at)
2354
- VALUES (?, ?, ?, ?, ?, ?)`)
2355
- .run(replyId, commentId, user.id, text(body.content, "回复", 5000), createdAt, createdAt);
2356
- const created = repliesForComment(db, commentId).find((item) => item.id === replyId);
2357
- collaboration.signalComments(id);
2358
- return reply.code(201).send({ reply: created });
2359
- });
2360
- app.patch("/api/projects/:id/comments/:commentId", async (request, reply) => {
2361
- const user = requireUser(request, reply, db);
2362
- if (!user)
2363
- return;
2364
- const { id, commentId } = request.params;
2365
- if (!accessibleProject(db, id, user))
2366
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2367
- const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
2368
- .get(commentId, id);
2369
- if (!comment)
2370
- return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
2371
- const body = request.body;
2372
- const changedAt = now();
2373
- let changed = false;
2374
- if (typeof body.content === "string") {
2375
- if (comment.author_id !== user.id)
2376
- return apiError(reply, 403, "COMMENT_EDIT_FORBIDDEN", "只能编辑自己的批注");
2377
- db.prepare("UPDATE comments SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
2378
- .run(text(body.content, "批注", 5000), changedAt, changedAt, commentId);
2379
- changed = true;
2380
- }
2381
- if (typeof body.resolved === "boolean") {
2382
- db.prepare("UPDATE comments SET resolved = ?, updated_at = ? WHERE id = ?")
2383
- .run(body.resolved ? 1 : 0, changedAt, commentId);
2384
- changed = true;
2385
- }
2386
- if (!changed)
2387
- return apiError(reply, 400, "COMMENT_UPDATE_EMPTY", "没有可更新的批注内容");
2388
- collaboration.signalComments(id);
2389
- return { ok: true };
2390
- });
2391
- app.delete("/api/projects/:id/comments/:commentId", async (request, reply) => {
2392
- const user = requireUser(request, reply, db);
2393
- if (!user)
2394
- return;
2395
- const { id, commentId } = request.params;
2396
- if (!accessibleProject(db, id, user))
2397
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2398
- const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
2399
- .get(commentId, id);
2400
- if (!comment)
2401
- return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
2402
- if (comment.author_id !== user.id)
2403
- return apiError(reply, 403, "COMMENT_DELETE_FORBIDDEN", "只能删除自己的批注");
2404
- db.prepare("DELETE FROM comments WHERE id = ?").run(commentId);
2405
- collaboration.signalComments(id);
2406
- return { ok: true };
2407
- });
2408
- app.patch("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
2409
- const user = requireUser(request, reply, db);
2410
- if (!user)
2411
- return;
2412
- const { id, commentId, replyId } = request.params;
2413
- if (!accessibleProject(db, id, user))
2414
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2415
- const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
2416
- JOIN comments comment ON comment.id = reply.comment_id
2417
- WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
2418
- .get(replyId, commentId, id);
2419
- if (!commentReply)
2420
- return apiError(reply, 404, "REPLY_NOT_FOUND", "回复不存在");
2421
- if (commentReply.author_id !== user.id)
2422
- return apiError(reply, 403, "REPLY_EDIT_FORBIDDEN", "只能编辑自己的回复");
2423
- const body = request.body;
2424
- const changedAt = now();
2425
- db.prepare("UPDATE comment_replies SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
2426
- .run(text(body.content, "回复", 5000), changedAt, changedAt, replyId);
2427
- const updated = repliesForComment(db, commentId).find((item) => item.id === replyId);
2428
- collaboration.signalComments(id);
2429
- return { reply: updated };
2430
- });
2431
- app.delete("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
2432
- const user = requireUser(request, reply, db);
2433
- if (!user)
2434
- return;
2435
- const { id, commentId, replyId } = request.params;
2436
- if (!accessibleProject(db, id, user))
2437
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
2438
- const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
2439
- JOIN comments comment ON comment.id = reply.comment_id
2440
- WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
2441
- .get(replyId, commentId, id);
2442
- if (!commentReply)
2443
- return apiError(reply, 404, "REPLY_NOT_FOUND", "回复不存在");
2444
- if (commentReply.author_id !== user.id)
2445
- return apiError(reply, 403, "REPLY_DELETE_FORBIDDEN", "只能删除自己的回复");
2446
- db.prepare("DELETE FROM comment_replies WHERE id = ?").run(replyId);
2447
- collaboration.signalComments(id);
2448
- return { ok: true };
150
+ void apiError(reply, 500, "SERVER_ERROR");
151
+ }
152
+ });
153
+ registerSystemRoutes(app, {
154
+ config,
155
+ db,
156
+ queue,
157
+ collaboration,
158
+ latexCompletions,
159
+ projectOutlines,
160
+ metrics,
161
+ eventLoopDelay
162
+ });
163
+ registerCollaborationRoutes(app, { db, collaboration, metrics });
164
+ registerAuthRoutes(app, { config, db, loginLimiter });
165
+ registerCitationRoutes(app, { db });
166
+ registerUserManagementRoutes(app, {
167
+ config,
168
+ db,
169
+ collaboration,
170
+ projectMutations,
171
+ latexCompletions,
172
+ projectOutlines
173
+ });
174
+ registerCommentRoutes(app, { config, db, collaboration, projectMutations });
175
+ registerProjectMemberRoutes(app, { db, collaboration, projectMutations });
176
+ registerProjectFileRoutes(app, {
177
+ config,
178
+ db,
179
+ collaboration,
180
+ projectMutations,
181
+ latexCompletions,
182
+ projectOutlines,
183
+ metrics,
184
+ recordHistory
185
+ });
186
+ registerProjectHistoryRoutes(app, { config, db, history, projectMutations, recordHistory });
187
+ registerProjectGitRoutes(app, { config, db, collaboration, projectMutations, projectGit, recordHistory });
188
+ registerProjectCatalogRoutes(app, {
189
+ config,
190
+ db,
191
+ collaboration,
192
+ projectMutations,
193
+ latexCompletions,
194
+ projectOutlines,
195
+ harper,
196
+ recordHistory
2449
197
  });
2450
198
  registerCompileRoutes(app, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
199
+ // The factory owns shared services and lifecycle hooks; route modules own endpoint behavior.
2451
200
  app.addHook("onRequest", async (request) => {
2452
201
  if (request.url.startsWith("/api/") && request.url !== "/api/auth/login" && request.url !== "/api/health" && request.url !== "/api/config") {
2453
202
  const token = request.cookies.texlite_session;
@@ -2458,10 +207,29 @@ export async function buildApp(config, db, options = {}) {
2458
207
  });
2459
208
  app.addHook("onClose", async () => collaboration.destroy());
2460
209
  if (fs.existsSync(config.clientDir)) {
2461
- await app.register(staticPlugin, { root: config.clientDir, wildcard: false });
210
+ await app.register(staticPlugin, {
211
+ root: config.clientDir,
212
+ wildcard: false,
213
+ // Vite fingerprints everything under assets/. These large JS/WASM files
214
+ // are safe to cache indefinitely; a new build produces a new URL. Keep
215
+ // index.html fresh so it always points at the current fingerprints.
216
+ cacheControl: false,
217
+ setHeaders(reply, filePath) {
218
+ const relativePath = path.relative(config.clientDir, filePath).split(path.sep).join("/");
219
+ if (relativePath === "index.html") {
220
+ reply.header("Cache-Control", "no-store");
221
+ }
222
+ else if (relativePath.startsWith("assets/")) {
223
+ reply.header("Cache-Control", "public, max-age=31536000, immutable");
224
+ }
225
+ else {
226
+ reply.header("Cache-Control", "public, max-age=3600");
227
+ }
228
+ }
229
+ });
2462
230
  app.get("/*", async (request, reply) => {
2463
231
  if (request.url.startsWith("/api/"))
2464
- return apiError(reply, 404, "API_NOT_FOUND", "接口不存在");
232
+ return apiError(reply, 404, "API_NOT_FOUND");
2465
233
  reply.header("Cache-Control", "no-store");
2466
234
  return reply.sendFile("index.html");
2467
235
  });