texlite 0.2.0

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 (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +403 -0
  3. package/README.zh-CN.md +391 -0
  4. package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
  5. package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
  6. package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
  7. package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
  8. package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
  9. package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
  10. package/dist/client/assets/editor-JxT6uSYX.js +14 -0
  11. package/dist/client/assets/index-Bc5CmFMl.js +15 -0
  12. package/dist/client/assets/index-CMqYAbDx.css +1 -0
  13. package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
  14. package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
  15. package/dist/client/assets/plus-DzbLkp6l.js +1 -0
  16. package/dist/client/assets/react-CsO_xvbl.js +49 -0
  17. package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
  18. package/dist/client/index.html +20 -0
  19. package/dist/client/logo.svg +4 -0
  20. package/dist/server/anchors.js +95 -0
  21. package/dist/server/app.js +1624 -0
  22. package/dist/server/archive.js +26 -0
  23. package/dist/server/auth.js +42 -0
  24. package/dist/server/cli.js +318 -0
  25. package/dist/server/collaboration.js +635 -0
  26. package/dist/server/compileArtifacts.js +86 -0
  27. package/dist/server/compileDiagnostics.js +227 -0
  28. package/dist/server/compiler.js +514 -0
  29. package/dist/server/config.js +336 -0
  30. package/dist/server/db.js +280 -0
  31. package/dist/server/environment.js +57 -0
  32. package/dist/server/files.js +121 -0
  33. package/dist/server/git.js +414 -0
  34. package/dist/server/history.js +379 -0
  35. package/dist/server/http.js +7 -0
  36. package/dist/server/index.js +75 -0
  37. package/dist/server/latexCompletion.js +296 -0
  38. package/dist/server/metrics.js +34 -0
  39. package/dist/server/pm2.js +234 -0
  40. package/dist/server/projectOutline.js +101 -0
  41. package/dist/server/projectSearch.js +112 -0
  42. package/dist/server/projects.js +29 -0
  43. package/dist/server/routes/compile.js +343 -0
  44. package/dist/server/runtimePaths.js +42 -0
  45. package/dist/server/security.js +29 -0
  46. package/dist/server/spellCheck.js +195 -0
  47. package/dist/server/synctex.js +56 -0
  48. package/dist/server/zip.js +185 -0
  49. package/package.json +110 -0
  50. package/preview.png +0 -0
  51. package/texlite.config.example.json +33 -0
@@ -0,0 +1,1624 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { monitorEventLoopDelay, performance } from "node:perf_hooks";
5
+ import Fastify from "fastify";
6
+ import cookie from "@fastify/cookie";
7
+ import multipart from "@fastify/multipart";
8
+ import staticPlugin from "@fastify/static";
9
+ import websocket from "@fastify/websocket";
10
+ import { activeAdminCount } from "./db.js";
11
+ import { createSessionToken, hashPassword, verifyPassword, digestToken } from "./security.js";
12
+ import { currentUser, publicUser, requireAdmin, requireUser } from "./auth.js";
13
+ import { accessibleProject, canEdit } from "./projects.js";
14
+ import { createProjectFiles, duplicateProjectFiles, listProjectFiles, outputRoot, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
15
+ import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns } from "./compiler.js";
16
+ import { createSourceAnchor, offsetToLine, reanchorFileComments } from "./anchors.js";
17
+ import { extractProjectZip } from "./zip.js";
18
+ import { createProjectArchive } from "./archive.js";
19
+ import { CollaborationService } from "./collaboration.js";
20
+ import { ProjectGitService } from "./git.js";
21
+ import { LatexCompletionService } from "./latexCompletion.js";
22
+ import { ProjectHistoryService } from "./history.js";
23
+ import { buildProjectOutline } from "./projectOutline.js";
24
+ import { replaceProject, searchProject } from "./projectSearch.js";
25
+ import { MetricRegistry } from "./metrics.js";
26
+ import { compileMainFile } from "./compileArtifacts.js";
27
+ import { apiError, contentDisposition } from "./http.js";
28
+ import { registerCompileRoutes } from "./routes/compile.js";
29
+ const now = () => new Date().toISOString();
30
+ function text(value, name, max = 200) {
31
+ if (typeof value !== "string" || !value.trim() || value.length > max) {
32
+ throw new Error(`${name}格式不正确`);
33
+ }
34
+ return value.trim();
35
+ }
36
+ function dictionaryWord(value) {
37
+ if (typeof value !== "string")
38
+ throw new Error("自定义词格式不正确");
39
+ const word = value.trim();
40
+ if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
41
+ throw new Error("自定义词格式不正确");
42
+ return word;
43
+ }
44
+ const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
45
+ function tagsForProject(db, projectId, userId) {
46
+ return db.prepare(`SELECT tag.id, tag.name, tag.color
47
+ FROM user_tags tag JOIN user_project_tag_links link ON link.tag_id = tag.id
48
+ WHERE link.project_id = ? AND tag.user_id = ? ORDER BY tag.name COLLATE NOCASE`)
49
+ .all(projectId, userId);
50
+ }
51
+ function tagsForProjects(db, projectIds, userId) {
52
+ const result = new Map(projectIds.map((projectId) => [projectId, []]));
53
+ for (let offset = 0; offset < projectIds.length; offset += 500) {
54
+ const chunk = projectIds.slice(offset, offset + 500);
55
+ const placeholders = chunk.map(() => "?").join(", ");
56
+ const rows = db.prepare(`SELECT link.project_id, tag.id, tag.name, tag.color
57
+ FROM user_project_tag_links link JOIN user_tags tag ON tag.id = link.tag_id
58
+ WHERE tag.user_id = ? AND link.project_id IN (${placeholders})
59
+ ORDER BY tag.name COLLATE NOCASE`).all(userId, ...chunk);
60
+ for (const row of rows)
61
+ result.get(row.project_id)?.push({ id: row.id, name: row.name, color: row.color });
62
+ }
63
+ return result;
64
+ }
65
+ function projectJson(project, tags = []) {
66
+ return {
67
+ id: project.id,
68
+ ownerId: project.owner_id,
69
+ ownerUsername: project.owner_username,
70
+ ownerDisplayName: project.owner_display_name,
71
+ lastModifiedBy: project.last_modified_by,
72
+ lastModifiedUsername: project.last_modified_username,
73
+ lastModifiedDisplayName: project.last_modified_display_name,
74
+ name: project.name,
75
+ mainFile: project.main_file,
76
+ latexmkrc: project.latexmkrc,
77
+ engine: project.engine,
78
+ permission: project.permission,
79
+ tags,
80
+ archived: Boolean(project.archived),
81
+ createdAt: project.created_at,
82
+ updatedAt: project.updated_at
83
+ };
84
+ }
85
+ function touchProject(db, projectId, userId) {
86
+ db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
87
+ .run(now(), userId, projectId);
88
+ }
89
+ function projectTextSnapshot(config, projectId) {
90
+ const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
91
+ return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
92
+ const absolute = resolveSourcePath(config, projectId, entry.path);
93
+ return [entry.path, fs.statSync(absolute).size <= 5 * 1024 * 1024 ? fs.readFileSync(absolute, "utf8") : ""];
94
+ }));
95
+ }
96
+ function reanchorProjectSnapshot(db, projectId, before, after) {
97
+ for (const filePath of new Set([...before.keys(), ...after.keys()])) {
98
+ reanchorFileComments(db, projectId, filePath, before.get(filePath) ?? "", after.get(filePath) ?? "");
99
+ }
100
+ }
101
+ function movedProjectPath(value, source, destination) {
102
+ if (value === null)
103
+ return null;
104
+ if (value === source)
105
+ return destination;
106
+ return value.startsWith(`${source}/`) ? `${destination}${value.slice(source.length)}` : value;
107
+ }
108
+ function repliesForComment(db, commentId) {
109
+ const rows = db.prepare(`SELECT reply.*, user.username AS author_username,
110
+ user.display_name AS author_display_name
111
+ FROM comment_replies reply LEFT JOIN users user ON user.id = reply.author_id
112
+ WHERE reply.comment_id = ? ORDER BY reply.created_at`)
113
+ .all(commentId);
114
+ return rows.map((reply) => ({
115
+ id: reply.id,
116
+ authorId: reply.author_id,
117
+ authorUsername: reply.author_username,
118
+ authorDisplayName: reply.author_display_name,
119
+ content: reply.content,
120
+ createdAt: reply.created_at,
121
+ updatedAt: reply.updated_at,
122
+ editedAt: reply.edited_at
123
+ }));
124
+ }
125
+ function commentsForFile(db, config, projectId, filePath) {
126
+ const absolute = resolveSourcePath(config, projectId, filePath);
127
+ const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
128
+ const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
129
+ LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
130
+ .all(projectId, filePath);
131
+ return rows.map((comment) => ({
132
+ id: comment.id,
133
+ authorId: comment.author_id,
134
+ authorUsername: comment.author_username,
135
+ authorDisplayName: comment.author_display_name,
136
+ selectedText: comment.selected_text,
137
+ startOffset: comment.start_offset,
138
+ endOffset: comment.end_offset,
139
+ startLine: offsetToLine(source, comment.start_offset),
140
+ endLine: offsetToLine(source, comment.end_offset),
141
+ content: comment.content,
142
+ resolved: Boolean(comment.resolved),
143
+ orphaned: Boolean(comment.orphaned),
144
+ createdAt: comment.created_at,
145
+ updatedAt: comment.updated_at,
146
+ editedAt: comment.edited_at,
147
+ replies: repliesForComment(db, comment.id)
148
+ }));
149
+ }
150
+ export async function buildApp(config, db, options = {}) {
151
+ const app = Fastify({
152
+ logger: options.logger ?? true,
153
+ bodyLimit: Math.max(12 * 1024 * 1024, config.maxUploadBytes + 1024 * 1024)
154
+ });
155
+ const queue = new CompileQueue(config.maxCompileJobs);
156
+ const compileCoordinator = new ProjectCompileCoordinator(queue);
157
+ const metrics = new MetricRegistry(200);
158
+ const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
159
+ eventLoopDelay.enable();
160
+ const history = new ProjectHistoryService(config, db);
161
+ const latexCompletions = new LatexCompletionService(config);
162
+ const recordHistory = (projectId, userId, reason, paths) => {
163
+ try {
164
+ return history.record(projectId, userId, reason, paths);
165
+ }
166
+ catch (error) {
167
+ app.log.error({ err: error, projectId }, "Failed to record project history");
168
+ return null;
169
+ }
170
+ };
171
+ const collaboration = new CollaborationService(config, db, ({ projectId, userId, paths, durationMs }) => {
172
+ metrics.record("collaboration.persist", durationMs);
173
+ recordHistory(projectId, userId, "autosave", paths);
174
+ });
175
+ const projectGit = new ProjectGitService(config, db, options.githubFetch);
176
+ db.prepare(`UPDATE compile_runs SET status = 'failed',
177
+ log = CASE WHEN log = '' THEN 'Server restarted before compilation finished.' ELSE log END,
178
+ finished_at = ? WHERE status IN ('queued', 'running')`).run(now());
179
+ const pruneCompileRuns = (projectId) => {
180
+ const keep = new Set(listPublishedCompileArtifacts(config, projectId).map((artifact) => artifact.runId));
181
+ const completed = db.prepare(`SELECT id, main_file FROM compile_runs
182
+ WHERE project_id = ? AND status NOT IN ('queued', 'running')
183
+ ORDER BY created_at DESC, rowid DESC`).all(projectId);
184
+ const latestTargets = new Set();
185
+ for (const run of completed) {
186
+ if (!latestTargets.has(run.main_file)) {
187
+ latestTargets.add(run.main_file);
188
+ keep.add(run.id);
189
+ }
190
+ }
191
+ const remove = db.prepare("DELETE FROM compile_runs WHERE id = ?");
192
+ for (const run of completed)
193
+ if (!keep.has(run.id))
194
+ remove.run(run.id);
195
+ };
196
+ for (const row of db.prepare("SELECT id FROM projects").all()) {
197
+ history.enforceRetention(row.id);
198
+ pruneCompileRuns(row.id);
199
+ pruneOrphanedCompileRuns(config, row.id);
200
+ }
201
+ app.addHook("onClose", async () => { eventLoopDelay.disable(); });
202
+ await app.register(cookie, { hook: "onRequest" });
203
+ await app.register(websocket, { options: { maxPayload: 6 * 1024 * 1024 } });
204
+ await app.register(multipart, {
205
+ limits: { files: 1, fileSize: config.maxUploadBytes }
206
+ });
207
+ const requestStarts = new WeakMap();
208
+ app.addHook("onRequest", async (request) => { requestStarts.set(request, performance.now()); });
209
+ app.addHook("onResponse", async (request) => {
210
+ const startedAt = requestStarts.get(request);
211
+ if (startedAt === undefined)
212
+ return;
213
+ const route = request.routeOptions.url;
214
+ if (!route)
215
+ return;
216
+ const metric = {
217
+ "/api/projects/:id": "workspace.project",
218
+ "/api/projects/:id/files": "workspace.files",
219
+ "/api/projects/:id/compile/latest": "workspace.compileState"
220
+ }[route];
221
+ if (metric)
222
+ metrics.record(metric, performance.now() - startedAt);
223
+ });
224
+ app.setErrorHandler((error, _request, reply) => {
225
+ app.log.error(error);
226
+ const status = typeof error === "object" && error !== null && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : 400;
227
+ const message = error instanceof Error ? error.message : "请求格式不正确";
228
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
229
+ ? error.code : status >= 500 ? "SERVER_ERROR" : "REQUEST_INVALID";
230
+ void reply.code(status >= 500 ? 500 : status).send({
231
+ code, error: status >= 500 ? "服务器内部错误" : message
232
+ });
233
+ });
234
+ app.get("/api/config", async () => ({
235
+ siteName: config.siteName,
236
+ adminEmail: config.adminEmail,
237
+ maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
238
+ allowedEngines: config.allowedEngines,
239
+ allowProjectLatexmkrc: config.allowProjectLatexmkrc
240
+ }));
241
+ app.get("/api/health", async () => ({ ok: true, latexmk: config.latexmk }));
242
+ app.get("/api/health/metrics", async (request, reply) => {
243
+ if (!requireAdmin(request, reply, db))
244
+ return;
245
+ const memory = process.memoryUsage();
246
+ return {
247
+ uptimeSeconds: Math.round(process.uptime()),
248
+ memory: { rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, heapTotalBytes: memory.heapTotal },
249
+ eventLoopDelay: {
250
+ p50Ms: Math.round(eventLoopDelay.percentile(50) / 100_000) / 10,
251
+ p95Ms: Math.round(eventLoopDelay.percentile(95) / 100_000) / 10,
252
+ maxMs: Math.round(eventLoopDelay.max / 100_000) / 10
253
+ },
254
+ compileQueue: queue.stats(),
255
+ collaboration: collaboration.stats(),
256
+ durationsMs: metrics.summaries()
257
+ };
258
+ });
259
+ app.get("/api/collaboration/:id", { websocket: true }, (socket, request) => {
260
+ const user = currentUser(request, db);
261
+ if (!user) {
262
+ socket.close(1008, "Authentication required");
263
+ return;
264
+ }
265
+ const { id } = request.params;
266
+ const startedAt = performance.now();
267
+ try {
268
+ collaboration.connect(socket, id, user);
269
+ }
270
+ finally {
271
+ metrics.record("collaboration.connect", performance.now() - startedAt);
272
+ }
273
+ });
274
+ app.post("/api/auth/login", async (request, reply) => {
275
+ const body = request.body;
276
+ const username = text(body?.username, "用户名", 64);
277
+ const password = typeof body?.password === "string" ? body.password : "";
278
+ const user = db.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE").get(username);
279
+ if (!user || user.disabled || !(await verifyPassword(password, user.password_hash))) {
280
+ return apiError(reply, 401, "AUTH_INVALID", "用户名或密码错误");
281
+ }
282
+ const session = createSessionToken();
283
+ const createdAt = now();
284
+ const expires = new Date(Date.now() + config.sessionDays * 86_400_000);
285
+ db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
286
+ .run(session.digest, user.id, expires.toISOString(), createdAt);
287
+ reply.setCookie("texlite_session", session.token, {
288
+ path: "/",
289
+ httpOnly: true,
290
+ sameSite: "strict",
291
+ secure: false,
292
+ expires
293
+ });
294
+ return { user: publicUser(user) };
295
+ });
296
+ app.post("/api/auth/logout", async (request, reply) => {
297
+ const token = request.cookies.texlite_session;
298
+ if (token)
299
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
300
+ reply.clearCookie("texlite_session", { path: "/" });
301
+ return { ok: true };
302
+ });
303
+ app.get("/api/me", async (request, reply) => {
304
+ const user = requireUser(request, reply, db);
305
+ if (!user)
306
+ return;
307
+ return { user: publicUser(user) };
308
+ });
309
+ app.put("/api/me/password", async (request, reply) => {
310
+ const user = requireUser(request, reply, db);
311
+ if (!user)
312
+ return;
313
+ const body = request.body;
314
+ const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
315
+ const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
316
+ if (!(await verifyPassword(currentPassword, user.password_hash))) {
317
+ return apiError(reply, 400, "CURRENT_PASSWORD_INVALID", "当前密码错误");
318
+ }
319
+ const passwordHash = await hashPassword(newPassword);
320
+ db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?")
321
+ .run(passwordHash, user.id);
322
+ db.prepare("DELETE FROM sessions WHERE user_id = ? AND id != ?")
323
+ .run(user.id, digestToken(request.cookies.texlite_session ?? ""));
324
+ return { ok: true };
325
+ });
326
+ app.get("/api/admin/users", async (request, reply) => {
327
+ if (!requireAdmin(request, reply, db))
328
+ return;
329
+ const users = db.prepare(`
330
+ SELECT u.*,
331
+ (SELECT COUNT(*) FROM projects p WHERE p.owner_id = u.id) AS owned_projects
332
+ FROM users u ORDER BY u.created_at
333
+ `).all();
334
+ return { users: users.map((user) => ({ ...publicUser(user), ownedProjects: user.owned_projects })) };
335
+ });
336
+ app.post("/api/admin/users", async (request, reply) => {
337
+ if (!requireAdmin(request, reply, db))
338
+ return;
339
+ const body = request.body;
340
+ const username = text(body?.username, "用户名", 64);
341
+ if (!/^[\p{L}\p{N}_.-]+$/u.test(username))
342
+ throw new Error("用户名只能包含字母、数字、点、横线或下划线");
343
+ const displayName = text(body?.displayName ?? username, "显示名称", 100);
344
+ const password = typeof body?.password === "string" ? body.password : "";
345
+ const role = body?.role === "admin" ? "admin" : "user";
346
+ const user = {
347
+ id: randomUUID(), username, display_name: displayName,
348
+ password_hash: await hashPassword(password), role, disabled: 0,
349
+ must_change_password: 0, can_create_projects: body?.canCreateProjects === true ? 1 : 0, created_at: now()
350
+ };
351
+ db.prepare(`INSERT INTO users
352
+ (id, username, display_name, password_hash, role, disabled, must_change_password, can_create_projects, created_at)
353
+ VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`)
354
+ .run(user.id, user.username, user.display_name, user.password_hash, user.role, user.can_create_projects, user.created_at);
355
+ return reply.code(201).send({ user: publicUser(user) });
356
+ });
357
+ app.patch("/api/admin/users/:id", async (request, reply) => {
358
+ const admin = requireAdmin(request, reply, db);
359
+ if (!admin)
360
+ return;
361
+ const { id } = request.params;
362
+ const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
363
+ if (!target)
364
+ return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在");
365
+ const body = request.body;
366
+ const role = body.role === "admin" ? "admin" : body.role === "user" ? "user" : target.role;
367
+ const disabled = typeof body.disabled === "boolean" ? Number(body.disabled) : target.disabled;
368
+ const canCreateProjects = typeof body.canCreateProjects === "boolean"
369
+ ? Number(body.canCreateProjects) : target.can_create_projects;
370
+ if (target.role === "admin" && (!role || role !== "admin" || disabled) && activeAdminCount(db) <= 1) {
371
+ return apiError(reply, 400, "LAST_ADMIN", "不能禁用或降级最后一个管理员");
372
+ }
373
+ const displayName = typeof body.displayName === "string" ? text(body.displayName, "显示名称", 100) : target.display_name;
374
+ let passwordHash = target.password_hash;
375
+ let mustChange = target.must_change_password;
376
+ if (typeof body.password === "string" && body.password) {
377
+ passwordHash = await hashPassword(body.password);
378
+ mustChange = 1;
379
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
380
+ }
381
+ db.prepare(`UPDATE users SET display_name = ?, role = ?, disabled = ?, password_hash = ?, must_change_password = ?, can_create_projects = ? WHERE id = ?`)
382
+ .run(displayName, role, disabled, passwordHash, mustChange, canCreateProjects, id);
383
+ const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
384
+ return { user: publicUser(updated) };
385
+ });
386
+ app.delete("/api/admin/users/:id", async (request, reply) => {
387
+ const admin = requireAdmin(request, reply, db);
388
+ if (!admin)
389
+ return;
390
+ const { id } = request.params;
391
+ const body = (request.body ?? {});
392
+ const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
393
+ if (!target)
394
+ return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在");
395
+ if (target.id === admin.id)
396
+ return apiError(reply, 400, "SELF_DELETE_FORBIDDEN", "不能删除当前登录的管理员");
397
+ if (target.role === "admin" && activeAdminCount(db) <= 1) {
398
+ return apiError(reply, 400, "LAST_ADMIN", "不能删除最后一个管理员");
399
+ }
400
+ const owned = db.prepare("SELECT id FROM projects WHERE owner_id = ?").all(id);
401
+ for (const project of owned) {
402
+ if (body.deleteProjects)
403
+ collaboration.closeProject(project.id);
404
+ else
405
+ collaboration.flushProject(project.id);
406
+ }
407
+ db.exec("BEGIN IMMEDIATE");
408
+ try {
409
+ if (body.deleteProjects) {
410
+ db.prepare("DELETE FROM projects WHERE owner_id = ?").run(id);
411
+ }
412
+ else {
413
+ 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 = ?)")
414
+ .run(now(), id);
415
+ db.prepare("DELETE FROM project_members WHERE user_id = ? AND project_id IN (SELECT id FROM projects WHERE owner_id = ?)")
416
+ .run(admin.id, id);
417
+ db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE owner_id = ?")
418
+ .run(admin.id, admin.id, now(), id);
419
+ }
420
+ db.prepare("DELETE FROM users WHERE id = ?").run(id);
421
+ db.exec("COMMIT");
422
+ }
423
+ catch (error) {
424
+ db.exec("ROLLBACK");
425
+ throw error;
426
+ }
427
+ if (body.deleteProjects) {
428
+ for (const project of owned)
429
+ removeProjectDirectory(config, project.id);
430
+ }
431
+ else {
432
+ for (const project of owned)
433
+ collaboration.resetProject(project.id);
434
+ }
435
+ return { ok: true, deletedProjects: body.deleteProjects ? owned.length : 0 };
436
+ });
437
+ app.get("/api/users", async (request, reply) => {
438
+ if (!requireUser(request, reply, db))
439
+ return;
440
+ const rows = db.prepare("SELECT id, username, display_name AS displayName FROM users WHERE disabled = 0 ORDER BY username").all();
441
+ return { users: rows };
442
+ });
443
+ app.get("/api/tags", async (request, reply) => {
444
+ const user = requireUser(request, reply, db);
445
+ if (!user)
446
+ return;
447
+ const tags = db.prepare("SELECT id, name, color FROM user_tags WHERE user_id = ? ORDER BY name COLLATE NOCASE").all(user.id);
448
+ return { tags };
449
+ });
450
+ app.post("/api/tags", async (request, reply) => {
451
+ const user = requireUser(request, reply, db);
452
+ if (!user)
453
+ return;
454
+ const body = request.body;
455
+ const name = text(body.name, "标签名称", 32);
456
+ const color = tagColors.includes(body.color)
457
+ ? body.color : "gray";
458
+ const tag = { id: randomUUID(), name, color };
459
+ const createdAt = now();
460
+ db.prepare("INSERT INTO user_tags (id, name, color, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
461
+ .run(tag.id, tag.name, tag.color, user.id, createdAt, createdAt);
462
+ return reply.code(201).send({ tag });
463
+ });
464
+ app.get("/api/projects", async (request, reply) => {
465
+ const user = requireUser(request, reply, db);
466
+ if (!user)
467
+ return;
468
+ const query = request.query;
469
+ const archivedOnly = query.archived === "1" || query.archived === "true";
470
+ const requestedPage = Number.parseInt(query.page ?? "1", 10);
471
+ const page = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
472
+ const requestedPageSize = Number.parseInt(query.pageSize ?? "20", 10);
473
+ const pageSize = Math.min(100, Math.max(1, Number.isFinite(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 20));
474
+ const search = typeof query.search === "string" ? query.search.trim() : "";
475
+ const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
476
+ const sortColumn = query.sort === "created" ? "p.created_at" : "p.updated_at";
477
+ const archiveCondition = archivedOnly
478
+ ? "EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)"
479
+ : "NOT EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)";
480
+ const from = user.role === "admin"
481
+ ? `FROM projects p JOIN users owner ON owner.id = p.owner_id
482
+ LEFT JOIN users modifier ON modifier.id = p.last_modified_by`
483
+ : `FROM projects p JOIN users owner ON owner.id = p.owner_id
484
+ LEFT JOIN users modifier ON modifier.id = p.last_modified_by
485
+ LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
486
+ const conditions = [
487
+ ...(user.role === "admin" ? [] : ["(p.owner_id = :userId OR pm.user_id = :userId)"]),
488
+ archiveCondition
489
+ ];
490
+ const params = { userId: user.id };
491
+ if (search) {
492
+ conditions.push("(p.name LIKE :search OR owner.username LIKE :search OR owner.display_name LIKE :search)");
493
+ params.search = `%${search}%`;
494
+ }
495
+ if (tagId) {
496
+ conditions.push(`EXISTS (
497
+ SELECT 1 FROM user_project_tag_links tag_link
498
+ JOIN user_tags tag ON tag.id = tag_link.tag_id
499
+ WHERE tag_link.project_id = p.id AND tag.user_id = :userId AND tag.id = :tagId
500
+ )`);
501
+ params.tagId = tagId;
502
+ }
503
+ const where = `WHERE ${conditions.join(" AND ")}`;
504
+ const countRow = db.prepare(`SELECT COUNT(DISTINCT p.id) AS total ${from} ${where}`).get(params);
505
+ const total = Number(countRow.total);
506
+ const totalPages = Math.ceil(total / pageSize);
507
+ const currentPage = totalPages === 0 ? 1 : Math.min(page, totalPages);
508
+ const rowsParams = { ...params, limit: pageSize, offset: (currentPage - 1) * pageSize };
509
+ const select = user.role === "admin"
510
+ ? `SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
511
+ owner.display_name AS owner_display_name,
512
+ modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`
513
+ : `SELECT DISTINCT p.*,
514
+ CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
515
+ owner.username AS owner_username, owner.display_name AS owner_display_name,
516
+ modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
517
+ const rows = db.prepare(`${select} ${from} ${where}
518
+ ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
519
+ LIMIT :limit OFFSET :offset`).all(rowsParams);
520
+ const projects = rows;
521
+ const projectTags = tagsForProjects(db, projects.map((project) => project.id), user.id);
522
+ return {
523
+ projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [])),
524
+ pagination: { page: currentPage, pageSize, total, totalPages }
525
+ };
526
+ });
527
+ app.post("/api/projects", async (request, reply) => {
528
+ const user = requireUser(request, reply, db);
529
+ if (!user)
530
+ return;
531
+ if (user.role !== "admin" && !user.can_create_projects) {
532
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
533
+ }
534
+ const body = request.body;
535
+ const project = {
536
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(body?.name, "项目名称", 120),
537
+ main_file: "main.tex", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
538
+ };
539
+ createProjectFiles(config, project.id);
540
+ try {
541
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
542
+ .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);
543
+ }
544
+ catch (error) {
545
+ removeProjectDirectory(config, project.id);
546
+ throw error;
547
+ }
548
+ recordHistory(project.id, user.id, "initial");
549
+ return reply.code(201).send({ project: projectJson({
550
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
551
+ last_modified_username: user.username, last_modified_display_name: user.display_name
552
+ }) });
553
+ });
554
+ app.post("/api/projects/import", async (request, reply) => {
555
+ const user = requireUser(request, reply, db);
556
+ if (!user)
557
+ return;
558
+ if (user.role !== "admin" && !user.can_create_projects) {
559
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
560
+ }
561
+ const part = await request.file();
562
+ if (!part || !part.filename.toLowerCase().endsWith(".zip")) {
563
+ return apiError(reply, 400, "ZIP_ONLY", "请选择 ZIP 压缩包");
564
+ }
565
+ const query = request.query;
566
+ const fallbackName = path.basename(part.filename, path.extname(part.filename));
567
+ const project = {
568
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(query.name || fallbackName, "项目名称", 120),
569
+ main_file: "", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
570
+ };
571
+ fs.mkdirSync(sourceRoot(config, project.id), { recursive: true, mode: 0o700 });
572
+ fs.mkdirSync(outputRoot(config, project.id), { recursive: true, mode: 0o700 });
573
+ try {
574
+ const extracted = await extractProjectZip(await part.toBuffer(), sourceRoot(config, project.id), config.maxUploadBytes);
575
+ project.main_file = extracted.mainFile;
576
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
577
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
578
+ .run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.engine, project.created_at, project.updated_at);
579
+ }
580
+ catch (error) {
581
+ removeProjectDirectory(config, project.id);
582
+ throw error;
583
+ }
584
+ recordHistory(project.id, user.id, "initial");
585
+ return reply.code(201).send({ project: projectJson({
586
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
587
+ last_modified_username: user.username, last_modified_display_name: user.display_name
588
+ }) });
589
+ });
590
+ app.post("/api/projects/:id/duplicate", async (request, reply) => {
591
+ const user = requireUser(request, reply, db);
592
+ if (!user)
593
+ return;
594
+ if (user.role !== "admin" && !user.can_create_projects) {
595
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN", "管理员尚未授予你创建项目的权限");
596
+ }
597
+ const { id } = request.params;
598
+ const source = accessibleProject(db, id, user);
599
+ if (!source)
600
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
601
+ const body = request.body;
602
+ const requestedName = typeof body?.name === "string" && body.name.trim() ? body.name : `${source.name.slice(0, 115)} (1)`;
603
+ const project = {
604
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(requestedName, "项目名称", 120),
605
+ main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
606
+ };
607
+ try {
608
+ duplicateProjectFiles(config, source.id, project.id);
609
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
610
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
611
+ .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);
612
+ }
613
+ catch (error) {
614
+ removeProjectDirectory(config, project.id);
615
+ throw error;
616
+ }
617
+ recordHistory(project.id, user.id, "initial");
618
+ return reply.code(201).send({ project: projectJson({
619
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
620
+ last_modified_username: user.username, last_modified_display_name: user.display_name
621
+ }) });
622
+ });
623
+ app.get("/api/projects/:id", async (request, reply) => {
624
+ const user = requireUser(request, reply, db);
625
+ if (!user)
626
+ return;
627
+ const { id } = request.params;
628
+ const project = accessibleProject(db, id, user);
629
+ if (!project)
630
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
631
+ return { project: projectJson(project, tagsForProject(db, id, user.id)) };
632
+ });
633
+ app.get("/api/projects/:id/history", async (request, reply) => {
634
+ const user = requireUser(request, reply, db);
635
+ if (!user)
636
+ return;
637
+ const { id } = request.params;
638
+ const project = accessibleProject(db, id, user);
639
+ if (!project)
640
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
641
+ const { limit: limitInput } = request.query;
642
+ const limit = Number.parseInt(limitInput ?? "100", 10);
643
+ return {
644
+ versions: history.list(id, Number.isFinite(limit) ? limit : 100),
645
+ stats: project.permission === "owner" ? history.stats(id) : null
646
+ };
647
+ });
648
+ app.get("/api/projects/:id/history/:versionId", async (request, reply) => {
649
+ const user = requireUser(request, reply, db);
650
+ if (!user)
651
+ return;
652
+ const { id, versionId } = request.params;
653
+ if (!accessibleProject(db, id, user))
654
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
655
+ const version = history.version(versionId, id);
656
+ const manifest = history.manifest(id, versionId);
657
+ if (!version || !manifest)
658
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
659
+ return {
660
+ version,
661
+ settings: manifest.settings,
662
+ files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
663
+ };
664
+ });
665
+ app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
666
+ const user = requireUser(request, reply, db);
667
+ if (!user)
668
+ return;
669
+ const { id, versionId } = request.params;
670
+ if (!accessibleProject(db, id, user))
671
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
672
+ const query = request.query;
673
+ const filePath = safeRelativePath(query.path ?? "");
674
+ const historical = history.readTextFile(id, versionId, filePath);
675
+ if (historical === null)
676
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", "该历史文件不存在或不能作为文本比较", { path: filePath });
677
+ let comparison = "";
678
+ if (query.against) {
679
+ comparison = history.readTextFile(id, query.against, filePath) ?? "";
680
+ }
681
+ else {
682
+ const current = resolveSourcePath(config, id, filePath);
683
+ if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
684
+ comparison = fs.readFileSync(current, "utf8");
685
+ }
686
+ }
687
+ return { path: filePath, historical, comparison, against: query.against ?? "current" };
688
+ });
689
+ app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
690
+ const user = requireUser(request, reply, db);
691
+ if (!user)
692
+ return;
693
+ const { id, versionId } = request.params;
694
+ const project = accessibleProject(db, id, user);
695
+ if (!project || !canEdit(project))
696
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
697
+ const body = request.body;
698
+ const label = body.label === null || body.label === "" ? null : text(body.label, "版本标签", 80);
699
+ const version = history.setLabel(id, versionId, label);
700
+ if (!version)
701
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
702
+ return { version, stats: project.permission === "owner" ? history.stats(id) : null };
703
+ });
704
+ app.delete("/api/projects/:id/history/:versionId", async (request, reply) => {
705
+ const user = requireUser(request, reply, db);
706
+ if (!user)
707
+ return;
708
+ const { id, versionId } = request.params;
709
+ const project = accessibleProject(db, id, user);
710
+ if (!project || project.permission !== "owner")
711
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以删除历史版本");
712
+ collaboration.flushProject(id);
713
+ if (!history.deleteVersion(id, versionId))
714
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
715
+ return { ok: true, stats: history.stats(id) };
716
+ });
717
+ app.delete("/api/projects/:id/history", async (request, reply) => {
718
+ const user = requireUser(request, reply, db);
719
+ if (!user)
720
+ return;
721
+ const { id } = request.params;
722
+ const project = accessibleProject(db, id, user);
723
+ if (!project || project.permission !== "owner")
724
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以清空历史版本");
725
+ collaboration.flushProject(id);
726
+ history.clear(id);
727
+ return { ok: true, stats: history.stats(id) };
728
+ });
729
+ app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
730
+ const user = requireUser(request, reply, db);
731
+ if (!user)
732
+ return;
733
+ const { id, versionId } = request.params;
734
+ const project = accessibleProject(db, id, user);
735
+ if (!project || !canEdit(project))
736
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
737
+ const body = request.body;
738
+ const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
739
+ collaboration.flushProject(id);
740
+ recordHistory(id, user.id, "checkpoint");
741
+ const before = projectTextSnapshot(config, id);
742
+ const restored = history.restore(id, versionId, filePath);
743
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
744
+ touchProject(db, id, user.id);
745
+ recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
746
+ collaboration.resetProject(id);
747
+ return { ok: true, restoredPaths: restored.restoredPaths, project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id)) };
748
+ });
749
+ app.put("/api/projects/:id/archive", async (request, reply) => {
750
+ const user = requireUser(request, reply, db);
751
+ if (!user)
752
+ return;
753
+ const { id } = request.params;
754
+ if (!accessibleProject(db, id, user))
755
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
756
+ db.prepare(`INSERT OR IGNORE INTO user_project_archives (user_id, project_id, archived_at) VALUES (?, ?, ?)`)
757
+ .run(user.id, id, now());
758
+ return { ok: true, archived: true };
759
+ });
760
+ app.delete("/api/projects/:id/archive", async (request, reply) => {
761
+ const user = requireUser(request, reply, db);
762
+ if (!user)
763
+ return;
764
+ const { id } = request.params;
765
+ if (!accessibleProject(db, id, user))
766
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
767
+ db.prepare("DELETE FROM user_project_archives WHERE user_id = ? AND project_id = ?").run(user.id, id);
768
+ return { ok: true, archived: false };
769
+ });
770
+ app.get("/api/projects/:id/dictionary", async (request, reply) => {
771
+ const user = requireUser(request, reply, db);
772
+ if (!user)
773
+ return;
774
+ const { id } = request.params;
775
+ if (!accessibleProject(db, id, user))
776
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
777
+ const rows = db.prepare(`SELECT word FROM project_dictionary_words
778
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
779
+ return { words: rows.map((row) => row.word) };
780
+ });
781
+ app.post("/api/projects/:id/dictionary", async (request, reply) => {
782
+ const user = requireUser(request, reply, db);
783
+ if (!user)
784
+ return;
785
+ const { id } = request.params;
786
+ const project = accessibleProject(db, id, user);
787
+ if (!project)
788
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
789
+ if (!canEdit(project))
790
+ return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN", "没有维护项目词典的权限");
791
+ const body = request.body;
792
+ const word = dictionaryWord(body?.word);
793
+ db.prepare(`INSERT OR IGNORE INTO project_dictionary_words (project_id, word, created_by, created_at)
794
+ VALUES (?, ?, ?, ?)`).run(id, word, user.id, now());
795
+ collaboration.signalDictionary(id);
796
+ const words = db.prepare(`SELECT word FROM project_dictionary_words
797
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
798
+ return reply.code(201).send({ word, words: words.map((row) => row.word) });
799
+ });
800
+ app.delete("/api/projects/:id/dictionary/:word", async (request, reply) => {
801
+ const user = requireUser(request, reply, db);
802
+ if (!user)
803
+ return;
804
+ const { id, word: rawWord } = request.params;
805
+ const project = accessibleProject(db, id, user);
806
+ if (!project)
807
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
808
+ if (!canEdit(project))
809
+ return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN", "没有维护项目词典的权限");
810
+ const word = dictionaryWord(rawWord);
811
+ db.prepare("DELETE FROM project_dictionary_words WHERE project_id = ? AND word = ?").run(id, word);
812
+ collaboration.signalDictionary(id);
813
+ const words = db.prepare(`SELECT word FROM project_dictionary_words
814
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
815
+ return { words: words.map((row) => row.word) };
816
+ });
817
+ app.patch("/api/projects/:id", async (request, reply) => {
818
+ const user = requireUser(request, reply, db);
819
+ if (!user)
820
+ return;
821
+ const { id } = request.params;
822
+ const project = accessibleProject(db, id, user);
823
+ if (!project || project.permission !== "owner")
824
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
825
+ const body = request.body;
826
+ const name = typeof body.name === "string" ? text(body.name, "项目名称", 120) : project.name;
827
+ const mainFile = typeof body.mainFile === "string" ? safeRelativePath(body.mainFile) : project.main_file;
828
+ const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
829
+ ? body.engine : project.engine;
830
+ const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
831
+ ? null
832
+ : typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : project.latexmkrc;
833
+ if (!fs.existsSync(resolveSourcePath(config, id, mainFile)))
834
+ return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", "主文件不存在", { path: mainFile });
835
+ if (latexmkrc && !config.allowProjectLatexmkrc)
836
+ return apiError(reply, 400, "LATEXMKRC_DISABLED", "管理员已禁用项目级 latexmkrc");
837
+ if (latexmkrc && !fs.existsSync(resolveSourcePath(config, id, latexmkrc)))
838
+ return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", "latexmkrc 文件不存在", { path: latexmkrc });
839
+ db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
840
+ .run(name, mainFile, latexmkrc, engine, now(), user.id, id);
841
+ recordHistory(id, user.id, "settings", []);
842
+ return { project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id)) };
843
+ });
844
+ app.post("/api/projects/:id/tags", async (request, reply) => {
845
+ const user = requireUser(request, reply, db);
846
+ if (!user)
847
+ return;
848
+ const { id } = request.params;
849
+ const project = accessibleProject(db, id, user);
850
+ if (!project)
851
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
852
+ const body = request.body;
853
+ if (typeof body.tagId !== "string" || !db.prepare("SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?").get(body.tagId, user.id)) {
854
+ return apiError(reply, 404, "TAG_NOT_FOUND", "标签不存在");
855
+ }
856
+ db.prepare("INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at) VALUES (?, ?, ?)")
857
+ .run(id, body.tagId, now());
858
+ const tags = tagsForProject(db, id, user.id);
859
+ return reply.code(201).send({ tags, project: projectJson(accessibleProject(db, id, user), tags) });
860
+ });
861
+ app.delete("/api/projects/:id/tags/:tagId", async (request, reply) => {
862
+ const user = requireUser(request, reply, db);
863
+ if (!user)
864
+ return;
865
+ const { id, tagId } = request.params;
866
+ const project = accessibleProject(db, id, user);
867
+ if (!project)
868
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
869
+ db.prepare(`DELETE FROM user_project_tag_links WHERE tag_id = ? AND project_id = ?
870
+ AND EXISTS (SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?)`)
871
+ .run(tagId, id, tagId, user.id);
872
+ const tags = tagsForProject(db, id, user.id);
873
+ return { tags, project: projectJson(accessibleProject(db, id, user), tags) };
874
+ });
875
+ const requireGitOwner = (projectId, user) => {
876
+ const project = accessibleProject(db, projectId, user);
877
+ if (!project)
878
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
879
+ if (project.owner_id !== user.id)
880
+ throw Object.assign(new Error("只有项目创建者可以执行 Git 操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
881
+ return project;
882
+ };
883
+ app.get("/api/projects/:id/git", async (request, reply) => {
884
+ const user = requireUser(request, reply, db);
885
+ if (!user)
886
+ return;
887
+ const { id } = request.params;
888
+ return { status: await projectGit.status(requireGitOwner(id, user)) };
889
+ });
890
+ app.put("/api/projects/:id/git/token", async (request, reply) => {
891
+ const user = requireUser(request, reply, db);
892
+ if (!user)
893
+ return;
894
+ const { id } = request.params;
895
+ const body = request.body;
896
+ if (typeof body.token !== "string")
897
+ return apiError(reply, 400, "GIT_TOKEN_INVALID", "请输入 GitHub token");
898
+ return { status: await projectGit.configureToken(requireGitOwner(id, user), body.token) };
899
+ });
900
+ app.delete("/api/projects/:id/git/token", async (request, reply) => {
901
+ const user = requireUser(request, reply, db);
902
+ if (!user)
903
+ return;
904
+ const { id } = request.params;
905
+ return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
906
+ });
907
+ app.post("/api/projects/:id/git/repository", async (request, reply) => {
908
+ const user = requireUser(request, reply, db);
909
+ if (!user)
910
+ return;
911
+ const { id } = request.params;
912
+ const body = request.body;
913
+ if (typeof body.name !== "string")
914
+ return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID", "请输入 GitHub 仓库名称");
915
+ return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), body.name.trim(), body.private !== false) };
916
+ });
917
+ app.post("/api/projects/:id/git/commit", async (request, reply) => {
918
+ const user = requireUser(request, reply, db);
919
+ if (!user)
920
+ return;
921
+ const { id } = request.params;
922
+ const project = requireGitOwner(id, user);
923
+ const body = request.body;
924
+ collaboration.flushProject(id);
925
+ const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
926
+ return reply.code(201).send({ commit, status: await projectGit.status(project) });
927
+ });
928
+ app.post("/api/projects/:id/git/push", async (request, reply) => {
929
+ const user = requireUser(request, reply, db);
930
+ if (!user)
931
+ return;
932
+ const { id } = request.params;
933
+ const project = requireGitOwner(id, user);
934
+ collaboration.flushProject(id);
935
+ return { status: await projectGit.push(project) };
936
+ });
937
+ app.get("/api/projects/:id/git/history", async (request, reply) => {
938
+ const user = requireUser(request, reply, db);
939
+ if (!user)
940
+ return;
941
+ const { id } = request.params;
942
+ return { commits: await projectGit.history(requireGitOwner(id, user)) };
943
+ });
944
+ app.get("/api/projects/:id/git/diff", async (request, reply) => {
945
+ const user = requireUser(request, reply, db);
946
+ if (!user)
947
+ return;
948
+ const { id } = request.params;
949
+ const { revision } = request.query;
950
+ const project = requireGitOwner(id, user);
951
+ collaboration.flushProject(id);
952
+ return projectGit.diff(project, revision);
953
+ });
954
+ app.post("/api/projects/:id/git/checkout", async (request, reply) => {
955
+ const user = requireUser(request, reply, db);
956
+ if (!user)
957
+ return;
958
+ const { id } = request.params;
959
+ const project = requireGitOwner(id, user);
960
+ const body = request.body;
961
+ if (body.revision !== null && typeof body.revision !== "string")
962
+ return apiError(reply, 400, "GIT_REVISION_INVALID", "请选择要 checkout 的 Git 版本");
963
+ collaboration.flushProject(id);
964
+ recordHistory(id, user.id, "checkpoint");
965
+ const before = projectTextSnapshot(config, id);
966
+ const revision = await projectGit.checkout(project, body.revision, body.force === true);
967
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
968
+ touchProject(db, id, user.id);
969
+ recordHistory(id, user.id, "git");
970
+ collaboration.resetProject(id);
971
+ return { revision, status: await projectGit.status(project) };
972
+ });
973
+ app.post("/api/projects/:id/git/discard", async (request, reply) => {
974
+ const user = requireUser(request, reply, db);
975
+ if (!user)
976
+ return;
977
+ const { id } = request.params;
978
+ const project = requireGitOwner(id, user);
979
+ collaboration.flushProject(id);
980
+ recordHistory(id, user.id, "checkpoint");
981
+ const before = projectTextSnapshot(config, id);
982
+ await projectGit.discardChanges(project);
983
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
984
+ touchProject(db, id, user.id);
985
+ recordHistory(id, user.id, "git");
986
+ collaboration.resetProject(id);
987
+ return { status: await projectGit.status(project) };
988
+ });
989
+ app.get("/api/projects/:id/download", async (request, reply) => {
990
+ const user = requireUser(request, reply, db);
991
+ if (!user)
992
+ return;
993
+ const { id } = request.params;
994
+ const project = accessibleProject(db, id, user);
995
+ if (!project)
996
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
997
+ collaboration.flushProject(id);
998
+ const archive = createProjectArchive(config, id);
999
+ const filename = `${project.name}.zip`;
1000
+ reply.header("Content-Type", "application/zip");
1001
+ reply.header("Content-Disposition", contentDisposition(filename, "attachment"));
1002
+ return reply.send(archive.outputStream);
1003
+ });
1004
+ app.delete("/api/projects/:id", async (request, reply) => {
1005
+ const user = requireUser(request, reply, db);
1006
+ if (!user)
1007
+ return;
1008
+ const { id } = request.params;
1009
+ const project = accessibleProject(db, id, user);
1010
+ if (!project || project.permission !== "owner")
1011
+ return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN", "只有项目所有者可以删除项目");
1012
+ collaboration.closeProject(id);
1013
+ db.prepare("DELETE FROM projects WHERE id = ?").run(id);
1014
+ removeProjectDirectory(config, id);
1015
+ return { ok: true };
1016
+ });
1017
+ app.get("/api/projects/:id/files", async (request, reply) => {
1018
+ const user = requireUser(request, reply, db);
1019
+ if (!user)
1020
+ return;
1021
+ const { id } = request.params;
1022
+ if (!accessibleProject(db, id, user))
1023
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1024
+ return { files: listProjectFiles(config, id) };
1025
+ });
1026
+ app.get("/api/projects/:id/outline", async (request, reply) => {
1027
+ const user = requireUser(request, reply, db);
1028
+ if (!user)
1029
+ return;
1030
+ const { id } = request.params;
1031
+ const project = accessibleProject(db, id, user);
1032
+ if (!project)
1033
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1034
+ const query = request.query;
1035
+ const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
1036
+ if (!mainFile)
1037
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
1038
+ collaboration.flushProject(id);
1039
+ const startedAt = performance.now();
1040
+ try {
1041
+ return { outline: buildProjectOutline(config, id, mainFile), mainFile };
1042
+ }
1043
+ finally {
1044
+ metrics.record("outline.build", performance.now() - startedAt);
1045
+ }
1046
+ });
1047
+ app.get("/api/projects/:id/search", async (request, reply) => {
1048
+ const user = requireUser(request, reply, db);
1049
+ if (!user)
1050
+ return;
1051
+ const { id } = request.params;
1052
+ if (!accessibleProject(db, id, user))
1053
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1054
+ const query = request.query;
1055
+ collaboration.flushProject(id);
1056
+ const startedAt = performance.now();
1057
+ try {
1058
+ return await searchProject(config, id, {
1059
+ query: query.q ?? "",
1060
+ caseSensitive: query.caseSensitive === "1",
1061
+ wholeWord: query.wholeWord === "1"
1062
+ });
1063
+ }
1064
+ finally {
1065
+ metrics.record("search.project", performance.now() - startedAt);
1066
+ }
1067
+ });
1068
+ app.post("/api/projects/:id/search/replace", async (request, reply) => {
1069
+ const user = requireUser(request, reply, db);
1070
+ if (!user)
1071
+ return;
1072
+ const { id } = request.params;
1073
+ const project = accessibleProject(db, id, user);
1074
+ if (!project || !canEdit(project))
1075
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1076
+ const body = request.body;
1077
+ if (typeof body.query !== "string" || typeof body.replacement !== "string" || body.replacement.length > 100_000) {
1078
+ return apiError(reply, 400, "SEARCH_QUERY_INVALID", "搜索或替换内容格式不正确");
1079
+ }
1080
+ collaboration.flushProject(id);
1081
+ const changed = await replaceProject(config, id, {
1082
+ query: body.query,
1083
+ caseSensitive: body.caseSensitive === true,
1084
+ wholeWord: body.wholeWord === true
1085
+ }, body.replacement);
1086
+ let replacements = 0;
1087
+ for (const file of changed) {
1088
+ reanchorFileComments(db, id, file.path, file.previous, file.content);
1089
+ collaboration.updateFile(id, file.path, file.content, user.id);
1090
+ replacements += file.count;
1091
+ }
1092
+ if (changed.length) {
1093
+ touchProject(db, id, user.id);
1094
+ recordHistory(id, user.id, "file", changed.map((file) => file.path));
1095
+ }
1096
+ return { ok: true, replacements, files: changed.map((file) => file.path) };
1097
+ });
1098
+ app.get("/api/projects/:id/completions", async (request, reply) => {
1099
+ const user = requireUser(request, reply, db);
1100
+ if (!user)
1101
+ return;
1102
+ const { id } = request.params;
1103
+ if (!accessibleProject(db, id, user))
1104
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1105
+ collaboration.flushProject(id);
1106
+ const startedAt = performance.now();
1107
+ try {
1108
+ return { index: await latexCompletions.build(id) };
1109
+ }
1110
+ finally {
1111
+ metrics.record("completions.build", performance.now() - startedAt);
1112
+ }
1113
+ });
1114
+ app.post("/api/projects/:id/folders", async (request, reply) => {
1115
+ const user = requireUser(request, reply, db);
1116
+ if (!user)
1117
+ return;
1118
+ const { id } = request.params;
1119
+ const project = accessibleProject(db, id, user);
1120
+ if (!project || !canEdit(project))
1121
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1122
+ const body = request.body;
1123
+ const folderPath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1124
+ const absolute = resolveSourcePath(config, id, folderPath);
1125
+ if (fs.existsSync(absolute))
1126
+ return apiError(reply, 409, "PATH_EXISTS", "同名文件或目录已存在", { path: folderPath });
1127
+ fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
1128
+ touchProject(db, id, user.id);
1129
+ return reply.code(201).send({ ok: true, path: folderPath });
1130
+ });
1131
+ app.patch("/api/projects/:id/path", async (request, reply) => {
1132
+ const user = requireUser(request, reply, db);
1133
+ if (!user)
1134
+ return;
1135
+ const { id } = request.params;
1136
+ const project = accessibleProject(db, id, user);
1137
+ if (!project || !canEdit(project))
1138
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1139
+ const body = request.body;
1140
+ const source = safeRelativePath(typeof body.source === "string" ? body.source : "");
1141
+ const destinationDirectory = body.destinationDirectory === "" ? ""
1142
+ : safeRelativePath(typeof body.destinationDirectory === "string" ? body.destinationDirectory : "");
1143
+ const sourceAbsolute = resolveSourcePath(config, id, source);
1144
+ if (!fs.existsSync(sourceAbsolute))
1145
+ return apiError(reply, 404, "PATH_NOT_FOUND", "要移动的文件或目录不存在", { path: source });
1146
+ const sourceStat = fs.statSync(sourceAbsolute);
1147
+ if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
1148
+ return apiError(reply, 400, "MOVE_INTO_SELF", "不能把目录移动到自身内部", { path: source });
1149
+ }
1150
+ const destinationRoot = destinationDirectory
1151
+ ? resolveSourcePath(config, id, destinationDirectory)
1152
+ : sourceRoot(config, id);
1153
+ if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
1154
+ return apiError(reply, 404, "DIRECTORY_NOT_FOUND", "目标目录不存在", { path: destinationDirectory });
1155
+ }
1156
+ const destination = destinationDirectory
1157
+ ? `${destinationDirectory}/${path.posix.basename(source)}`
1158
+ : path.posix.basename(source);
1159
+ if (destination === source)
1160
+ return { ok: true, path: source };
1161
+ const destinationAbsolute = resolveSourcePath(config, id, destination);
1162
+ if (fs.existsSync(destinationAbsolute))
1163
+ return apiError(reply, 409, "PATH_EXISTS", "目标目录中存在同名文件或目录", { path: destination });
1164
+ collaboration.flushProject(id);
1165
+ fs.renameSync(sourceAbsolute, destinationAbsolute);
1166
+ db.exec("BEGIN IMMEDIATE");
1167
+ try {
1168
+ const mainFile = movedProjectPath(project.main_file, source, destination);
1169
+ const latexmkrc = movedProjectPath(project.latexmkrc, source, destination);
1170
+ const changedAt = now();
1171
+ db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
1172
+ .run(mainFile, latexmkrc, changedAt, user.id, id);
1173
+ const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
1174
+ const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
1175
+ for (const comment of comments) {
1176
+ const nextPath = movedProjectPath(comment.file_path, source, destination);
1177
+ if (nextPath !== comment.file_path)
1178
+ updateComment.run(nextPath, changedAt, comment.id);
1179
+ }
1180
+ db.exec("COMMIT");
1181
+ }
1182
+ catch (error) {
1183
+ db.exec("ROLLBACK");
1184
+ fs.renameSync(destinationAbsolute, sourceAbsolute);
1185
+ throw error;
1186
+ }
1187
+ collaboration.movePath(id, source, destination, user.id);
1188
+ recordHistory(id, user.id, "file", [source, destination]);
1189
+ return { ok: true, path: destination };
1190
+ });
1191
+ app.get("/api/projects/:id/file/raw", async (request, reply) => {
1192
+ const user = requireUser(request, reply, db);
1193
+ if (!user)
1194
+ return;
1195
+ const { id } = request.params;
1196
+ if (!accessibleProject(db, id, user))
1197
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1198
+ const query = request.query;
1199
+ const filePath = safeRelativePath(query.path ?? "");
1200
+ const absolute = resolveSourcePath(config, id, filePath);
1201
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1202
+ return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: filePath });
1203
+ const extension = path.extname(filePath).toLocaleLowerCase();
1204
+ const contentTypes = {
1205
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
1206
+ ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
1207
+ ".pdf": "application/pdf"
1208
+ };
1209
+ const contentType = contentTypes[extension] ?? "application/octet-stream";
1210
+ const downloading = query.download === "1";
1211
+ reply.header("Content-Type", contentType);
1212
+ reply.header("Content-Disposition", contentDisposition(path.basename(filePath), downloading ? "attachment" : "inline"));
1213
+ reply.header("Cache-Control", "private, no-cache");
1214
+ reply.header("Content-Length", fs.statSync(absolute).size);
1215
+ return reply.send(fs.createReadStream(absolute));
1216
+ });
1217
+ app.get("/api/projects/:id/file", async (request, reply) => {
1218
+ const user = requireUser(request, reply, db);
1219
+ if (!user)
1220
+ return;
1221
+ const { id } = request.params;
1222
+ const { path: filePath } = request.query;
1223
+ if (!accessibleProject(db, id, user))
1224
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1225
+ const absolute = resolveSourcePath(config, id, filePath ?? "");
1226
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
1227
+ return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: filePath });
1228
+ if (fs.statSync(absolute).size > 5 * 1024 * 1024)
1229
+ return apiError(reply, 413, "FILE_TOO_LARGE", "文件过大,不能作为文本打开", { path: filePath });
1230
+ return { path: safeRelativePath(filePath ?? ""), content: fs.readFileSync(absolute, "utf8") };
1231
+ });
1232
+ // Creating a file is intentionally separate from updating one. The editor
1233
+ // uses PUT for autosaves, but a user action such as "New file" must never
1234
+ // silently replace an existing file.
1235
+ app.post("/api/projects/:id/file", async (request, reply) => {
1236
+ const user = requireUser(request, reply, db);
1237
+ if (!user)
1238
+ return;
1239
+ const { id } = request.params;
1240
+ const project = accessibleProject(db, id, user);
1241
+ if (!project || !canEdit(project))
1242
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1243
+ const body = request.body;
1244
+ const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1245
+ if (typeof body.content !== "string")
1246
+ throw new Error("文件内容格式不正确");
1247
+ if (Buffer.byteLength(body.content, "utf8") > config.maxUploadBytes) {
1248
+ return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(config.maxUploadBytes / 1024 / 1024) });
1249
+ }
1250
+ const absolute = resolveSourcePath(config, id, filePath);
1251
+ try {
1252
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1253
+ }
1254
+ catch (error) {
1255
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1256
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: filePath });
1257
+ }
1258
+ throw error;
1259
+ }
1260
+ try {
1261
+ fs.writeFileSync(absolute, body.content, { encoding: "utf8", mode: 0o600, flag: "wx" });
1262
+ }
1263
+ catch (error) {
1264
+ if (error.code === "EEXIST") {
1265
+ return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
1266
+ }
1267
+ throw error;
1268
+ }
1269
+ touchProject(db, id, user.id);
1270
+ collaboration.updateFile(id, filePath, body.content, user.id);
1271
+ recordHistory(id, user.id, "file", [filePath]);
1272
+ return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
1273
+ });
1274
+ app.put("/api/projects/:id/file", async (request, reply) => {
1275
+ const user = requireUser(request, reply, db);
1276
+ if (!user)
1277
+ return;
1278
+ const { id } = request.params;
1279
+ const project = accessibleProject(db, id, user);
1280
+ if (!project || !canEdit(project))
1281
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1282
+ const body = request.body;
1283
+ const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1284
+ if (typeof body.content !== "string")
1285
+ throw new Error("文件内容格式不正确");
1286
+ if (Buffer.byteLength(body.content, "utf8") > config.maxUploadBytes) {
1287
+ return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(config.maxUploadBytes / 1024 / 1024) });
1288
+ }
1289
+ const absolute = resolveSourcePath(config, id, filePath);
1290
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1291
+ const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
1292
+ reanchorFileComments(db, id, filePath, previousContent, body.content);
1293
+ fs.writeFileSync(absolute, body.content, { encoding: "utf8", mode: 0o600 });
1294
+ touchProject(db, id, user.id);
1295
+ collaboration.updateFile(id, filePath, body.content, user.id);
1296
+ recordHistory(id, user.id, "file", [filePath]);
1297
+ return { ok: true, comments: commentsForFile(db, config, id, filePath) };
1298
+ });
1299
+ app.delete("/api/projects/:id/file", async (request, reply) => {
1300
+ const user = requireUser(request, reply, db);
1301
+ if (!user)
1302
+ return;
1303
+ const { id } = request.params;
1304
+ const { path: filePath } = request.query;
1305
+ const project = accessibleProject(db, id, user);
1306
+ if (!project || !canEdit(project))
1307
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1308
+ const relative = safeRelativePath(filePath ?? "");
1309
+ if (relative === project.main_file || project.main_file.startsWith(`${relative}/`)) {
1310
+ return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", "不能删除当前主文件或包含主文件的目录", { path: relative });
1311
+ }
1312
+ if (!fs.existsSync(resolveSourcePath(config, id, relative)))
1313
+ return apiError(reply, 404, "PATH_NOT_FOUND", "文件或目录不存在", { path: relative });
1314
+ fs.rmSync(resolveSourcePath(config, id, relative), { recursive: true, force: true });
1315
+ touchProject(db, id, user.id);
1316
+ collaboration.removePath(id, relative);
1317
+ recordHistory(id, user.id, "file", [relative]);
1318
+ return { ok: true };
1319
+ });
1320
+ app.post("/api/projects/:id/upload", async (request, reply) => {
1321
+ const user = requireUser(request, reply, db);
1322
+ if (!user)
1323
+ return;
1324
+ const { id } = request.params;
1325
+ const project = accessibleProject(db, id, user);
1326
+ if (!project || !canEdit(project))
1327
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
1328
+ const part = await request.file();
1329
+ if (!part)
1330
+ return apiError(reply, 400, "UPLOAD_EMPTY", "没有收到上传文件");
1331
+ const { directory, overwrite } = request.query;
1332
+ const relative = safeRelativePath(directory ? `${safeRelativePath(directory)}/${part.filename}` : part.filename);
1333
+ const absolute = resolveSourcePath(config, id, relative);
1334
+ try {
1335
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
1336
+ }
1337
+ catch (error) {
1338
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
1339
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: relative });
1340
+ }
1341
+ throw error;
1342
+ }
1343
+ const uploaded = await part.toBuffer();
1344
+ const replacing = overwrite === "1";
1345
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
1346
+ return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
1347
+ }
1348
+ try {
1349
+ fs.writeFileSync(absolute, uploaded, { mode: 0o600, flag: replacing ? "w" : "wx" });
1350
+ }
1351
+ catch (error) {
1352
+ if (error.code === "EEXIST") {
1353
+ return apiError(reply, 409, "FILE_EXISTS", "同名文件已存在", { path: relative });
1354
+ }
1355
+ throw error;
1356
+ }
1357
+ touchProject(db, id, user.id);
1358
+ collaboration.updateFile(id, relative, uploaded.toString("utf8"), user.id);
1359
+ recordHistory(id, user.id, "file", [relative]);
1360
+ return reply.code(201).send({ ok: true, path: relative });
1361
+ });
1362
+ app.get("/api/projects/:id/members", async (request, reply) => {
1363
+ const user = requireUser(request, reply, db);
1364
+ if (!user)
1365
+ return;
1366
+ const { id } = request.params;
1367
+ if (!accessibleProject(db, id, user))
1368
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1369
+ const members = db.prepare(`SELECT pm.user_id AS id, u.username, u.display_name AS displayName, pm.permission
1370
+ FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = ? ORDER BY u.username`).all(id);
1371
+ return { members };
1372
+ });
1373
+ app.put("/api/projects/:id/owner", async (request, reply) => {
1374
+ const user = requireUser(request, reply, db);
1375
+ if (!user)
1376
+ return;
1377
+ const { id } = request.params;
1378
+ const project = accessibleProject(db, id, user);
1379
+ if (!project || project.owner_id !== user.id) {
1380
+ return apiError(reply, 403, "PROJECT_TRANSFER_FORBIDDEN", "只有当前项目所有者可以转让项目");
1381
+ }
1382
+ const body = (request.body ?? {});
1383
+ if (typeof body.userId !== "string" || !body.userId) {
1384
+ return apiError(reply, 400, "PROJECT_TRANSFER_TARGET_INVALID", "请选择新的项目所有者");
1385
+ }
1386
+ if (body.userId === user.id) {
1387
+ return apiError(reply, 400, "PROJECT_TRANSFER_SELF", "当前用户已经是项目所有者");
1388
+ }
1389
+ const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
1390
+ if (!target)
1391
+ return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
1392
+ collaboration.flushProject(id);
1393
+ const changedAt = now();
1394
+ db.exec("BEGIN IMMEDIATE");
1395
+ try {
1396
+ // The new owner may already be a shared member. The previous owner keeps
1397
+ // edit access so a transfer does not unexpectedly lock them out.
1398
+ db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, target.id);
1399
+ db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
1400
+ VALUES (?, ?, 'edit', ?)
1401
+ ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
1402
+ .run(id, user.id, changedAt);
1403
+ db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
1404
+ .run(target.id, user.id, changedAt, id);
1405
+ // A project token belongs to the account that supplied it. Preserve the
1406
+ // local repository/remote metadata, but require the new owner to add
1407
+ // their own credential before the next GitHub operation.
1408
+ db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
1409
+ .run(changedAt, id);
1410
+ db.exec("COMMIT");
1411
+ }
1412
+ catch (error) {
1413
+ db.exec("ROLLBACK");
1414
+ throw error;
1415
+ }
1416
+ collaboration.resetProject(id);
1417
+ return {
1418
+ project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id))
1419
+ };
1420
+ });
1421
+ app.put("/api/projects/:id/members/:userId", async (request, reply) => {
1422
+ const user = requireUser(request, reply, db);
1423
+ if (!user)
1424
+ return;
1425
+ const { id, userId } = request.params;
1426
+ const project = accessibleProject(db, id, user);
1427
+ if (!project || project.permission !== "owner")
1428
+ return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
1429
+ if (userId === project.owner_id)
1430
+ return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN", "项目所有者不能作为成员添加");
1431
+ const body = request.body;
1432
+ const permission = body.permission === "edit" ? "edit" : "read";
1433
+ db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
1434
+ ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
1435
+ .run(id, userId, permission, now());
1436
+ touchProject(db, id, user.id);
1437
+ return { ok: true };
1438
+ });
1439
+ app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
1440
+ const user = requireUser(request, reply, db);
1441
+ if (!user)
1442
+ return;
1443
+ const { id, userId } = request.params;
1444
+ const project = accessibleProject(db, id, user);
1445
+ if (!project || project.permission !== "owner")
1446
+ return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
1447
+ db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
1448
+ touchProject(db, id, user.id);
1449
+ return { ok: true };
1450
+ });
1451
+ app.get("/api/projects/:id/comments", async (request, reply) => {
1452
+ const user = requireUser(request, reply, db);
1453
+ if (!user)
1454
+ return;
1455
+ const { id } = request.params;
1456
+ const { path: filePath } = request.query;
1457
+ if (!accessibleProject(db, id, user))
1458
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1459
+ const relative = safeRelativePath(filePath ?? "");
1460
+ return { comments: commentsForFile(db, config, id, relative) };
1461
+ });
1462
+ app.post("/api/projects/:id/comments", async (request, reply) => {
1463
+ const user = requireUser(request, reply, db);
1464
+ if (!user)
1465
+ return;
1466
+ const { id } = request.params;
1467
+ if (!accessibleProject(db, id, user))
1468
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1469
+ const body = request.body;
1470
+ const createdAt = now();
1471
+ const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
1472
+ collaboration.flushProject(id);
1473
+ const absolute = resolveSourcePath(config, id, filePath);
1474
+ if (!fs.existsSync(absolute))
1475
+ return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND", "批注文件不存在");
1476
+ const source = fs.readFileSync(absolute, "utf8");
1477
+ if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
1478
+ return apiError(reply, 400, "COMMENT_RANGE_INVALID", "批注缺少有效的源码区间");
1479
+ }
1480
+ const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
1481
+ const comment = {
1482
+ id: randomUUID(), filePath,
1483
+ content: text(body.content, "批注", 5000)
1484
+ };
1485
+ db.prepare(`INSERT INTO comments
1486
+ (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
1487
+ context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
1488
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
1489
+ .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);
1490
+ const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
1491
+ collaboration.signalComments(id);
1492
+ return reply.code(201).send({ comment: created });
1493
+ });
1494
+ app.post("/api/projects/:id/comments/:commentId/replies", async (request, reply) => {
1495
+ const user = requireUser(request, reply, db);
1496
+ if (!user)
1497
+ return;
1498
+ const { id, commentId } = request.params;
1499
+ if (!accessibleProject(db, id, user))
1500
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1501
+ if (!db.prepare("SELECT 1 FROM comments WHERE id = ? AND project_id = ?").get(commentId, id)) {
1502
+ return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
1503
+ }
1504
+ const body = request.body;
1505
+ const createdAt = now();
1506
+ const replyId = randomUUID();
1507
+ db.prepare(`INSERT INTO comment_replies (id, comment_id, author_id, content, created_at, updated_at)
1508
+ VALUES (?, ?, ?, ?, ?, ?)`)
1509
+ .run(replyId, commentId, user.id, text(body.content, "回复", 5000), createdAt, createdAt);
1510
+ const created = repliesForComment(db, commentId).find((item) => item.id === replyId);
1511
+ collaboration.signalComments(id);
1512
+ return reply.code(201).send({ reply: created });
1513
+ });
1514
+ app.patch("/api/projects/:id/comments/:commentId", async (request, reply) => {
1515
+ const user = requireUser(request, reply, db);
1516
+ if (!user)
1517
+ return;
1518
+ const { id, commentId } = request.params;
1519
+ if (!accessibleProject(db, id, user))
1520
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1521
+ const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
1522
+ .get(commentId, id);
1523
+ if (!comment)
1524
+ return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
1525
+ const body = request.body;
1526
+ const changedAt = now();
1527
+ let changed = false;
1528
+ if (typeof body.content === "string") {
1529
+ if (comment.author_id !== user.id)
1530
+ return apiError(reply, 403, "COMMENT_EDIT_FORBIDDEN", "只能编辑自己的批注");
1531
+ db.prepare("UPDATE comments SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
1532
+ .run(text(body.content, "批注", 5000), changedAt, changedAt, commentId);
1533
+ changed = true;
1534
+ }
1535
+ if (typeof body.resolved === "boolean") {
1536
+ db.prepare("UPDATE comments SET resolved = ?, updated_at = ? WHERE id = ?")
1537
+ .run(body.resolved ? 1 : 0, changedAt, commentId);
1538
+ changed = true;
1539
+ }
1540
+ if (!changed)
1541
+ return apiError(reply, 400, "COMMENT_UPDATE_EMPTY", "没有可更新的批注内容");
1542
+ collaboration.signalComments(id);
1543
+ return { ok: true };
1544
+ });
1545
+ app.delete("/api/projects/:id/comments/:commentId", async (request, reply) => {
1546
+ const user = requireUser(request, reply, db);
1547
+ if (!user)
1548
+ return;
1549
+ const { id, commentId } = request.params;
1550
+ if (!accessibleProject(db, id, user))
1551
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1552
+ const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
1553
+ .get(commentId, id);
1554
+ if (!comment)
1555
+ return apiError(reply, 404, "COMMENT_NOT_FOUND", "批注不存在");
1556
+ if (comment.author_id !== user.id)
1557
+ return apiError(reply, 403, "COMMENT_DELETE_FORBIDDEN", "只能删除自己的批注");
1558
+ db.prepare("DELETE FROM comments WHERE id = ?").run(commentId);
1559
+ collaboration.signalComments(id);
1560
+ return { ok: true };
1561
+ });
1562
+ app.patch("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
1563
+ const user = requireUser(request, reply, db);
1564
+ if (!user)
1565
+ return;
1566
+ const { id, commentId, replyId } = request.params;
1567
+ if (!accessibleProject(db, id, user))
1568
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1569
+ const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
1570
+ JOIN comments comment ON comment.id = reply.comment_id
1571
+ WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
1572
+ .get(replyId, commentId, id);
1573
+ if (!commentReply)
1574
+ return apiError(reply, 404, "REPLY_NOT_FOUND", "回复不存在");
1575
+ if (commentReply.author_id !== user.id)
1576
+ return apiError(reply, 403, "REPLY_EDIT_FORBIDDEN", "只能编辑自己的回复");
1577
+ const body = request.body;
1578
+ const changedAt = now();
1579
+ db.prepare("UPDATE comment_replies SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
1580
+ .run(text(body.content, "回复", 5000), changedAt, changedAt, replyId);
1581
+ const updated = repliesForComment(db, commentId).find((item) => item.id === replyId);
1582
+ collaboration.signalComments(id);
1583
+ return { reply: updated };
1584
+ });
1585
+ app.delete("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
1586
+ const user = requireUser(request, reply, db);
1587
+ if (!user)
1588
+ return;
1589
+ const { id, commentId, replyId } = request.params;
1590
+ if (!accessibleProject(db, id, user))
1591
+ return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
1592
+ const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
1593
+ JOIN comments comment ON comment.id = reply.comment_id
1594
+ WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
1595
+ .get(replyId, commentId, id);
1596
+ if (!commentReply)
1597
+ return apiError(reply, 404, "REPLY_NOT_FOUND", "回复不存在");
1598
+ if (commentReply.author_id !== user.id)
1599
+ return apiError(reply, 403, "REPLY_DELETE_FORBIDDEN", "只能删除自己的回复");
1600
+ db.prepare("DELETE FROM comment_replies WHERE id = ?").run(replyId);
1601
+ collaboration.signalComments(id);
1602
+ return { ok: true };
1603
+ });
1604
+ registerCompileRoutes(app, { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns });
1605
+ app.addHook("onRequest", async (request) => {
1606
+ if (request.url.startsWith("/api/") && request.url !== "/api/auth/login" && request.url !== "/api/health" && request.url !== "/api/config") {
1607
+ const token = request.cookies.texlite_session;
1608
+ if (token && !currentUser(request, db)) {
1609
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
1610
+ }
1611
+ }
1612
+ });
1613
+ app.addHook("onClose", async () => collaboration.destroy());
1614
+ if (fs.existsSync(config.clientDir)) {
1615
+ await app.register(staticPlugin, { root: config.clientDir, wildcard: false });
1616
+ app.get("/*", async (request, reply) => {
1617
+ if (request.url.startsWith("/api/"))
1618
+ return apiError(reply, 404, "API_NOT_FOUND", "接口不存在");
1619
+ reply.header("Cache-Control", "no-store");
1620
+ return reply.sendFile("index.html");
1621
+ });
1622
+ }
1623
+ return app;
1624
+ }