texlite 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +19 -3
  2. package/README.zh-CN.md +8 -3
  3. package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-Bm5yMpQK.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-Po4KHT_2.js +5 -0
  5. package/dist/client/assets/LatexEditor-CnKCXaHr.js +59 -0
  6. package/dist/client/assets/PdfPreview-Cd20VIjT.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-BDOtIoTy.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-DdOlcqqK.js +1 -0
  9. package/dist/client/assets/index-MDwr9W9R.css +1 -0
  10. package/dist/client/assets/index-zIjCeZFK.js +64 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-O6pK-6mA.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-Ce3o_xO-.js +4 -0
  15. package/dist/client/index.html +3 -3
  16. package/dist/server/anchors.js +22 -1
  17. package/dist/server/app.js +103 -2335
  18. package/dist/server/auth.js +3 -2
  19. package/dist/server/cli.js +8 -2
  20. package/dist/server/collaboration.js +283 -11
  21. package/dist/server/compileArtifacts.js +10 -7
  22. package/dist/server/compiler.js +9 -4
  23. package/dist/server/config.js +25 -1
  24. package/dist/server/environment.js +5 -1
  25. package/dist/server/files.js +7 -6
  26. package/dist/server/git.js +28 -39
  27. package/dist/server/harper.js +144 -0
  28. package/dist/server/harperRuntime.js +38 -0
  29. package/dist/server/harperWorker.js +15 -0
  30. package/dist/server/history.js +11 -9
  31. package/dist/server/http.js +33 -13
  32. package/dist/server/i18n.js +276 -0
  33. package/dist/server/latexRoot.js +78 -0
  34. package/dist/server/limits.js +9 -0
  35. package/dist/server/projectSearch.js +3 -4
  36. package/dist/server/routes/auth.js +77 -0
  37. package/dist/server/routes/citations.js +331 -0
  38. package/dist/server/routes/collaboration.js +17 -0
  39. package/dist/server/routes/comments.js +176 -0
  40. package/dist/server/routes/compile.js +71 -50
  41. package/dist/server/routes/projectFiles.js +572 -0
  42. package/dist/server/routes/projectGit.js +145 -0
  43. package/dist/server/routes/projectHistory.js +150 -0
  44. package/dist/server/routes/projectMembers.js +112 -0
  45. package/dist/server/routes/projectShared.js +238 -0
  46. package/dist/server/routes/projects.js +473 -0
  47. package/dist/server/routes/system.js +37 -0
  48. package/dist/server/routes/users.js +163 -0
  49. package/dist/server/security.js +2 -4
  50. package/dist/server/synctex.js +7 -4
  51. package/dist/server/zip.js +29 -13
  52. package/package.json +3 -3
  53. package/texlite.config.example.json +5 -1
  54. package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
  55. package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
  56. package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
  57. package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
  58. package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
  59. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  60. package/dist/client/assets/binary-BoldOm0z.js +0 -1
  61. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  62. package/dist/client/assets/index-BQCB3z5H.js +0 -64
  63. package/dist/client/assets/index-BnvpaWsa.css +0 -1
  64. package/dist/client/assets/index-Byn2Miy9.js +0 -3456
  65. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  66. package/dist/client/assets/minus-K-5IK4nL.js +0 -1
  67. package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
  68. package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
