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/git.js
CHANGED
|
@@ -3,14 +3,14 @@ import path from "node:path";
|
|
|
3
3
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { assertGitAvailable } from "./environment.js";
|
|
6
|
-
import { sourceRoot } from "./files.js";
|
|
6
|
+
import { assertNoSourceSymlinks, sourceRoot, symbolicLinkError } from "./files.js";
|
|
7
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
7
8
|
const MAX_COMMAND_OUTPUT = 8 * 1024 * 1024;
|
|
8
9
|
const MAX_DIFF_OUTPUT = 1024 * 1024;
|
|
9
10
|
export class ProjectGitService {
|
|
10
11
|
config;
|
|
11
12
|
db;
|
|
12
13
|
githubFetch;
|
|
13
|
-
locks = new Map();
|
|
14
14
|
gitAvailability = null;
|
|
15
15
|
constructor(config, db, githubFetch = fetch) {
|
|
16
16
|
this.config = config;
|
|
@@ -19,8 +19,20 @@ export class ProjectGitService {
|
|
|
19
19
|
}
|
|
20
20
|
async status(project) {
|
|
21
21
|
await this.ensureGitAvailable();
|
|
22
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
22
23
|
const settings = this.settings(project.id);
|
|
23
|
-
const
|
|
24
|
+
const gitDirectory = path.join(sourceRoot(this.config, project.id), ".git");
|
|
25
|
+
let gitStat = null;
|
|
26
|
+
try {
|
|
27
|
+
gitStat = fs.lstatSync(gitDirectory);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code !== "ENOENT")
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (gitStat && !gitStat.isDirectory())
|
|
34
|
+
throw symbolicLinkError(".git");
|
|
35
|
+
const initialized = Boolean(gitStat);
|
|
24
36
|
if (!initialized)
|
|
25
37
|
return {
|
|
26
38
|
initialized: false,
|
|
@@ -65,102 +77,94 @@ export class ProjectGitService {
|
|
|
65
77
|
if (token.length < 20 || token.length > 500)
|
|
66
78
|
throw httpError(400, "GitHub token 格式不正确");
|
|
67
79
|
await this.ensureGitAvailable();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return this.status(project);
|
|
81
|
-
});
|
|
80
|
+
const account = await this.githubRequest(token, "/user", { method: "GET" });
|
|
81
|
+
if (typeof account.login !== "string" || !account.login)
|
|
82
|
+
throw httpError(502, "GitHub 没有返回有效的用户信息");
|
|
83
|
+
await this.ensureRepository(project);
|
|
84
|
+
const timestamp = new Date().toISOString();
|
|
85
|
+
this.db.prepare(`INSERT INTO project_git_settings
|
|
86
|
+
(project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
|
|
87
|
+
VALUES (?, ?, ?, 'main', ?, ?)
|
|
88
|
+
ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
|
|
89
|
+
github_login = excluded.github_login, updated_at = excluded.updated_at`)
|
|
90
|
+
.run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
|
|
91
|
+
return this.status(project);
|
|
82
92
|
}
|
|
83
93
|
async removeToken(project) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return this.status(project);
|
|
88
|
-
});
|
|
94
|
+
this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
|
|
95
|
+
.run(new Date().toISOString(), project.id);
|
|
96
|
+
return this.status(project);
|
|
89
97
|
}
|
|
90
98
|
async createGitHubRepository(project, name, isPrivate) {
|
|
91
99
|
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
|
|
92
100
|
throw httpError(400, "GitHub 仓库名称只能包含字母、数字、点、横线或下划线");
|
|
93
101
|
await this.ensureGitAvailable();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
|
|
102
|
-
});
|
|
103
|
-
if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
|
|
104
|
-
throw httpError(502, "GitHub 没有返回有效的仓库地址");
|
|
105
|
-
}
|
|
106
|
-
await this.ensureRepository(project);
|
|
107
|
-
const root = sourceRoot(this.config, project.id);
|
|
108
|
-
const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
|
|
109
|
-
if (currentRemote.code === 0)
|
|
110
|
-
await this.git(root, ["remote", "set-url", "origin", repository.clone_url]);
|
|
111
|
-
else
|
|
112
|
-
await this.git(root, ["remote", "add", "origin", repository.clone_url]);
|
|
113
|
-
const defaultBranch = typeof repository.default_branch === "string" && repository.default_branch
|
|
114
|
-
? repository.default_branch : "main";
|
|
115
|
-
this.db.prepare(`UPDATE project_git_settings SET remote_url = ?, repository_name = ?,
|
|
116
|
-
repository_html_url = ?, default_branch = ?, updated_at = ? WHERE project_id = ?`)
|
|
117
|
-
.run(repository.clone_url, typeof repository.name === "string" ? repository.name : name, repository.html_url, defaultBranch, new Date().toISOString(), project.id);
|
|
118
|
-
return this.status(project);
|
|
102
|
+
const settings = this.requireTokenSettings(project.id);
|
|
103
|
+
if (settings.remote_url)
|
|
104
|
+
throw httpError(409, "该项目已经配置远程仓库");
|
|
105
|
+
const token = decryptToken(this.config, settings.token_ciphertext);
|
|
106
|
+
const repository = await this.githubRequest(token, "/user/repos", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
|
|
119
109
|
});
|
|
110
|
+
if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
|
|
111
|
+
throw httpError(502, "GitHub 没有返回有效的仓库地址");
|
|
112
|
+
}
|
|
113
|
+
await this.ensureRepository(project);
|
|
114
|
+
const root = sourceRoot(this.config, project.id);
|
|
115
|
+
const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
|
|
116
|
+
if (currentRemote.code === 0)
|
|
117
|
+
await this.git(root, ["remote", "set-url", "origin", repository.clone_url]);
|
|
118
|
+
else
|
|
119
|
+
await this.git(root, ["remote", "add", "origin", repository.clone_url]);
|
|
120
|
+
const defaultBranch = typeof repository.default_branch === "string" && repository.default_branch
|
|
121
|
+
? repository.default_branch : "main";
|
|
122
|
+
this.db.prepare(`UPDATE project_git_settings SET remote_url = ?, repository_name = ?,
|
|
123
|
+
repository_html_url = ?, default_branch = ?, updated_at = ? WHERE project_id = ?`)
|
|
124
|
+
.run(repository.clone_url, typeof repository.name === "string" ? repository.name : name, repository.html_url, defaultBranch, new Date().toISOString(), project.id);
|
|
125
|
+
return this.status(project);
|
|
120
126
|
}
|
|
121
127
|
async commit(project, owner, messageInput) {
|
|
122
128
|
const message = messageInput.trim();
|
|
123
129
|
if (!message || message.length > 300)
|
|
124
130
|
throw httpError(400, "提交说明不能为空且不能超过 300 个字符");
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
131
|
+
const root = this.requireRepository(project.id);
|
|
132
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
133
|
+
const branch = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
|
|
134
|
+
if (branch.code !== 0)
|
|
135
|
+
throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再提交");
|
|
136
|
+
await this.git(root, ["add", "--all"]);
|
|
137
|
+
const staged = await this.git(root, ["diff", "--cached", "--quiet"], [1]);
|
|
138
|
+
if (staged.code === 0)
|
|
139
|
+
throw httpError(409, "当前没有可以提交的修改");
|
|
140
|
+
await this.git(root, [
|
|
141
|
+
"-c", `user.name=${owner.username}`,
|
|
142
|
+
"-c", `user.email=${owner.username}@texlite.com`,
|
|
143
|
+
"-c", "commit.gpgSign=false",
|
|
144
|
+
"-c", "core.hooksPath=/dev/null",
|
|
145
|
+
"commit", "-m", message
|
|
146
|
+
]);
|
|
147
|
+
const result = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"]);
|
|
148
|
+
return parseCommit(result.stdout.trim());
|
|
144
149
|
}
|
|
145
150
|
async push(project) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
});
|
|
151
|
+
const settings = this.requireTokenSettings(project.id);
|
|
152
|
+
if (!settings.remote_url)
|
|
153
|
+
throw httpError(409, "请先创建 GitHub 仓库");
|
|
154
|
+
const root = this.requireRepository(project.id);
|
|
155
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
156
|
+
const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
|
|
157
|
+
if (head.code !== 0)
|
|
158
|
+
throw httpError(409, "请先提交项目修改");
|
|
159
|
+
const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
|
|
160
|
+
if (branchResult.code !== 0)
|
|
161
|
+
throw httpError(409, "当前处于 detached HEAD,请先返回默认分支再推送");
|
|
162
|
+
const branch = branchResult.stdout.trim();
|
|
163
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch))
|
|
164
|
+
throw httpError(400, "本地分支名称无效");
|
|
165
|
+
const token = decryptToken(this.config, settings.token_ciphertext);
|
|
166
|
+
await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
|
|
167
|
+
return this.status(project);
|
|
164
168
|
}
|
|
165
169
|
async history(project) {
|
|
166
170
|
const root = this.requireRepository(project.id);
|
|
@@ -200,40 +204,65 @@ export class ProjectGitService {
|
|
|
200
204
|
};
|
|
201
205
|
}
|
|
202
206
|
async checkout(project, revisionInput, force) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
const root = this.requireRepository(project.id);
|
|
208
|
+
const settings = this.settings(project.id);
|
|
209
|
+
const revision = revisionInput === null ? settings?.default_branch ?? "main" : validateRevision(revisionInput);
|
|
210
|
+
const target = revisionInput === null ? revision : await this.verifyCommit(root, revision);
|
|
211
|
+
await this.assertRevisionHasNoSymlinks(root, target);
|
|
212
|
+
if (force) {
|
|
213
|
+
await this.git(root, ["reset", "--hard", "HEAD"]);
|
|
214
|
+
await this.git(root, ["clean", "-fdx"]);
|
|
215
|
+
}
|
|
216
|
+
await this.git(root, ["-c", "core.hooksPath=/dev/null", "checkout", ...(revisionInput === null ? [] : ["--detach"]), ...(force ? ["--force"] : []), target]);
|
|
217
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
218
|
+
return target;
|
|
215
219
|
}
|
|
216
220
|
async discardChanges(project) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
const root = this.requireRepository(project.id);
|
|
222
|
+
const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
|
|
223
|
+
if (head.code !== 0)
|
|
224
|
+
throw httpError(409, "该项目尚无可恢复的提交");
|
|
225
|
+
await this.assertRevisionHasNoSymlinks(root, head.stdout.trim());
|
|
226
|
+
await this.git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."]);
|
|
227
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
224
228
|
}
|
|
225
229
|
async ensureRepository(project) {
|
|
226
230
|
const root = sourceRoot(this.config, project.id);
|
|
231
|
+
assertNoSourceSymlinks(this.config, project.id);
|
|
227
232
|
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
228
|
-
|
|
233
|
+
const gitDirectory = path.join(root, ".git");
|
|
234
|
+
let gitStat = null;
|
|
235
|
+
try {
|
|
236
|
+
gitStat = fs.lstatSync(gitDirectory);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error.code !== "ENOENT")
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
if (gitStat) {
|
|
243
|
+
if (gitStat.isSymbolicLink() || !gitStat.isDirectory())
|
|
244
|
+
throw symbolicLinkError(".git");
|
|
229
245
|
return;
|
|
246
|
+
}
|
|
230
247
|
await this.git(root, ["init"]);
|
|
231
248
|
await this.git(root, ["symbolic-ref", "HEAD", "refs/heads/main"]);
|
|
232
249
|
}
|
|
233
250
|
requireRepository(projectId) {
|
|
234
251
|
const root = sourceRoot(this.config, projectId);
|
|
235
|
-
|
|
252
|
+
assertNoSourceSymlinks(this.config, projectId);
|
|
253
|
+
const gitDirectory = path.join(root, ".git");
|
|
254
|
+
let gitStat = null;
|
|
255
|
+
try {
|
|
256
|
+
gitStat = fs.lstatSync(gitDirectory);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error.code !== "ENOENT")
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
if (!gitStat)
|
|
236
263
|
throw httpError(409, "该项目尚未初始化 Git");
|
|
264
|
+
if (gitStat.isSymbolicLink() || !gitStat.isDirectory())
|
|
265
|
+
throw symbolicLinkError(".git");
|
|
237
266
|
return root;
|
|
238
267
|
}
|
|
239
268
|
settings(projectId) {
|
|
@@ -251,6 +280,12 @@ export class ProjectGitService {
|
|
|
251
280
|
throw httpError(404, "Git 版本不存在");
|
|
252
281
|
return result.stdout.trim();
|
|
253
282
|
}
|
|
283
|
+
async assertRevisionHasNoSymlinks(root, revision) {
|
|
284
|
+
const tree = await this.git(root, ["ls-tree", "-r", "-z", "--full-tree", revision]);
|
|
285
|
+
if (tree.stdout.split("\0").some((entry) => entry.startsWith("120000 "))) {
|
|
286
|
+
throw symbolicLinkError(`Git revision ${revision}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
254
289
|
async githubRequest(token, route, init) {
|
|
255
290
|
const controller = new AbortController();
|
|
256
291
|
const timer = setTimeout(() => controller.abort(), this.config.gitOperationTimeoutMs);
|
|
@@ -309,16 +344,25 @@ export class ProjectGitService {
|
|
|
309
344
|
async git(cwd, args, allowedCodes = [], env = process.env) {
|
|
310
345
|
await this.ensureGitAvailable();
|
|
311
346
|
return new Promise((resolve, reject) => {
|
|
312
|
-
const child = spawn(this.config.git, args, {
|
|
347
|
+
const child = spawn(this.config.git, args, {
|
|
348
|
+
cwd, env, shell: false, detached: detachedProcessGroup(), stdio: ["ignore", "pipe", "pipe"]
|
|
349
|
+
});
|
|
313
350
|
const stdout = [];
|
|
314
351
|
const stderr = [];
|
|
315
352
|
let size = 0;
|
|
316
353
|
let timedOut = false;
|
|
317
|
-
|
|
354
|
+
let terminated = false;
|
|
355
|
+
const terminate = () => {
|
|
356
|
+
if (terminated)
|
|
357
|
+
return;
|
|
358
|
+
terminated = true;
|
|
359
|
+
killProcessGroup(child);
|
|
360
|
+
};
|
|
361
|
+
const timer = setTimeout(() => { timedOut = true; terminate(); }, this.config.gitOperationTimeoutMs);
|
|
318
362
|
const collect = (target) => (chunk) => {
|
|
319
363
|
size += chunk.length;
|
|
320
364
|
if (size > MAX_COMMAND_OUTPUT)
|
|
321
|
-
|
|
365
|
+
terminate();
|
|
322
366
|
else
|
|
323
367
|
target.push(chunk);
|
|
324
368
|
};
|
|
@@ -340,22 +384,6 @@ export class ProjectGitService {
|
|
|
340
384
|
});
|
|
341
385
|
});
|
|
342
386
|
}
|
|
343
|
-
async exclusive(projectId, operation) {
|
|
344
|
-
const previous = this.locks.get(projectId) ?? Promise.resolve();
|
|
345
|
-
let release = () => undefined;
|
|
346
|
-
const gate = new Promise((resolve) => { release = resolve; });
|
|
347
|
-
const tail = previous.catch(() => undefined).then(() => gate);
|
|
348
|
-
this.locks.set(projectId, tail);
|
|
349
|
-
await previous.catch(() => undefined);
|
|
350
|
-
try {
|
|
351
|
-
return await operation();
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
release();
|
|
355
|
-
if (this.locks.get(projectId) === tail)
|
|
356
|
-
this.locks.delete(projectId);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
387
|
}
|
|
360
388
|
function parseCommit(line) {
|
|
361
389
|
const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
|
package/dist/server/history.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
4
|
+
import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
5
5
|
const AUTOSAVE_COALESCE_MS = 2 * 60_000;
|
|
6
6
|
export class ProjectHistoryService {
|
|
7
7
|
config;
|
|
@@ -240,23 +240,90 @@ export class ProjectHistoryService {
|
|
|
240
240
|
return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
|
|
241
241
|
}
|
|
242
242
|
pruneVersions(projectId) {
|
|
243
|
-
const
|
|
244
|
-
WHERE project_id = ?
|
|
245
|
-
ORDER BY created_at DESC, rowid DESC
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
243
|
+
const rows = this.db.prepare(`SELECT id, reason, label, manifest_json, created_at
|
|
244
|
+
FROM project_history_versions WHERE project_id = ?
|
|
245
|
+
ORDER BY created_at DESC, rowid DESC`).all(projectId);
|
|
246
|
+
if (!rows.length)
|
|
247
|
+
return;
|
|
248
|
+
// Parse manifests and build reference counts & object size tracking in a single pass
|
|
249
|
+
const manifests = new Map();
|
|
250
|
+
const refCounts = new Map();
|
|
251
|
+
const sizeMap = new Map();
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
const manifest = parseManifest(row.manifest_json);
|
|
254
|
+
manifests.set(row.id, manifest);
|
|
255
|
+
for (const file of Object.values(manifest.files)) {
|
|
256
|
+
refCounts.set(file.digest, (refCounts.get(file.digest) ?? 0) + 1);
|
|
257
|
+
if (!sizeMap.has(file.digest))
|
|
258
|
+
sizeMap.set(file.digest, file.size);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const baseline = this.baseline(projectId);
|
|
262
|
+
if (baseline) {
|
|
263
|
+
for (const file of Object.values(baseline.files)) {
|
|
264
|
+
refCounts.set(file.digest, (refCounts.get(file.digest) ?? 0) + 1);
|
|
265
|
+
if (!sizeMap.has(file.digest))
|
|
266
|
+
sizeMap.set(file.digest, file.size);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let currentObjectBytes = 0;
|
|
270
|
+
for (const [digest, count] of refCounts.entries()) {
|
|
271
|
+
if (count > 0)
|
|
272
|
+
currentObjectBytes += sizeMap.get(digest) ?? 0;
|
|
273
|
+
}
|
|
274
|
+
const latestId = rows[0]?.id;
|
|
275
|
+
// Ordinary versions eligible for version count capping (ordered newest to oldest)
|
|
276
|
+
const ordinaryDesc = rows.filter((row) => row.label === null && row.reason !== "initial");
|
|
277
|
+
const toDeleteIds = new Set();
|
|
278
|
+
const removeVersionRefs = (versionId) => {
|
|
279
|
+
if (toDeleteIds.has(versionId))
|
|
280
|
+
return;
|
|
281
|
+
toDeleteIds.add(versionId);
|
|
282
|
+
const manifest = manifests.get(versionId);
|
|
283
|
+
if (!manifest)
|
|
284
|
+
return;
|
|
285
|
+
for (const file of Object.values(manifest.files)) {
|
|
286
|
+
const count = refCounts.get(file.digest);
|
|
287
|
+
if (count !== undefined) {
|
|
288
|
+
if (count === 1) {
|
|
289
|
+
refCounts.set(file.digest, 0);
|
|
290
|
+
currentObjectBytes -= sizeMap.get(file.digest) ?? 0;
|
|
291
|
+
}
|
|
292
|
+
else if (count > 1) {
|
|
293
|
+
refCounts.set(file.digest, count - 1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
// 1. Cap by historyMaxVersions (prune excess ordinary versions from newest to oldest offset)
|
|
299
|
+
if (ordinaryDesc.length > this.config.historyMaxVersions) {
|
|
300
|
+
const obsolete = ordinaryDesc.slice(this.config.historyMaxVersions);
|
|
301
|
+
for (const row of obsolete)
|
|
302
|
+
removeVersionRefs(row.id);
|
|
303
|
+
}
|
|
304
|
+
// 2. Cap by historyMaxStorageBytes (prune remaining eligible versions from oldest to newest)
|
|
305
|
+
if (currentObjectBytes > this.config.historyMaxStorageBytes) {
|
|
306
|
+
const remainingEligibleAsc = ordinaryDesc
|
|
307
|
+
.filter((row) => row.id !== latestId && !toDeleteIds.has(row.id))
|
|
308
|
+
.reverse();
|
|
309
|
+
for (const row of remainingEligibleAsc) {
|
|
310
|
+
if (currentObjectBytes <= this.config.historyMaxStorageBytes)
|
|
311
|
+
break;
|
|
312
|
+
removeVersionRefs(row.id);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!toDeleteIds.size)
|
|
316
|
+
return;
|
|
317
|
+
// 3. Batch delete rows in a single transaction
|
|
318
|
+
const remove = this.db.prepare("DELETE FROM project_history_versions WHERE id = ? AND project_id = ?");
|
|
319
|
+
this.db.transaction(() => {
|
|
320
|
+
for (const id of toDeleteIds)
|
|
321
|
+
remove.run(id, projectId);
|
|
322
|
+
})();
|
|
323
|
+
// 4. Remove unreferenced CAS objects on disk
|
|
324
|
+
for (const [digest, count] of refCounts.entries()) {
|
|
325
|
+
if (count === 0)
|
|
326
|
+
fs.rmSync(this.objectPath(projectId, digest), { force: true });
|
|
260
327
|
}
|
|
261
328
|
}
|
|
262
329
|
deleteVersions(projectId, rows) {
|
|
@@ -292,6 +359,7 @@ export class ProjectHistoryService {
|
|
|
292
359
|
fs.rmSync(this.objectPath(projectId, digest), { force: true });
|
|
293
360
|
}
|
|
294
361
|
restoreProjectTree(projectId, manifest) {
|
|
362
|
+
assertNoSourceSymlinks(this.config, projectId);
|
|
295
363
|
const root = projectRoot(this.config, projectId);
|
|
296
364
|
const live = sourceRoot(this.config, projectId);
|
|
297
365
|
const temporary = path.join(root, `.history-restore-${randomUUID()}`);
|
package/dist/server/http.js
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
export class ValidationError extends Error {
|
|
2
|
+
statusCode = 400;
|
|
3
|
+
code = "REQUEST_INVALID";
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ValidationError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A collaborative draft could not be made durable on disk. Mutations that
|
|
11
|
+
* replace or remove source files must stop at this boundary instead of
|
|
12
|
+
* continuing with a stale filesystem snapshot.
|
|
13
|
+
*/
|
|
14
|
+
export class SourceFlushError extends Error {
|
|
15
|
+
statusCode = 409;
|
|
16
|
+
code = "SOURCE_FLUSH_FAILED";
|
|
17
|
+
failedPaths;
|
|
18
|
+
constructor(failedPaths = []) {
|
|
19
|
+
const paths = [...new Set(failedPaths)].slice(0, 100);
|
|
20
|
+
super(paths.length
|
|
21
|
+
? `部分源码未能保存至磁盘,无法继续项目操作:${paths.join(", ")}`
|
|
22
|
+
: "部分源码未能保存至磁盘,无法继续项目操作");
|
|
23
|
+
this.name = "SourceFlushError";
|
|
24
|
+
this.failedPaths = paths;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
1
27
|
export function apiError(reply, status, code, message, details = {}) {
|
|
2
28
|
return reply.code(status).send({ code, error: message, ...details });
|
|
3
29
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -4,44 +4,57 @@ import { loadConfig } from "./config.js";
|
|
|
4
4
|
import { activeAdminCount, openDatabase } from "./db.js";
|
|
5
5
|
import { buildApp } from "./app.js";
|
|
6
6
|
import { assertEnvironment } from "./environment.js";
|
|
7
|
+
import { acquireDataDirectoryLock } from "./instanceLock.js";
|
|
7
8
|
export async function startServer(configPath) {
|
|
8
9
|
const config = loadConfig(configPath);
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
db.close();
|
|
13
|
-
throw new Error("No active administrator found. Run `texlite init` first (or `npm run init` from a source checkout); the server will not start.");
|
|
14
|
-
}
|
|
15
|
-
let app;
|
|
10
|
+
const lock = acquireDataDirectoryLock(config);
|
|
11
|
+
let db = null;
|
|
12
|
+
let app = null;
|
|
16
13
|
try {
|
|
14
|
+
const environment = await assertEnvironment(config);
|
|
15
|
+
db = openDatabase(config);
|
|
16
|
+
if (activeAdminCount(db) === 0) {
|
|
17
|
+
throw new Error("No active administrator found. Run `texlite init` first (or `npm run init` from a source checkout); the server will not start.");
|
|
18
|
+
}
|
|
17
19
|
app = await buildApp(config, db);
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
db.close();
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
|
-
try {
|
|
24
20
|
await app.listen({ host: config.host, port: config.port });
|
|
21
|
+
app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
|
|
22
|
+
app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
|
|
23
|
+
if (typeof process.send === "function")
|
|
24
|
+
process.send("ready");
|
|
25
|
+
return {
|
|
26
|
+
address: `http://${config.host}:${config.port}`,
|
|
27
|
+
close: async () => {
|
|
28
|
+
try {
|
|
29
|
+
if (app)
|
|
30
|
+
await app.close();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
try {
|
|
34
|
+
db?.close();
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
lock.release();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
25
42
|
}
|
|
26
43
|
catch (error) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
|
|
31
|
-
app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
|
|
32
|
-
if (typeof process.send === "function")
|
|
33
|
-
process.send("ready");
|
|
34
|
-
return {
|
|
35
|
-
address: `http://${config.host}:${config.port}`,
|
|
36
|
-
close: async () => {
|
|
37
|
-
try {
|
|
44
|
+
try {
|
|
45
|
+
if (app)
|
|
38
46
|
await app.close();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
try {
|
|
50
|
+
db?.close();
|
|
39
51
|
}
|
|
40
52
|
finally {
|
|
41
|
-
|
|
53
|
+
lock.release();
|
|
42
54
|
}
|
|
43
55
|
}
|
|
44
|
-
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
45
58
|
}
|
|
46
59
|
export async function serve(configPath) {
|
|
47
60
|
const running = await startServer(configPath);
|