texlite 0.7.3 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/README.zh-CN.md +9 -9
- package/dist/client/assets/{GitDialog-BmRzMv-C.js → GitDialog-W3hLOWrF.js} +2 -2
- package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
- package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
- package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-BVQb4qdD.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
- package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
- package/dist/client/assets/index-DRShM2ob.js +64 -0
- package/dist/client/assets/index-DzcH7hBu.css +1 -0
- package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
- package/dist/client/assets/minus-BfqMIeUg.js +1 -0
- package/dist/client/assets/pdf-BB1XvBIF.js +55 -0
- package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
- package/dist/client/assets/tex_fmt_bg-VEAUIuFz.wasm +0 -0
- package/dist/client/index.html +3 -3
- package/dist/server/anchors.js +22 -1
- package/dist/server/app.js +103 -2375
- package/dist/server/auth.js +3 -2
- package/dist/server/cli.js +8 -2
- package/dist/server/collaboration.js +250 -2
- 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 +4 -2
- package/texlite.config.example.json +5 -1
- package/dist/client/assets/BinaryModule-Aj1vLnwf-_qIOaefD.js +0 -4
- package/dist/client/assets/HistoryDialog-V0IkSDtJ.js +0 -1
- package/dist/client/assets/LatexEditor-CokkLLxi.js +0 -59
- package/dist/client/assets/PdfPreview-Cs1WYPsC.js +0 -1
- package/dist/client/assets/SystemMetricsDialog-DBrendnp.js +0 -1
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/binary-BQKEW09b.js +0 -1
- package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
- package/dist/client/assets/index-BS1IMOaB.js +0 -62
- package/dist/client/assets/index-Lw53KTaT.css +0 -1
- package/dist/client/assets/index-QyVe7I1s.js +0 -3456
- package/dist/client/assets/index-rBWMBTI-.js +0 -5
- package/dist/client/assets/minus-yEV4SLvG.js +0 -1
- package/dist/client/assets/pdf-DPnwNQHb.js +0 -55
- package/dist/client/assets/spellCheck-OZGa6ljx.js +0 -5
- package/dist/server/latexFormatter.js +0 -118
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { apiError, httpError } from "../http.js";
|
|
3
|
+
import { accessibleProject } from "../projects.js";
|
|
4
|
+
import { projectTextSnapshot, reanchorProjectSnapshot, requireActiveUser, touchProject } from "./projectShared.js";
|
|
5
|
+
/** Register owner-only local Git and GitHub backup routes. */
|
|
6
|
+
export function registerProjectGitRoutes(app, context) {
|
|
7
|
+
const { config, db, collaboration, projectMutations, projectGit, recordHistory } = context;
|
|
8
|
+
const requireGitOwner = (projectId, user) => {
|
|
9
|
+
requireActiveUser(db, user);
|
|
10
|
+
const project = accessibleProject(db, projectId, user);
|
|
11
|
+
if (!project)
|
|
12
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
13
|
+
if (project.owner_id !== user.id)
|
|
14
|
+
throw httpError(403, "PROJECT_OWNER_ONLY");
|
|
15
|
+
return project;
|
|
16
|
+
};
|
|
17
|
+
app.get("/api/projects/:id/git", async (request, reply) => {
|
|
18
|
+
const user = requireUser(request, reply, db);
|
|
19
|
+
if (!user)
|
|
20
|
+
return;
|
|
21
|
+
const { id } = request.params;
|
|
22
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
23
|
+
return { status: await projectGit.status(requireGitOwner(id, user)) };
|
|
24
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
25
|
+
});
|
|
26
|
+
app.put("/api/projects/:id/git/token", async (request, reply) => {
|
|
27
|
+
const user = requireUser(request, reply, db);
|
|
28
|
+
if (!user)
|
|
29
|
+
return;
|
|
30
|
+
const { id } = request.params;
|
|
31
|
+
const body = request.body;
|
|
32
|
+
if (typeof body.token !== "string")
|
|
33
|
+
return apiError(reply, 400, "GIT_TOKEN_INVALID");
|
|
34
|
+
const token = body.token;
|
|
35
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
36
|
+
return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
|
|
37
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
38
|
+
});
|
|
39
|
+
app.delete("/api/projects/:id/git/token", async (request, reply) => {
|
|
40
|
+
const user = requireUser(request, reply, db);
|
|
41
|
+
if (!user)
|
|
42
|
+
return;
|
|
43
|
+
const { id } = request.params;
|
|
44
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
45
|
+
return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
|
|
46
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
47
|
+
});
|
|
48
|
+
app.post("/api/projects/:id/git/repository", async (request, reply) => {
|
|
49
|
+
const user = requireUser(request, reply, db);
|
|
50
|
+
if (!user)
|
|
51
|
+
return;
|
|
52
|
+
const { id } = request.params;
|
|
53
|
+
const body = request.body;
|
|
54
|
+
if (typeof body.name !== "string")
|
|
55
|
+
return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID");
|
|
56
|
+
const repositoryName = body.name.trim();
|
|
57
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
58
|
+
return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), repositoryName, body.private !== false) };
|
|
59
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
60
|
+
});
|
|
61
|
+
app.post("/api/projects/:id/git/commit", async (request, reply) => {
|
|
62
|
+
const user = requireUser(request, reply, db);
|
|
63
|
+
if (!user)
|
|
64
|
+
return;
|
|
65
|
+
const { id } = request.params;
|
|
66
|
+
if (collaboration.isMaintaining(id))
|
|
67
|
+
return apiError(reply, 409, "PROJECT_BUSY");
|
|
68
|
+
const body = request.body;
|
|
69
|
+
return await projectMutations.runExclusive(id, "Git commit", async () => {
|
|
70
|
+
const project = requireGitOwner(id, user);
|
|
71
|
+
const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
|
|
72
|
+
return reply.code(201).send({ commit, status: await projectGit.status(project) });
|
|
73
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
74
|
+
});
|
|
75
|
+
app.post("/api/projects/:id/git/push", async (request, reply) => {
|
|
76
|
+
const user = requireUser(request, reply, db);
|
|
77
|
+
if (!user)
|
|
78
|
+
return;
|
|
79
|
+
const { id } = request.params;
|
|
80
|
+
if (collaboration.isMaintaining(id))
|
|
81
|
+
return apiError(reply, 409, "PROJECT_BUSY");
|
|
82
|
+
return await projectMutations.runSerialized(id, async () => {
|
|
83
|
+
const project = requireGitOwner(id, user);
|
|
84
|
+
return { status: await projectGit.push(project) };
|
|
85
|
+
}, { flush: false, preflight: () => { requireGitOwner(id, user); } });
|
|
86
|
+
});
|
|
87
|
+
app.get("/api/projects/:id/git/history", async (request, reply) => {
|
|
88
|
+
const user = requireUser(request, reply, db);
|
|
89
|
+
if (!user)
|
|
90
|
+
return;
|
|
91
|
+
const { id } = request.params;
|
|
92
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
93
|
+
return { commits: await projectGit.history(requireGitOwner(id, user)) };
|
|
94
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
95
|
+
});
|
|
96
|
+
app.get("/api/projects/:id/git/diff", async (request, reply) => {
|
|
97
|
+
const user = requireUser(request, reply, db);
|
|
98
|
+
if (!user)
|
|
99
|
+
return;
|
|
100
|
+
const { id } = request.params;
|
|
101
|
+
const { revision } = request.query;
|
|
102
|
+
return await projectMutations.runConsistentRead(id, async () => {
|
|
103
|
+
const project = requireGitOwner(id, user);
|
|
104
|
+
return projectGit.diff(project, revision);
|
|
105
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
106
|
+
});
|
|
107
|
+
app.post("/api/projects/:id/git/checkout", async (request, reply) => {
|
|
108
|
+
const user = requireUser(request, reply, db);
|
|
109
|
+
if (!user)
|
|
110
|
+
return;
|
|
111
|
+
const { id } = request.params;
|
|
112
|
+
requireGitOwner(id, user);
|
|
113
|
+
const body = request.body;
|
|
114
|
+
if (body.revision !== null && typeof body.revision !== "string")
|
|
115
|
+
return apiError(reply, 400, "GIT_REVISION_INVALID");
|
|
116
|
+
const revisionInput = body.revision === null ? null : body.revision;
|
|
117
|
+
return await projectMutations.runExclusive(id, "Git checkout", async () => {
|
|
118
|
+
const currentProject = requireGitOwner(id, user);
|
|
119
|
+
recordHistory(id, user.id, "checkpoint");
|
|
120
|
+
const before = projectTextSnapshot(config, id);
|
|
121
|
+
const revision = await projectGit.checkout(currentProject, revisionInput, body.force === true);
|
|
122
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
123
|
+
touchProject(db, id, user.id);
|
|
124
|
+
recordHistory(id, user.id, "git");
|
|
125
|
+
return { revision, status: await projectGit.status(currentProject) };
|
|
126
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
127
|
+
});
|
|
128
|
+
app.post("/api/projects/:id/git/discard", async (request, reply) => {
|
|
129
|
+
const user = requireUser(request, reply, db);
|
|
130
|
+
if (!user)
|
|
131
|
+
return;
|
|
132
|
+
const { id } = request.params;
|
|
133
|
+
requireGitOwner(id, user);
|
|
134
|
+
return await projectMutations.runExclusive(id, "Git restore", async () => {
|
|
135
|
+
const currentProject = requireGitOwner(id, user);
|
|
136
|
+
recordHistory(id, user.id, "checkpoint");
|
|
137
|
+
const before = projectTextSnapshot(config, id);
|
|
138
|
+
await projectGit.discardChanges(currentProject);
|
|
139
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
140
|
+
touchProject(db, id, user.id);
|
|
141
|
+
recordHistory(id, user.id, "git");
|
|
142
|
+
return { status: await projectGit.status(currentProject) };
|
|
143
|
+
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { requireUser } from "../auth.js";
|
|
3
|
+
import { resolveSourcePath, safeRelativePath } from "../files.js";
|
|
4
|
+
import { apiError, httpError } from "../http.js";
|
|
5
|
+
import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
|
|
6
|
+
import { accessibleProject, canEdit } from "../projects.js";
|
|
7
|
+
import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
|
|
8
|
+
/** Register retained project version listing, inspection, restoration, and cleanup routes. */
|
|
9
|
+
export function registerProjectHistoryRoutes(app, context) {
|
|
10
|
+
const { config, db, history, projectMutations, recordHistory } = context;
|
|
11
|
+
app.get("/api/projects/:id/history", async (request, reply) => {
|
|
12
|
+
const user = requireUser(request, reply, db);
|
|
13
|
+
if (!user)
|
|
14
|
+
return;
|
|
15
|
+
const { id } = request.params;
|
|
16
|
+
const project = accessibleProject(db, id, user);
|
|
17
|
+
if (!project)
|
|
18
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
19
|
+
const { limit: limitInput } = request.query;
|
|
20
|
+
const limit = Number.parseInt(limitInput ?? "100", 10);
|
|
21
|
+
return {
|
|
22
|
+
versions: history.list(id, Number.isFinite(limit) ? limit : 100),
|
|
23
|
+
stats: project.permission === "owner" ? history.stats(id) : null
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
app.get("/api/projects/:id/history/:versionId", async (request, reply) => {
|
|
27
|
+
const user = requireUser(request, reply, db);
|
|
28
|
+
if (!user)
|
|
29
|
+
return;
|
|
30
|
+
const { id, versionId } = request.params;
|
|
31
|
+
if (!accessibleProject(db, id, user))
|
|
32
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
33
|
+
const version = history.version(versionId, id);
|
|
34
|
+
const manifest = history.manifest(id, versionId);
|
|
35
|
+
if (!version || !manifest)
|
|
36
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
37
|
+
return {
|
|
38
|
+
version,
|
|
39
|
+
settings: manifest.settings,
|
|
40
|
+
files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
|
|
44
|
+
const user = requireUser(request, reply, db);
|
|
45
|
+
if (!user)
|
|
46
|
+
return;
|
|
47
|
+
const { id, versionId } = request.params;
|
|
48
|
+
if (!accessibleProject(db, id, user))
|
|
49
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
50
|
+
const query = request.query;
|
|
51
|
+
const filePath = safeRelativePath(query.path ?? "");
|
|
52
|
+
return await projectMutations.runConsistentRead(id, () => {
|
|
53
|
+
const historical = history.readTextFile(id, versionId, filePath);
|
|
54
|
+
if (historical === null)
|
|
55
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
56
|
+
let comparison = "";
|
|
57
|
+
if (query.against) {
|
|
58
|
+
comparison = history.readTextFile(id, query.against, filePath) ?? "";
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const current = resolveSourcePath(config, id, filePath);
|
|
62
|
+
if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= MAX_TEXT_PREVIEW_BYTES) {
|
|
63
|
+
comparison = fs.readFileSync(current, "utf8");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { path: filePath, historical, comparison, against: query.against ?? "current" };
|
|
67
|
+
}, { preflight: () => {
|
|
68
|
+
if (!accessibleProject(db, id, user))
|
|
69
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
70
|
+
} });
|
|
71
|
+
});
|
|
72
|
+
app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
|
|
73
|
+
const user = requireUser(request, reply, db);
|
|
74
|
+
if (!user)
|
|
75
|
+
return;
|
|
76
|
+
const { id, versionId } = request.params;
|
|
77
|
+
const project = accessibleProject(db, id, user);
|
|
78
|
+
if (!project || !canEdit(project))
|
|
79
|
+
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
|
|
80
|
+
const body = request.body;
|
|
81
|
+
const label = body.label === null || body.label === "" ? null : text(body.label, 80);
|
|
82
|
+
const version = history.setLabel(id, versionId, label);
|
|
83
|
+
if (!version)
|
|
84
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
85
|
+
return { version, stats: project.permission === "owner" ? history.stats(id) : null };
|
|
86
|
+
});
|
|
87
|
+
app.delete("/api/projects/:id/history/:versionId", async (request, reply) => {
|
|
88
|
+
const user = requireUser(request, reply, db);
|
|
89
|
+
if (!user)
|
|
90
|
+
return;
|
|
91
|
+
const { id, versionId } = request.params;
|
|
92
|
+
const project = accessibleProject(db, id, user);
|
|
93
|
+
if (!project || project.permission !== "owner")
|
|
94
|
+
return apiError(reply, 403, "PROJECT_OWNER_ONLY");
|
|
95
|
+
return await projectMutations.runWrite(id, () => {
|
|
96
|
+
if (!history.deleteVersion(id, versionId))
|
|
97
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
98
|
+
return { ok: true, stats: history.stats(id) };
|
|
99
|
+
}, { preflight: () => {
|
|
100
|
+
requireProjectOwnerPermission(db, id, user);
|
|
101
|
+
if (!history.version(versionId, id)) {
|
|
102
|
+
throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
|
|
103
|
+
}
|
|
104
|
+
} });
|
|
105
|
+
});
|
|
106
|
+
app.delete("/api/projects/:id/history", async (request, reply) => {
|
|
107
|
+
const user = requireUser(request, reply, db);
|
|
108
|
+
if (!user)
|
|
109
|
+
return;
|
|
110
|
+
const { id } = request.params;
|
|
111
|
+
const project = accessibleProject(db, id, user);
|
|
112
|
+
if (!project || project.permission !== "owner")
|
|
113
|
+
return apiError(reply, 403, "PROJECT_OWNER_ONLY");
|
|
114
|
+
return await projectMutations.runWrite(id, () => {
|
|
115
|
+
history.clear(id);
|
|
116
|
+
return { ok: true, stats: history.stats(id) };
|
|
117
|
+
}, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
|
|
118
|
+
});
|
|
119
|
+
app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
|
|
120
|
+
const user = requireUser(request, reply, db);
|
|
121
|
+
if (!user)
|
|
122
|
+
return;
|
|
123
|
+
const { id, versionId } = request.params;
|
|
124
|
+
const project = accessibleProject(db, id, user);
|
|
125
|
+
if (!project || !canEdit(project))
|
|
126
|
+
return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
|
|
127
|
+
const body = request.body;
|
|
128
|
+
const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
|
|
129
|
+
return await projectMutations.runExclusive(id, "history restore", () => {
|
|
130
|
+
const currentProject = requireEditableProject(db, id, user);
|
|
131
|
+
recordHistory(id, user.id, "checkpoint");
|
|
132
|
+
const before = projectTextSnapshot(config, id);
|
|
133
|
+
const restored = history.restore(id, versionId, filePath);
|
|
134
|
+
reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
|
|
135
|
+
touchProject(db, id, user.id);
|
|
136
|
+
recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
restoredPaths: restored.restoredPaths,
|
|
140
|
+
project: projectJson(accessibleProject(db, id, user) ?? currentProject, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
141
|
+
};
|
|
142
|
+
}, { preflight: () => {
|
|
143
|
+
requireEditableProject(db, id, user);
|
|
144
|
+
if (!history.version(versionId, id)) {
|
|
145
|
+
throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
|
|
146
|
+
}
|
|
147
|
+
history.validateRestoreTarget(id, versionId, filePath);
|
|
148
|
+
} });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { apiError, httpError } from "../http.js";
|
|
3
|
+
import { accessibleProject } from "../projects.js";
|
|
4
|
+
import { commentsSummaryForProject, now, projectJson, requireActualProjectOwner, tagsForProject, touchProject } from "./projectShared.js";
|
|
5
|
+
/** Register project sharing, member permission, and ownership-transfer routes. */
|
|
6
|
+
export function registerProjectMemberRoutes(app, context) {
|
|
7
|
+
const { db, collaboration, projectMutations } = context;
|
|
8
|
+
app.get("/api/projects/:id/members", async (request, reply) => {
|
|
9
|
+
const user = requireUser(request, reply, db);
|
|
10
|
+
if (!user)
|
|
11
|
+
return;
|
|
12
|
+
const { id } = request.params;
|
|
13
|
+
if (!accessibleProject(db, id, user))
|
|
14
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
15
|
+
const members = db.prepare(`SELECT pm.user_id AS id, u.username, u.display_name AS displayName, pm.permission
|
|
16
|
+
FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = ? ORDER BY u.username`).all(id);
|
|
17
|
+
return { members };
|
|
18
|
+
});
|
|
19
|
+
app.put("/api/projects/:id/owner", async (request, reply) => {
|
|
20
|
+
const user = requireUser(request, reply, db);
|
|
21
|
+
if (!user)
|
|
22
|
+
return;
|
|
23
|
+
const { id } = request.params;
|
|
24
|
+
const project = accessibleProject(db, id, user);
|
|
25
|
+
if (!project || project.owner_id !== user.id) {
|
|
26
|
+
return apiError(reply, 403, "PROJECT_TRANSFER_FORBIDDEN");
|
|
27
|
+
}
|
|
28
|
+
const body = (request.body ?? {});
|
|
29
|
+
if (typeof body.userId !== "string" || !body.userId) {
|
|
30
|
+
return apiError(reply, 400, "PROJECT_TRANSFER_TARGET_INVALID");
|
|
31
|
+
}
|
|
32
|
+
if (body.userId === user.id) {
|
|
33
|
+
return apiError(reply, 400, "PROJECT_TRANSFER_SELF");
|
|
34
|
+
}
|
|
35
|
+
const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
|
|
36
|
+
if (!target)
|
|
37
|
+
return apiError(reply, 404, "USER_NOT_FOUND");
|
|
38
|
+
return await projectMutations.runExclusive(id, "project transfer", () => {
|
|
39
|
+
const currentTarget = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
|
|
40
|
+
// The synchronous preflight immediately precedes maintenance, so this
|
|
41
|
+
// lookup cannot change before the operation starts.
|
|
42
|
+
if (!currentTarget)
|
|
43
|
+
throw httpError(404, "USER_NOT_FOUND");
|
|
44
|
+
const changedAt = now();
|
|
45
|
+
db.exec("BEGIN IMMEDIATE");
|
|
46
|
+
try {
|
|
47
|
+
// The new owner may already be a shared member. The previous owner keeps
|
|
48
|
+
// edit access so a transfer does not unexpectedly lock them out.
|
|
49
|
+
db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, currentTarget.id);
|
|
50
|
+
db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
|
|
51
|
+
VALUES (?, ?, 'edit', ?)
|
|
52
|
+
ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
|
|
53
|
+
.run(id, user.id, changedAt);
|
|
54
|
+
db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
|
|
55
|
+
.run(currentTarget.id, user.id, changedAt, id);
|
|
56
|
+
// A project token belongs to the account that supplied it. Preserve the
|
|
57
|
+
// local repository/remote metadata, but require the new owner to add
|
|
58
|
+
// their own credential before the next GitHub operation.
|
|
59
|
+
db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
|
|
60
|
+
.run(changedAt, id);
|
|
61
|
+
db.exec("COMMIT");
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
db.exec("ROLLBACK");
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
69
|
+
};
|
|
70
|
+
}, { preflight: () => {
|
|
71
|
+
requireActualProjectOwner(db, id, user);
|
|
72
|
+
if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(body.userId)) {
|
|
73
|
+
throw httpError(404, "USER_NOT_FOUND");
|
|
74
|
+
}
|
|
75
|
+
} });
|
|
76
|
+
});
|
|
77
|
+
app.put("/api/projects/:id/members/:userId", async (request, reply) => {
|
|
78
|
+
const user = requireUser(request, reply, db);
|
|
79
|
+
if (!user)
|
|
80
|
+
return;
|
|
81
|
+
const { id, userId } = request.params;
|
|
82
|
+
const project = accessibleProject(db, id, user);
|
|
83
|
+
if (!project || project.permission !== "owner")
|
|
84
|
+
return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN");
|
|
85
|
+
if (userId === project.owner_id)
|
|
86
|
+
return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN");
|
|
87
|
+
if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(userId)) {
|
|
88
|
+
return apiError(reply, 404, "USER_NOT_FOUND");
|
|
89
|
+
}
|
|
90
|
+
const body = request.body;
|
|
91
|
+
const permission = body.permission === "edit" ? "edit" : "read";
|
|
92
|
+
db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
|
|
93
|
+
ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
|
|
94
|
+
.run(id, userId, permission, now());
|
|
95
|
+
touchProject(db, id, user.id);
|
|
96
|
+
collaboration.notifyPermissionChanged(id, userId, permission);
|
|
97
|
+
return { ok: true };
|
|
98
|
+
});
|
|
99
|
+
app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
|
|
100
|
+
const user = requireUser(request, reply, db);
|
|
101
|
+
if (!user)
|
|
102
|
+
return;
|
|
103
|
+
const { id, userId } = request.params;
|
|
104
|
+
const project = accessibleProject(db, id, user);
|
|
105
|
+
if (!project || project.permission !== "owner")
|
|
106
|
+
return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN");
|
|
107
|
+
db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
|
|
108
|
+
touchProject(db, id, user.id);
|
|
109
|
+
collaboration.notifyPermissionChanged(id, userId, "revoked");
|
|
110
|
+
return { ok: true };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { reanchorFileComments, offsetToLine } from "../anchors.js";
|
|
3
|
+
import { maxCollaborativeFileBytes } from "../collaboration.js";
|
|
4
|
+
import { listProjectFiles, resolveSourcePath } from "../files.js";
|
|
5
|
+
import { httpError, ValidationError } from "../http.js";
|
|
6
|
+
import { accessibleProject, canEdit } from "../projects.js";
|
|
7
|
+
export const now = () => new Date().toISOString();
|
|
8
|
+
export function escapeLikePattern(value) {
|
|
9
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
10
|
+
}
|
|
11
|
+
export function escapeGlobPattern(value) {
|
|
12
|
+
return value.replace(/[*?[]/g, "[$&]");
|
|
13
|
+
}
|
|
14
|
+
export function text(value, max = 200) {
|
|
15
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
16
|
+
throw new ValidationError();
|
|
17
|
+
}
|
|
18
|
+
return value.trim();
|
|
19
|
+
}
|
|
20
|
+
export function dictionaryWord(value) {
|
|
21
|
+
if (typeof value !== "string")
|
|
22
|
+
throw new ValidationError();
|
|
23
|
+
const word = value.trim();
|
|
24
|
+
if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
|
|
25
|
+
throw new ValidationError();
|
|
26
|
+
return word;
|
|
27
|
+
}
|
|
28
|
+
export const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
|
|
29
|
+
export function tagsForProject(db, projectId, userId) {
|
|
30
|
+
return db.prepare(`SELECT tag.id, tag.name, tag.color
|
|
31
|
+
FROM user_tags tag JOIN user_project_tag_links link ON link.tag_id = tag.id
|
|
32
|
+
WHERE link.project_id = ? AND tag.user_id = ? ORDER BY tag.name COLLATE NOCASE`)
|
|
33
|
+
.all(projectId, userId);
|
|
34
|
+
}
|
|
35
|
+
export function tagsForProjects(db, projectIds, userId) {
|
|
36
|
+
const result = new Map(projectIds.map((projectId) => [projectId, []]));
|
|
37
|
+
for (let offset = 0; offset < projectIds.length; offset += 500) {
|
|
38
|
+
const chunk = projectIds.slice(offset, offset + 500);
|
|
39
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
40
|
+
const rows = db.prepare(`SELECT link.project_id, tag.id, tag.name, tag.color
|
|
41
|
+
FROM user_project_tag_links link JOIN user_tags tag ON tag.id = link.tag_id
|
|
42
|
+
WHERE tag.user_id = ? AND link.project_id IN (${placeholders})
|
|
43
|
+
ORDER BY tag.name COLLATE NOCASE`).all(userId, ...chunk);
|
|
44
|
+
for (const row of rows)
|
|
45
|
+
result.get(row.project_id)?.push({ id: row.id, name: row.name, color: row.color });
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
export function commentsSummaryForProjects(db, projectIds) {
|
|
50
|
+
const result = new Map();
|
|
51
|
+
if (!projectIds.length)
|
|
52
|
+
return result;
|
|
53
|
+
for (let index = 0; index < projectIds.length; index += 100) {
|
|
54
|
+
const chunk = projectIds.slice(index, index + 100);
|
|
55
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
56
|
+
const rows = db.prepare(`
|
|
57
|
+
SELECT project_id,
|
|
58
|
+
COUNT(*) AS total_count,
|
|
59
|
+
SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
|
|
60
|
+
FROM comments
|
|
61
|
+
WHERE project_id IN (${placeholders})
|
|
62
|
+
GROUP BY project_id
|
|
63
|
+
`).all(...chunk);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
result.set(row.project_id, {
|
|
66
|
+
totalCount: Number(row.total_count) || 0,
|
|
67
|
+
unresolvedCount: Number(row.unresolved_count) || 0
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
export function commentsSummaryForProject(db, projectId) {
|
|
74
|
+
const row = db.prepare(`
|
|
75
|
+
SELECT
|
|
76
|
+
COUNT(*) AS total_count,
|
|
77
|
+
SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
|
|
78
|
+
FROM comments
|
|
79
|
+
WHERE project_id = ?
|
|
80
|
+
`).get(projectId);
|
|
81
|
+
return {
|
|
82
|
+
totalCount: Number(row?.total_count) || 0,
|
|
83
|
+
unresolvedCount: Number(row?.unresolved_count) || 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function projectJson(project, tags = [], commentsSummary) {
|
|
87
|
+
return {
|
|
88
|
+
id: project.id,
|
|
89
|
+
ownerId: project.owner_id,
|
|
90
|
+
ownerUsername: project.owner_username,
|
|
91
|
+
ownerDisplayName: project.owner_display_name,
|
|
92
|
+
lastModifiedBy: project.last_modified_by,
|
|
93
|
+
lastModifiedUsername: project.last_modified_username,
|
|
94
|
+
lastModifiedDisplayName: project.last_modified_display_name,
|
|
95
|
+
name: project.name,
|
|
96
|
+
mainFile: project.main_file,
|
|
97
|
+
latexmkrc: project.latexmkrc,
|
|
98
|
+
engine: project.engine,
|
|
99
|
+
permission: project.permission,
|
|
100
|
+
tags,
|
|
101
|
+
unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
|
|
102
|
+
commentCount: commentsSummary?.totalCount ?? 0,
|
|
103
|
+
archived: Boolean(project.archived),
|
|
104
|
+
createdAt: project.created_at,
|
|
105
|
+
updatedAt: project.updated_at
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function touchProject(db, projectId, userId) {
|
|
109
|
+
db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
|
|
110
|
+
.run(now(), userId, projectId);
|
|
111
|
+
}
|
|
112
|
+
export function requireActiveUser(db, user) {
|
|
113
|
+
const current = db.prepare("SELECT disabled FROM users WHERE id = ?").get(user.id);
|
|
114
|
+
if (!current || current.disabled) {
|
|
115
|
+
throw httpError(401, "AUTH_REQUIRED");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Authorization must be checked again after a queued mutation acquires its
|
|
120
|
+
* project lock. A member can be revoked, or ownership can be transferred,
|
|
121
|
+
* while the request is waiting behind another filesystem operation.
|
|
122
|
+
*/
|
|
123
|
+
export function requireEditableProject(db, projectId, user) {
|
|
124
|
+
requireActiveUser(db, user);
|
|
125
|
+
const project = accessibleProject(db, projectId, user);
|
|
126
|
+
if (!project)
|
|
127
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
128
|
+
if (!canEdit(project))
|
|
129
|
+
throw httpError(403, "PROJECT_EDIT_FORBIDDEN");
|
|
130
|
+
return project;
|
|
131
|
+
}
|
|
132
|
+
/** Owner permission includes an administrator's effective owner access. */
|
|
133
|
+
export function requireProjectOwnerPermission(db, projectId, user) {
|
|
134
|
+
requireActiveUser(db, user);
|
|
135
|
+
const project = accessibleProject(db, projectId, user);
|
|
136
|
+
if (!project)
|
|
137
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
138
|
+
if (project.permission !== "owner") {
|
|
139
|
+
throw httpError(403, "PROJECT_OWNER_ONLY");
|
|
140
|
+
}
|
|
141
|
+
return project;
|
|
142
|
+
}
|
|
143
|
+
/** Operations such as ownership transfer require the actual stored owner. */
|
|
144
|
+
export function requireActualProjectOwner(db, projectId, user) {
|
|
145
|
+
requireActiveUser(db, user);
|
|
146
|
+
const project = accessibleProject(db, projectId, user);
|
|
147
|
+
if (!project)
|
|
148
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
149
|
+
if (project.owner_id !== user.id) {
|
|
150
|
+
throw httpError(403, "PROJECT_OWNER_ONLY");
|
|
151
|
+
}
|
|
152
|
+
return project;
|
|
153
|
+
}
|
|
154
|
+
export function projectTextSnapshot(config, projectId) {
|
|
155
|
+
const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
|
|
156
|
+
return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
|
|
157
|
+
const absolute = resolveSourcePath(config, projectId, entry.path);
|
|
158
|
+
return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
export function reanchorProjectSnapshot(db, projectId, before, after) {
|
|
162
|
+
for (const filePath of new Set([...before.keys(), ...after.keys()])) {
|
|
163
|
+
reanchorFileComments(db, projectId, filePath, before.get(filePath) ?? "", after.get(filePath) ?? "");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export function movedProjectPath(value, source, destination) {
|
|
167
|
+
if (value === null)
|
|
168
|
+
return null;
|
|
169
|
+
if (value === source)
|
|
170
|
+
return destination;
|
|
171
|
+
return value.startsWith(`${source}/`) ? `${destination}${value.slice(source.length)}` : value;
|
|
172
|
+
}
|
|
173
|
+
export function repliesForComment(db, commentId) {
|
|
174
|
+
const rows = db.prepare(`SELECT reply.*, user.username AS author_username,
|
|
175
|
+
user.display_name AS author_display_name
|
|
176
|
+
FROM comment_replies reply LEFT JOIN users user ON user.id = reply.author_id
|
|
177
|
+
WHERE reply.comment_id = ? ORDER BY reply.created_at`)
|
|
178
|
+
.all(commentId);
|
|
179
|
+
return rows.map((reply) => ({
|
|
180
|
+
id: reply.id,
|
|
181
|
+
authorId: reply.author_id,
|
|
182
|
+
authorUsername: reply.author_username,
|
|
183
|
+
authorDisplayName: reply.author_display_name,
|
|
184
|
+
content: reply.content,
|
|
185
|
+
createdAt: reply.created_at,
|
|
186
|
+
updatedAt: reply.updated_at,
|
|
187
|
+
editedAt: reply.edited_at
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
export function commentsForFile(db, config, projectId, filePath) {
|
|
191
|
+
const absolute = resolveSourcePath(config, projectId, filePath);
|
|
192
|
+
const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
|
|
193
|
+
const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
|
|
194
|
+
LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
|
|
195
|
+
.all(projectId, filePath);
|
|
196
|
+
if (!rows.length)
|
|
197
|
+
return [];
|
|
198
|
+
const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
|
|
199
|
+
FROM comment_replies reply
|
|
200
|
+
JOIN comments c ON c.id = reply.comment_id
|
|
201
|
+
LEFT JOIN users user ON user.id = reply.author_id
|
|
202
|
+
WHERE c.project_id = ? AND c.file_path = ?
|
|
203
|
+
ORDER BY reply.created_at`)
|
|
204
|
+
.all(projectId, filePath);
|
|
205
|
+
const replyMap = new Map();
|
|
206
|
+
for (const reply of replies) {
|
|
207
|
+
const list = replyMap.get(reply.comment_id) ?? [];
|
|
208
|
+
list.push({
|
|
209
|
+
id: reply.id,
|
|
210
|
+
authorId: reply.author_id,
|
|
211
|
+
authorUsername: reply.author_username,
|
|
212
|
+
authorDisplayName: reply.author_display_name,
|
|
213
|
+
content: reply.content,
|
|
214
|
+
createdAt: reply.created_at,
|
|
215
|
+
updatedAt: reply.updated_at,
|
|
216
|
+
editedAt: reply.edited_at
|
|
217
|
+
});
|
|
218
|
+
replyMap.set(reply.comment_id, list);
|
|
219
|
+
}
|
|
220
|
+
return rows.map((comment) => ({
|
|
221
|
+
id: comment.id,
|
|
222
|
+
authorId: comment.author_id,
|
|
223
|
+
authorUsername: comment.author_username,
|
|
224
|
+
authorDisplayName: comment.author_display_name,
|
|
225
|
+
selectedText: comment.selected_text,
|
|
226
|
+
startOffset: comment.start_offset,
|
|
227
|
+
endOffset: comment.end_offset,
|
|
228
|
+
startLine: offsetToLine(source, comment.start_offset),
|
|
229
|
+
endLine: offsetToLine(source, comment.end_offset),
|
|
230
|
+
content: comment.content,
|
|
231
|
+
resolved: Boolean(comment.resolved),
|
|
232
|
+
orphaned: Boolean(comment.orphaned),
|
|
233
|
+
createdAt: comment.created_at,
|
|
234
|
+
updatedAt: comment.updated_at,
|
|
235
|
+
editedAt: comment.edited_at,
|
|
236
|
+
replies: replyMap.get(comment.id) ?? []
|
|
237
|
+
}));
|
|
238
|
+
}
|