@@ -0,0 +1,473 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { requireUser } from "../auth.js";
5
+ import { maxCollaborativeFileBytes } from "../collaboration.js";
6
+ import { createProjectFiles, duplicateProjectFiles, outputRoot, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "../files.js";
7
+ import { apiError, contentDisposition, httpError } from "../http.js";
8
+ import { isMainDocumentCandidate } from "../latexRoot.js";
9
+ import { accessibleProject, canEdit } from "../projects.js";
10
+ import { writeProjectArchive } from "../archive.js";
11
+ import { extractProjectZip, ZipValidationError } from "../zip.js";
12
+ import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
13
+ /** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
14
+ export function registerProjectCatalogRoutes(app, context) {
15
+ const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, harper, recordHistory } = context;
16
+ app.get("/api/tags", async (request, reply) => {
17
+ const user = requireUser(request, reply, db);
18
+ if (!user)
19
+ return;
20
+ const tags = db.prepare("SELECT id, name, color FROM user_tags WHERE user_id = ? ORDER BY name COLLATE NOCASE").all(user.id);
21
+ return { tags };
22
+ });
23
+ app.post("/api/tags", async (request, reply) => {
24
+ const user = requireUser(request, reply, db);
25
+ if (!user)
26
+ return;
27
+ const body = request.body;
28
+ const name = text(body.name, 32);
29
+ const color = tagColors.includes(body.color)
30
+ ? body.color : "gray";
31
+ const tag = { id: randomUUID(), name, color };
32
+ const createdAt = now();
33
+ db.prepare("INSERT INTO user_tags (id, name, color, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
34
+ .run(tag.id, tag.name, tag.color, user.id, createdAt, createdAt);
35
+ return reply.code(201).send({ tag });
36
+ });
37
+ app.get("/api/projects", async (request, reply) => {
38
+ const user = requireUser(request, reply, db);
39
+ if (!user)
40
+ return;
41
+ const query = request.query;
42
+ const archivedOnly = query.archived === "1" || query.archived === "true";
43
+ const requestedPage = Number.parseInt(query.page ?? "1", 10);
44
+ const page = Number.isFinite(requestedPage) && requestedPage > 0 ? requestedPage : 1;
45
+ const requestedPageSize = Number.parseInt(query.pageSize ?? "20", 10);
46
+ const pageSize = Math.min(100, Math.max(1, Number.isFinite(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 20));
47
+ const search = typeof query.search === "string" ? query.search.trim() : "";
48
+ const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
49
+ const sortColumn = query.sort === "created" ? "p.created_at" : "p.updated_at";
50
+ const archiveCondition = archivedOnly
51
+ ? "EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)"
52
+ : "NOT EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)";
53
+ const from = user.role === "admin"
54
+ ? `FROM projects p JOIN users owner ON owner.id = p.owner_id
55
+ LEFT JOIN users modifier ON modifier.id = p.last_modified_by`
56
+ : `FROM projects p JOIN users owner ON owner.id = p.owner_id
57
+ LEFT JOIN users modifier ON modifier.id = p.last_modified_by
58
+ LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
59
+ const conditions = [
60
+ ...(user.role === "admin" ? [] : ["(p.owner_id = :userId OR pm.user_id = :userId)"]),
61
+ archiveCondition
62
+ ];
63
+ const params = { userId: user.id };
64
+ if (search) {
65
+ conditions.push("(p.name LIKE :search ESCAPE '\\' OR owner.username LIKE :search ESCAPE '\\' OR owner.display_name LIKE :search ESCAPE '\\')");
66
+ params.search = `%${escapeLikePattern(search)}%`;
67
+ }
68
+ if (tagId) {
69
+ conditions.push(`EXISTS (
70
+ SELECT 1 FROM user_project_tag_links tag_link
71
+ JOIN user_tags tag ON tag.id = tag_link.tag_id
72
+ WHERE tag_link.project_id = p.id AND tag.user_id = :userId AND tag.id = :tagId
73
+ )`);
74
+ params.tagId = tagId;
75
+ }
76
+ const where = `WHERE ${conditions.join(" AND ")}`;
77
+ const countRow = db.prepare(`SELECT COUNT(DISTINCT p.id) AS total ${from} ${where}`).get(params);
78
+ const total = Number(countRow.total);
79
+ const totalPages = Math.ceil(total / pageSize);
80
+ const currentPage = totalPages === 0 ? 1 : Math.min(page, totalPages);
81
+ const rowsParams = { ...params, limit: pageSize, offset: (currentPage - 1) * pageSize };
82
+ const select = user.role === "admin"
83
+ ? `SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
84
+ owner.display_name AS owner_display_name,
85
+ modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`
86
+ : `SELECT DISTINCT p.*,
87
+ CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
88
+ owner.username AS owner_username, owner.display_name AS owner_display_name,
89
+ modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
90
+ const rows = db.prepare(`${select} ${from} ${where}
91
+ ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
92
+ LIMIT :limit OFFSET :offset`).all(rowsParams);
93
+ const projects = rows;
94
+ const projectIds = projects.map((project) => project.id);
95
+ const projectTags = tagsForProjects(db, projectIds, user.id);
96
+ const commentsSummaries = commentsSummaryForProjects(db, projectIds);
97
+ return {
98
+ projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id))),
99
+ pagination: { page: currentPage, pageSize, total, totalPages }
100
+ };
101
+ });
102
+ app.post("/api/projects", async (request, reply) => {
103
+ const user = requireUser(request, reply, db);
104
+ if (!user)
105
+ return;
106
+ if (user.role !== "admin" && !user.can_create_projects) {
107
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN");
108
+ }
109
+ const body = request.body;
110
+ const project = {
111
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(body?.name, 120),
112
+ main_file: "main.tex", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
113
+ };
114
+ createProjectFiles(config, project.id);
115
+ try {
116
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
117
+ .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);
118
+ }
119
+ catch (error) {
120
+ removeProjectDirectory(config, project.id);
121
+ throw error;
122
+ }
123
+ recordHistory(project.id, user.id, "initial");
124
+ return reply.code(201).send({ project: projectJson({
125
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
126
+ last_modified_username: user.username, last_modified_display_name: user.display_name
127
+ }) });
128
+ });
129
+ app.post("/api/projects/import", async (request, reply) => {
130
+ const user = requireUser(request, reply, db);
131
+ if (!user)
132
+ return;
133
+ if (user.role !== "admin" && !user.can_create_projects) {
134
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN");
135
+ }
136
+ const part = await request.file();
137
+ if (!part || !part.filename.toLowerCase().endsWith(".zip")) {
138
+ return apiError(reply, 400, "ZIP_ONLY");
139
+ }
140
+ const query = request.query;
141
+ const fallbackName = path.basename(part.filename, path.extname(part.filename));
142
+ const project = {
143
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(query.name || fallbackName, 120),
144
+ main_file: "", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
145
+ };
146
+ fs.mkdirSync(sourceRoot(config, project.id), { recursive: true, mode: 0o700 });
147
+ fs.mkdirSync(outputRoot(config, project.id), { recursive: true, mode: 0o700 });
148
+ try {
149
+ const extracted = await extractProjectZip(await part.toBuffer(), sourceRoot(config, project.id), config.maxUploadBytes);
150
+ project.main_file = extracted.mainFile;
151
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
152
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
153
+ .run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.engine, project.created_at, project.updated_at);
154
+ }
155
+ catch (error) {
156
+ removeProjectDirectory(config, project.id);
157
+ if (error instanceof ZipValidationError)
158
+ return apiError(reply, 400, error.code, error.details);
159
+ return apiError(reply, 400, "ZIP_INVALID");
160
+ }
161
+ recordHistory(project.id, user.id, "initial");
162
+ return reply.code(201).send({ project: projectJson({
163
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
164
+ last_modified_username: user.username, last_modified_display_name: user.display_name
165
+ }) });
166
+ });
167
+ app.post("/api/projects/:id/duplicate", async (request, reply) => {
168
+ const user = requireUser(request, reply, db);
169
+ if (!user)
170
+ return;
171
+ if (user.role !== "admin" && !user.can_create_projects) {
172
+ return apiError(reply, 403, "PROJECT_CREATE_FORBIDDEN");
173
+ }
174
+ const { id } = request.params;
175
+ const source = accessibleProject(db, id, user);
176
+ if (!source)
177
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
178
+ const body = request.body;
179
+ const requestedName = typeof body?.name === "string" && body.name.trim() ? body.name : `${source.name.slice(0, 115)} (1)`;
180
+ const project = {
181
+ id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(requestedName, 120),
182
+ main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
183
+ };
184
+ try {
185
+ // Duplicate the source tree only after flushing the live Yjs room and
186
+ // while a short source barrier prevents autosave from changing files
187
+ // between directory entries. The copy is asynchronous, so a large
188
+ // project does not block the Node.js event loop for its entire duration.
189
+ await projectMutations.runConsistentRead(source.id, () => duplicateProjectFiles(config, source.id, project.id), {
190
+ preflight: () => {
191
+ if (!accessibleProject(db, source.id, user)) {
192
+ throw httpError(404, "PROJECT_NOT_FOUND");
193
+ }
194
+ }
195
+ });
196
+ db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
197
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
198
+ .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);
199
+ }
200
+ catch (error) {
201
+ removeProjectDirectory(config, project.id);
202
+ throw error;
203
+ }
204
+ recordHistory(project.id, user.id, "initial");
205
+ return reply.code(201).send({ project: projectJson({
206
+ ...project, permission: "owner", owner_username: user.username, owner_display_name: user.display_name,
207
+ last_modified_username: user.username, last_modified_display_name: user.display_name
208
+ }) });
209
+ });
210
+ app.get("/api/projects/:id", async (request, reply) => {
211
+ const user = requireUser(request, reply, db);
212
+ if (!user)
213
+ return;
214
+ const { id } = request.params;
215
+ const project = accessibleProject(db, id, user);
216
+ if (!project)
217
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
218
+ return {
219
+ project: projectJson(project, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
220
+ };
221
+ });
222
+ app.put("/api/projects/:id/archive", async (request, reply) => {
223
+ const user = requireUser(request, reply, db);
224
+ if (!user)
225
+ return;
226
+ const { id } = request.params;
227
+ if (!accessibleProject(db, id, user))
228
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
229
+ db.prepare(`INSERT OR IGNORE INTO user_project_archives (user_id, project_id, archived_at) VALUES (?, ?, ?)`)
230
+ .run(user.id, id, now());
231
+ return { ok: true, archived: true };
232
+ });
233
+ app.delete("/api/projects/:id/archive", async (request, reply) => {
234
+ const user = requireUser(request, reply, db);
235
+ if (!user)
236
+ return;
237
+ const { id } = request.params;
238
+ if (!accessibleProject(db, id, user))
239
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
240
+ db.prepare("DELETE FROM user_project_archives WHERE user_id = ? AND project_id = ?").run(user.id, id);
241
+ return { ok: true, archived: false };
242
+ });
243
+ app.get("/api/projects/:id/dictionary", async (request, reply) => {
244
+ const user = requireUser(request, reply, db);
245
+ if (!user)
246
+ return;
247
+ const { id } = request.params;
248
+ if (!accessibleProject(db, id, user))
249
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
250
+ const rows = db.prepare(`SELECT word FROM project_dictionary_words
251
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
252
+ return { words: rows.map((row) => row.word) };
253
+ });
254
+ app.post("/api/projects/:id/spellcheck", async (request, reply) => {
255
+ const user = requireUser(request, reply, db);
256
+ if (!user)
257
+ return;
258
+ const { id } = request.params;
259
+ if (!accessibleProject(db, id, user))
260
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
261
+ const body = request.body;
262
+ if (typeof body?.source !== "string")
263
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
264
+ if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
265
+ return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
266
+ }
267
+ try {
268
+ return { lints: await harper.lint(body.source) };
269
+ }
270
+ catch (error) {
271
+ request.log.error({ err: error, projectId: id }, "Harper writing check failed");
272
+ return apiError(reply, 503, "HARPER_UNAVAILABLE");
273
+ }
274
+ });
275
+ app.post("/api/projects/:id/dictionary", async (request, reply) => {
276
+ const user = requireUser(request, reply, db);
277
+ if (!user)
278
+ return;
279
+ const { id } = request.params;
280
+ const project = accessibleProject(db, id, user);
281
+ if (!project)
282
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
283
+ if (!canEdit(project))
284
+ return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN");
285
+ const body = request.body;
286
+ const word = dictionaryWord(body?.word);
287
+ db.prepare(`INSERT OR IGNORE INTO project_dictionary_words (project_id, word, created_by, created_at)
288
+ VALUES (?, ?, ?, ?)`).run(id, word, user.id, now());
289
+ collaboration.signalDictionary(id);
290
+ const words = db.prepare(`SELECT word FROM project_dictionary_words
291
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
292
+ return reply.code(201).send({ word, words: words.map((row) => row.word) });
293
+ });
294
+ app.delete("/api/projects/:id/dictionary/:word", async (request, reply) => {
295
+ const user = requireUser(request, reply, db);
296
+ if (!user)
297
+ return;
298
+ const { id, word: rawWord } = request.params;
299
+ const project = accessibleProject(db, id, user);
300
+ if (!project)
301
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
302
+ if (!canEdit(project))
303
+ return apiError(reply, 403, "DICTIONARY_EDIT_FORBIDDEN");
304
+ const word = dictionaryWord(rawWord);
305
+ db.prepare("DELETE FROM project_dictionary_words WHERE project_id = ? AND word = ?").run(id, word);
306
+ collaboration.signalDictionary(id);
307
+ const words = db.prepare(`SELECT word FROM project_dictionary_words
308
+ WHERE project_id = ? ORDER BY word COLLATE NOCASE`).all(id);
309
+ return { words: words.map((row) => row.word) };
310
+ });
311
+ app.patch("/api/projects/:id", async (request, reply) => {
312
+ const user = requireUser(request, reply, db);
313
+ if (!user)
314
+ return;
315
+ const { id } = request.params;
316
+ if (collaboration.isMaintaining(id))
317
+ return apiError(reply, 409, "PROJECT_BUSY");
318
+ const project = accessibleProject(db, id, user);
319
+ if (!project || project.permission !== "owner")
320
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
321
+ const body = request.body;
322
+ return await projectMutations.runWrite(id, async () => {
323
+ const currentProject = accessibleProject(db, id, user);
324
+ if (!currentProject || currentProject.permission !== "owner") {
325
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
326
+ }
327
+ const name = typeof body.name === "string" ? text(body.name, 120) : currentProject.name;
328
+ let mainFile = currentProject.main_file;
329
+ if (body.mainFile !== undefined) {
330
+ if (typeof body.mainFile !== "string" || !body.mainFile.trim()) {
331
+ return apiError(reply, 400, "MAIN_FILE_INVALID");
332
+ }
333
+ mainFile = safeRelativePath(body.mainFile);
334
+ }
335
+ const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
336
+ ? body.engine : currentProject.engine;
337
+ const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
338
+ ? null
339
+ : typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : currentProject.latexmkrc;
340
+ if (!mainFile.toLocaleLowerCase().endsWith(".tex")) {
341
+ return apiError(reply, 400, "MAIN_FILE_INVALID", { path: mainFile });
342
+ }
343
+ const mainFileAbsolute = resolveSourcePath(config, id, mainFile);
344
+ let stat = null;
345
+ try {
346
+ stat = fs.statSync(mainFileAbsolute);
347
+ }
348
+ catch (error) {
349
+ if (error.code === "ENOENT") {
350
+ return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", { path: mainFile });
351
+ }
352
+ throw error;
353
+ }
354
+ if (!stat.isFile()) {
355
+ return apiError(reply, 400, "MAIN_FILE_INVALID", { path: mainFile });
356
+ }
357
+ if (body.mainFile !== undefined && !await isMainDocumentCandidate(config, id, mainFile)) {
358
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", { path: mainFile });
359
+ }
360
+ if (latexmkrc && !config.allowProjectLatexmkrc)
361
+ return apiError(reply, 400, "LATEXMKRC_DISABLED");
362
+ if (latexmkrc) {
363
+ const rcAbsolute = resolveSourcePath(config, id, latexmkrc);
364
+ let rcStat = null;
365
+ try {
366
+ rcStat = fs.statSync(rcAbsolute);
367
+ }
368
+ catch (error) {
369
+ if (error.code === "ENOENT") {
370
+ return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", { path: latexmkrc });
371
+ }
372
+ throw error;
373
+ }
374
+ if (!rcStat.isFile()) {
375
+ return apiError(reply, 400, "LATEXMKRC_INVALID", { path: latexmkrc });
376
+ }
377
+ }
378
+ db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
379
+ .run(name, mainFile, latexmkrc, engine, now(), user.id, id);
380
+ recordHistory(id, user.id, "settings", []);
381
+ return {
382
+ project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
383
+ };
384
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
385
+ });
386
+ app.post("/api/projects/:id/tags", async (request, reply) => {
387
+ const user = requireUser(request, reply, db);
388
+ if (!user)
389
+ return;
390
+ const { id } = request.params;
391
+ const project = accessibleProject(db, id, user);
392
+ if (!project)
393
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
394
+ const body = request.body;
395
+ if (typeof body.tagId !== "string" || !db.prepare("SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?").get(body.tagId, user.id)) {
396
+ return apiError(reply, 404, "TAG_NOT_FOUND");
397
+ }
398
+ db.prepare("INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at) VALUES (?, ?, ?)")
399
+ .run(id, body.tagId, now());
400
+ const tags = tagsForProject(db, id, user.id);
401
+ return reply.code(201).send({
402
+ tags,
403
+ project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
404
+ });
405
+ });
406
+ app.delete("/api/projects/:id/tags/:tagId", async (request, reply) => {
407
+ const user = requireUser(request, reply, db);
408
+ if (!user)
409
+ return;
410
+ const { id, tagId } = request.params;
411
+ const project = accessibleProject(db, id, user);
412
+ if (!project)
413
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
414
+ db.prepare(`DELETE FROM user_project_tag_links WHERE tag_id = ? AND project_id = ?
415
+ AND EXISTS (SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?)`)
416
+ .run(tagId, id, tagId, user.id);
417
+ const tags = tagsForProject(db, id, user.id);
418
+ return {
419
+ tags,
420
+ project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
421
+ };
422
+ });
423
+ app.get("/api/projects/:id/download", async (request, reply) => {
424
+ const user = requireUser(request, reply, db);
425
+ if (!user)
426
+ return;
427
+ const { id } = request.params;
428
+ const project = accessibleProject(db, id, user);
429
+ if (!project)
430
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
431
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
432
+ const temporaryArchive = path.join(temporaryDirectory, `project-${id}-${randomUUID()}.zip`);
433
+ try {
434
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
435
+ await projectMutations.runConsistentRead(id, () => writeProjectArchive(config, id, temporaryArchive), {
436
+ preflight: () => {
437
+ const current = accessibleProject(db, id, user);
438
+ if (!current)
439
+ throw httpError(404, "PROJECT_NOT_FOUND");
440
+ }
441
+ });
442
+ }
443
+ catch (error) {
444
+ await fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined);
445
+ throw error;
446
+ }
447
+ const filename = `${project.name}.zip`;
448
+ reply.header("Content-Type", "application/zip");
449
+ reply.header("Content-Disposition", contentDisposition(filename, "attachment"));
450
+ const stream = fs.createReadStream(temporaryArchive);
451
+ const cleanup = () => { void fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined); };
452
+ stream.once("close", cleanup);
453
+ stream.once("error", cleanup);
454
+ return reply.send(stream);
455
+ });
456
+ app.delete("/api/projects/:id", async (request, reply) => {
457
+ const user = requireUser(request, reply, db);
458
+ if (!user)
459
+ return;
460
+ const { id } = request.params;
461
+ const project = accessibleProject(db, id, user);
462
+ if (!project || project.permission !== "owner")
463
+ return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN");
464
+ return await projectMutations.runExclusive(id, "project deletion", () => {
465
+ collaboration.resetProject(id);
466
+ removeProjectDirectory(config, id);
467
+ db.prepare("DELETE FROM projects WHERE id = ?").run(id);
468
+ latexCompletions.invalidate(id);
469
+ projectOutlines.invalidate(id);
470
+ return { ok: true };
471
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
472
+ });
473
+ }
@@ -0,0 +1,37 @@
1
+ import { requireAdmin } from "../auth.js";
2
+ import { maxCollaborativeFileBytes } from "../collaboration.js";
3
+ import { MAX_CITATION_BIBTEX_BYTES } from "../limits.js";
4
+ import { MIN_PASSWORD_LENGTH } from "../security.js";
5
+ /** Register routes that describe the running server or its live connections. */
6
+ export function registerSystemRoutes(app, context) {
7
+ const { config, db, queue, collaboration, latexCompletions, projectOutlines, metrics, eventLoopDelay } = context;
8
+ app.get("/api/config", async () => ({
9
+ siteName: config.siteName,
10
+ adminEmail: config.adminEmail,
11
+ minPasswordLength: MIN_PASSWORD_LENGTH,
12
+ maxCitationBibtexBytes: MAX_CITATION_BIBTEX_BYTES,
13
+ maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
14
+ maxCollaborativeFileSizeMB: Math.floor(maxCollaborativeFileBytes(config) / 1024 / 1024),
15
+ allowedEngines: config.allowedEngines,
16
+ allowProjectLatexmkrc: config.allowProjectLatexmkrc
17
+ }));
18
+ app.get("/api/health", async () => ({ ok: true, pid: process.pid, latexmk: config.latexmk }));
19
+ app.get("/api/health/metrics", async (request, reply) => {
20
+ if (!requireAdmin(request, reply, db))
21
+ return;
22
+ const memory = process.memoryUsage();
23
+ return {
24
+ uptimeSeconds: Math.round(process.uptime()),
25
+ memory: { rssBytes: memory.rss, heapUsedBytes: memory.heapUsed, heapTotalBytes: memory.heapTotal },
26
+ eventLoopDelay: {
27
+ p50Ms: Math.round(eventLoopDelay.percentile(50) / 100_000) / 10,
28
+ p95Ms: Math.round(eventLoopDelay.percentile(95) / 100_000) / 10,
29
+ maxMs: Math.round(eventLoopDelay.max / 100_000) / 10
30
+ },
31
+ compileQueue: queue.stats(),
32
+ collaboration: collaboration.stats(),
33
+ caches: { completions: latexCompletions.stats(), outlines: projectOutlines.stats() },
34
+ durationsMs: metrics.summaries()
35
+ };
36
+ });
37
+ }