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