texlite 0.10.0 → 0.12.0
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/DESIGN.md +44 -26
- package/OPERATIONS.md +12 -6
- package/README.md +9 -4
- package/README.zh-CN.md +7 -4
- package/dist/client/assets/AdminUsers-DMsBqC48.js +1 -0
- package/dist/client/assets/{CitationLibraryDialog-CU2b0xtm.js → CitationLibraryDialog-C_333dTc.js} +1 -1
- package/dist/client/assets/Dashboard-EOaNnS1A.js +2 -0
- package/dist/client/assets/GitDialog-DU-sVP-o.js +3 -0
- package/dist/client/assets/GitHubAccountDialog-IrB3M7nt.js +1 -0
- package/dist/client/assets/HistoryDialog-CDUbT7yG.js +7 -0
- package/dist/client/assets/LatexEditor-BYgW2otE.js +138 -0
- package/dist/client/assets/PdfPreview--HsIb8Fn.js +1 -0
- package/dist/client/assets/ProjectIconPickerDialog-CaF7Kkda.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-_JY0yVob.js → ProjectNavigationDialogs-3Ce7-tVp.js} +1 -1
- package/dist/client/assets/ProjectSettings-9hYSUKDo.js +1 -0
- package/dist/client/assets/ProjectWorkspace-D9zmy0KR.js +32 -0
- package/dist/client/assets/{SelectionHistoryDialog-D8CeqjLD.js → SelectionHistoryDialog-COpXO4kL.js} +2 -2
- package/dist/client/assets/SystemMetricsDialog-D9dthbAB.js +1 -0
- package/dist/client/assets/{TagManagementDialog-4R1Gs43j.js → TagManagementDialog-S8zDQAaB.js} +1 -1
- package/dist/client/assets/{WordCountDialog-C-MCscAu.js → WordCountDialog-CMsFa_pM.js} +1 -1
- package/dist/client/assets/check-Hv1kodh8.js +1 -0
- package/dist/client/assets/chktex-BFELQm91.js +3 -0
- package/dist/client/assets/{editor-B9HNwtsd.js → editor-taw1iptB.js} +1 -1
- package/dist/client/assets/external-link-C2GCFJxP.js +1 -0
- package/dist/client/assets/github-B_Eh2y6s.js +1 -0
- package/dist/client/assets/{hard-drive-Bsk-RETn.js → hard-drive-bzz70DQ5.js} +1 -1
- package/dist/client/assets/{index-Csp5J5aI.js → index-CXtmuEUm.js} +1 -1
- package/dist/client/assets/index-CxrDisif.css +1 -0
- package/dist/client/assets/index-Dik-2GCl.js +10 -0
- package/dist/client/assets/{minimize-2-92UqFWI9.js → minimize-2-CmPZ9Iwp.js} +1 -1
- package/dist/client/assets/{minus-bx4ZLZnb.js → minus-Dzy8rgfA.js} +1 -1
- package/dist/client/assets/{plus-DQg_bV50.js → plus-DlX82JCl.js} +1 -1
- package/dist/client/assets/{refresh-cw-BoMEkk5J.js → refresh-cw-BWndInnG.js} +1 -1
- package/dist/client/assets/{search-UZspmceH.js → search-DMqOy-Ex.js} +1 -1
- package/dist/client/assets/{sigma-C0IvFdKV.js → sigma-B0zOJKVj.js} +1 -1
- package/dist/client/assets/{spellCheck-DyKIHf5C.js → spellCheck-BjkrQxRU.js} +1 -1
- package/dist/client/assets/{tags-BTUtvaAc.js → tags-XvdXg-yc.js} +1 -1
- package/dist/client/assets/{type-XvvfEJRs.js → type-DsGlKUKR.js} +1 -1
- package/dist/client/assets/{user-plus-Chr9T97G.js → user-plus-Bcq_EsqH.js} +1 -1
- package/dist/client/assets/{x-DlFXD9Vl.js → x-BJOMjm8Z.js} +1 -1
- package/dist/client/index.html +3 -3
- package/dist/server/app.js +8 -0
- package/dist/server/chktex.js +377 -0
- package/dist/server/collaboration.js +6 -0
- package/dist/server/db.js +35 -1
- package/dist/server/environment.js +1 -0
- package/dist/server/git.js +64 -29
- package/dist/server/harper.js +14 -0
- package/dist/server/history.js +18 -9
- package/dist/server/i18n.js +14 -4
- package/dist/server/projectImportUploads.js +133 -0
- package/dist/server/routes/projectFiles.js +3 -0
- package/dist/server/routes/projectGit.js +10 -11
- package/dist/server/routes/projectHistory.js +32 -5
- package/dist/server/routes/projectMembers.js +0 -5
- package/dist/server/routes/projectShared.js +1 -0
- package/dist/server/routes/projects.js +97 -15
- package/dist/server/routes/users.js +0 -2
- package/dist/shared/historyFiles.js +6 -0
- package/dist/shared/writingChecks.js +4 -0
- package/package.json +1 -1
- package/dist/client/assets/AdminUsers-C7oU_1Y_.js +0 -1
- package/dist/client/assets/Dashboard-BB4RO4x2.js +0 -2
- package/dist/client/assets/GitDialog-C2Np1i8v.js +0 -3
- package/dist/client/assets/HistoryDialog-CdoYeLyX.js +0 -7
- package/dist/client/assets/LatexEditor-Dmy6_gqt.js +0 -138
- package/dist/client/assets/PdfPreview-fDxKh6hP.js +0 -1
- package/dist/client/assets/ProjectIconPickerDialog-CsjMKq7h.js +0 -1
- package/dist/client/assets/ProjectSettings-DXGA-lmB.js +0 -1
- package/dist/client/assets/ProjectWorkspace-mCYjyc6e.js +0 -32
- package/dist/client/assets/SystemMetricsDialog-BZx7ZMQt.js +0 -1
- package/dist/client/assets/check-Jf8WBxDX.js +0 -1
- package/dist/client/assets/index-B6rP7QsM.js +0 -12
- package/dist/client/assets/index-DGZNAyxW.css +0 -1
- package/dist/client/assets/key-round-DqptHHKC.js +0 -1
package/dist/server/git.js
CHANGED
|
@@ -22,6 +22,7 @@ export class ProjectGitService {
|
|
|
22
22
|
await this.ensureGitAvailable();
|
|
23
23
|
assertNoSourceSymlinks(this.config, project.id);
|
|
24
24
|
const settings = this.settings(project.id);
|
|
25
|
+
const account = this.userSettings(project.owner_id);
|
|
25
26
|
const gitDirectory = path.join(sourceRoot(this.config, project.id), ".git");
|
|
26
27
|
let gitStat = null;
|
|
27
28
|
try {
|
|
@@ -37,8 +38,8 @@ export class ProjectGitService {
|
|
|
37
38
|
if (!initialized)
|
|
38
39
|
return {
|
|
39
40
|
initialized: false,
|
|
40
|
-
tokenConfigured: Boolean(
|
|
41
|
-
githubLogin:
|
|
41
|
+
tokenConfigured: Boolean(account?.token_ciphertext),
|
|
42
|
+
githubLogin: account?.github_login ?? null,
|
|
42
43
|
remoteUrl: settings?.remote_url ?? null,
|
|
43
44
|
repositoryName: settings?.repository_name ?? null,
|
|
44
45
|
repositoryHtmlUrl: settings?.repository_html_url ?? null,
|
|
@@ -56,11 +57,17 @@ export class ProjectGitService {
|
|
|
56
57
|
const branch = branchResult.code === 0 ? branchResult.stdout.trim() : null;
|
|
57
58
|
const remoteResult = await this.git(root, ["remote", "get-url", "origin"], [2]);
|
|
58
59
|
const latestResult = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"], [128]);
|
|
59
|
-
const
|
|
60
|
+
const upstreamResult = branch
|
|
61
|
+
? await this.git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], [128])
|
|
62
|
+
: null;
|
|
63
|
+
// A newly initialized backup repository has no upstream until its first
|
|
64
|
+
// push. In that case, every local commit is pending for the remote.
|
|
65
|
+
const aheadRevision = upstreamResult?.code === 0 ? "@{upstream}..HEAD" : "HEAD";
|
|
66
|
+
const aheadResult = branch ? await this.git(root, ["rev-list", "--count", aheadRevision], [128]) : null;
|
|
60
67
|
return {
|
|
61
68
|
initialized: true,
|
|
62
|
-
tokenConfigured: Boolean(
|
|
63
|
-
githubLogin:
|
|
69
|
+
tokenConfigured: Boolean(account?.token_ciphertext),
|
|
70
|
+
githubLogin: account?.github_login ?? null,
|
|
64
71
|
remoteUrl: settings?.remote_url ?? (remoteResult.code === 0 ? remoteResult.stdout.trim() : null),
|
|
65
72
|
repositoryName: settings?.repository_name ?? null,
|
|
66
73
|
repositoryHtmlUrl: settings?.repository_html_url ?? null,
|
|
@@ -69,41 +76,43 @@ export class ProjectGitService {
|
|
|
69
76
|
dirty: Boolean(porcelain),
|
|
70
77
|
restorable: porcelain.split("\n").some((line) => Boolean(line) && !line.startsWith("??")),
|
|
71
78
|
changedFiles: porcelain ? porcelain.split("\n").length : 0,
|
|
72
|
-
ahead: aheadResult
|
|
79
|
+
ahead: aheadResult?.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
|
|
73
80
|
latestCommit: latestResult.code === 0 ? parseCommit(latestResult.stdout.trim()) : null
|
|
74
81
|
};
|
|
75
82
|
}
|
|
76
|
-
|
|
83
|
+
githubAccountStatus(userId) {
|
|
84
|
+
const settings = this.userSettings(userId);
|
|
85
|
+
return { tokenConfigured: Boolean(settings?.token_ciphertext), githubLogin: settings?.github_login ?? null };
|
|
86
|
+
}
|
|
87
|
+
async configureUserToken(userId, tokenInput) {
|
|
77
88
|
const token = tokenInput.trim();
|
|
78
89
|
if (token.length < 20 || token.length > 500)
|
|
79
90
|
throw httpError(400, "GIT_TOKEN_INVALID");
|
|
80
|
-
await this.ensureGitAvailable();
|
|
81
91
|
const account = await this.githubRequest(token, "/user", { method: "GET" });
|
|
82
92
|
if (typeof account.login !== "string" || !account.login)
|
|
83
93
|
throw httpError(502, "GIT_ACCOUNT_INVALID");
|
|
84
|
-
await this.ensureRepository(project);
|
|
85
94
|
const timestamp = new Date().toISOString();
|
|
86
|
-
this.db.prepare(`INSERT INTO
|
|
87
|
-
(
|
|
88
|
-
VALUES (?, ?, ?,
|
|
89
|
-
ON CONFLICT(
|
|
95
|
+
this.db.prepare(`INSERT INTO user_github_settings
|
|
96
|
+
(user_id, token_ciphertext, github_login, created_at, updated_at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?)
|
|
98
|
+
ON CONFLICT(user_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
|
|
90
99
|
github_login = excluded.github_login, updated_at = excluded.updated_at`)
|
|
91
|
-
.run(
|
|
92
|
-
return this.
|
|
100
|
+
.run(userId, encryptToken(this.config, token), account.login, timestamp, timestamp);
|
|
101
|
+
return this.githubAccountStatus(userId);
|
|
93
102
|
}
|
|
94
|
-
|
|
95
|
-
this.db.prepare("
|
|
96
|
-
|
|
97
|
-
return this.status(project);
|
|
103
|
+
removeUserToken(userId) {
|
|
104
|
+
this.db.prepare("DELETE FROM user_github_settings WHERE user_id = ?").run(userId);
|
|
105
|
+
return this.githubAccountStatus(userId);
|
|
98
106
|
}
|
|
99
107
|
async createGitHubRepository(project, name, isPrivate) {
|
|
100
108
|
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
|
|
101
109
|
throw httpError(400, "GIT_REPOSITORY_NAME_INVALID");
|
|
102
110
|
await this.ensureGitAvailable();
|
|
103
|
-
const
|
|
104
|
-
|
|
111
|
+
const account = this.requireUserTokenSettings(project.owner_id);
|
|
112
|
+
const settings = this.settings(project.id);
|
|
113
|
+
if (settings?.remote_url)
|
|
105
114
|
throw httpError(409, "GIT_REMOTE_ALREADY_CONFIGURED");
|
|
106
|
-
const token = decryptToken(this.config,
|
|
115
|
+
const token = decryptToken(this.config, account.token_ciphertext);
|
|
107
116
|
const repository = await this.githubRequest(token, "/user/repos", {
|
|
108
117
|
method: "POST",
|
|
109
118
|
body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
|
|
@@ -112,6 +121,7 @@ export class ProjectGitService {
|
|
|
112
121
|
throw httpError(502, "GIT_REPOSITORY_RESPONSE_INVALID");
|
|
113
122
|
}
|
|
114
123
|
await this.ensureRepository(project);
|
|
124
|
+
this.ensureProjectSettings(project.id);
|
|
115
125
|
const root = sourceRoot(this.config, project.id);
|
|
116
126
|
const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
|
|
117
127
|
if (currentRemote.code === 0)
|
|
@@ -149,8 +159,8 @@ export class ProjectGitService {
|
|
|
149
159
|
return parseCommit(result.stdout.trim());
|
|
150
160
|
}
|
|
151
161
|
async push(project) {
|
|
152
|
-
const
|
|
153
|
-
if (!settings.remote_url)
|
|
162
|
+
const account = this.requireUserTokenSettings(project.owner_id);
|
|
163
|
+
if (!this.settings(project.id)?.remote_url)
|
|
154
164
|
throw httpError(409, "GIT_REMOTE_NOT_CONFIGURED");
|
|
155
165
|
const root = this.requireRepository(project.id);
|
|
156
166
|
assertNoSourceSymlinks(this.config, project.id);
|
|
@@ -163,7 +173,7 @@ export class ProjectGitService {
|
|
|
163
173
|
const branch = branchResult.stdout.trim();
|
|
164
174
|
if (!/^[A-Za-z0-9._/-]+$/.test(branch))
|
|
165
175
|
throw httpError(400, "GIT_BRANCH_INVALID");
|
|
166
|
-
const token = decryptToken(this.config,
|
|
176
|
+
const token = decryptToken(this.config, account.token_ciphertext);
|
|
167
177
|
await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
|
|
168
178
|
return this.status(project);
|
|
169
179
|
}
|
|
@@ -269,12 +279,20 @@ export class ProjectGitService {
|
|
|
269
279
|
settings(projectId) {
|
|
270
280
|
return this.db.prepare("SELECT * FROM project_git_settings WHERE project_id = ?").get(projectId);
|
|
271
281
|
}
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
userSettings(userId) {
|
|
283
|
+
return this.db.prepare("SELECT * FROM user_github_settings WHERE user_id = ?").get(userId);
|
|
284
|
+
}
|
|
285
|
+
requireUserTokenSettings(userId) {
|
|
286
|
+
const settings = this.userSettings(userId);
|
|
274
287
|
if (!settings?.token_ciphertext)
|
|
275
288
|
throw httpError(409, "GIT_TOKEN_NOT_CONFIGURED");
|
|
276
289
|
return settings;
|
|
277
290
|
}
|
|
291
|
+
ensureProjectSettings(projectId) {
|
|
292
|
+
const timestamp = new Date().toISOString();
|
|
293
|
+
this.db.prepare(`INSERT INTO project_git_settings (project_id, default_branch, created_at, updated_at)
|
|
294
|
+
VALUES (?, 'main', ?, ?) ON CONFLICT(project_id) DO NOTHING`).run(projectId, timestamp, timestamp);
|
|
295
|
+
}
|
|
278
296
|
async verifyCommit(root, revision) {
|
|
279
297
|
const result = await this.git(root, ["rev-parse", "--verify", `${revision}^{commit}`], [128]);
|
|
280
298
|
if (result.code !== 0)
|
|
@@ -303,7 +321,10 @@ export class ProjectGitService {
|
|
|
303
321
|
}
|
|
304
322
|
});
|
|
305
323
|
if (!response.ok) {
|
|
306
|
-
|
|
324
|
+
const authError = classifyGitHubAuthFailure(response.status);
|
|
325
|
+
if (authError)
|
|
326
|
+
throw httpError(400, authError);
|
|
327
|
+
throw httpError(502, "GIT_OPERATION_FAILED");
|
|
307
328
|
}
|
|
308
329
|
return await response.json();
|
|
309
330
|
}
|
|
@@ -376,13 +397,27 @@ export class ProjectGitService {
|
|
|
376
397
|
if (size > MAX_COMMAND_OUTPUT)
|
|
377
398
|
return reject(httpError(413, "GIT_OUTPUT_TOO_LARGE"));
|
|
378
399
|
if (result.code !== 0 && !allowedCodes.includes(result.code)) {
|
|
379
|
-
|
|
400
|
+
const pushAuthError = args[0] === "push" ? classifyGitPushFailure(result.stderr) : null;
|
|
401
|
+
return reject(httpError(400, pushAuthError ?? "GIT_COMMAND_FAILED"));
|
|
380
402
|
}
|
|
381
403
|
resolve(result);
|
|
382
404
|
});
|
|
383
405
|
});
|
|
384
406
|
}
|
|
385
407
|
}
|
|
408
|
+
function classifyGitHubAuthFailure(status) {
|
|
409
|
+
// GitHub's 401/403 responses do not reliably distinguish invalid or expired
|
|
410
|
+
// credentials from insufficient repository access (including SSO/policy
|
|
411
|
+
// restrictions), so give users one actionable message for these cases.
|
|
412
|
+
return status === 401 || status === 403 ? "GIT_TOKEN_REJECTED" : null;
|
|
413
|
+
}
|
|
414
|
+
export function classifyGitPushFailure(stderr) {
|
|
415
|
+
if (/saml|single[- ]sign[- ]on|\bsso\b/i.test(stderr) ||
|
|
416
|
+
/invalid username or token|bad credentials|authentication failed|could not read username/i.test(stderr) ||
|
|
417
|
+
/write access.{0,80}(?:not granted|denied)|permission to .{1,200} denied|repository not found|returned error:\s*403/i.test(stderr))
|
|
418
|
+
return "GIT_TOKEN_REJECTED";
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
386
421
|
function parseCommit(line) {
|
|
387
422
|
const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
|
|
388
423
|
return { sha, shortSha, authorName, authorEmail, authoredAt, message: message.join("\x1f") };
|
package/dist/server/harper.js
CHANGED
|
@@ -115,6 +115,20 @@ export class HarperService {
|
|
|
115
115
|
async preload() {
|
|
116
116
|
await this.ensureAvailable();
|
|
117
117
|
}
|
|
118
|
+
/** Availability probe only; does not run a writing check or join its queue. */
|
|
119
|
+
async isAvailable() {
|
|
120
|
+
if (this.disposed)
|
|
121
|
+
return false;
|
|
122
|
+
try {
|
|
123
|
+
await this.ensureAvailable();
|
|
124
|
+
return !this.disposed;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof HarperUnavailableError)
|
|
128
|
+
return false;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
118
132
|
/**
|
|
119
133
|
* Schedule one host-side check. A lane represents one browser page's
|
|
120
134
|
* current file, so an edit can replace only its own stale waiting request
|
package/dist/server/history.js
CHANGED
|
@@ -4,6 +4,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
4
4
|
import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
5
5
|
import { httpError } from "./http.js";
|
|
6
6
|
import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
7
|
+
import { isUtf8 } from "node:buffer";
|
|
8
|
+
import { isHistoryTextFile } from "../shared/historyFiles.js";
|
|
7
9
|
const AUTOSAVE_COALESCE_MS = 2 * 60_000;
|
|
8
10
|
// Logical stored payload; SQLite pages, indexes and WAL are shared database overhead.
|
|
9
11
|
function historyMetadataBytes(row) {
|
|
@@ -39,7 +41,9 @@ export class ProjectHistoryService {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
const changedPaths = previousManifest ? changedManifestPaths(previousManifest, manifest) : Object.keys(manifest.files).sort();
|
|
42
|
-
|
|
44
|
+
// Settings alone do not create a file-history entry. Keep the baseline
|
|
45
|
+
// current so the next real file change captures the effective settings.
|
|
46
|
+
if (changedPaths.length === 0) {
|
|
43
47
|
this.saveBaseline(projectId, manifest);
|
|
44
48
|
return null;
|
|
45
49
|
}
|
|
@@ -72,7 +76,8 @@ export class ProjectHistoryService {
|
|
|
72
76
|
list(projectId, limit = 100) {
|
|
73
77
|
const rows = this.db.prepare(`SELECT history.*, user.username AS author_username, user.display_name AS author_name
|
|
74
78
|
FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
|
|
75
|
-
WHERE history.project_id = ?
|
|
79
|
+
WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0
|
|
80
|
+
ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
|
|
76
81
|
.all(projectId, Math.min(200, Math.max(1, limit)));
|
|
77
82
|
return rows.map((row) => versionJson(row));
|
|
78
83
|
}
|
|
@@ -97,7 +102,7 @@ export class ProjectHistoryService {
|
|
|
97
102
|
const rows = this.db.prepare(`SELECT history.*, history.rowid AS history_rowid,
|
|
98
103
|
user.username AS author_username, user.display_name AS author_name
|
|
99
104
|
FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
|
|
100
|
-
WHERE history.project_id = ? ${cursorFilter}
|
|
105
|
+
WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0 ${cursorFilter}
|
|
101
106
|
ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
|
|
102
107
|
.all(...values);
|
|
103
108
|
const pageRows = rows.slice(0, limitValue);
|
|
@@ -231,7 +236,8 @@ export class ProjectHistoryService {
|
|
|
231
236
|
previousVersion(projectId, versionId) {
|
|
232
237
|
const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
|
|
233
238
|
JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
|
|
234
|
-
WHERE older.project_id = ? AND (older.
|
|
239
|
+
WHERE older.project_id = ? AND json_array_length(older.changed_paths_json) > 0
|
|
240
|
+
AND (older.created_at < selected.created_at
|
|
235
241
|
OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
|
|
236
242
|
ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
|
|
237
243
|
return row ? this.version(row.id, projectId) : null;
|
|
@@ -243,6 +249,8 @@ export class ProjectHistoryService {
|
|
|
243
249
|
}
|
|
244
250
|
readTextFile(projectId, versionId, filePathInput) {
|
|
245
251
|
const filePath = safeRelativePath(filePathInput);
|
|
252
|
+
if (!isHistoryTextFile(filePath))
|
|
253
|
+
return null;
|
|
246
254
|
const entry = this.manifest(projectId, versionId)?.files[filePath];
|
|
247
255
|
if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
|
|
248
256
|
return null;
|
|
@@ -272,8 +280,8 @@ export class ProjectHistoryService {
|
|
|
272
280
|
return { restoredPaths: [filePath], manifest };
|
|
273
281
|
}
|
|
274
282
|
this.restoreProjectTree(projectId, manifest);
|
|
275
|
-
this.db.prepare("UPDATE projects SET main_file = ?, engine = ?, latexmkrc = ? WHERE id = ?")
|
|
276
|
-
.run(manifest.settings.mainFile, manifest.settings.engine, manifest.settings.latexmkrc, projectId);
|
|
283
|
+
this.db.prepare("UPDATE projects SET main_file = ?, engine = ?, latexmkrc = ?, chktex_enabled = ? WHERE id = ?")
|
|
284
|
+
.run(manifest.settings.mainFile, manifest.settings.engine, manifest.settings.latexmkrc, manifest.settings.chktexEnabled ? 1 : 0, projectId);
|
|
277
285
|
return { restoredPaths: Object.keys(manifest.files).sort(), manifest };
|
|
278
286
|
}
|
|
279
287
|
assertRestoreTargetIsFile(projectId, filePath) {
|
|
@@ -364,7 +372,8 @@ export class ProjectHistoryService {
|
|
|
364
372
|
*/
|
|
365
373
|
readStoredObject(projectId, digest, filePath) {
|
|
366
374
|
try {
|
|
367
|
-
|
|
375
|
+
const bytes = fs.readFileSync(this.objectPath(projectId, digest));
|
|
376
|
+
return !bytes.includes(0) && isUtf8(bytes) ? bytes.toString("utf8") : null;
|
|
368
377
|
}
|
|
369
378
|
catch (error) {
|
|
370
379
|
this.rethrowHistoryObjectError(error, filePath);
|
|
@@ -570,7 +579,7 @@ export class ProjectHistoryService {
|
|
|
570
579
|
}
|
|
571
580
|
}
|
|
572
581
|
function settings(project) {
|
|
573
|
-
return { mainFile: project.main_file, engine: project.engine, latexmkrc: project.latexmkrc };
|
|
582
|
+
return { mainFile: project.main_file, engine: project.engine, latexmkrc: project.latexmkrc, chktexEnabled: Boolean(project.chktex_enabled) };
|
|
574
583
|
}
|
|
575
584
|
function cloneManifest(manifest) {
|
|
576
585
|
return { version: 1, files: { ...manifest.files }, settings: { ...manifest.settings } };
|
|
@@ -579,7 +588,7 @@ function parseManifest(value) {
|
|
|
579
588
|
const parsed = JSON.parse(value);
|
|
580
589
|
if (parsed.version !== 1 || !parsed.files || !parsed.settings)
|
|
581
590
|
throw new Error("History manifest is invalid");
|
|
582
|
-
return parsed;
|
|
591
|
+
return { ...parsed, settings: { ...parsed.settings, chktexEnabled: parsed.settings.chktexEnabled === true } };
|
|
583
592
|
}
|
|
584
593
|
function parseStringArray(value) {
|
|
585
594
|
try {
|
package/dist/server/i18n.js
CHANGED
|
@@ -11,6 +11,10 @@ const enMessages = {
|
|
|
11
11
|
CITATION_NOT_FOUND: "The citation does not exist.",
|
|
12
12
|
CITATION_TAG_NOT_FOUND: "The citation tag does not exist.",
|
|
13
13
|
CITATION_TOO_LARGE: "The BibTeX entry is too large.",
|
|
14
|
+
CHK_TEX_SOURCE_INVALID: "The source to check is invalid.",
|
|
15
|
+
CHK_TEX_SOURCE_TOO_LARGE: "The source to check is too large.",
|
|
16
|
+
CHK_TEX_SUPERSEDED: "A newer ChkTeX check replaced this request.",
|
|
17
|
+
CHK_TEX_UNAVAILABLE: "ChkTeX is not installed or cannot run on this server. Install the TeX Live chktex package and try again.",
|
|
14
18
|
COMMENT_DELETE_FORBIDDEN: "You can only delete your own comments.",
|
|
15
19
|
COMMENT_EDIT_FORBIDDEN: "You can only edit your own comments.",
|
|
16
20
|
COMMENT_FILE_NOT_FOUND: "The file for this comment does not exist.",
|
|
@@ -42,7 +46,7 @@ const enMessages = {
|
|
|
42
46
|
GIT_REVISION_INVALID: "Choose a Git revision to check out.",
|
|
43
47
|
GIT_TOKEN_INVALID: "Enter a GitHub token.",
|
|
44
48
|
GIT_TOKEN_NOT_CONFIGURED: "Configure a GitHub token first.",
|
|
45
|
-
GIT_TOKEN_REJECTED: "GitHub rejected this token.
|
|
49
|
+
GIT_TOKEN_REJECTED: "GitHub rejected this token. It may be invalid, expired, or lack the required permissions.",
|
|
46
50
|
GIT_UNAVAILABLE: "Git is not available on this server.",
|
|
47
51
|
HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
|
|
48
52
|
HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
|
|
@@ -77,6 +81,7 @@ const enMessages = {
|
|
|
77
81
|
PROJECT_ICON_INVALID: "Enter a valid installed Lucide icon short name.",
|
|
78
82
|
PROJECT_NOT_FOUND: "The project does not exist or is no longer accessible.",
|
|
79
83
|
PROJECT_OWNER_ONLY: "Only the project owner can perform this action.",
|
|
84
|
+
PROJECT_SETTINGS_CONFLICT: "These project settings changed in another session. Refresh the project page before saving again.",
|
|
80
85
|
PROJECT_TRANSFER_FORBIDDEN: "Only the current project owner can transfer ownership.",
|
|
81
86
|
PROJECT_TRANSFER_SELF: "You already own this project.",
|
|
82
87
|
PROJECT_TRANSFER_TARGET_INVALID: "Choose a new project owner.",
|
|
@@ -106,7 +111,7 @@ const enMessages = {
|
|
|
106
111
|
WORD_COUNT_FAILED: "TeXcount could not count this source.",
|
|
107
112
|
WORD_COUNT_SOURCE_INVALID: "Select LaTeX source text to count.",
|
|
108
113
|
WORD_COUNT_SOURCE_TOO_LARGE: "The selected source is too large to count.",
|
|
109
|
-
WORD_COUNT_UNAVAILABLE: "
|
|
114
|
+
WORD_COUNT_UNAVAILABLE: "The TeX Live texcount package is not installed on this server. Install it and try again.",
|
|
110
115
|
ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
|
|
111
116
|
ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
|
|
112
117
|
ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
|
|
@@ -143,6 +148,10 @@ const zhMessages = {
|
|
|
143
148
|
CITATION_NOT_FOUND: "引用条目不存在。",
|
|
144
149
|
CITATION_TAG_NOT_FOUND: "引用标签不存在。",
|
|
145
150
|
CITATION_TOO_LARGE: "BibTeX 条目过大。",
|
|
151
|
+
CHK_TEX_SOURCE_INVALID: "待检查源码格式不正确。",
|
|
152
|
+
CHK_TEX_SOURCE_TOO_LARGE: "待检查源码过大。",
|
|
153
|
+
CHK_TEX_SUPERSEDED: "更新的 ChkTeX 检查已替代此请求。",
|
|
154
|
+
CHK_TEX_UNAVAILABLE: "服务器未安装 ChkTeX,或 ChkTeX 当前无法运行。请在服务器安装 TeX Live 的 chktex 包后重试。",
|
|
146
155
|
COMMENT_DELETE_FORBIDDEN: "只能删除自己的批注。",
|
|
147
156
|
COMMENT_EDIT_FORBIDDEN: "只能编辑自己的批注。",
|
|
148
157
|
COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
|
|
@@ -174,7 +183,7 @@ const zhMessages = {
|
|
|
174
183
|
GIT_REVISION_INVALID: "请选择要 checkout 的 Git 版本。",
|
|
175
184
|
GIT_TOKEN_INVALID: "请输入 GitHub token。",
|
|
176
185
|
GIT_TOKEN_NOT_CONFIGURED: "请先配置 GitHub token。",
|
|
177
|
-
GIT_TOKEN_REJECTED: "GitHub
|
|
186
|
+
GIT_TOKEN_REJECTED: "GitHub 拒绝了此 token:它可能无效、已过期或权限不足。",
|
|
178
187
|
GIT_UNAVAILABLE: "服务器未安装 Git。",
|
|
179
188
|
HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
|
|
180
189
|
HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
|
|
@@ -209,6 +218,7 @@ const zhMessages = {
|
|
|
209
218
|
PROJECT_ICON_INVALID: "请输入已安装的有效 Lucide 图标短名称。",
|
|
210
219
|
PROJECT_NOT_FOUND: "项目不存在或你已无法访问。",
|
|
211
220
|
PROJECT_OWNER_ONLY: "只有项目所有者可以执行此操作。",
|
|
221
|
+
PROJECT_SETTINGS_CONFLICT: "项目设置已在其他会话中更改。请刷新项目页面后再保存。",
|
|
212
222
|
PROJECT_TRANSFER_FORBIDDEN: "只有当前项目所有者可以转让项目。",
|
|
213
223
|
PROJECT_TRANSFER_SELF: "你已经是该项目的所有者。",
|
|
214
224
|
PROJECT_TRANSFER_TARGET_INVALID: "请选择新的项目所有者。",
|
|
@@ -238,7 +248,7 @@ const zhMessages = {
|
|
|
238
248
|
WORD_COUNT_FAILED: "TeXcount 无法统计这段源码。",
|
|
239
249
|
WORD_COUNT_SOURCE_INVALID: "请选择 LaTeX 源码后再统计。",
|
|
240
250
|
WORD_COUNT_SOURCE_TOO_LARGE: "所选源码过大,无法统计。",
|
|
241
|
-
WORD_COUNT_UNAVAILABLE: "服务器未安装
|
|
251
|
+
WORD_COUNT_UNAVAILABLE: "服务器未安装 TeX Live 的 texcount 包,请安装后重试。",
|
|
242
252
|
ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
|
|
243
253
|
ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
|
|
244
254
|
ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
const CHUNK_SIZE = 4 * 1024 * 1024;
|
|
5
|
+
const SESSION_TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
7
|
+
const MAX_ACTIVE_UPLOADS = 8;
|
|
8
|
+
const MAX_ACTIVE_UPLOADS_PER_USER = 2;
|
|
9
|
+
const MAX_IN_FLIGHT_CHUNKS_PER_UPLOAD = 4;
|
|
10
|
+
export class ProjectImportUploadError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "ProjectImportUploadError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Bounded, per-user temporary storage for parallel ZIP upload chunks. */
|
|
19
|
+
export class ProjectImportUploads {
|
|
20
|
+
rootDirectory;
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
cleanupTimer;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.rootDirectory = path.join(config.dataDir, "tmp", "project-imports");
|
|
25
|
+
this.cleanupTimer = setInterval(() => { void this.cleanupExpired().catch(() => undefined); }, CLEANUP_INTERVAL_MS);
|
|
26
|
+
this.cleanupTimer.unref();
|
|
27
|
+
}
|
|
28
|
+
async initialize() {
|
|
29
|
+
await fs.mkdir(this.rootDirectory, { recursive: true, mode: 0o700 });
|
|
30
|
+
// Any remaining entries are abandoned uploads from a previous server run.
|
|
31
|
+
const entries = await fs.readdir(this.rootDirectory, { withFileTypes: true });
|
|
32
|
+
await Promise.all(entries.map((entry) => fs.rm(path.join(this.rootDirectory, entry.name), { recursive: true, force: true })));
|
|
33
|
+
}
|
|
34
|
+
async start(userId, filename, size, maxBytes) {
|
|
35
|
+
await this.cleanupExpired();
|
|
36
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > maxBytes)
|
|
37
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_SIZE_INVALID");
|
|
38
|
+
const active = [...this.sessions.values()];
|
|
39
|
+
const reservedBytes = active.reduce((sum, session) => sum + session.size, 0);
|
|
40
|
+
if (active.length >= MAX_ACTIVE_UPLOADS || active.filter((session) => session.userId === userId).length >= MAX_ACTIVE_UPLOADS_PER_USER || reservedBytes + size > maxBytes * 2) {
|
|
41
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_LIMIT");
|
|
42
|
+
}
|
|
43
|
+
const id = randomUUID();
|
|
44
|
+
const directory = path.join(this.rootDirectory, id);
|
|
45
|
+
await fs.mkdir(directory, { recursive: false, mode: 0o700 });
|
|
46
|
+
const chunkCount = Math.ceil(size / CHUNK_SIZE);
|
|
47
|
+
const timestamp = Date.now();
|
|
48
|
+
this.sessions.set(id, {
|
|
49
|
+
id, userId, filename, size, chunkCount, directory, received: new Set(), inFlight: new Set(),
|
|
50
|
+
updatedAt: timestamp, completing: false
|
|
51
|
+
});
|
|
52
|
+
return { uploadId: id, chunkSize: CHUNK_SIZE, chunkCount };
|
|
53
|
+
}
|
|
54
|
+
async writeChunk(userId, uploadId, index, bytes) {
|
|
55
|
+
const session = this.getSession(userId, uploadId);
|
|
56
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= session.chunkCount || session.completing) {
|
|
57
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
|
|
58
|
+
}
|
|
59
|
+
if (session.inFlight.size >= MAX_IN_FLIGHT_CHUNKS_PER_UPLOAD)
|
|
60
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_LIMIT");
|
|
61
|
+
if (session.received.has(index) || session.inFlight.has(index))
|
|
62
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_DUPLICATE");
|
|
63
|
+
const expectedSize = Math.min(CHUNK_SIZE, session.size - index * CHUNK_SIZE);
|
|
64
|
+
if (bytes.length !== expectedSize)
|
|
65
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
|
|
66
|
+
session.inFlight.add(index);
|
|
67
|
+
try {
|
|
68
|
+
await fs.writeFile(path.join(session.directory, `${index}.part`), bytes, { flag: "wx", mode: 0o600 });
|
|
69
|
+
session.received.add(index);
|
|
70
|
+
session.updatedAt = Date.now();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
session.inFlight.delete(index);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async assemble(userId, uploadId) {
|
|
77
|
+
const session = this.getSession(userId, uploadId);
|
|
78
|
+
if (session.completing || session.inFlight.size > 0 || session.received.size !== session.chunkCount) {
|
|
79
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_INCOMPLETE");
|
|
80
|
+
}
|
|
81
|
+
session.completing = true;
|
|
82
|
+
try {
|
|
83
|
+
const buffer = Buffer.allocUnsafe(session.size);
|
|
84
|
+
let offset = 0;
|
|
85
|
+
for (let index = 0; index < session.chunkCount; index += 1) {
|
|
86
|
+
const chunk = await fs.readFile(path.join(session.directory, `${index}.part`));
|
|
87
|
+
if (chunk.length !== Math.min(CHUNK_SIZE, session.size - offset))
|
|
88
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
|
|
89
|
+
chunk.copy(buffer, offset);
|
|
90
|
+
offset += chunk.length;
|
|
91
|
+
}
|
|
92
|
+
if (offset !== session.size)
|
|
93
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
|
|
94
|
+
return { filename: session.filename, buffer };
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error instanceof ProjectImportUploadError)
|
|
98
|
+
throw error;
|
|
99
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_INCOMPLETE");
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await this.removeSession(session);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async cancel(userId, uploadId) {
|
|
106
|
+
const session = this.sessions.get(uploadId);
|
|
107
|
+
if (!session || session.userId !== userId || session.completing || session.inFlight.size > 0)
|
|
108
|
+
return;
|
|
109
|
+
await this.removeSession(session);
|
|
110
|
+
}
|
|
111
|
+
async close() {
|
|
112
|
+
clearInterval(this.cleanupTimer);
|
|
113
|
+
await Promise.all([...this.sessions.values()].map((session) => this.removeSession(session)));
|
|
114
|
+
}
|
|
115
|
+
getSession(userId, uploadId) {
|
|
116
|
+
const session = this.sessions.get(uploadId);
|
|
117
|
+
if (!session || session.userId !== userId || Date.now() - session.updatedAt > SESSION_TTL_MS) {
|
|
118
|
+
if (session && Date.now() - session.updatedAt > SESSION_TTL_MS)
|
|
119
|
+
void this.removeSession(session).catch(() => undefined);
|
|
120
|
+
throw new ProjectImportUploadError("ZIP_UPLOAD_NOT_FOUND");
|
|
121
|
+
}
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
async cleanupExpired() {
|
|
125
|
+
const expired = [...this.sessions.values()].filter((session) => !session.completing && session.inFlight.size === 0 && Date.now() - session.updatedAt > SESSION_TTL_MS);
|
|
126
|
+
await Promise.all(expired.map((session) => this.removeSession(session)));
|
|
127
|
+
}
|
|
128
|
+
async removeSession(session) {
|
|
129
|
+
if (this.sessions.get(session.id) === session)
|
|
130
|
+
this.sessions.delete(session.id);
|
|
131
|
+
await fs.rm(session.directory, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -477,6 +477,9 @@ export function registerProjectFileRoutes(app, context) {
|
|
|
477
477
|
throw error;
|
|
478
478
|
}
|
|
479
479
|
fs.rmSync(absolute, { recursive: true, force: true });
|
|
480
|
+
if (currentProject.latexmkrc === relative || currentProject.latexmkrc?.startsWith(`${relative}/`)) {
|
|
481
|
+
db.prepare("UPDATE projects SET latexmkrc = NULL WHERE id = ?").run(id);
|
|
482
|
+
}
|
|
480
483
|
const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
|
|
481
484
|
touchProject(db, id, user.id);
|
|
482
485
|
collaboration.removePath(id, relative);
|
|
@@ -23,27 +23,26 @@ export function registerProjectGitRoutes(app, context) {
|
|
|
23
23
|
return { status: await projectGit.status(requireGitOwner(id, user)) };
|
|
24
24
|
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
25
25
|
});
|
|
26
|
-
app.
|
|
26
|
+
app.get("/api/account/github", async (request, reply) => {
|
|
27
|
+
const user = requireUser(request, reply, db);
|
|
28
|
+
if (!user)
|
|
29
|
+
return;
|
|
30
|
+
return { status: projectGit.githubAccountStatus(user.id) };
|
|
31
|
+
});
|
|
32
|
+
app.put("/api/account/github", async (request, reply) => {
|
|
27
33
|
const user = requireUser(request, reply, db);
|
|
28
34
|
if (!user)
|
|
29
35
|
return;
|
|
30
|
-
const { id } = request.params;
|
|
31
36
|
const body = request.body;
|
|
32
37
|
if (typeof body.token !== "string")
|
|
33
38
|
return apiError(reply, 400, "GIT_TOKEN_INVALID");
|
|
34
|
-
|
|
35
|
-
return await projectMutations.runSerialized(id, async () => {
|
|
36
|
-
return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
|
|
37
|
-
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
39
|
+
return { status: await projectGit.configureUserToken(user.id, body.token) };
|
|
38
40
|
});
|
|
39
|
-
app.delete("/api/
|
|
41
|
+
app.delete("/api/account/github", async (request, reply) => {
|
|
40
42
|
const user = requireUser(request, reply, db);
|
|
41
43
|
if (!user)
|
|
42
44
|
return;
|
|
43
|
-
|
|
44
|
-
return await projectMutations.runSerialized(id, async () => {
|
|
45
|
-
return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
|
|
46
|
-
}, { preflight: () => { requireGitOwner(id, user); } });
|
|
45
|
+
return { status: projectGit.removeUserToken(user.id) };
|
|
47
46
|
});
|
|
48
47
|
app.post("/api/projects/:id/git/repository", async (request, reply) => {
|
|
49
48
|
const user = requireUser(request, reply, db);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { isUtf8 } from "node:buffer";
|
|
2
3
|
import { requireUser } from "../auth.js";
|
|
3
4
|
import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
|
|
4
5
|
import { hashText } from "../editHistory.js";
|
|
@@ -6,6 +7,7 @@ import { HISTORY_RETENTION_DELAY_MS } from "../historyRetention.js";
|
|
|
6
7
|
import { resolveSourcePath, safeRelativePath } from "../files.js";
|
|
7
8
|
import { apiError, httpError } from "../http.js";
|
|
8
9
|
import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
|
|
10
|
+
import { isHistoryTextFile } from "../../shared/historyFiles.js";
|
|
9
11
|
import { accessibleProject, canEdit } from "../projects.js";
|
|
10
12
|
import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
|
|
11
13
|
/** Register retained project version listing, inspection, restoration, and cleanup routes. */
|
|
@@ -112,7 +114,10 @@ export function registerProjectHistoryRoutes(app, context) {
|
|
|
112
114
|
return {
|
|
113
115
|
version,
|
|
114
116
|
settings: manifest.settings,
|
|
115
|
-
files:
|
|
117
|
+
files: [
|
|
118
|
+
...Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })),
|
|
119
|
+
...version.changedPaths.filter((filePath) => !manifest.files[filePath]).map((filePath) => ({ path: filePath, size: 0, deleted: true }))
|
|
120
|
+
].sort((left, right) => left.path.localeCompare(right.path))
|
|
116
121
|
};
|
|
117
122
|
});
|
|
118
123
|
app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
|
|
@@ -125,19 +130,41 @@ export function registerProjectHistoryRoutes(app, context) {
|
|
|
125
130
|
const query = request.query;
|
|
126
131
|
const filePath = safeRelativePath(query.path ?? "");
|
|
127
132
|
return await projectMutations.runConsistentRead(id, () => {
|
|
128
|
-
const
|
|
133
|
+
const manifest = history.manifest(id, versionId);
|
|
134
|
+
const version = history.version(versionId, id);
|
|
135
|
+
if (!manifest || !version)
|
|
136
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
137
|
+
if (!manifest.files[filePath] && !version.changedPaths.includes(filePath))
|
|
138
|
+
return apiError(reply, 404, "HISTORY_FILE_NOT_FOUND");
|
|
139
|
+
// Never decode attachment bytes as UTF-8, including direct API calls.
|
|
140
|
+
if (!isHistoryTextFile(filePath))
|
|
141
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
142
|
+
const historical = manifest.files[filePath] ? history.readTextFile(id, versionId, filePath) : "";
|
|
129
143
|
if (historical === null)
|
|
130
144
|
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
131
145
|
let comparison = "";
|
|
132
146
|
const previousVersion = query.against === "__previous__" ? history.previousVersion(id, versionId) : null;
|
|
133
147
|
const against = query.against === "__previous__" ? previousVersion?.id ?? "__none__" : query.against;
|
|
134
148
|
if (query.against) {
|
|
135
|
-
|
|
149
|
+
const comparisonManifest = history.manifest(id, against);
|
|
150
|
+
if (against !== "__none__" && !comparisonManifest)
|
|
151
|
+
return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
|
|
152
|
+
if (comparisonManifest?.files[filePath]) {
|
|
153
|
+
const text = history.readTextFile(id, against, filePath);
|
|
154
|
+
if (text === null)
|
|
155
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
156
|
+
comparison = text;
|
|
157
|
+
}
|
|
136
158
|
}
|
|
137
159
|
else {
|
|
138
160
|
const current = resolveSourcePath(config, id, filePath);
|
|
139
|
-
if (fs.existsSync(current) && fs.statSync(current).isFile()
|
|
140
|
-
|
|
161
|
+
if (fs.existsSync(current) && fs.statSync(current).isFile()) {
|
|
162
|
+
if (fs.statSync(current).size > MAX_TEXT_PREVIEW_BYTES)
|
|
163
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
164
|
+
const bytes = fs.readFileSync(current);
|
|
165
|
+
if (bytes.includes(0) || !isUtf8(bytes))
|
|
166
|
+
return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
|
|
167
|
+
comparison = bytes.toString("utf8");
|
|
141
168
|
}
|
|
142
169
|
}
|
|
143
170
|
return { path: filePath, historical, comparison, against: against ?? "current", previousVersion };
|