texlite 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-W3hLOWrF.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
  5. package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
  6. package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
  9. package/dist/client/assets/index-DRShM2ob.js +64 -0
  10. package/dist/client/assets/index-DzcH7hBu.css +1 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-BfqMIeUg.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-BBnCnxmQ.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 +250 -2
  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,572 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Transform } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { performance } from "node:perf_hooks";
7
+ import { requireUser } from "../auth.js";
8
+ import { reanchorFileComments } from "../anchors.js";
9
+ import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
10
+ import { compileMainFile } from "../compileArtifacts.js";
11
+ import { assertNoSourceSymlinks, listProjectFilesAsync, resolveSourcePath, safePathSegment, safeRelativePath, sourceRoot } from "../files.js";
12
+ import { apiError, contentDisposition, httpError } from "../http.js";
13
+ import { accessibleProject, canEdit } from "../projects.js";
14
+ import { replaceProject, searchProject } from "../projectSearch.js";
15
+ import { mainDocumentCandidates } from "../latexRoot.js";
16
+ import { commentsForFile, escapeGlobPattern, movedProjectPath, now, requireEditableProject, touchProject } from "./projectShared.js";
17
+ /** Register project file-tree, editor content, search, and upload routes. */
18
+ export function registerProjectFileRoutes(app, context) {
19
+ const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, metrics, recordHistory } = context;
20
+ app.get("/api/projects/:id/files", async (request, reply) => {
21
+ const user = requireUser(request, reply, db);
22
+ if (!user)
23
+ return;
24
+ const { id } = request.params;
25
+ if (!accessibleProject(db, id, user))
26
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
27
+ // Directory walks can be slow on external/project-mounted storage. Keep
28
+ // this request off the event loop and unblocked by Yjs room initialization
29
+ // so the file tree and retained-PDF stream can be served concurrently, but
30
+ // still serialize it with source-tree mutations and collaborative flushes.
31
+ return await projectMutations.runFilesystemRead(id, async () => ({
32
+ files: await listProjectFilesAsync(config, id)
33
+ }), {
34
+ preflight: () => {
35
+ if (!accessibleProject(db, id, user)) {
36
+ throw httpError(404, "PROJECT_NOT_FOUND");
37
+ }
38
+ }
39
+ });
40
+ });
41
+ app.get("/api/projects/:id/main-files", async (request, reply) => {
42
+ const user = requireUser(request, reply, db);
43
+ if (!user)
44
+ return;
45
+ const { id } = request.params;
46
+ if (!accessibleProject(db, id, user))
47
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
48
+ return await projectMutations.runFilesystemRead(id, async () => {
49
+ const files = await listProjectFilesAsync(config, id);
50
+ return await mainDocumentCandidates(config, id, files);
51
+ }, {
52
+ preflight: () => {
53
+ if (!accessibleProject(db, id, user)) {
54
+ throw httpError(404, "PROJECT_NOT_FOUND");
55
+ }
56
+ }
57
+ });
58
+ });
59
+ app.get("/api/projects/:id/outline", async (request, reply) => {
60
+ const user = requireUser(request, reply, db);
61
+ if (!user)
62
+ return;
63
+ const { id } = request.params;
64
+ const project = accessibleProject(db, id, user);
65
+ if (!project)
66
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
67
+ const query = request.query;
68
+ const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
69
+ if (!mainFile)
70
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
71
+ const startedAt = performance.now();
72
+ try {
73
+ return await projectMutations.runConsistentRead(id, async () => ({
74
+ outline: await projectOutlines.build(id, mainFile), mainFile
75
+ }), {
76
+ preflight: () => {
77
+ const current = accessibleProject(db, id, user);
78
+ if (!current)
79
+ throw httpError(404, "PROJECT_NOT_FOUND");
80
+ const selected = compileMainFile(config, id, current.main_file, query.mainFile);
81
+ if (!selected || selected !== mainFile)
82
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
83
+ }
84
+ });
85
+ }
86
+ finally {
87
+ metrics.record("outline.build", performance.now() - startedAt);
88
+ }
89
+ });
90
+ app.get("/api/projects/:id/search", async (request, reply) => {
91
+ const user = requireUser(request, reply, db);
92
+ if (!user)
93
+ return;
94
+ const { id } = request.params;
95
+ if (!accessibleProject(db, id, user))
96
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
97
+ const query = request.query;
98
+ const startedAt = performance.now();
99
+ try {
100
+ return await projectMutations.runConsistentRead(id, () => searchProject(config, id, {
101
+ query: query.q ?? "",
102
+ caseSensitive: query.caseSensitive === "1",
103
+ wholeWord: query.wholeWord === "1"
104
+ }), {
105
+ preflight: () => {
106
+ if (!accessibleProject(db, id, user))
107
+ throw httpError(404, "PROJECT_NOT_FOUND");
108
+ }
109
+ });
110
+ }
111
+ finally {
112
+ metrics.record("search.project", performance.now() - startedAt);
113
+ }
114
+ });
115
+ app.post("/api/projects/:id/search/replace", async (request, reply) => {
116
+ const user = requireUser(request, reply, db);
117
+ if (!user)
118
+ return;
119
+ const { id } = request.params;
120
+ const project = accessibleProject(db, id, user);
121
+ if (!project || !canEdit(project))
122
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
123
+ const body = request.body;
124
+ if (typeof body.query !== "string" || typeof body.replacement !== "string" || body.replacement.length > 100_000) {
125
+ return apiError(reply, 400, "SEARCH_QUERY_INVALID");
126
+ }
127
+ return await projectMutations.runExclusive(id, "project-wide replace", async () => {
128
+ const changed = await replaceProject(config, id, {
129
+ query: body.query,
130
+ caseSensitive: body.caseSensitive === true,
131
+ wholeWord: body.wholeWord === true,
132
+ maxFileBytes: maxCollaborativeFileBytes(config)
133
+ }, body.replacement);
134
+ let replacements = 0;
135
+ for (const file of changed) {
136
+ reanchorFileComments(db, id, file.path, file.previous, file.content);
137
+ collaboration.updateFile(id, file.path, file.content, user.id);
138
+ replacements += file.count;
139
+ }
140
+ if (changed.length) {
141
+ touchProject(db, id, user.id);
142
+ recordHistory(id, user.id, "file", changed.map((file) => file.path));
143
+ }
144
+ return { ok: true, replacements, files: changed.map((file) => file.path) };
145
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
146
+ });
147
+ app.get("/api/projects/:id/completions", async (request, reply) => {
148
+ const user = requireUser(request, reply, db);
149
+ if (!user)
150
+ return;
151
+ const { id } = request.params;
152
+ if (!accessibleProject(db, id, user))
153
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
154
+ const startedAt = performance.now();
155
+ try {
156
+ return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
157
+ preflight: () => {
158
+ if (!accessibleProject(db, id, user))
159
+ throw httpError(404, "PROJECT_NOT_FOUND");
160
+ }
161
+ });
162
+ }
163
+ finally {
164
+ metrics.record("completions.build", performance.now() - startedAt);
165
+ }
166
+ });
167
+ app.post("/api/projects/:id/folders", async (request, reply) => {
168
+ const user = requireUser(request, reply, db);
169
+ if (!user)
170
+ return;
171
+ const { id } = request.params;
172
+ if (collaboration.isMaintaining(id))
173
+ return apiError(reply, 409, "PROJECT_BUSY");
174
+ const project = accessibleProject(db, id, user);
175
+ if (!project || !canEdit(project))
176
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
177
+ const body = request.body;
178
+ const folderPath = safeRelativePath(typeof body.path === "string" ? body.path : "");
179
+ return await projectMutations.runWrite(id, () => {
180
+ const absolute = resolveSourcePath(config, id, folderPath);
181
+ if (fs.existsSync(absolute))
182
+ return apiError(reply, 409, "PATH_EXISTS", { path: folderPath });
183
+ fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
184
+ touchProject(db, id, user.id);
185
+ // Empty folders have no file entry to trigger a refresh on their own.
186
+ // Publish a source-tree event so other open sessions see the folder
187
+ // immediately instead of waiting for a later file operation.
188
+ collaboration.invalidateSourceTree(id, folderPath);
189
+ return reply.code(201).send({ ok: true, path: folderPath });
190
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
191
+ });
192
+ app.patch("/api/projects/:id/path", async (request, reply) => {
193
+ const user = requireUser(request, reply, db);
194
+ if (!user)
195
+ return;
196
+ const { id } = request.params;
197
+ if (collaboration.isMaintaining(id))
198
+ return apiError(reply, 409, "PROJECT_BUSY");
199
+ const project = accessibleProject(db, id, user);
200
+ if (!project || !canEdit(project))
201
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
202
+ const body = request.body;
203
+ const source = safeRelativePath(typeof body.source === "string" ? body.source : "");
204
+ const destinationDirectory = body.destinationDirectory === "" ? ""
205
+ : safeRelativePath(typeof body.destinationDirectory === "string" ? body.destinationDirectory : "");
206
+ const destinationName = body.destinationName === undefined
207
+ ? path.posix.basename(source)
208
+ : safePathSegment(typeof body.destinationName === "string" ? body.destinationName : "");
209
+ const destination = destinationDirectory ? `${destinationDirectory}/${destinationName}` : destinationName;
210
+ const sourceAbsolute = resolveSourcePath(config, id, source);
211
+ if (!fs.existsSync(sourceAbsolute))
212
+ return apiError(reply, 404, "PATH_NOT_FOUND", { path: source });
213
+ if (destination === source)
214
+ return { ok: true, path: source };
215
+ const initialSourceStat = fs.statSync(sourceAbsolute);
216
+ if (initialSourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
217
+ return apiError(reply, 400, "MOVE_INTO_SELF", { path: source });
218
+ }
219
+ const destinationRoot = destinationDirectory
220
+ ? resolveSourcePath(config, id, destinationDirectory)
221
+ : sourceRoot(config, id);
222
+ if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
223
+ return apiError(reply, 404, "DIRECTORY_NOT_FOUND", { path: destinationDirectory });
224
+ }
225
+ const destinationAbsolute = resolveSourcePath(config, id, destination);
226
+ if (fs.existsSync(destinationAbsolute))
227
+ return apiError(reply, 409, "PATH_EXISTS", { path: destination });
228
+ let currentProject = project;
229
+ const validateMove = () => {
230
+ currentProject = requireEditableProject(db, id, user);
231
+ assertNoSourceSymlinks(config, id);
232
+ if (!fs.existsSync(sourceAbsolute)) {
233
+ throw httpError(404, "PATH_NOT_FOUND", { path: source });
234
+ }
235
+ const sourceStat = fs.statSync(sourceAbsolute);
236
+ if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
237
+ throw httpError(400, "MOVE_INTO_SELF", { path: source });
238
+ }
239
+ if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
240
+ throw httpError(404, "DIRECTORY_NOT_FOUND", { path: destinationDirectory });
241
+ }
242
+ if (fs.existsSync(destinationAbsolute)) {
243
+ throw httpError(409, "PATH_EXISTS", { path: destination });
244
+ }
245
+ };
246
+ return await projectMutations.runExclusive(id, "move project path", () => {
247
+ fs.renameSync(sourceAbsolute, destinationAbsolute);
248
+ let hasCommentUpdates = false;
249
+ db.exec("BEGIN IMMEDIATE");
250
+ try {
251
+ const mainFile = movedProjectPath(currentProject.main_file, source, destination);
252
+ const latexmkrc = movedProjectPath(currentProject.latexmkrc, source, destination);
253
+ const changedAt = now();
254
+ db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
255
+ .run(mainFile, latexmkrc, changedAt, user.id, id);
256
+ const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
257
+ const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
258
+ for (const comment of comments) {
259
+ const nextPath = movedProjectPath(comment.file_path, source, destination);
260
+ if (nextPath !== comment.file_path) {
261
+ updateComment.run(nextPath, changedAt, comment.id);
262
+ hasCommentUpdates = true;
263
+ }
264
+ }
265
+ db.exec("COMMIT");
266
+ }
267
+ catch (error) {
268
+ db.exec("ROLLBACK");
269
+ fs.renameSync(destinationAbsolute, sourceAbsolute);
270
+ throw error;
271
+ }
272
+ collaboration.movePath(id, source, destination, user.id);
273
+ if (hasCommentUpdates)
274
+ collaboration.signalComments(id);
275
+ recordHistory(id, user.id, "file", [source, destination]);
276
+ return { ok: true, path: destination };
277
+ }, { preflight: validateMove });
278
+ });
279
+ app.get("/api/projects/:id/file/raw", async (request, reply) => {
280
+ const user = requireUser(request, reply, db);
281
+ if (!user)
282
+ return;
283
+ const { id } = request.params;
284
+ if (!accessibleProject(db, id, user))
285
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
286
+ const query = request.query;
287
+ const filePath = safeRelativePath(query.path ?? "");
288
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
289
+ const temporaryFile = path.join(temporaryDirectory, `file-${id}-${randomUUID()}`);
290
+ try {
291
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
292
+ await projectMutations.runConsistentRead(id, async () => {
293
+ const absolute = resolveSourcePath(config, id, filePath);
294
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
295
+ throw httpError(404, "FILE_NOT_FOUND", { path: filePath });
296
+ }
297
+ await fs.promises.copyFile(absolute, temporaryFile);
298
+ }, { preflight: () => {
299
+ if (!accessibleProject(db, id, user))
300
+ throw httpError(404, "PROJECT_NOT_FOUND");
301
+ } });
302
+ }
303
+ catch (error) {
304
+ await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
305
+ throw error;
306
+ }
307
+ const extension = path.extname(filePath).toLocaleLowerCase();
308
+ const contentTypes = {
309
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
310
+ ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", ".avif": "image/avif",
311
+ ".pdf": "application/pdf"
312
+ };
313
+ const contentType = contentTypes[extension] ?? "application/octet-stream";
314
+ const downloading = query.download === "1";
315
+ reply.header("Content-Type", contentType);
316
+ reply.header("Content-Disposition", contentDisposition(path.basename(filePath), downloading ? "attachment" : "inline"));
317
+ reply.header("Cache-Control", "private, no-cache");
318
+ reply.header("Content-Length", fs.statSync(temporaryFile).size);
319
+ const stream = fs.createReadStream(temporaryFile);
320
+ const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
321
+ stream.once("close", cleanup);
322
+ stream.once("error", cleanup);
323
+ return reply.send(stream);
324
+ });
325
+ app.get("/api/projects/:id/file", async (request, reply) => {
326
+ const user = requireUser(request, reply, db);
327
+ if (!user)
328
+ return;
329
+ const { id } = request.params;
330
+ const { path: filePath } = request.query;
331
+ if (!accessibleProject(db, id, user))
332
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
333
+ const relative = safeRelativePath(filePath ?? "");
334
+ return await projectMutations.runConsistentRead(id, () => {
335
+ const absolute = resolveSourcePath(config, id, relative);
336
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
337
+ return apiError(reply, 404, "FILE_NOT_FOUND", { path: relative });
338
+ if (isCollaborativeTextFile(relative) && fs.statSync(absolute).size > maxCollaborativeFileBytes(config)) {
339
+ return apiError(reply, 413, "FILE_TOO_LARGE", { path: relative });
340
+ }
341
+ return { path: relative, content: fs.readFileSync(absolute, "utf8") };
342
+ }, { preflight: () => {
343
+ if (!accessibleProject(db, id, user))
344
+ throw httpError(404, "PROJECT_NOT_FOUND");
345
+ } });
346
+ });
347
+ // Creating a file is intentionally separate from updating one. The editor
348
+ // uses PUT for autosaves, but a user action such as "New file" must never
349
+ // silently replace an existing file.
350
+ app.post("/api/projects/:id/file", async (request, reply) => {
351
+ const user = requireUser(request, reply, db);
352
+ if (!user)
353
+ return;
354
+ const { id } = request.params;
355
+ if (collaboration.isMaintaining(id))
356
+ return apiError(reply, 409, "PROJECT_BUSY");
357
+ const project = accessibleProject(db, id, user);
358
+ if (!project || !canEdit(project))
359
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
360
+ const body = request.body;
361
+ const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
362
+ if (typeof body.content !== "string")
363
+ return apiError(reply, 400, "FILE_CONTENT_INVALID");
364
+ const content = body.content;
365
+ const byteLength = Buffer.byteLength(content, "utf8");
366
+ const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
367
+ if (byteLength > limit) {
368
+ return apiError(reply, 413, "FILE_TOO_LARGE", { path: filePath, size: Math.floor(limit / 1024 / 1024) });
369
+ }
370
+ return await projectMutations.runWrite(id, () => {
371
+ const absolute = resolveSourcePath(config, id, filePath);
372
+ try {
373
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
374
+ }
375
+ catch (error) {
376
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
377
+ return apiError(reply, 409, "PATH_EXISTS", { path: filePath });
378
+ }
379
+ throw error;
380
+ }
381
+ try {
382
+ fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
383
+ }
384
+ catch (error) {
385
+ if (error.code === "EEXIST") {
386
+ return apiError(reply, 409, "FILE_EXISTS", { path: filePath });
387
+ }
388
+ throw error;
389
+ }
390
+ touchProject(db, id, user.id);
391
+ collaboration.updateFile(id, filePath, content, user.id);
392
+ recordHistory(id, user.id, "file", [filePath]);
393
+ return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
394
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
395
+ });
396
+ app.put("/api/projects/:id/file", async (request, reply) => {
397
+ const user = requireUser(request, reply, db);
398
+ if (!user)
399
+ return;
400
+ const { id } = request.params;
401
+ if (collaboration.isMaintaining(id))
402
+ return apiError(reply, 409, "PROJECT_BUSY");
403
+ const project = accessibleProject(db, id, user);
404
+ if (!project || !canEdit(project))
405
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
406
+ const body = request.body;
407
+ const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
408
+ if (typeof body.content !== "string")
409
+ return apiError(reply, 400, "FILE_CONTENT_INVALID");
410
+ const content = body.content;
411
+ const byteLength = Buffer.byteLength(content, "utf8");
412
+ const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
413
+ if (byteLength > limit) {
414
+ return apiError(reply, 413, "FILE_TOO_LARGE", { path: filePath, size: Math.floor(limit / 1024 / 1024) });
415
+ }
416
+ return await projectMutations.runWrite(id, () => {
417
+ const absolute = resolveSourcePath(config, id, filePath);
418
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
419
+ const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
420
+ reanchorFileComments(db, id, filePath, previousContent, content);
421
+ fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600 });
422
+ touchProject(db, id, user.id);
423
+ collaboration.updateFile(id, filePath, content, user.id);
424
+ recordHistory(id, user.id, "file", [filePath]);
425
+ return { ok: true, comments: commentsForFile(db, config, id, filePath) };
426
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
427
+ });
428
+ app.delete("/api/projects/:id/file", async (request, reply) => {
429
+ const user = requireUser(request, reply, db);
430
+ if (!user)
431
+ return;
432
+ const { id } = request.params;
433
+ if (collaboration.isMaintaining(id))
434
+ return apiError(reply, 409, "PROJECT_BUSY");
435
+ const { path: filePath } = request.query;
436
+ const relative = safeRelativePath(filePath ?? "");
437
+ const project = accessibleProject(db, id, user);
438
+ if (!project || !canEdit(project))
439
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
440
+ return await projectMutations.runWrite(id, () => {
441
+ const currentProject = requireEditableProject(db, id, user);
442
+ if (relative === currentProject.main_file || currentProject.main_file.startsWith(`${relative}/`)) {
443
+ return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", { path: relative });
444
+ }
445
+ const absolute = resolveSourcePath(config, id, relative, { allowFinalSymlink: true });
446
+ try {
447
+ fs.lstatSync(absolute);
448
+ }
449
+ catch (error) {
450
+ if (error.code === "ENOENT")
451
+ return apiError(reply, 404, "PATH_NOT_FOUND", { path: relative });
452
+ throw error;
453
+ }
454
+ fs.rmSync(absolute, { recursive: true, force: true });
455
+ const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
456
+ touchProject(db, id, user.id);
457
+ collaboration.removePath(id, relative);
458
+ if (deleteResult.changes > 0)
459
+ collaboration.signalComments(id);
460
+ recordHistory(id, user.id, "file", [relative]);
461
+ return { ok: true };
462
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
463
+ });
464
+ app.post("/api/projects/:id/upload", async (request, reply) => {
465
+ const user = requireUser(request, reply, db);
466
+ if (!user)
467
+ return;
468
+ const { id } = request.params;
469
+ if (collaboration.isMaintaining(id))
470
+ return apiError(reply, 409, "PROJECT_BUSY");
471
+ const project = accessibleProject(db, id, user);
472
+ if (!project || !canEdit(project))
473
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
474
+ const part = await request.file();
475
+ if (!part)
476
+ return apiError(reply, 400, "UPLOAD_EMPTY");
477
+ const { directory, overwrite } = request.query;
478
+ const relative = safeRelativePath(directory ? `${safeRelativePath(directory)}/${part.filename}` : part.filename);
479
+ const uploadLimit = isCollaborativeTextFile(relative) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
480
+ const tmpDir = path.join(config.dataDir, "tmp");
481
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
482
+ const tmpPath = path.join(tmpDir, `upload-${randomUUID()}.tmp`);
483
+ let byteLength = 0;
484
+ try {
485
+ const limiter = new Transform({
486
+ transform(chunk, _encoding, callback) {
487
+ byteLength += chunk.length;
488
+ if (byteLength > uploadLimit) {
489
+ callback(new Error("FILE_TOO_LARGE"));
490
+ }
491
+ else {
492
+ callback(null, chunk);
493
+ }
494
+ }
495
+ });
496
+ await pipeline(part.file, limiter, fs.createWriteStream(tmpPath, { mode: 0o600 }));
497
+ }
498
+ catch (error) {
499
+ if (fs.existsSync(tmpPath)) {
500
+ try {
501
+ fs.unlinkSync(tmpPath);
502
+ }
503
+ catch { /* ignore */ }
504
+ }
505
+ if (error instanceof Error && error.message === "FILE_TOO_LARGE") {
506
+ return apiError(reply, 413, "FILE_TOO_LARGE", { path: relative, size: Math.floor(uploadLimit / 1024 / 1024) });
507
+ }
508
+ throw error;
509
+ }
510
+ try {
511
+ return await projectMutations.runWrite(id, () => {
512
+ const absolute = resolveSourcePath(config, id, relative);
513
+ try {
514
+ fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
515
+ }
516
+ catch (error) {
517
+ if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
518
+ return apiError(reply, 409, "PATH_EXISTS", { path: relative });
519
+ }
520
+ throw error;
521
+ }
522
+ const replacing = overwrite === "1";
523
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
524
+ return apiError(reply, 409, "PATH_EXISTS", { path: relative });
525
+ }
526
+ if (!replacing && fs.existsSync(absolute)) {
527
+ return apiError(reply, 409, "FILE_EXISTS", { path: relative });
528
+ }
529
+ const collaborativeText = isCollaborativeTextFile(relative);
530
+ let previousContent = null;
531
+ if (replacing && collaborativeText && fs.existsSync(absolute)) {
532
+ // runWrite() has flushed any live Yjs edits before entering this
533
+ // callback. Prefer the room's text so comment anchors are based on
534
+ // exactly what collaborators last saved, then fall back to disk for
535
+ // projects without an active collaboration room.
536
+ previousContent = collaboration.fileContent(id, relative);
537
+ if (previousContent === null)
538
+ previousContent = fs.readFileSync(absolute, "utf8");
539
+ }
540
+ try {
541
+ fs.renameSync(tmpPath, absolute);
542
+ }
543
+ catch {
544
+ fs.copyFileSync(tmpPath, absolute);
545
+ fs.unlinkSync(tmpPath);
546
+ }
547
+ touchProject(db, id, user.id);
548
+ if (collaborativeText) {
549
+ const content = fs.readFileSync(absolute, "utf8");
550
+ if (previousContent !== null && previousContent !== content) {
551
+ reanchorFileComments(db, id, relative, previousContent, content);
552
+ collaboration.signalComments(id);
553
+ }
554
+ collaboration.updateFile(id, relative, content, user.id);
555
+ }
556
+ else {
557
+ collaboration.invalidateSourceTree(id, relative);
558
+ }
559
+ recordHistory(id, user.id, "file", [relative]);
560
+ return reply.code(201).send({ ok: true, path: relative });
561
+ }, { preflight: () => { requireEditableProject(db, id, user); } });
562
+ }
563
+ finally {
564
+ if (fs.existsSync(tmpPath)) {
565
+ try {
566
+ fs.unlinkSync(tmpPath);
567
+ }
568
+ catch { /* ignore */ }
569
+ }
570
+ }
571
+ });
572
+ }