texlite 0.6.0 → 0.6.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.
- package/README.md +1 -0
- package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-FnkCpIDV.js} +1 -1
- package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-BtWtaeZV.js} +1 -1
- package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DOzAQAQA.js} +1 -1
- package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-C5S1jijC.js} +20 -20
- package/dist/client/assets/PdfPreview-B3-kvd02.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-DeEMFRsp.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-DWh6yS0w.js} +1 -1
- package/dist/client/assets/binary-D_GeVl1T.js +1 -0
- package/dist/client/assets/index-C4KkkecE.js +17 -0
- package/dist/client/assets/index-DwzHKaKD.css +1 -0
- package/dist/client/assets/{index-CyEG4l54.js → index-nywS6bYC.js} +1 -1
- package/dist/client/assets/{plus-Bup73Wq6.js → plus-B_Bl12ZY.js} +1 -1
- package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-BynsoX26.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +888 -339
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +31 -13
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +505 -128
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
- package/dist/client/assets/binary-DTriWQzf.js +0 -1
- package/dist/client/assets/index-CI87C5Wy.js +0 -17
- package/dist/client/assets/index-DB6GC33g.css +0 -1
package/dist/server/app.js
CHANGED
|
@@ -1,45 +1,55 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { Transform } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
4
6
|
import { monitorEventLoopDelay, performance } from "node:perf_hooks";
|
|
5
7
|
import Fastify from "fastify";
|
|
6
8
|
import cookie from "@fastify/cookie";
|
|
7
9
|
import multipart from "@fastify/multipart";
|
|
8
10
|
import staticPlugin from "@fastify/static";
|
|
9
11
|
import websocket from "@fastify/websocket";
|
|
10
|
-
import { activeAdminCount } from "./db.js";
|
|
11
|
-
import { createSessionToken, hashPassword, verifyPassword, digestToken } from "./security.js";
|
|
12
|
+
import { activeAdminCount, pruneExpiredSessions } from "./db.js";
|
|
13
|
+
import { createSessionToken, hashPassword, verifyPassword, digestToken, LoginRateLimiter } from "./security.js";
|
|
12
14
|
import { currentUser, publicUser, requireAdmin, requireUser } from "./auth.js";
|
|
13
15
|
import { accessibleProject, canEdit } from "./projects.js";
|
|
14
|
-
import { createProjectFiles, duplicateProjectFiles, listProjectFiles, listProjectFilesAsync, outputRoot, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
15
|
-
import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns } from "./compiler.js";
|
|
16
|
+
import { createProjectFiles, duplicateProjectFiles, assertNoSourceSymlinks, listProjectFiles, listProjectFilesAsync, outputRoot, pruneTrashDirectory, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
17
|
+
import { CompileQueue, ProjectCompileCoordinator, listPublishedCompileArtifacts, pruneOrphanedCompileRuns, reconcilePublishedCompileRuns } from "./compiler.js";
|
|
16
18
|
import { createSourceAnchor, offsetToLine, reanchorFileComments } from "./anchors.js";
|
|
17
19
|
import { extractProjectZip } from "./zip.js";
|
|
18
|
-
import {
|
|
19
|
-
import { CollaborationService } from "./collaboration.js";
|
|
20
|
+
import { writeProjectArchive } from "./archive.js";
|
|
21
|
+
import { CollaborationService, isCollaborativeTextFile, maxCollaborativeFileBytes } from "./collaboration.js";
|
|
22
|
+
import { ProjectMutationCoordinator } from "./projectMutations.js";
|
|
20
23
|
import { ProjectGitService } from "./git.js";
|
|
21
24
|
import { LatexCompletionService } from "./latexCompletion.js";
|
|
22
25
|
import { ProjectHistoryService } from "./history.js";
|
|
23
26
|
import { LatexFormatterService } from "./latexFormatter.js";
|
|
24
|
-
import {
|
|
27
|
+
import { ProjectOutlineService } from "./projectOutline.js";
|
|
25
28
|
import { replaceProject, searchProject } from "./projectSearch.js";
|
|
26
29
|
import { MetricRegistry } from "./metrics.js";
|
|
27
30
|
import { compileMainFile } from "./compileArtifacts.js";
|
|
28
|
-
import { apiError, contentDisposition } from "./http.js";
|
|
31
|
+
import { apiError, contentDisposition, ValidationError } from "./http.js";
|
|
29
32
|
import { registerCompileRoutes } from "./routes/compile.js";
|
|
30
33
|
const now = () => new Date().toISOString();
|
|
34
|
+
const SESSION_CLEANUP_INTERVAL_MS = 15 * 60_000;
|
|
35
|
+
function escapeLikePattern(value) {
|
|
36
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
37
|
+
}
|
|
38
|
+
export function escapeGlobPattern(value) {
|
|
39
|
+
return value.replace(/[*?[]/g, "[$&]");
|
|
40
|
+
}
|
|
31
41
|
function text(value, name, max = 200) {
|
|
32
42
|
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
33
|
-
throw new
|
|
43
|
+
throw new ValidationError(`${name}格式不正确`);
|
|
34
44
|
}
|
|
35
45
|
return value.trim();
|
|
36
46
|
}
|
|
37
47
|
function dictionaryWord(value) {
|
|
38
48
|
if (typeof value !== "string")
|
|
39
|
-
throw new
|
|
49
|
+
throw new ValidationError("自定义词格式不正确");
|
|
40
50
|
const word = value.trim();
|
|
41
51
|
if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
|
|
42
|
-
throw new
|
|
52
|
+
throw new ValidationError("自定义词格式不正确");
|
|
43
53
|
return word;
|
|
44
54
|
}
|
|
45
55
|
const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
|
|
@@ -63,7 +73,44 @@ function tagsForProjects(db, projectIds, userId) {
|
|
|
63
73
|
}
|
|
64
74
|
return result;
|
|
65
75
|
}
|
|
66
|
-
function
|
|
76
|
+
function commentsSummaryForProjects(db, projectIds) {
|
|
77
|
+
const result = new Map();
|
|
78
|
+
if (!projectIds.length)
|
|
79
|
+
return result;
|
|
80
|
+
for (let index = 0; index < projectIds.length; index += 100) {
|
|
81
|
+
const chunk = projectIds.slice(index, index + 100);
|
|
82
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
83
|
+
const rows = db.prepare(`
|
|
84
|
+
SELECT project_id,
|
|
85
|
+
COUNT(*) AS total_count,
|
|
86
|
+
SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
|
|
87
|
+
FROM comments
|
|
88
|
+
WHERE project_id IN (${placeholders})
|
|
89
|
+
GROUP BY project_id
|
|
90
|
+
`).all(...chunk);
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
result.set(row.project_id, {
|
|
93
|
+
totalCount: Number(row.total_count) || 0,
|
|
94
|
+
unresolvedCount: Number(row.unresolved_count) || 0
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
function commentsSummaryForProject(db, projectId) {
|
|
101
|
+
const row = db.prepare(`
|
|
102
|
+
SELECT
|
|
103
|
+
COUNT(*) AS total_count,
|
|
104
|
+
SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
|
|
105
|
+
FROM comments
|
|
106
|
+
WHERE project_id = ?
|
|
107
|
+
`).get(projectId);
|
|
108
|
+
return {
|
|
109
|
+
totalCount: Number(row?.total_count) || 0,
|
|
110
|
+
unresolvedCount: Number(row?.unresolved_count) || 0
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function projectJson(project, tags = [], commentsSummary) {
|
|
67
114
|
return {
|
|
68
115
|
id: project.id,
|
|
69
116
|
ownerId: project.owner_id,
|
|
@@ -78,6 +125,8 @@ function projectJson(project, tags = []) {
|
|
|
78
125
|
engine: project.engine,
|
|
79
126
|
permission: project.permission,
|
|
80
127
|
tags,
|
|
128
|
+
unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
|
|
129
|
+
commentCount: commentsSummary?.totalCount ?? 0,
|
|
81
130
|
archived: Boolean(project.archived),
|
|
82
131
|
createdAt: project.created_at,
|
|
83
132
|
updatedAt: project.updated_at
|
|
@@ -87,11 +136,53 @@ function touchProject(db, projectId, userId) {
|
|
|
87
136
|
db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
|
|
88
137
|
.run(now(), userId, projectId);
|
|
89
138
|
}
|
|
139
|
+
function requireActiveUser(db, user) {
|
|
140
|
+
const current = db.prepare("SELECT disabled FROM users WHERE id = ?").get(user.id);
|
|
141
|
+
if (!current || current.disabled) {
|
|
142
|
+
throw Object.assign(new Error("用户已被禁用或删除"), { statusCode: 401, code: "AUTH_REQUIRED" });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Authorization must be checked again after a queued mutation acquires its
|
|
147
|
+
* project lock. A member can be revoked, or ownership can be transferred,
|
|
148
|
+
* while the request is waiting behind another filesystem operation.
|
|
149
|
+
*/
|
|
150
|
+
function requireEditableProject(db, projectId, user) {
|
|
151
|
+
requireActiveUser(db, user);
|
|
152
|
+
const project = accessibleProject(db, projectId, user);
|
|
153
|
+
if (!project)
|
|
154
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
155
|
+
if (!canEdit(project))
|
|
156
|
+
throw Object.assign(new Error("没有编辑权限"), { statusCode: 403, code: "PROJECT_EDIT_FORBIDDEN" });
|
|
157
|
+
return project;
|
|
158
|
+
}
|
|
159
|
+
/** Owner permission includes an administrator's effective owner access. */
|
|
160
|
+
function requireProjectOwnerPermission(db, projectId, user) {
|
|
161
|
+
requireActiveUser(db, user);
|
|
162
|
+
const project = accessibleProject(db, projectId, user);
|
|
163
|
+
if (!project)
|
|
164
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
165
|
+
if (project.permission !== "owner") {
|
|
166
|
+
throw Object.assign(new Error("只有项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
|
|
167
|
+
}
|
|
168
|
+
return project;
|
|
169
|
+
}
|
|
170
|
+
/** Operations such as ownership transfer require the actual stored owner. */
|
|
171
|
+
function requireActualProjectOwner(db, projectId, user) {
|
|
172
|
+
requireActiveUser(db, user);
|
|
173
|
+
const project = accessibleProject(db, projectId, user);
|
|
174
|
+
if (!project)
|
|
175
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
176
|
+
if (project.owner_id !== user.id) {
|
|
177
|
+
throw Object.assign(new Error("只有当前项目所有者可以执行此操作"), { statusCode: 403, code: "PROJECT_OWNER_ONLY" });
|
|
178
|
+
}
|
|
179
|
+
return project;
|
|
180
|
+
}
|
|
90
181
|
function projectTextSnapshot(config, projectId) {
|
|
91
182
|
const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
|
|
92
183
|
return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
|
|
93
184
|
const absolute = resolveSourcePath(config, projectId, entry.path);
|
|
94
|
-
return [entry.path, fs.statSync(absolute).size <=
|
|
185
|
+
return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
|
|
95
186
|
}));
|
|
96
187
|
}
|
|
97
188
|
function reanchorProjectSnapshot(db, projectId, before, after) {
|
|
@@ -129,6 +220,30 @@ function commentsForFile(db, config, projectId, filePath) {
|
|
|
129
220
|
const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
|
|
130
221
|
LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
|
|
131
222
|
.all(projectId, filePath);
|
|
223
|
+
if (!rows.length)
|
|
224
|
+
return [];
|
|
225
|
+
const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
|
|
226
|
+
FROM comment_replies reply
|
|
227
|
+
JOIN comments c ON c.id = reply.comment_id
|
|
228
|
+
LEFT JOIN users user ON user.id = reply.author_id
|
|
229
|
+
WHERE c.project_id = ? AND c.file_path = ?
|
|
230
|
+
ORDER BY reply.created_at`)
|
|
231
|
+
.all(projectId, filePath);
|
|
232
|
+
const replyMap = new Map();
|
|
233
|
+
for (const reply of replies) {
|
|
234
|
+
const list = replyMap.get(reply.comment_id) ?? [];
|
|
235
|
+
list.push({
|
|
236
|
+
id: reply.id,
|
|
237
|
+
authorId: reply.author_id,
|
|
238
|
+
authorUsername: reply.author_username,
|
|
239
|
+
authorDisplayName: reply.author_display_name,
|
|
240
|
+
content: reply.content,
|
|
241
|
+
createdAt: reply.created_at,
|
|
242
|
+
updatedAt: reply.updated_at,
|
|
243
|
+
editedAt: reply.edited_at
|
|
244
|
+
});
|
|
245
|
+
replyMap.set(reply.comment_id, list);
|
|
246
|
+
}
|
|
132
247
|
return rows.map((comment) => ({
|
|
133
248
|
id: comment.id,
|
|
134
249
|
authorId: comment.author_id,
|
|
@@ -145,7 +260,7 @@ function commentsForFile(db, config, projectId, filePath) {
|
|
|
145
260
|
createdAt: comment.created_at,
|
|
146
261
|
updatedAt: comment.updated_at,
|
|
147
262
|
editedAt: comment.edited_at,
|
|
148
|
-
replies:
|
|
263
|
+
replies: replyMap.get(comment.id) ?? []
|
|
149
264
|
}));
|
|
150
265
|
}
|
|
151
266
|
export async function buildApp(config, db, options = {}) {
|
|
@@ -160,6 +275,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
160
275
|
eventLoopDelay.enable();
|
|
161
276
|
const history = new ProjectHistoryService(config, db);
|
|
162
277
|
const latexCompletions = new LatexCompletionService(config);
|
|
278
|
+
const projectOutlines = new ProjectOutlineService(config);
|
|
163
279
|
const latexFormatter = new LatexFormatterService();
|
|
164
280
|
const recordHistory = (projectId, userId, reason, paths) => {
|
|
165
281
|
try {
|
|
@@ -174,7 +290,12 @@ export async function buildApp(config, db, options = {}) {
|
|
|
174
290
|
metrics.record("collaboration.persist", durationMs);
|
|
175
291
|
recordHistory(projectId, userId, "autosave", paths);
|
|
176
292
|
});
|
|
293
|
+
const projectMutations = new ProjectMutationCoordinator(collaboration);
|
|
177
294
|
const projectGit = new ProjectGitService(config, db, options.githubFetch);
|
|
295
|
+
const loginLimiter = new LoginRateLimiter();
|
|
296
|
+
for (const row of db.prepare("SELECT id FROM projects").all()) {
|
|
297
|
+
reconcilePublishedCompileRuns(config, db, row.id);
|
|
298
|
+
}
|
|
178
299
|
db.prepare(`UPDATE compile_runs SET status = 'failed',
|
|
179
300
|
log = CASE WHEN log = '' THEN 'Server restarted before compilation finished.' ELSE log END,
|
|
180
301
|
finished_at = ? WHERE status IN ('queued', 'running')`).run(now());
|
|
@@ -191,10 +312,16 @@ export async function buildApp(config, db, options = {}) {
|
|
|
191
312
|
}
|
|
192
313
|
}
|
|
193
314
|
const remove = db.prepare("DELETE FROM compile_runs WHERE id = ?");
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
315
|
+
db.transaction(() => {
|
|
316
|
+
for (const run of completed)
|
|
317
|
+
if (!keep.has(run.id))
|
|
318
|
+
remove.run(run.id);
|
|
319
|
+
})();
|
|
197
320
|
};
|
|
321
|
+
// No second TexLite instance can mutate the data directory while the
|
|
322
|
+
// instance lock is held. Finish cleanup before accepting requests so a
|
|
323
|
+
// freshly started server never races a stale trash/tmp removal.
|
|
324
|
+
await pruneTrashDirectory(config);
|
|
198
325
|
for (const row of db.prepare("SELECT id FROM projects").all()) {
|
|
199
326
|
history.enforceRetention(row.id);
|
|
200
327
|
pruneCompileRuns(row.id);
|
|
@@ -225,18 +352,39 @@ export async function buildApp(config, db, options = {}) {
|
|
|
225
352
|
});
|
|
226
353
|
app.setErrorHandler((error, _request, reply) => {
|
|
227
354
|
app.log.error(error);
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
355
|
+
const rawStatus = typeof error === "object" && error !== null && "statusCode" in error && typeof error.statusCode === "number"
|
|
356
|
+
? error.statusCode
|
|
357
|
+
: undefined;
|
|
358
|
+
const isClientError = rawStatus !== undefined && rawStatus >= 400 && rawStatus < 500;
|
|
359
|
+
const status = isClientError ? rawStatus : 500;
|
|
360
|
+
if (isClientError) {
|
|
361
|
+
const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
362
|
+
? error.code
|
|
363
|
+
: "REQUEST_INVALID";
|
|
364
|
+
const message = error instanceof Error ? error.message : "请求格式不正确";
|
|
365
|
+
const failedPaths = typeof error === "object" && error !== null && "failedPaths" in error
|
|
366
|
+
&& Array.isArray(error.failedPaths)
|
|
367
|
+
&& error.failedPaths.every((path) => typeof path === "string")
|
|
368
|
+
? error.failedPaths.slice(0, 100)
|
|
369
|
+
: undefined;
|
|
370
|
+
void reply.code(status).send({
|
|
371
|
+
code,
|
|
372
|
+
error: message,
|
|
373
|
+
...(failedPaths ? { failedPaths } : {})
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
void reply.code(500).send({
|
|
378
|
+
code: "SERVER_ERROR",
|
|
379
|
+
error: "服务器内部错误"
|
|
380
|
+
});
|
|
381
|
+
}
|
|
235
382
|
});
|
|
236
383
|
app.get("/api/config", async () => ({
|
|
237
384
|
siteName: config.siteName,
|
|
238
385
|
adminEmail: config.adminEmail,
|
|
239
386
|
maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
|
|
387
|
+
maxCollaborativeFileSizeMB: Math.floor(maxCollaborativeFileBytes(config) / 1024 / 1024),
|
|
240
388
|
allowedEngines: config.allowedEngines,
|
|
241
389
|
allowProjectLatexmkrc: config.allowProjectLatexmkrc
|
|
242
390
|
}));
|
|
@@ -255,6 +403,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
255
403
|
},
|
|
256
404
|
compileQueue: queue.stats(),
|
|
257
405
|
collaboration: collaboration.stats(),
|
|
406
|
+
caches: { completions: latexCompletions.stats(), outlines: projectOutlines.stats() },
|
|
258
407
|
durationsMs: metrics.summaries()
|
|
259
408
|
};
|
|
260
409
|
});
|
|
@@ -266,31 +415,38 @@ export async function buildApp(config, db, options = {}) {
|
|
|
266
415
|
}
|
|
267
416
|
const { id } = request.params;
|
|
268
417
|
const startedAt = performance.now();
|
|
269
|
-
|
|
270
|
-
collaboration.connect
|
|
271
|
-
}
|
|
272
|
-
finally {
|
|
273
|
-
metrics.record("collaboration.connect", performance.now() - startedAt);
|
|
274
|
-
}
|
|
418
|
+
void collaboration.connect(socket, id, user)
|
|
419
|
+
.finally(() => metrics.record("collaboration.connect", performance.now() - startedAt));
|
|
275
420
|
});
|
|
276
421
|
app.post("/api/auth/login", async (request, reply) => {
|
|
277
422
|
const body = request.body;
|
|
278
423
|
const username = text(body?.username, "用户名", 64);
|
|
424
|
+
const ip = request.ip || "127.0.0.1";
|
|
425
|
+
const rateLimitKey = `${ip}:${username.toLowerCase()}`;
|
|
426
|
+
if (loginLimiter.isLocked(rateLimitKey)) {
|
|
427
|
+
return apiError(reply, 429, "AUTH_RATE_LIMITED", "登录尝试过于频繁,账号已临时锁定,请在 15 分钟后再试");
|
|
428
|
+
}
|
|
279
429
|
const password = typeof body?.password === "string" ? body.password : "";
|
|
280
430
|
const user = db.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE").get(username);
|
|
281
431
|
if (!user || user.disabled || !(await verifyPassword(password, user.password_hash))) {
|
|
432
|
+
const result = loginLimiter.recordFailure(rateLimitKey);
|
|
433
|
+
if (result.locked) {
|
|
434
|
+
return apiError(reply, 429, "AUTH_RATE_LIMITED", "密码错误次数过多,账号已临时锁定,请在 15 分钟后再试");
|
|
435
|
+
}
|
|
282
436
|
return apiError(reply, 401, "AUTH_INVALID", "用户名或密码错误");
|
|
283
437
|
}
|
|
438
|
+
loginLimiter.reset(rateLimitKey);
|
|
284
439
|
const session = createSessionToken();
|
|
285
440
|
const createdAt = now();
|
|
286
441
|
const expires = new Date(Date.now() + config.sessionDays * 86_400_000);
|
|
287
442
|
db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
288
443
|
.run(session.digest, user.id, expires.toISOString(), createdAt);
|
|
444
|
+
const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
|
|
289
445
|
reply.setCookie("texlite_session", session.token, {
|
|
290
446
|
path: "/",
|
|
291
447
|
httpOnly: true,
|
|
292
448
|
sameSite: "strict",
|
|
293
|
-
secure:
|
|
449
|
+
secure: isSecure,
|
|
294
450
|
expires
|
|
295
451
|
});
|
|
296
452
|
return { user: publicUser(user) };
|
|
@@ -341,7 +497,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
341
497
|
const body = request.body;
|
|
342
498
|
const username = text(body?.username, "用户名", 64);
|
|
343
499
|
if (!/^[\p{L}\p{N}_.-]+$/u.test(username))
|
|
344
|
-
|
|
500
|
+
return apiError(reply, 400, "USERNAME_INVALID", "用户名只能包含字母、数字、点、横线或下划线");
|
|
345
501
|
const displayName = text(body?.displayName ?? username, "显示名称", 100);
|
|
346
502
|
const password = typeof body?.password === "string" ? body.password : "";
|
|
347
503
|
const role = body?.role === "admin" ? "admin" : "user";
|
|
@@ -382,6 +538,14 @@ export async function buildApp(config, db, options = {}) {
|
|
|
382
538
|
}
|
|
383
539
|
db.prepare(`UPDATE users SET display_name = ?, role = ?, disabled = ?, password_hash = ?, must_change_password = ?, can_create_projects = ? WHERE id = ?`)
|
|
384
540
|
.run(displayName, role, disabled, passwordHash, mustChange, canCreateProjects, id);
|
|
541
|
+
// A disabled account must not regain access by being re-enabled while an
|
|
542
|
+
// old cookie is still within its normal lifetime. Password resets already
|
|
543
|
+
// revoke sessions above; disabling does the same for every active token.
|
|
544
|
+
if (disabled === 1)
|
|
545
|
+
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(id);
|
|
546
|
+
if (disabled === 1 || (typeof body.password === "string" && body.password)) {
|
|
547
|
+
collaboration.disconnectUser(id, "用户已被禁用或密码已重置");
|
|
548
|
+
}
|
|
385
549
|
const updated = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
|
|
386
550
|
return { user: publicUser(updated) };
|
|
387
551
|
});
|
|
@@ -399,15 +563,27 @@ export async function buildApp(config, db, options = {}) {
|
|
|
399
563
|
if (target.role === "admin" && activeAdminCount(db) <= 1) {
|
|
400
564
|
return apiError(reply, 400, "LAST_ADMIN", "不能删除最后一个管理员");
|
|
401
565
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
collaboration.flushProject(project.id);
|
|
408
|
-
}
|
|
566
|
+
let owned = [];
|
|
567
|
+
// Capture and mutate the complete owned-project set in the same synchronous
|
|
568
|
+
// transaction. There is deliberately no await before COMMIT: another
|
|
569
|
+
// request cannot transfer or create a project for this user between the
|
|
570
|
+
// snapshot and the owner/delete statements.
|
|
409
571
|
db.exec("BEGIN IMMEDIATE");
|
|
410
572
|
try {
|
|
573
|
+
const currentTarget = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
|
|
574
|
+
if (!currentTarget)
|
|
575
|
+
throw Object.assign(new Error("用户不存在"), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
576
|
+
if (currentTarget.role === "admin" && activeAdminCount(db) <= 1) {
|
|
577
|
+
throw Object.assign(new Error("不能删除最后一个管理员"), { statusCode: 400, code: "LAST_ADMIN" });
|
|
578
|
+
}
|
|
579
|
+
owned = db.prepare("SELECT id FROM projects WHERE owner_id = ?").all(id);
|
|
580
|
+
if (!body.deleteProjects) {
|
|
581
|
+
// Persist any active drafts while the old owner row still exists. This
|
|
582
|
+
// is synchronous, so no edit can arrive between this flush and the
|
|
583
|
+
// ownership update below, and last_modified_by remains FK-safe.
|
|
584
|
+
for (const project of owned)
|
|
585
|
+
projectMutations.flushProject(project.id);
|
|
586
|
+
}
|
|
411
587
|
if (body.deleteProjects) {
|
|
412
588
|
db.prepare("DELETE FROM projects WHERE owner_id = ?").run(id);
|
|
413
589
|
}
|
|
@@ -426,14 +602,23 @@ export async function buildApp(config, db, options = {}) {
|
|
|
426
602
|
db.exec("ROLLBACK");
|
|
427
603
|
throw error;
|
|
428
604
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
605
|
+
// Requests already queued for the removed user fail their lock-time
|
|
606
|
+
// preflight. Cleanup is then serialized after them and invalidates any
|
|
607
|
+
// room initialization that started before the transaction committed.
|
|
608
|
+
for (const project of owned) {
|
|
609
|
+
await projectMutations.runExclusive(project.id, "admin user deletion cleanup", () => {
|
|
610
|
+
if (body.deleteProjects) {
|
|
611
|
+
collaboration.resetProject(project.id);
|
|
612
|
+
removeProjectDirectory(config, project.id);
|
|
613
|
+
latexCompletions.invalidate(project.id);
|
|
614
|
+
projectOutlines.invalidate(project.id);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
collaboration.resetProject(project.id);
|
|
618
|
+
}
|
|
619
|
+
}, { flush: false });
|
|
620
|
+
}
|
|
621
|
+
collaboration.disconnectUser(id, "用户已被删除");
|
|
437
622
|
return { ok: true, deletedProjects: body.deleteProjects ? owned.length : 0 };
|
|
438
623
|
});
|
|
439
624
|
app.get("/api/users", async (request, reply) => {
|
|
@@ -491,8 +676,8 @@ export async function buildApp(config, db, options = {}) {
|
|
|
491
676
|
];
|
|
492
677
|
const params = { userId: user.id };
|
|
493
678
|
if (search) {
|
|
494
|
-
conditions.push("(p.name LIKE :search OR owner.username LIKE :search OR owner.display_name LIKE :search)");
|
|
495
|
-
params.search = `%${search}%`;
|
|
679
|
+
conditions.push("(p.name LIKE :search ESCAPE '\\' OR owner.username LIKE :search ESCAPE '\\' OR owner.display_name LIKE :search ESCAPE '\\')");
|
|
680
|
+
params.search = `%${escapeLikePattern(search)}%`;
|
|
496
681
|
}
|
|
497
682
|
if (tagId) {
|
|
498
683
|
conditions.push(`EXISTS (
|
|
@@ -520,9 +705,11 @@ export async function buildApp(config, db, options = {}) {
|
|
|
520
705
|
ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
|
|
521
706
|
LIMIT :limit OFFSET :offset`).all(rowsParams);
|
|
522
707
|
const projects = rows;
|
|
523
|
-
const
|
|
708
|
+
const projectIds = projects.map((project) => project.id);
|
|
709
|
+
const projectTags = tagsForProjects(db, projectIds, user.id);
|
|
710
|
+
const commentsSummaries = commentsSummaryForProjects(db, projectIds);
|
|
524
711
|
return {
|
|
525
|
-
projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [])),
|
|
712
|
+
projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id))),
|
|
526
713
|
pagination: { page: currentPage, pageSize, total, totalPages }
|
|
527
714
|
};
|
|
528
715
|
});
|
|
@@ -581,7 +768,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
581
768
|
}
|
|
582
769
|
catch (error) {
|
|
583
770
|
removeProjectDirectory(config, project.id);
|
|
584
|
-
|
|
771
|
+
return apiError(reply, 400, "ZIP_INVALID", error instanceof Error ? error.message : "无法解压 ZIP 文件");
|
|
585
772
|
}
|
|
586
773
|
recordHistory(project.id, user.id, "initial");
|
|
587
774
|
return reply.code(201).send({ project: projectJson({
|
|
@@ -607,7 +794,17 @@ export async function buildApp(config, db, options = {}) {
|
|
|
607
794
|
main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
|
|
608
795
|
};
|
|
609
796
|
try {
|
|
610
|
-
|
|
797
|
+
// Duplicate the source tree only after flushing the live Yjs room and
|
|
798
|
+
// while a short source barrier prevents autosave from changing files
|
|
799
|
+
// between directory entries. The copy is asynchronous, so a large
|
|
800
|
+
// project does not block the Node.js event loop for its entire duration.
|
|
801
|
+
await projectMutations.runConsistentRead(source.id, () => duplicateProjectFiles(config, source.id, project.id), {
|
|
802
|
+
preflight: () => {
|
|
803
|
+
if (!accessibleProject(db, source.id, user)) {
|
|
804
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
});
|
|
611
808
|
db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
|
|
612
809
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
613
810
|
.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);
|
|
@@ -630,7 +827,9 @@ export async function buildApp(config, db, options = {}) {
|
|
|
630
827
|
const project = accessibleProject(db, id, user);
|
|
631
828
|
if (!project)
|
|
632
829
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
633
|
-
return {
|
|
830
|
+
return {
|
|
831
|
+
project: projectJson(project, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
832
|
+
};
|
|
634
833
|
});
|
|
635
834
|
app.get("/api/projects/:id/history", async (request, reply) => {
|
|
636
835
|
const user = requireUser(request, reply, db);
|
|
@@ -673,20 +872,25 @@ export async function buildApp(config, db, options = {}) {
|
|
|
673
872
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
674
873
|
const query = request.query;
|
|
675
874
|
const filePath = safeRelativePath(query.path ?? "");
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
else {
|
|
684
|
-
const current = resolveSourcePath(config, id, filePath);
|
|
685
|
-
if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
|
|
686
|
-
comparison = fs.readFileSync(current, "utf8");
|
|
875
|
+
return await projectMutations.runConsistentRead(id, () => {
|
|
876
|
+
const historical = history.readTextFile(id, versionId, filePath);
|
|
877
|
+
if (historical === null)
|
|
878
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", "该历史文件不存在或不能作为文本比较", { path: filePath });
|
|
879
|
+
let comparison = "";
|
|
880
|
+
if (query.against) {
|
|
881
|
+
comparison = history.readTextFile(id, query.against, filePath) ?? "";
|
|
687
882
|
}
|
|
688
|
-
|
|
689
|
-
|
|
883
|
+
else {
|
|
884
|
+
const current = resolveSourcePath(config, id, filePath);
|
|
885
|
+
if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= 2 * 1024 * 1024) {
|
|
886
|
+
comparison = fs.readFileSync(current, "utf8");
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return { path: filePath, historical, comparison, against: query.against ?? "current" };
|
|
890
|
+
}, { preflight: () => {
|
|
891
|
+
if (!accessibleProject(db, id, user))
|
|
892
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
893
|
+
} });
|
|
690
894
|
});
|
|
691
895
|
app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
|
|
692
896
|
const user = requireUser(request, reply, db);
|
|
@@ -711,10 +915,16 @@ export async function buildApp(config, db, options = {}) {
|
|
|
711
915
|
const project = accessibleProject(db, id, user);
|
|
712
916
|
if (!project || project.permission !== "owner")
|
|
713
917
|
return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以删除历史版本");
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
918
|
+
return await projectMutations.runWrite(id, () => {
|
|
919
|
+
if (!history.deleteVersion(id, versionId))
|
|
920
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND", "历史版本不存在");
|
|
921
|
+
return { ok: true, stats: history.stats(id) };
|
|
922
|
+
}, { preflight: () => {
|
|
923
|
+
requireProjectOwnerPermission(db, id, user);
|
|
924
|
+
if (!history.version(versionId, id)) {
|
|
925
|
+
throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
|
|
926
|
+
}
|
|
927
|
+
} });
|
|
718
928
|
});
|
|
719
929
|
app.delete("/api/projects/:id/history", async (request, reply) => {
|
|
720
930
|
const user = requireUser(request, reply, db);
|
|
@@ -724,9 +934,10 @@ export async function buildApp(config, db, options = {}) {
|
|
|
724
934
|
const project = accessibleProject(db, id, user);
|
|
725
935
|
if (!project || project.permission !== "owner")
|
|
726
936
|
return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以清空历史版本");
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
937
|
+
return await projectMutations.runWrite(id, () => {
|
|
938
|
+
history.clear(id);
|
|
939
|
+
return { ok: true, stats: history.stats(id) };
|
|
940
|
+
}, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
|
|
730
941
|
});
|
|
731
942
|
app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
|
|
732
943
|
const user = requireUser(request, reply, db);
|
|
@@ -738,15 +949,25 @@ export async function buildApp(config, db, options = {}) {
|
|
|
738
949
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
739
950
|
const body = request.body;
|
|
740
951
|
const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
952
|
+
return await projectMutations.runExclusive(id, "history restore", () => {
|
|
953
|
+
const currentProject = requireEditableProject(db, id, user);
|
|
954
|
+
recordHistory(id, user.id, "checkpoint");
|
|
955
|
+
const before = projectTextSnapshot(config, id);
|
|
956
|
+
const restored = history.restore(id, versionId, filePath);
|
|
957
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
958
|
+
touchProject(db, id, user.id);
|
|
959
|
+
recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
|
|
960
|
+
return {
|
|
961
|
+
ok: true,
|
|
962
|
+
restoredPaths: restored.restoredPaths,
|
|
963
|
+
project: projectJson(accessibleProject(db, id, user) ?? currentProject, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
964
|
+
};
|
|
965
|
+
}, { preflight: () => {
|
|
966
|
+
requireEditableProject(db, id, user);
|
|
967
|
+
if (!history.version(versionId, id)) {
|
|
968
|
+
throw Object.assign(new Error("历史版本不存在"), { statusCode: 404, code: "HISTORY_VERSION_NOT_FOUND" });
|
|
969
|
+
}
|
|
970
|
+
} });
|
|
750
971
|
});
|
|
751
972
|
app.put("/api/projects/:id/archive", async (request, reply) => {
|
|
752
973
|
const user = requireUser(request, reply, db);
|
|
@@ -821,27 +1042,72 @@ export async function buildApp(config, db, options = {}) {
|
|
|
821
1042
|
if (!user)
|
|
822
1043
|
return;
|
|
823
1044
|
const { id } = request.params;
|
|
1045
|
+
if (collaboration.isMaintaining(id))
|
|
1046
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
824
1047
|
const project = accessibleProject(db, id, user);
|
|
825
1048
|
if (!project || project.permission !== "owner")
|
|
826
1049
|
return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
|
|
827
1050
|
const body = request.body;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
?
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1051
|
+
return await projectMutations.runWrite(id, () => {
|
|
1052
|
+
const currentProject = accessibleProject(db, id, user);
|
|
1053
|
+
if (!currentProject || currentProject.permission !== "owner") {
|
|
1054
|
+
return apiError(reply, 403, "PROJECT_OWNER_ONLY", "只有项目所有者可以修改项目设置");
|
|
1055
|
+
}
|
|
1056
|
+
const name = typeof body.name === "string" ? text(body.name, "项目名称", 120) : currentProject.name;
|
|
1057
|
+
let mainFile = currentProject.main_file;
|
|
1058
|
+
if (body.mainFile !== undefined) {
|
|
1059
|
+
if (typeof body.mainFile !== "string" || !body.mainFile.trim()) {
|
|
1060
|
+
return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是存在的 .tex 文件");
|
|
1061
|
+
}
|
|
1062
|
+
mainFile = safeRelativePath(body.mainFile);
|
|
1063
|
+
}
|
|
1064
|
+
const engine = typeof body.engine === "string" && config.allowedEngines.includes(body.engine)
|
|
1065
|
+
? body.engine : currentProject.engine;
|
|
1066
|
+
const latexmkrc = body.latexmkrc === null || body.latexmkrc === ""
|
|
1067
|
+
? null
|
|
1068
|
+
: typeof body.latexmkrc === "string" ? safeRelativePath(body.latexmkrc) : currentProject.latexmkrc;
|
|
1069
|
+
if (!mainFile.toLocaleLowerCase().endsWith(".tex")) {
|
|
1070
|
+
return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是 .tex 文件", { path: mainFile });
|
|
1071
|
+
}
|
|
1072
|
+
const mainFileAbsolute = resolveSourcePath(config, id, mainFile);
|
|
1073
|
+
let stat = null;
|
|
1074
|
+
try {
|
|
1075
|
+
stat = fs.statSync(mainFileAbsolute);
|
|
1076
|
+
}
|
|
1077
|
+
catch (error) {
|
|
1078
|
+
if (error.code === "ENOENT") {
|
|
1079
|
+
return apiError(reply, 400, "MAIN_FILE_NOT_FOUND", "主文件不存在", { path: mainFile });
|
|
1080
|
+
}
|
|
1081
|
+
throw error;
|
|
1082
|
+
}
|
|
1083
|
+
if (!stat.isFile()) {
|
|
1084
|
+
return apiError(reply, 400, "MAIN_FILE_INVALID", "主文件必须是常规文件,不能是目录", { path: mainFile });
|
|
1085
|
+
}
|
|
1086
|
+
if (latexmkrc && !config.allowProjectLatexmkrc)
|
|
1087
|
+
return apiError(reply, 400, "LATEXMKRC_DISABLED", "管理员已禁用项目级 latexmkrc");
|
|
1088
|
+
if (latexmkrc) {
|
|
1089
|
+
const rcAbsolute = resolveSourcePath(config, id, latexmkrc);
|
|
1090
|
+
let rcStat = null;
|
|
1091
|
+
try {
|
|
1092
|
+
rcStat = fs.statSync(rcAbsolute);
|
|
1093
|
+
}
|
|
1094
|
+
catch (error) {
|
|
1095
|
+
if (error.code === "ENOENT") {
|
|
1096
|
+
return apiError(reply, 400, "LATEXMKRC_NOT_FOUND", "latexmkrc 文件不存在", { path: latexmkrc });
|
|
1097
|
+
}
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
if (!rcStat.isFile()) {
|
|
1101
|
+
return apiError(reply, 400, "LATEXMKRC_INVALID", "latexmkrc 必须是常规文件,不能是目录", { path: latexmkrc });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
db.prepare("UPDATE projects SET name = ?, main_file = ?, latexmkrc = ?, engine = ?, updated_at = ?, last_modified_by = ? WHERE id = ?")
|
|
1105
|
+
.run(name, mainFile, latexmkrc, engine, now(), user.id, id);
|
|
1106
|
+
recordHistory(id, user.id, "settings", []);
|
|
1107
|
+
return {
|
|
1108
|
+
project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
1109
|
+
};
|
|
1110
|
+
}, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
|
|
845
1111
|
});
|
|
846
1112
|
app.post("/api/projects/:id/tags", async (request, reply) => {
|
|
847
1113
|
const user = requireUser(request, reply, db);
|
|
@@ -858,7 +1124,10 @@ export async function buildApp(config, db, options = {}) {
|
|
|
858
1124
|
db.prepare("INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at) VALUES (?, ?, ?)")
|
|
859
1125
|
.run(id, body.tagId, now());
|
|
860
1126
|
const tags = tagsForProject(db, id, user.id);
|
|
861
|
-
return reply.code(201).send({
|
|
1127
|
+
return reply.code(201).send({
|
|
1128
|
+
tags,
|
|
1129
|
+
project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
|
|
1130
|
+
});
|
|
862
1131
|
});
|
|
863
1132
|
app.delete("/api/projects/:id/tags/:tagId", async (request, reply) => {
|
|
864
1133
|
const user = requireUser(request, reply, db);
|
|
@@ -872,9 +1141,13 @@ export async function buildApp(config, db, options = {}) {
|
|
|
872
1141
|
AND EXISTS (SELECT 1 FROM user_tags WHERE id = ? AND user_id = ?)`)
|
|
873
1142
|
.run(tagId, id, tagId, user.id);
|
|
874
1143
|
const tags = tagsForProject(db, id, user.id);
|
|
875
|
-
return {
|
|
1144
|
+
return {
|
|
1145
|
+
tags,
|
|
1146
|
+
project: projectJson(accessibleProject(db, id, user), tags, commentsSummaryForProject(db, id))
|
|
1147
|
+
};
|
|
876
1148
|
});
|
|
877
1149
|
const requireGitOwner = (projectId, user) => {
|
|
1150
|
+
requireActiveUser(db, user);
|
|
878
1151
|
const project = accessibleProject(db, projectId, user);
|
|
879
1152
|
if (!project)
|
|
880
1153
|
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
@@ -887,7 +1160,9 @@ export async function buildApp(config, db, options = {}) {
|
|
|
887
1160
|
if (!user)
|
|
888
1161
|
return;
|
|
889
1162
|
const { id } = request.params;
|
|
890
|
-
return
|
|
1163
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
1164
|
+
return { status: await projectGit.status(requireGitOwner(id, user)) };
|
|
1165
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
891
1166
|
});
|
|
892
1167
|
app.put("/api/projects/:id/git/token", async (request, reply) => {
|
|
893
1168
|
const user = requireUser(request, reply, db);
|
|
@@ -897,14 +1172,19 @@ export async function buildApp(config, db, options = {}) {
|
|
|
897
1172
|
const body = request.body;
|
|
898
1173
|
if (typeof body.token !== "string")
|
|
899
1174
|
return apiError(reply, 400, "GIT_TOKEN_INVALID", "请输入 GitHub token");
|
|
900
|
-
|
|
1175
|
+
const token = body.token;
|
|
1176
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
1177
|
+
return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
|
|
1178
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
901
1179
|
});
|
|
902
1180
|
app.delete("/api/projects/:id/git/token", async (request, reply) => {
|
|
903
1181
|
const user = requireUser(request, reply, db);
|
|
904
1182
|
if (!user)
|
|
905
1183
|
return;
|
|
906
1184
|
const { id } = request.params;
|
|
907
|
-
return
|
|
1185
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
1186
|
+
return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
|
|
1187
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
908
1188
|
});
|
|
909
1189
|
app.post("/api/projects/:id/git/repository", async (request, reply) => {
|
|
910
1190
|
const user = requireUser(request, reply, db);
|
|
@@ -914,34 +1194,45 @@ export async function buildApp(config, db, options = {}) {
|
|
|
914
1194
|
const body = request.body;
|
|
915
1195
|
if (typeof body.name !== "string")
|
|
916
1196
|
return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID", "请输入 GitHub 仓库名称");
|
|
917
|
-
|
|
1197
|
+
const repositoryName = body.name.trim();
|
|
1198
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
1199
|
+
return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), repositoryName, body.private !== false) };
|
|
1200
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
918
1201
|
});
|
|
919
1202
|
app.post("/api/projects/:id/git/commit", async (request, reply) => {
|
|
920
1203
|
const user = requireUser(request, reply, db);
|
|
921
1204
|
if (!user)
|
|
922
1205
|
return;
|
|
923
1206
|
const { id } = request.params;
|
|
924
|
-
|
|
1207
|
+
if (collaboration.isMaintaining(id))
|
|
1208
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
925
1209
|
const body = request.body;
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1210
|
+
return await projectMutations.runExclusive(id, "Git commit", async () => {
|
|
1211
|
+
const project = requireGitOwner(id, user);
|
|
1212
|
+
const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
|
|
1213
|
+
return reply.code(201).send({ commit, status: await projectGit.status(project) });
|
|
1214
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
929
1215
|
});
|
|
930
1216
|
app.post("/api/projects/:id/git/push", async (request, reply) => {
|
|
931
1217
|
const user = requireUser(request, reply, db);
|
|
932
1218
|
if (!user)
|
|
933
1219
|
return;
|
|
934
1220
|
const { id } = request.params;
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
return
|
|
1221
|
+
if (collaboration.isMaintaining(id))
|
|
1222
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1223
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
1224
|
+
const project = requireGitOwner(id, user);
|
|
1225
|
+
return { status: await projectGit.push(project) };
|
|
1226
|
+
}, { flush: false, preflight: () => { requireGitOwner(id, user); } });
|
|
938
1227
|
});
|
|
939
1228
|
app.get("/api/projects/:id/git/history", async (request, reply) => {
|
|
940
1229
|
const user = requireUser(request, reply, db);
|
|
941
1230
|
if (!user)
|
|
942
1231
|
return;
|
|
943
1232
|
const { id } = request.params;
|
|
944
|
-
return
|
|
1233
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
1234
|
+
return { commits: await projectGit.history(requireGitOwner(id, user)) };
|
|
1235
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
945
1236
|
});
|
|
946
1237
|
app.get("/api/projects/:id/git/diff", async (request, reply) => {
|
|
947
1238
|
const user = requireUser(request, reply, db);
|
|
@@ -949,44 +1240,48 @@ export async function buildApp(config, db, options = {}) {
|
|
|
949
1240
|
return;
|
|
950
1241
|
const { id } = request.params;
|
|
951
1242
|
const { revision } = request.query;
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1243
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
1244
|
+
const project = requireGitOwner(id, user);
|
|
1245
|
+
return projectGit.diff(project, revision);
|
|
1246
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
955
1247
|
});
|
|
956
1248
|
app.post("/api/projects/:id/git/checkout", async (request, reply) => {
|
|
957
1249
|
const user = requireUser(request, reply, db);
|
|
958
1250
|
if (!user)
|
|
959
1251
|
return;
|
|
960
1252
|
const { id } = request.params;
|
|
961
|
-
|
|
1253
|
+
requireGitOwner(id, user);
|
|
962
1254
|
const body = request.body;
|
|
963
1255
|
if (body.revision !== null && typeof body.revision !== "string")
|
|
964
1256
|
return apiError(reply, 400, "GIT_REVISION_INVALID", "请选择要 checkout 的 Git 版本");
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1257
|
+
const revisionInput = body.revision === null ? null : body.revision;
|
|
1258
|
+
return await projectMutations.runExclusive(id, "Git checkout", async () => {
|
|
1259
|
+
const currentProject = requireGitOwner(id, user);
|
|
1260
|
+
recordHistory(id, user.id, "checkpoint");
|
|
1261
|
+
const before = projectTextSnapshot(config, id);
|
|
1262
|
+
const revision = await projectGit.checkout(currentProject, revisionInput, body.force === true);
|
|
1263
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
1264
|
+
touchProject(db, id, user.id);
|
|
1265
|
+
recordHistory(id, user.id, "git");
|
|
1266
|
+
return { revision, status: await projectGit.status(currentProject) };
|
|
1267
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
974
1268
|
});
|
|
975
1269
|
app.post("/api/projects/:id/git/discard", async (request, reply) => {
|
|
976
1270
|
const user = requireUser(request, reply, db);
|
|
977
1271
|
if (!user)
|
|
978
1272
|
return;
|
|
979
1273
|
const { id } = request.params;
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1274
|
+
requireGitOwner(id, user);
|
|
1275
|
+
return await projectMutations.runExclusive(id, "Git restore", async () => {
|
|
1276
|
+
const currentProject = requireGitOwner(id, user);
|
|
1277
|
+
recordHistory(id, user.id, "checkpoint");
|
|
1278
|
+
const before = projectTextSnapshot(config, id);
|
|
1279
|
+
await projectGit.discardChanges(currentProject);
|
|
1280
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
1281
|
+
touchProject(db, id, user.id);
|
|
1282
|
+
recordHistory(id, user.id, "git");
|
|
1283
|
+
return { status: await projectGit.status(currentProject) };
|
|
1284
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
990
1285
|
});
|
|
991
1286
|
app.get("/api/projects/:id/download", async (request, reply) => {
|
|
992
1287
|
const user = requireUser(request, reply, db);
|
|
@@ -996,12 +1291,30 @@ export async function buildApp(config, db, options = {}) {
|
|
|
996
1291
|
const project = accessibleProject(db, id, user);
|
|
997
1292
|
if (!project)
|
|
998
1293
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
999
|
-
|
|
1000
|
-
const
|
|
1294
|
+
const temporaryDirectory = path.join(config.dataDir, "tmp");
|
|
1295
|
+
const temporaryArchive = path.join(temporaryDirectory, `project-${id}-${randomUUID()}.zip`);
|
|
1296
|
+
try {
|
|
1297
|
+
await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
|
|
1298
|
+
await projectMutations.runConsistentRead(id, () => writeProjectArchive(config, id, temporaryArchive), {
|
|
1299
|
+
preflight: () => {
|
|
1300
|
+
const current = accessibleProject(db, id, user);
|
|
1301
|
+
if (!current)
|
|
1302
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1303
|
+
}
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
await fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined);
|
|
1308
|
+
throw error;
|
|
1309
|
+
}
|
|
1001
1310
|
const filename = `${project.name}.zip`;
|
|
1002
1311
|
reply.header("Content-Type", "application/zip");
|
|
1003
1312
|
reply.header("Content-Disposition", contentDisposition(filename, "attachment"));
|
|
1004
|
-
|
|
1313
|
+
const stream = fs.createReadStream(temporaryArchive);
|
|
1314
|
+
const cleanup = () => { void fs.promises.rm(temporaryArchive, { force: true }).catch(() => undefined); };
|
|
1315
|
+
stream.once("close", cleanup);
|
|
1316
|
+
stream.once("error", cleanup);
|
|
1317
|
+
return reply.send(stream);
|
|
1005
1318
|
});
|
|
1006
1319
|
app.delete("/api/projects/:id", async (request, reply) => {
|
|
1007
1320
|
const user = requireUser(request, reply, db);
|
|
@@ -1011,10 +1324,14 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1011
1324
|
const project = accessibleProject(db, id, user);
|
|
1012
1325
|
if (!project || project.permission !== "owner")
|
|
1013
1326
|
return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN", "只有项目所有者可以删除项目");
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1327
|
+
return await projectMutations.runExclusive(id, "project deletion", () => {
|
|
1328
|
+
collaboration.resetProject(id);
|
|
1329
|
+
removeProjectDirectory(config, id);
|
|
1330
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(id);
|
|
1331
|
+
latexCompletions.invalidate(id);
|
|
1332
|
+
projectOutlines.invalidate(id);
|
|
1333
|
+
return { ok: true };
|
|
1334
|
+
}, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
|
|
1018
1335
|
});
|
|
1019
1336
|
app.get("/api/projects/:id/files", async (request, reply) => {
|
|
1020
1337
|
const user = requireUser(request, reply, db);
|
|
@@ -1024,9 +1341,18 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1024
1341
|
if (!accessibleProject(db, id, user))
|
|
1025
1342
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1026
1343
|
// Directory walks can be slow on external/project-mounted storage. Keep
|
|
1027
|
-
// this request off the event loop
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1344
|
+
// this request off the event loop and unblocked by Yjs room initialization
|
|
1345
|
+
// so the file tree and retained-PDF stream can be served concurrently, but
|
|
1346
|
+
// still serialize it with source-tree mutations and collaborative flushes.
|
|
1347
|
+
return await projectMutations.runFilesystemRead(id, async () => ({
|
|
1348
|
+
files: await listProjectFilesAsync(config, id)
|
|
1349
|
+
}), {
|
|
1350
|
+
preflight: () => {
|
|
1351
|
+
if (!accessibleProject(db, id, user)) {
|
|
1352
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1030
1356
|
});
|
|
1031
1357
|
app.post("/api/projects/:id/format", async (request, reply) => {
|
|
1032
1358
|
const user = requireUser(request, reply, db);
|
|
@@ -1045,11 +1371,17 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1045
1371
|
}
|
|
1046
1372
|
if (typeof body?.source !== "string")
|
|
1047
1373
|
return apiError(reply, 400, "FORMAT_SOURCE_INVALID", "待格式化内容格式不正确");
|
|
1048
|
-
|
|
1374
|
+
const source = body.source;
|
|
1375
|
+
if (Buffer.byteLength(source, "utf8") > config.maxUploadBytes) {
|
|
1049
1376
|
return apiError(reply, 413, "FILE_TOO_LARGE", `单个文件不能超过 ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB`, { path: filePath });
|
|
1050
1377
|
}
|
|
1051
1378
|
try {
|
|
1052
|
-
return await
|
|
1379
|
+
return await projectMutations.runConsistentRead(id, () => {
|
|
1380
|
+
assertNoSourceSymlinks(config, id);
|
|
1381
|
+
return latexFormatter.format(source, sourceRoot(config, id));
|
|
1382
|
+
}, {
|
|
1383
|
+
preflight: () => { requireEditableProject(db, id, user); }
|
|
1384
|
+
});
|
|
1053
1385
|
}
|
|
1054
1386
|
catch (formatError) {
|
|
1055
1387
|
const statusCode = typeof formatError === "object" && formatError !== null && "statusCode" in formatError && typeof formatError.statusCode === "number"
|
|
@@ -1072,10 +1404,20 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1072
1404
|
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
1073
1405
|
if (!mainFile)
|
|
1074
1406
|
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
1075
|
-
collaboration.flushProject(id);
|
|
1076
1407
|
const startedAt = performance.now();
|
|
1077
1408
|
try {
|
|
1078
|
-
return
|
|
1409
|
+
return await projectMutations.runConsistentRead(id, async () => ({
|
|
1410
|
+
outline: await projectOutlines.build(id, mainFile), mainFile
|
|
1411
|
+
}), {
|
|
1412
|
+
preflight: () => {
|
|
1413
|
+
const current = accessibleProject(db, id, user);
|
|
1414
|
+
if (!current)
|
|
1415
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1416
|
+
const selected = compileMainFile(config, id, current.main_file, query.mainFile);
|
|
1417
|
+
if (!selected || selected !== mainFile)
|
|
1418
|
+
throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1079
1421
|
}
|
|
1080
1422
|
finally {
|
|
1081
1423
|
metrics.record("outline.build", performance.now() - startedAt);
|
|
@@ -1089,13 +1431,17 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1089
1431
|
if (!accessibleProject(db, id, user))
|
|
1090
1432
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1091
1433
|
const query = request.query;
|
|
1092
|
-
collaboration.flushProject(id);
|
|
1093
1434
|
const startedAt = performance.now();
|
|
1094
1435
|
try {
|
|
1095
|
-
return await searchProject(config, id, {
|
|
1436
|
+
return await projectMutations.runConsistentRead(id, () => searchProject(config, id, {
|
|
1096
1437
|
query: query.q ?? "",
|
|
1097
1438
|
caseSensitive: query.caseSensitive === "1",
|
|
1098
1439
|
wholeWord: query.wholeWord === "1"
|
|
1440
|
+
}), {
|
|
1441
|
+
preflight: () => {
|
|
1442
|
+
if (!accessibleProject(db, id, user))
|
|
1443
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1444
|
+
}
|
|
1099
1445
|
});
|
|
1100
1446
|
}
|
|
1101
1447
|
finally {
|
|
@@ -1114,23 +1460,25 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1114
1460
|
if (typeof body.query !== "string" || typeof body.replacement !== "string" || body.replacement.length > 100_000) {
|
|
1115
1461
|
return apiError(reply, 400, "SEARCH_QUERY_INVALID", "搜索或替换内容格式不正确");
|
|
1116
1462
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1463
|
+
return await projectMutations.runExclusive(id, "project-wide replace", async () => {
|
|
1464
|
+
const changed = await replaceProject(config, id, {
|
|
1465
|
+
query: body.query,
|
|
1466
|
+
caseSensitive: body.caseSensitive === true,
|
|
1467
|
+
wholeWord: body.wholeWord === true,
|
|
1468
|
+
maxFileBytes: maxCollaborativeFileBytes(config)
|
|
1469
|
+
}, body.replacement);
|
|
1470
|
+
let replacements = 0;
|
|
1471
|
+
for (const file of changed) {
|
|
1472
|
+
reanchorFileComments(db, id, file.path, file.previous, file.content);
|
|
1473
|
+
collaboration.updateFile(id, file.path, file.content, user.id);
|
|
1474
|
+
replacements += file.count;
|
|
1475
|
+
}
|
|
1476
|
+
if (changed.length) {
|
|
1477
|
+
touchProject(db, id, user.id);
|
|
1478
|
+
recordHistory(id, user.id, "file", changed.map((file) => file.path));
|
|
1479
|
+
}
|
|
1480
|
+
return { ok: true, replacements, files: changed.map((file) => file.path) };
|
|
1481
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1134
1482
|
});
|
|
1135
1483
|
app.get("/api/projects/:id/completions", async (request, reply) => {
|
|
1136
1484
|
const user = requireUser(request, reply, db);
|
|
@@ -1139,10 +1487,14 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1139
1487
|
const { id } = request.params;
|
|
1140
1488
|
if (!accessibleProject(db, id, user))
|
|
1141
1489
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1142
|
-
collaboration.flushProject(id);
|
|
1143
1490
|
const startedAt = performance.now();
|
|
1144
1491
|
try {
|
|
1145
|
-
return { index: await latexCompletions.build(id) }
|
|
1492
|
+
return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
|
|
1493
|
+
preflight: () => {
|
|
1494
|
+
if (!accessibleProject(db, id, user))
|
|
1495
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1146
1498
|
}
|
|
1147
1499
|
finally {
|
|
1148
1500
|
metrics.record("completions.build", performance.now() - startedAt);
|
|
@@ -1153,23 +1505,33 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1153
1505
|
if (!user)
|
|
1154
1506
|
return;
|
|
1155
1507
|
const { id } = request.params;
|
|
1508
|
+
if (collaboration.isMaintaining(id))
|
|
1509
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1156
1510
|
const project = accessibleProject(db, id, user);
|
|
1157
1511
|
if (!project || !canEdit(project))
|
|
1158
1512
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
1159
1513
|
const body = request.body;
|
|
1160
1514
|
const folderPath = safeRelativePath(typeof body.path === "string" ? body.path : "");
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1515
|
+
return await projectMutations.runWrite(id, () => {
|
|
1516
|
+
const absolute = resolveSourcePath(config, id, folderPath);
|
|
1517
|
+
if (fs.existsSync(absolute))
|
|
1518
|
+
return apiError(reply, 409, "PATH_EXISTS", "同名文件或目录已存在", { path: folderPath });
|
|
1519
|
+
fs.mkdirSync(absolute, { recursive: true, mode: 0o700 });
|
|
1520
|
+
touchProject(db, id, user.id);
|
|
1521
|
+
// Empty folders have no file entry to trigger a refresh on their own.
|
|
1522
|
+
// Publish a source-tree event so other open sessions see the folder
|
|
1523
|
+
// immediately instead of waiting for a later file operation.
|
|
1524
|
+
collaboration.invalidateSourceTree(id, folderPath);
|
|
1525
|
+
return reply.code(201).send({ ok: true, path: folderPath });
|
|
1526
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1167
1527
|
});
|
|
1168
1528
|
app.patch("/api/projects/:id/path", async (request, reply) => {
|
|
1169
1529
|
const user = requireUser(request, reply, db);
|
|
1170
1530
|
if (!user)
|
|
1171
1531
|
return;
|
|
1172
1532
|
const { id } = request.params;
|
|
1533
|
+
if (collaboration.isMaintaining(id))
|
|
1534
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1173
1535
|
const project = accessibleProject(db, id, user);
|
|
1174
1536
|
if (!project || !canEdit(project))
|
|
1175
1537
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
@@ -1177,11 +1539,16 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1177
1539
|
const source = safeRelativePath(typeof body.source === "string" ? body.source : "");
|
|
1178
1540
|
const destinationDirectory = body.destinationDirectory === "" ? ""
|
|
1179
1541
|
: safeRelativePath(typeof body.destinationDirectory === "string" ? body.destinationDirectory : "");
|
|
1542
|
+
const destination = destinationDirectory
|
|
1543
|
+
? `${destinationDirectory}/${path.posix.basename(source)}`
|
|
1544
|
+
: path.posix.basename(source);
|
|
1180
1545
|
const sourceAbsolute = resolveSourcePath(config, id, source);
|
|
1181
1546
|
if (!fs.existsSync(sourceAbsolute))
|
|
1182
1547
|
return apiError(reply, 404, "PATH_NOT_FOUND", "要移动的文件或目录不存在", { path: source });
|
|
1183
|
-
|
|
1184
|
-
|
|
1548
|
+
if (destination === source)
|
|
1549
|
+
return { ok: true, path: source };
|
|
1550
|
+
const initialSourceStat = fs.statSync(sourceAbsolute);
|
|
1551
|
+
if (initialSourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
|
|
1185
1552
|
return apiError(reply, 400, "MOVE_INTO_SELF", "不能把目录移动到自身内部", { path: source });
|
|
1186
1553
|
}
|
|
1187
1554
|
const destinationRoot = destinationDirectory
|
|
@@ -1190,40 +1557,59 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1190
1557
|
if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
|
|
1191
1558
|
return apiError(reply, 404, "DIRECTORY_NOT_FOUND", "目标目录不存在", { path: destinationDirectory });
|
|
1192
1559
|
}
|
|
1193
|
-
const destination = destinationDirectory
|
|
1194
|
-
? `${destinationDirectory}/${path.posix.basename(source)}`
|
|
1195
|
-
: path.posix.basename(source);
|
|
1196
|
-
if (destination === source)
|
|
1197
|
-
return { ok: true, path: source };
|
|
1198
1560
|
const destinationAbsolute = resolveSourcePath(config, id, destination);
|
|
1199
1561
|
if (fs.existsSync(destinationAbsolute))
|
|
1200
1562
|
return apiError(reply, 409, "PATH_EXISTS", "目标目录中存在同名文件或目录", { path: destination });
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const changedAt = now();
|
|
1208
|
-
db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
|
|
1209
|
-
.run(mainFile, latexmkrc, changedAt, user.id, id);
|
|
1210
|
-
const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
|
|
1211
|
-
const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
|
|
1212
|
-
for (const comment of comments) {
|
|
1213
|
-
const nextPath = movedProjectPath(comment.file_path, source, destination);
|
|
1214
|
-
if (nextPath !== comment.file_path)
|
|
1215
|
-
updateComment.run(nextPath, changedAt, comment.id);
|
|
1563
|
+
let currentProject = project;
|
|
1564
|
+
const validateMove = () => {
|
|
1565
|
+
currentProject = requireEditableProject(db, id, user);
|
|
1566
|
+
assertNoSourceSymlinks(config, id);
|
|
1567
|
+
if (!fs.existsSync(sourceAbsolute)) {
|
|
1568
|
+
throw Object.assign(new Error("要移动的文件或目录不存在"), { statusCode: 404, code: "PATH_NOT_FOUND" });
|
|
1216
1569
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
fs.
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1570
|
+
const sourceStat = fs.statSync(sourceAbsolute);
|
|
1571
|
+
if (sourceStat.isDirectory() && (destinationDirectory === source || destinationDirectory.startsWith(`${source}/`))) {
|
|
1572
|
+
throw Object.assign(new Error("不能把目录移动到自身内部"), { statusCode: 400, code: "MOVE_INTO_SELF" });
|
|
1573
|
+
}
|
|
1574
|
+
if (!fs.existsSync(destinationRoot) || !fs.statSync(destinationRoot).isDirectory()) {
|
|
1575
|
+
throw Object.assign(new Error("目标目录不存在"), { statusCode: 404, code: "DIRECTORY_NOT_FOUND" });
|
|
1576
|
+
}
|
|
1577
|
+
if (fs.existsSync(destinationAbsolute)) {
|
|
1578
|
+
throw Object.assign(new Error("目标目录中存在同名文件或目录"), { statusCode: 409, code: "PATH_EXISTS" });
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
return await projectMutations.runExclusive(id, "move project path", () => {
|
|
1582
|
+
fs.renameSync(sourceAbsolute, destinationAbsolute);
|
|
1583
|
+
let hasCommentUpdates = false;
|
|
1584
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1585
|
+
try {
|
|
1586
|
+
const mainFile = movedProjectPath(currentProject.main_file, source, destination);
|
|
1587
|
+
const latexmkrc = movedProjectPath(currentProject.latexmkrc, source, destination);
|
|
1588
|
+
const changedAt = now();
|
|
1589
|
+
db.prepare(`UPDATE projects SET main_file = ?, latexmkrc = ?, updated_at = ?, last_modified_by = ? WHERE id = ?`)
|
|
1590
|
+
.run(mainFile, latexmkrc, changedAt, user.id, id);
|
|
1591
|
+
const comments = db.prepare("SELECT id, file_path FROM comments WHERE project_id = ?").all(id);
|
|
1592
|
+
const updateComment = db.prepare("UPDATE comments SET file_path = ?, updated_at = ? WHERE id = ?");
|
|
1593
|
+
for (const comment of comments) {
|
|
1594
|
+
const nextPath = movedProjectPath(comment.file_path, source, destination);
|
|
1595
|
+
if (nextPath !== comment.file_path) {
|
|
1596
|
+
updateComment.run(nextPath, changedAt, comment.id);
|
|
1597
|
+
hasCommentUpdates = true;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
db.exec("COMMIT");
|
|
1601
|
+
}
|
|
1602
|
+
catch (error) {
|
|
1603
|
+
db.exec("ROLLBACK");
|
|
1604
|
+
fs.renameSync(destinationAbsolute, sourceAbsolute);
|
|
1605
|
+
throw error;
|
|
1606
|
+
}
|
|
1607
|
+
collaboration.movePath(id, source, destination, user.id);
|
|
1608
|
+
if (hasCommentUpdates)
|
|
1609
|
+
collaboration.signalComments(id);
|
|
1610
|
+
recordHistory(id, user.id, "file", [source, destination]);
|
|
1611
|
+
return { ok: true, path: destination };
|
|
1612
|
+
}, { preflight: validateMove });
|
|
1227
1613
|
});
|
|
1228
1614
|
app.get("/api/projects/:id/file/raw", async (request, reply) => {
|
|
1229
1615
|
const user = requireUser(request, reply, db);
|
|
@@ -1234,9 +1620,25 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1234
1620
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1235
1621
|
const query = request.query;
|
|
1236
1622
|
const filePath = safeRelativePath(query.path ?? "");
|
|
1237
|
-
const
|
|
1238
|
-
|
|
1239
|
-
|
|
1623
|
+
const temporaryDirectory = path.join(config.dataDir, "tmp");
|
|
1624
|
+
const temporaryFile = path.join(temporaryDirectory, `file-${id}-${randomUUID()}`);
|
|
1625
|
+
try {
|
|
1626
|
+
await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
|
|
1627
|
+
await projectMutations.runConsistentRead(id, async () => {
|
|
1628
|
+
const absolute = resolveSourcePath(config, id, filePath);
|
|
1629
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
1630
|
+
throw Object.assign(new Error("文件不存在"), { statusCode: 404, code: "FILE_NOT_FOUND" });
|
|
1631
|
+
}
|
|
1632
|
+
await fs.promises.copyFile(absolute, temporaryFile);
|
|
1633
|
+
}, { preflight: () => {
|
|
1634
|
+
if (!accessibleProject(db, id, user))
|
|
1635
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1636
|
+
} });
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
|
|
1640
|
+
throw error;
|
|
1641
|
+
}
|
|
1240
1642
|
const extension = path.extname(filePath).toLocaleLowerCase();
|
|
1241
1643
|
const contentTypes = {
|
|
1242
1644
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
|
|
@@ -1248,8 +1650,12 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1248
1650
|
reply.header("Content-Type", contentType);
|
|
1249
1651
|
reply.header("Content-Disposition", contentDisposition(path.basename(filePath), downloading ? "attachment" : "inline"));
|
|
1250
1652
|
reply.header("Cache-Control", "private, no-cache");
|
|
1251
|
-
reply.header("Content-Length", fs.statSync(
|
|
1252
|
-
|
|
1653
|
+
reply.header("Content-Length", fs.statSync(temporaryFile).size);
|
|
1654
|
+
const stream = fs.createReadStream(temporaryFile);
|
|
1655
|
+
const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
|
|
1656
|
+
stream.once("close", cleanup);
|
|
1657
|
+
stream.once("error", cleanup);
|
|
1658
|
+
return reply.send(stream);
|
|
1253
1659
|
});
|
|
1254
1660
|
app.get("/api/projects/:id/file", async (request, reply) => {
|
|
1255
1661
|
const user = requireUser(request, reply, db);
|
|
@@ -1259,12 +1665,19 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1259
1665
|
const { path: filePath } = request.query;
|
|
1260
1666
|
if (!accessibleProject(db, id, user))
|
|
1261
1667
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1668
|
+
const relative = safeRelativePath(filePath ?? "");
|
|
1669
|
+
return await projectMutations.runConsistentRead(id, () => {
|
|
1670
|
+
const absolute = resolveSourcePath(config, id, relative);
|
|
1671
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile())
|
|
1672
|
+
return apiError(reply, 404, "FILE_NOT_FOUND", "文件不存在", { path: relative });
|
|
1673
|
+
if (isCollaborativeTextFile(relative) && fs.statSync(absolute).size > maxCollaborativeFileBytes(config)) {
|
|
1674
|
+
return apiError(reply, 413, "FILE_TOO_LARGE", "协作文本文件过大,不能作为编辑器内容打开", { path: relative });
|
|
1675
|
+
}
|
|
1676
|
+
return { path: relative, content: fs.readFileSync(absolute, "utf8") };
|
|
1677
|
+
}, { preflight: () => {
|
|
1678
|
+
if (!accessibleProject(db, id, user))
|
|
1679
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
1680
|
+
} });
|
|
1268
1681
|
});
|
|
1269
1682
|
// Creating a file is intentionally separate from updating one. The editor
|
|
1270
1683
|
// uses PUT for autosaves, but a user action such as "New file" must never
|
|
@@ -1274,91 +1687,122 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1274
1687
|
if (!user)
|
|
1275
1688
|
return;
|
|
1276
1689
|
const { id } = request.params;
|
|
1690
|
+
if (collaboration.isMaintaining(id))
|
|
1691
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1277
1692
|
const project = accessibleProject(db, id, user);
|
|
1278
1693
|
if (!project || !canEdit(project))
|
|
1279
1694
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
1280
1695
|
const body = request.body;
|
|
1281
1696
|
const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
|
|
1282
1697
|
if (typeof body.content !== "string")
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1698
|
+
return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
|
|
1699
|
+
const content = body.content;
|
|
1700
|
+
const byteLength = Buffer.byteLength(content, "utf8");
|
|
1701
|
+
const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
|
|
1702
|
+
if (byteLength > limit) {
|
|
1703
|
+
return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
|
|
1704
|
+
}
|
|
1705
|
+
return await projectMutations.runWrite(id, () => {
|
|
1706
|
+
const absolute = resolveSourcePath(config, id, filePath);
|
|
1707
|
+
try {
|
|
1708
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
|
|
1294
1709
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
catch (error) {
|
|
1301
|
-
if (error.code === "EEXIST") {
|
|
1302
|
-
return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
|
|
1712
|
+
return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: filePath });
|
|
1713
|
+
}
|
|
1714
|
+
throw error;
|
|
1303
1715
|
}
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1716
|
+
try {
|
|
1717
|
+
fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
1718
|
+
}
|
|
1719
|
+
catch (error) {
|
|
1720
|
+
if (error.code === "EEXIST") {
|
|
1721
|
+
return apiError(reply, 409, "FILE_EXISTS", "同名文件或目录已存在", { path: filePath });
|
|
1722
|
+
}
|
|
1723
|
+
throw error;
|
|
1724
|
+
}
|
|
1725
|
+
touchProject(db, id, user.id);
|
|
1726
|
+
collaboration.updateFile(id, filePath, content, user.id);
|
|
1727
|
+
recordHistory(id, user.id, "file", [filePath]);
|
|
1728
|
+
return reply.code(201).send({ ok: true, path: filePath, comments: commentsForFile(db, config, id, filePath) });
|
|
1729
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1310
1730
|
});
|
|
1311
1731
|
app.put("/api/projects/:id/file", async (request, reply) => {
|
|
1312
1732
|
const user = requireUser(request, reply, db);
|
|
1313
1733
|
if (!user)
|
|
1314
1734
|
return;
|
|
1315
1735
|
const { id } = request.params;
|
|
1736
|
+
if (collaboration.isMaintaining(id))
|
|
1737
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1316
1738
|
const project = accessibleProject(db, id, user);
|
|
1317
1739
|
if (!project || !canEdit(project))
|
|
1318
1740
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
1319
1741
|
const body = request.body;
|
|
1320
1742
|
const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
|
|
1321
1743
|
if (typeof body.content !== "string")
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1744
|
+
return apiError(reply, 400, "FILE_CONTENT_INVALID", "文件内容格式不正确");
|
|
1745
|
+
const content = body.content;
|
|
1746
|
+
const byteLength = Buffer.byteLength(content, "utf8");
|
|
1747
|
+
const limit = isCollaborativeTextFile(filePath) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
|
|
1748
|
+
if (byteLength > limit) {
|
|
1749
|
+
return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(limit / 1024 / 1024)} MB`, { path: filePath, size: Math.floor(limit / 1024 / 1024) });
|
|
1750
|
+
}
|
|
1751
|
+
return await projectMutations.runWrite(id, () => {
|
|
1752
|
+
const absolute = resolveSourcePath(config, id, filePath);
|
|
1753
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
|
|
1754
|
+
const previousContent = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
|
|
1755
|
+
reanchorFileComments(db, id, filePath, previousContent, content);
|
|
1756
|
+
fs.writeFileSync(absolute, content, { encoding: "utf8", mode: 0o600 });
|
|
1757
|
+
touchProject(db, id, user.id);
|
|
1758
|
+
collaboration.updateFile(id, filePath, content, user.id);
|
|
1759
|
+
recordHistory(id, user.id, "file", [filePath]);
|
|
1760
|
+
return { ok: true, comments: commentsForFile(db, config, id, filePath) };
|
|
1761
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1335
1762
|
});
|
|
1336
1763
|
app.delete("/api/projects/:id/file", async (request, reply) => {
|
|
1337
1764
|
const user = requireUser(request, reply, db);
|
|
1338
1765
|
if (!user)
|
|
1339
1766
|
return;
|
|
1340
1767
|
const { id } = request.params;
|
|
1768
|
+
if (collaboration.isMaintaining(id))
|
|
1769
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1341
1770
|
const { path: filePath } = request.query;
|
|
1771
|
+
const relative = safeRelativePath(filePath ?? "");
|
|
1342
1772
|
const project = accessibleProject(db, id, user);
|
|
1343
1773
|
if (!project || !canEdit(project))
|
|
1344
1774
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1775
|
+
return await projectMutations.runWrite(id, () => {
|
|
1776
|
+
const currentProject = requireEditableProject(db, id, user);
|
|
1777
|
+
if (relative === currentProject.main_file || currentProject.main_file.startsWith(`${relative}/`)) {
|
|
1778
|
+
return apiError(reply, 400, "MAIN_FILE_DELETE_FORBIDDEN", "不能删除当前主文件或包含主文件的目录", { path: relative });
|
|
1779
|
+
}
|
|
1780
|
+
const absolute = resolveSourcePath(config, id, relative, { allowFinalSymlink: true });
|
|
1781
|
+
try {
|
|
1782
|
+
fs.lstatSync(absolute);
|
|
1783
|
+
}
|
|
1784
|
+
catch (error) {
|
|
1785
|
+
if (error.code === "ENOENT")
|
|
1786
|
+
return apiError(reply, 404, "PATH_NOT_FOUND", "文件或目录不存在", { path: relative });
|
|
1787
|
+
throw error;
|
|
1788
|
+
}
|
|
1789
|
+
fs.rmSync(absolute, { recursive: true, force: true });
|
|
1790
|
+
const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
|
|
1791
|
+
touchProject(db, id, user.id);
|
|
1792
|
+
collaboration.removePath(id, relative);
|
|
1793
|
+
if (deleteResult.changes > 0)
|
|
1794
|
+
collaboration.signalComments(id);
|
|
1795
|
+
recordHistory(id, user.id, "file", [relative]);
|
|
1796
|
+
return { ok: true };
|
|
1797
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1356
1798
|
});
|
|
1357
1799
|
app.post("/api/projects/:id/upload", async (request, reply) => {
|
|
1358
1800
|
const user = requireUser(request, reply, db);
|
|
1359
1801
|
if (!user)
|
|
1360
1802
|
return;
|
|
1361
1803
|
const { id } = request.params;
|
|
1804
|
+
if (collaboration.isMaintaining(id))
|
|
1805
|
+
return apiError(reply, 409, "PROJECT_BUSY", "项目正在执行源文件操作,请稍后重试");
|
|
1362
1806
|
const project = accessibleProject(db, id, user);
|
|
1363
1807
|
if (!project || !canEdit(project))
|
|
1364
1808
|
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN", "没有编辑权限");
|
|
@@ -1367,34 +1811,98 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1367
1811
|
return apiError(reply, 400, "UPLOAD_EMPTY", "没有收到上传文件");
|
|
1368
1812
|
const { directory, overwrite } = request.query;
|
|
1369
1813
|
const relative = safeRelativePath(directory ? `${safeRelativePath(directory)}/${part.filename}` : part.filename);
|
|
1370
|
-
const
|
|
1814
|
+
const uploadLimit = isCollaborativeTextFile(relative) ? maxCollaborativeFileBytes(config) : config.maxUploadBytes;
|
|
1815
|
+
const tmpDir = path.join(config.dataDir, "tmp");
|
|
1816
|
+
fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
|
|
1817
|
+
const tmpPath = path.join(tmpDir, `upload-${randomUUID()}.tmp`);
|
|
1818
|
+
let byteLength = 0;
|
|
1371
1819
|
try {
|
|
1372
|
-
|
|
1820
|
+
const limiter = new Transform({
|
|
1821
|
+
transform(chunk, _encoding, callback) {
|
|
1822
|
+
byteLength += chunk.length;
|
|
1823
|
+
if (byteLength > uploadLimit) {
|
|
1824
|
+
callback(new Error("FILE_TOO_LARGE"));
|
|
1825
|
+
}
|
|
1826
|
+
else {
|
|
1827
|
+
callback(null, chunk);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
});
|
|
1831
|
+
await pipeline(part.file, limiter, fs.createWriteStream(tmpPath, { mode: 0o600 }));
|
|
1373
1832
|
}
|
|
1374
1833
|
catch (error) {
|
|
1375
|
-
if (
|
|
1376
|
-
|
|
1834
|
+
if (fs.existsSync(tmpPath)) {
|
|
1835
|
+
try {
|
|
1836
|
+
fs.unlinkSync(tmpPath);
|
|
1837
|
+
}
|
|
1838
|
+
catch { /* ignore */ }
|
|
1839
|
+
}
|
|
1840
|
+
if (error instanceof Error && error.message === "FILE_TOO_LARGE") {
|
|
1841
|
+
return apiError(reply, 413, "FILE_TOO_LARGE", `该文件不能超过 ${Math.floor(uploadLimit / 1024 / 1024)} MB`, { path: relative, size: Math.floor(uploadLimit / 1024 / 1024) });
|
|
1377
1842
|
}
|
|
1378
1843
|
throw error;
|
|
1379
1844
|
}
|
|
1380
|
-
const uploaded = await part.toBuffer();
|
|
1381
|
-
const replacing = overwrite === "1";
|
|
1382
|
-
if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
|
|
1383
|
-
return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
|
|
1384
|
-
}
|
|
1385
1845
|
try {
|
|
1386
|
-
|
|
1846
|
+
return await projectMutations.runWrite(id, () => {
|
|
1847
|
+
const absolute = resolveSourcePath(config, id, relative);
|
|
1848
|
+
try {
|
|
1849
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
|
|
1850
|
+
}
|
|
1851
|
+
catch (error) {
|
|
1852
|
+
if (["EEXIST", "ENOTDIR"].includes(error.code ?? "")) {
|
|
1853
|
+
return apiError(reply, 409, "PATH_EXISTS", "目标路径中的目录部分已被文件占用", { path: relative });
|
|
1854
|
+
}
|
|
1855
|
+
throw error;
|
|
1856
|
+
}
|
|
1857
|
+
const replacing = overwrite === "1";
|
|
1858
|
+
if (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory()) {
|
|
1859
|
+
return apiError(reply, 409, "PATH_EXISTS", "目标路径是一个目录", { path: relative });
|
|
1860
|
+
}
|
|
1861
|
+
if (!replacing && fs.existsSync(absolute)) {
|
|
1862
|
+
return apiError(reply, 409, "FILE_EXISTS", "同名文件已存在", { path: relative });
|
|
1863
|
+
}
|
|
1864
|
+
const collaborativeText = isCollaborativeTextFile(relative);
|
|
1865
|
+
let previousContent = null;
|
|
1866
|
+
if (replacing && collaborativeText && fs.existsSync(absolute)) {
|
|
1867
|
+
// runWrite() has flushed any live Yjs edits before entering this
|
|
1868
|
+
// callback. Prefer the room's text so comment anchors are based on
|
|
1869
|
+
// exactly what collaborators last saved, then fall back to disk for
|
|
1870
|
+
// projects without an active collaboration room.
|
|
1871
|
+
previousContent = collaboration.fileContent(id, relative);
|
|
1872
|
+
if (previousContent === null)
|
|
1873
|
+
previousContent = fs.readFileSync(absolute, "utf8");
|
|
1874
|
+
}
|
|
1875
|
+
try {
|
|
1876
|
+
fs.renameSync(tmpPath, absolute);
|
|
1877
|
+
}
|
|
1878
|
+
catch {
|
|
1879
|
+
fs.copyFileSync(tmpPath, absolute);
|
|
1880
|
+
fs.unlinkSync(tmpPath);
|
|
1881
|
+
}
|
|
1882
|
+
touchProject(db, id, user.id);
|
|
1883
|
+
if (collaborativeText) {
|
|
1884
|
+
const content = fs.readFileSync(absolute, "utf8");
|
|
1885
|
+
if (previousContent !== null && previousContent !== content) {
|
|
1886
|
+
reanchorFileComments(db, id, relative, previousContent, content);
|
|
1887
|
+
collaboration.signalComments(id);
|
|
1888
|
+
}
|
|
1889
|
+
collaboration.updateFile(id, relative, content, user.id);
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
collaboration.invalidateSourceTree(id, relative);
|
|
1893
|
+
}
|
|
1894
|
+
recordHistory(id, user.id, "file", [relative]);
|
|
1895
|
+
return reply.code(201).send({ ok: true, path: relative });
|
|
1896
|
+
}, { preflight: () => { requireEditableProject(db, id, user); } });
|
|
1387
1897
|
}
|
|
1388
|
-
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
1898
|
+
finally {
|
|
1899
|
+
if (fs.existsSync(tmpPath)) {
|
|
1900
|
+
try {
|
|
1901
|
+
fs.unlinkSync(tmpPath);
|
|
1902
|
+
}
|
|
1903
|
+
catch { /* ignore */ }
|
|
1391
1904
|
}
|
|
1392
|
-
throw error;
|
|
1393
1905
|
}
|
|
1394
|
-
touchProject(db, id, user.id);
|
|
1395
|
-
collaboration.updateFile(id, relative, uploaded.toString("utf8"), user.id);
|
|
1396
|
-
recordHistory(id, user.id, "file", [relative]);
|
|
1397
|
-
return reply.code(201).send({ ok: true, path: relative });
|
|
1398
1906
|
});
|
|
1399
1907
|
app.get("/api/projects/:id/members", async (request, reply) => {
|
|
1400
1908
|
const user = requireUser(request, reply, db);
|
|
@@ -1426,34 +1934,44 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1426
1934
|
const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
|
|
1427
1935
|
if (!target)
|
|
1428
1936
|
return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
db.
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
.
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1937
|
+
return await projectMutations.runExclusive(id, "project transfer", () => {
|
|
1938
|
+
const currentTarget = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
|
|
1939
|
+
// The synchronous preflight immediately precedes maintenance, so this
|
|
1940
|
+
// lookup cannot change before the operation starts.
|
|
1941
|
+
if (!currentTarget)
|
|
1942
|
+
throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
1943
|
+
const changedAt = now();
|
|
1944
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1945
|
+
try {
|
|
1946
|
+
// The new owner may already be a shared member. The previous owner keeps
|
|
1947
|
+
// edit access so a transfer does not unexpectedly lock them out.
|
|
1948
|
+
db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, currentTarget.id);
|
|
1949
|
+
db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
|
|
1950
|
+
VALUES (?, ?, 'edit', ?)
|
|
1951
|
+
ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
|
|
1952
|
+
.run(id, user.id, changedAt);
|
|
1953
|
+
db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
|
|
1954
|
+
.run(currentTarget.id, user.id, changedAt, id);
|
|
1955
|
+
// A project token belongs to the account that supplied it. Preserve the
|
|
1956
|
+
// local repository/remote metadata, but require the new owner to add
|
|
1957
|
+
// their own credential before the next GitHub operation.
|
|
1958
|
+
db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
|
|
1959
|
+
.run(changedAt, id);
|
|
1960
|
+
db.exec("COMMIT");
|
|
1961
|
+
}
|
|
1962
|
+
catch (error) {
|
|
1963
|
+
db.exec("ROLLBACK");
|
|
1964
|
+
throw error;
|
|
1965
|
+
}
|
|
1966
|
+
return {
|
|
1967
|
+
project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
1968
|
+
};
|
|
1969
|
+
}, { preflight: () => {
|
|
1970
|
+
requireActualProjectOwner(db, id, user);
|
|
1971
|
+
if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(body.userId)) {
|
|
1972
|
+
throw Object.assign(new Error("用户不存在或已被禁用"), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
1973
|
+
}
|
|
1974
|
+
} });
|
|
1457
1975
|
});
|
|
1458
1976
|
app.put("/api/projects/:id/members/:userId", async (request, reply) => {
|
|
1459
1977
|
const user = requireUser(request, reply, db);
|
|
@@ -1465,12 +1983,16 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1465
1983
|
return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
|
|
1466
1984
|
if (userId === project.owner_id)
|
|
1467
1985
|
return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN", "项目所有者不能作为成员添加");
|
|
1986
|
+
if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(userId)) {
|
|
1987
|
+
return apiError(reply, 404, "USER_NOT_FOUND", "用户不存在或已被禁用");
|
|
1988
|
+
}
|
|
1468
1989
|
const body = request.body;
|
|
1469
1990
|
const permission = body.permission === "edit" ? "edit" : "read";
|
|
1470
1991
|
db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
|
|
1471
1992
|
ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
|
|
1472
1993
|
.run(id, userId, permission, now());
|
|
1473
1994
|
touchProject(db, id, user.id);
|
|
1995
|
+
collaboration.notifyPermissionChanged(id, userId, permission);
|
|
1474
1996
|
return { ok: true };
|
|
1475
1997
|
});
|
|
1476
1998
|
app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
|
|
@@ -1483,6 +2005,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1483
2005
|
return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN", "只有项目所有者可以管理成员");
|
|
1484
2006
|
db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
|
|
1485
2007
|
touchProject(db, id, user.id);
|
|
2008
|
+
collaboration.notifyPermissionChanged(id, userId, "revoked");
|
|
1486
2009
|
return { ok: true };
|
|
1487
2010
|
});
|
|
1488
2011
|
app.get("/api/projects/:id/comments", async (request, reply) => {
|
|
@@ -1494,7 +2017,14 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1494
2017
|
if (!accessibleProject(db, id, user))
|
|
1495
2018
|
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
1496
2019
|
const relative = safeRelativePath(filePath ?? "");
|
|
1497
|
-
return {
|
|
2020
|
+
return {
|
|
2021
|
+
comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
|
|
2022
|
+
preflight: () => {
|
|
2023
|
+
if (!accessibleProject(db, id, user))
|
|
2024
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
2025
|
+
}
|
|
2026
|
+
})
|
|
2027
|
+
};
|
|
1498
2028
|
});
|
|
1499
2029
|
app.post("/api/projects/:id/comments", async (request, reply) => {
|
|
1500
2030
|
const user = requireUser(request, reply, db);
|
|
@@ -1506,27 +2036,31 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1506
2036
|
const body = request.body;
|
|
1507
2037
|
const createdAt = now();
|
|
1508
2038
|
const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2039
|
+
return await projectMutations.runWrite(id, () => {
|
|
2040
|
+
const absolute = resolveSourcePath(config, id, filePath);
|
|
2041
|
+
if (!fs.existsSync(absolute))
|
|
2042
|
+
return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND", "批注文件不存在");
|
|
2043
|
+
const source = fs.readFileSync(absolute, "utf8");
|
|
2044
|
+
if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
|
|
2045
|
+
return apiError(reply, 400, "COMMENT_RANGE_INVALID", "批注缺少有效的源码区间");
|
|
2046
|
+
}
|
|
2047
|
+
const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
|
|
2048
|
+
const comment = {
|
|
2049
|
+
id: randomUUID(), filePath,
|
|
2050
|
+
content: text(body.content, "批注", 5000)
|
|
2051
|
+
};
|
|
2052
|
+
db.prepare(`INSERT INTO comments
|
|
2053
|
+
(id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
|
|
2054
|
+
context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
|
|
2055
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
|
|
2056
|
+
.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);
|
|
2057
|
+
const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
|
|
2058
|
+
collaboration.signalComments(id);
|
|
2059
|
+
return reply.code(201).send({ comment: created });
|
|
2060
|
+
}, { preflight: () => {
|
|
2061
|
+
if (!accessibleProject(db, id, user))
|
|
2062
|
+
throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
|
|
2063
|
+
} });
|
|
1530
2064
|
});
|
|
1531
2065
|
app.post("/api/projects/:id/comments/:commentId/replies", async (request, reply) => {
|
|
1532
2066
|
const user = requireUser(request, reply, db);
|
|
@@ -1638,7 +2172,7 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1638
2172
|
collaboration.signalComments(id);
|
|
1639
2173
|
return { ok: true };
|
|
1640
2174
|
});
|
|
1641
|
-
registerCompileRoutes(app, { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns });
|
|
2175
|
+
registerCompileRoutes(app, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
|
|
1642
2176
|
app.addHook("onRequest", async (request) => {
|
|
1643
2177
|
if (request.url.startsWith("/api/") && request.url !== "/api/auth/login" && request.url !== "/api/health" && request.url !== "/api/config") {
|
|
1644
2178
|
const token = request.cookies.texlite_session;
|
|
@@ -1657,5 +2191,20 @@ export async function buildApp(config, db, options = {}) {
|
|
|
1657
2191
|
return reply.sendFile("index.html");
|
|
1658
2192
|
});
|
|
1659
2193
|
}
|
|
2194
|
+
const cleanupExpiredSessions = () => {
|
|
2195
|
+
try {
|
|
2196
|
+
pruneExpiredSessions(db, now());
|
|
2197
|
+
loginLimiter.prune();
|
|
2198
|
+
}
|
|
2199
|
+
catch (error) {
|
|
2200
|
+
app.log.error({ err: error }, "Failed to prune expired sessions");
|
|
2201
|
+
}
|
|
2202
|
+
};
|
|
2203
|
+
// Clean up at startup as well as periodically so long-running deployments do
|
|
2204
|
+
// not retain one row per historical login indefinitely.
|
|
2205
|
+
cleanupExpiredSessions();
|
|
2206
|
+
const sessionCleanupTimer = setInterval(cleanupExpiredSessions, SESSION_CLEANUP_INTERVAL_MS);
|
|
2207
|
+
sessionCleanupTimer.unref();
|
|
2208
|
+
app.addHook("onClose", async () => { clearInterval(sessionCleanupTimer); });
|
|
1660
2209
|
return app;
|
|
1661
2210
|
}
|