texlite 0.7.3 → 0.7.6

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