texlite 0.7.5 → 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 +19 -3
- package/README.zh-CN.md +8 -3
- package/dist/client/assets/{GitDialog-pn8n5Afm.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-CaovMu4V.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/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
- package/dist/client/index.html +3 -3
- package/dist/server/anchors.js +22 -1
- package/dist/server/app.js +103 -2335
- package/dist/server/auth.js +3 -2
- package/dist/server/cli.js +8 -2
- package/dist/server/collaboration.js +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 +3 -3
- package/texlite.config.example.json +5 -1
- package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
- package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
- package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
- package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
- package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/binary-BoldOm0z.js +0 -1
- package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
- package/dist/client/assets/index-BQCB3z5H.js +0 -64
- package/dist/client/assets/index-BnvpaWsa.css +0 -1
- package/dist/client/assets/index-Byn2Miy9.js +0 -3456
- package/dist/client/assets/index-rBWMBTI-.js +0 -5
- package/dist/client/assets/minus-K-5IK4nL.js +0 -1
- package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
- package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const enMessages = {
|
|
2
|
+
API_NOT_FOUND: "The API endpoint does not exist.",
|
|
3
|
+
ADMIN_REQUIRED: "Administrator permission is required.",
|
|
4
|
+
ARTIFACT_PREVIEW_UNSUPPORTED: "This artifact cannot be previewed as text. Download it instead.",
|
|
5
|
+
AUTH_INVALID: "The username or password is incorrect.",
|
|
6
|
+
AUTH_RATE_LIMITED: "Too many sign-in attempts. Try again in 15 minutes.",
|
|
7
|
+
AUTH_REQUIRED: "Authentication is required.",
|
|
8
|
+
CITATION_CONFLICT: "This citation was changed elsewhere. Refresh the library and try again.",
|
|
9
|
+
CITATION_KEY_EXISTS: "A citation with this key already exists.",
|
|
10
|
+
CITATION_LIBRARY_PRIVATE: "Citation libraries are private to their owner.",
|
|
11
|
+
CITATION_NOT_FOUND: "The citation does not exist.",
|
|
12
|
+
CITATION_TAG_NOT_FOUND: "The citation tag does not exist.",
|
|
13
|
+
CITATION_TOO_LARGE: "The BibTeX entry is too large.",
|
|
14
|
+
COMMENT_DELETE_FORBIDDEN: "You can only delete your own comments.",
|
|
15
|
+
COMMENT_EDIT_FORBIDDEN: "You can only edit your own comments.",
|
|
16
|
+
COMMENT_FILE_NOT_FOUND: "The file for this comment does not exist.",
|
|
17
|
+
COMMENT_NOT_FOUND: "The comment does not exist.",
|
|
18
|
+
COMMENT_RANGE_INVALID: "The comment source range is invalid.",
|
|
19
|
+
COMMENT_UPDATE_EMPTY: "There is no comment change to save.",
|
|
20
|
+
COMPILE_ARTIFACT_NOT_FOUND: "The compiled artifact does not exist.",
|
|
21
|
+
COMPILE_ARTIFACTS_NOT_FOUND: "No compiled artifacts are available yet.",
|
|
22
|
+
COMPILE_CLEAN_BUSY: "This root document is compiling. Try recovery again after it finishes.",
|
|
23
|
+
COMPILE_FORBIDDEN: "You do not have permission to compile this project.",
|
|
24
|
+
COMPILE_SNAPSHOT_BUSY: "The project source is still being saved. Try compiling again shortly.",
|
|
25
|
+
CURRENT_PASSWORD_INVALID: "The current password is incorrect.",
|
|
26
|
+
DICTIONARY_EDIT_FORBIDDEN: "You do not have permission to maintain this project's dictionary.",
|
|
27
|
+
DIRECTORY_NOT_FOUND: "The destination folder does not exist.",
|
|
28
|
+
FILE_CONTENT_INVALID: "The file content is invalid.",
|
|
29
|
+
FILE_EXISTS: "A file with this name already exists.",
|
|
30
|
+
FILE_NOT_FOUND: "The file does not exist.",
|
|
31
|
+
FILE_TOO_LARGE: "The file is too large to open or upload.",
|
|
32
|
+
FORMAT_FAILED: "The source could not be formatted.",
|
|
33
|
+
GIT_ACCOUNT_INVALID: "GitHub did not return a valid account.",
|
|
34
|
+
GIT_BRANCH_INVALID: "The local Git branch name is invalid.",
|
|
35
|
+
GIT_COMMAND_FAILED: "The Git command failed. Check the server log for details.",
|
|
36
|
+
GIT_COMMIT_MESSAGE_INVALID: "Enter a commit message of 300 characters or fewer.",
|
|
37
|
+
GIT_DETACHED_HEAD: "The repository is in detached HEAD state. Return to the default branch first.",
|
|
38
|
+
GIT_REPOSITORY_NAME_INVALID: "Enter a GitHub repository name.",
|
|
39
|
+
GIT_REPOSITORY_NOT_INITIALIZED: "Git is not initialized for this project.",
|
|
40
|
+
GIT_REPOSITORY_RESPONSE_INVALID: "GitHub did not return a valid repository.",
|
|
41
|
+
GIT_REVISION_INVALID: "Choose a Git revision to check out.",
|
|
42
|
+
GIT_TOKEN_INVALID: "Enter a GitHub token.",
|
|
43
|
+
GIT_TOKEN_NOT_CONFIGURED: "Configure a GitHub token first.",
|
|
44
|
+
GIT_TOKEN_REJECTED: "GitHub rejected this token. Check its permissions and try again.",
|
|
45
|
+
GIT_UNAVAILABLE: "Git is not available on this server.",
|
|
46
|
+
HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
|
|
47
|
+
HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
|
|
48
|
+
HISTORY_FILE_PREVIEW_UNSUPPORTED: "This historical file cannot be compared as text.",
|
|
49
|
+
HISTORY_TARGET_CONFLICT: "The historical version cannot be restored to the requested path.",
|
|
50
|
+
HISTORY_VERSION_NOT_FOUND: "The history version does not exist.",
|
|
51
|
+
INVALID_PATH: "The file path is invalid.",
|
|
52
|
+
LAST_ADMIN: "At least one active administrator must remain.",
|
|
53
|
+
LATEXMKRC_DISABLED: "Project latexmkrc files are disabled by the administrator.",
|
|
54
|
+
LATEXMKRC_INVALID: "The selected latexmkrc file is invalid.",
|
|
55
|
+
LATEXMKRC_NOT_FOUND: "The selected latexmkrc file does not exist.",
|
|
56
|
+
MAIN_DOCUMENT_CHANGED: "The main document changed while compilation was queued. Compile again.",
|
|
57
|
+
MAIN_DOCUMENT_INVALID: "The selected file is not a valid LaTeX main document.",
|
|
58
|
+
MAIN_FILE_DELETE_FORBIDDEN: "The main document cannot be deleted.",
|
|
59
|
+
MAIN_FILE_INVALID: "The main document must be an existing .tex file.",
|
|
60
|
+
MAIN_FILE_NOT_FOUND: "The selected main document does not exist.",
|
|
61
|
+
MEMBERS_MANAGE_FORBIDDEN: "Only the project owner can manage members.",
|
|
62
|
+
MOVE_INTO_SELF: "A folder cannot be moved into itself.",
|
|
63
|
+
OWNER_MEMBER_FORBIDDEN: "The project owner cannot be added as a member.",
|
|
64
|
+
PASSWORD_TOO_SHORT: "The password must contain at least {{minLength}} characters.",
|
|
65
|
+
PATH_EXISTS: "The destination path is already used by a file or folder.",
|
|
66
|
+
PATH_NOT_FOUND: "The file or folder does not exist.",
|
|
67
|
+
PDF_NOT_FOUND: "No PDF has been generated yet.",
|
|
68
|
+
PROJECT_BUSY: "The project is performing a source-file operation. Try again shortly.",
|
|
69
|
+
PROJECT_CREATE_FORBIDDEN: "You do not have permission to create projects.",
|
|
70
|
+
PROJECT_DELETE_FORBIDDEN: "Only the project owner can delete this project.",
|
|
71
|
+
PROJECT_EDIT_FORBIDDEN: "You do not have edit permission for this project.",
|
|
72
|
+
PROJECT_NOT_FOUND: "The project does not exist or is no longer accessible.",
|
|
73
|
+
PROJECT_OWNER_ONLY: "Only the project owner can perform this action.",
|
|
74
|
+
PROJECT_TRANSFER_FORBIDDEN: "Only the current project owner can transfer ownership.",
|
|
75
|
+
PROJECT_TRANSFER_SELF: "You already own this project.",
|
|
76
|
+
PROJECT_TRANSFER_TARGET_INVALID: "Choose a new project owner.",
|
|
77
|
+
REPLY_DELETE_FORBIDDEN: "You can only delete your own replies.",
|
|
78
|
+
REPLY_EDIT_FORBIDDEN: "You can only edit your own replies.",
|
|
79
|
+
REPLY_NOT_FOUND: "The reply does not exist.",
|
|
80
|
+
REQUEST_INVALID: "The request is invalid.",
|
|
81
|
+
RESERVED_PATH: "This path is reserved by TexLite.",
|
|
82
|
+
SEARCH_QUERY_INVALID: "The search or replacement text is invalid.",
|
|
83
|
+
SELF_DELETE_FORBIDDEN: "You cannot delete the currently signed-in administrator.",
|
|
84
|
+
SERVER_ERROR: "The server could not complete the request.",
|
|
85
|
+
SOURCE_FILE_NOT_FOUND: "The source file does not exist.",
|
|
86
|
+
SOURCE_FLUSH_FAILED: "Some source files could not be saved. The project operation was cancelled.",
|
|
87
|
+
SPELLCHECK_SOURCE_INVALID: "The source to check is invalid.",
|
|
88
|
+
SPELLCHECK_SOURCE_TOO_LARGE: "The source to check is too large.",
|
|
89
|
+
SYNCTEX_EXTERNAL_PATH: "SyncTeX returned a source path outside the project.",
|
|
90
|
+
SYNCTEX_NOT_AVAILABLE: "No SyncTeX data is available. Compile the project again.",
|
|
91
|
+
SYMLINK_FORBIDDEN: "Symbolic links are not allowed in project sources.",
|
|
92
|
+
SYNC_PDF_INVALID: "The PDF location is invalid.",
|
|
93
|
+
SYNC_SOURCE_INVALID: "The source location is invalid.",
|
|
94
|
+
TAG_NOT_FOUND: "The selected tag does not exist.",
|
|
95
|
+
UPLOAD_EMPTY: "No file was selected for upload.",
|
|
96
|
+
USERNAME_INVALID: "The username may contain only letters, numbers, dots, hyphens, or underscores.",
|
|
97
|
+
USER_NOT_FOUND: "The user does not exist or is disabled.",
|
|
98
|
+
ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
|
|
99
|
+
ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
|
|
100
|
+
ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
|
|
101
|
+
ZIP_INVALID: "The ZIP archive could not be imported.",
|
|
102
|
+
ZIP_INVALID_PATH: "The ZIP archive contains an invalid path.",
|
|
103
|
+
ZIP_MAIN_DOCUMENT_NOT_FOUND: "No LaTeX main document (.tex) was found in the ZIP archive.",
|
|
104
|
+
ZIP_ONLY: "Choose a ZIP archive.",
|
|
105
|
+
ZIP_READ_FAILED: "The ZIP archive could not be read.",
|
|
106
|
+
ZIP_SYMLINK_FORBIDDEN: "The ZIP archive contains a symbolic link.",
|
|
107
|
+
ZIP_TOO_MANY_ENTRIES: "The ZIP archive contains too many files.",
|
|
108
|
+
ZIP_TOTAL_TOO_LARGE: "The ZIP archive is too large after extraction.",
|
|
109
|
+
SYNCTEX_FAILED: "SyncTeX could not locate this position. Compile the project again.",
|
|
110
|
+
GIT_INVALID: "The Git request is invalid.",
|
|
111
|
+
GIT_REVISION_NOT_FOUND: "The requested Git revision does not exist.",
|
|
112
|
+
GIT_CONFLICT: "Git cannot complete this operation in the current repository state.",
|
|
113
|
+
GIT_NO_CHANGES: "There are no changes to commit.",
|
|
114
|
+
GIT_NO_COMMIT: "This project has no commit available for this operation.",
|
|
115
|
+
GIT_OUTPUT_TOO_LARGE: "Git produced too much output to display safely.",
|
|
116
|
+
GIT_REMOTE_ALREADY_CONFIGURED: "This project already has a remote repository.",
|
|
117
|
+
GIT_REMOTE_NOT_CONFIGURED: "Create a GitHub repository first.",
|
|
118
|
+
GIT_TIMEOUT: "The Git operation timed out.",
|
|
119
|
+
GIT_OPERATION_FAILED: "The Git operation could not be completed."
|
|
120
|
+
};
|
|
121
|
+
const zhMessages = {
|
|
122
|
+
API_NOT_FOUND: "接口不存在。",
|
|
123
|
+
ADMIN_REQUIRED: "需要管理员权限。",
|
|
124
|
+
ARTIFACT_PREVIEW_UNSUPPORTED: "该产物不能作为文本预览,请下载后查看。",
|
|
125
|
+
AUTH_INVALID: "用户名或密码错误。",
|
|
126
|
+
AUTH_RATE_LIMITED: "登录尝试过于频繁,请在 15 分钟后重试。",
|
|
127
|
+
AUTH_REQUIRED: "需要登录。",
|
|
128
|
+
CITATION_CONFLICT: "该引用已在其他位置发生修改,请刷新引用库后重试。",
|
|
129
|
+
CITATION_KEY_EXISTS: "引用 key 已经存在。",
|
|
130
|
+
CITATION_LIBRARY_PRIVATE: "引用库仅对其所有者可见。",
|
|
131
|
+
CITATION_NOT_FOUND: "引用条目不存在。",
|
|
132
|
+
CITATION_TAG_NOT_FOUND: "引用标签不存在。",
|
|
133
|
+
CITATION_TOO_LARGE: "BibTeX 条目过大。",
|
|
134
|
+
COMMENT_DELETE_FORBIDDEN: "只能删除自己的批注。",
|
|
135
|
+
COMMENT_EDIT_FORBIDDEN: "只能编辑自己的批注。",
|
|
136
|
+
COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
|
|
137
|
+
COMMENT_NOT_FOUND: "批注不存在。",
|
|
138
|
+
COMMENT_RANGE_INVALID: "批注的源码区间无效。",
|
|
139
|
+
COMMENT_UPDATE_EMPTY: "没有可保存的批注修改。",
|
|
140
|
+
COMPILE_ARTIFACT_NOT_FOUND: "编译产物不存在。",
|
|
141
|
+
COMPILE_ARTIFACTS_NOT_FOUND: "尚无可用的编译产物。",
|
|
142
|
+
COMPILE_CLEAN_BUSY: "当前主文档正在编译,请完成后再执行故障恢复。",
|
|
143
|
+
COMPILE_FORBIDDEN: "你没有编译此项目的权限。",
|
|
144
|
+
COMPILE_SNAPSHOT_BUSY: "项目源码仍在保存,请稍后重新编译。",
|
|
145
|
+
CURRENT_PASSWORD_INVALID: "当前密码错误。",
|
|
146
|
+
DICTIONARY_EDIT_FORBIDDEN: "你没有维护本项目词典的权限。",
|
|
147
|
+
DIRECTORY_NOT_FOUND: "目标文件夹不存在。",
|
|
148
|
+
FILE_CONTENT_INVALID: "文件内容格式不正确。",
|
|
149
|
+
FILE_EXISTS: "同名文件已经存在。",
|
|
150
|
+
FILE_NOT_FOUND: "文件不存在。",
|
|
151
|
+
FILE_TOO_LARGE: "文件过大,无法打开或上传。",
|
|
152
|
+
FORMAT_FAILED: "无法格式化源码。",
|
|
153
|
+
GIT_ACCOUNT_INVALID: "GitHub 没有返回有效的账户信息。",
|
|
154
|
+
GIT_BRANCH_INVALID: "本地 Git 分支名称无效。",
|
|
155
|
+
GIT_COMMAND_FAILED: "Git 命令执行失败,请查看服务器日志了解详情。",
|
|
156
|
+
GIT_COMMIT_MESSAGE_INVALID: "请输入不超过 300 个字符的提交说明。",
|
|
157
|
+
GIT_DETACHED_HEAD: "当前处于 detached HEAD,请先返回默认分支。",
|
|
158
|
+
GIT_REPOSITORY_NAME_INVALID: "请输入 GitHub 仓库名称。",
|
|
159
|
+
GIT_REPOSITORY_NOT_INITIALIZED: "该项目尚未初始化 Git。",
|
|
160
|
+
GIT_REPOSITORY_RESPONSE_INVALID: "GitHub 没有返回有效的仓库信息。",
|
|
161
|
+
GIT_REVISION_INVALID: "请选择要 checkout 的 Git 版本。",
|
|
162
|
+
GIT_TOKEN_INVALID: "请输入 GitHub token。",
|
|
163
|
+
GIT_TOKEN_NOT_CONFIGURED: "请先配置 GitHub token。",
|
|
164
|
+
GIT_TOKEN_REJECTED: "GitHub 拒绝了该 token,请检查权限后重试。",
|
|
165
|
+
GIT_UNAVAILABLE: "服务器未安装 Git。",
|
|
166
|
+
HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
|
|
167
|
+
HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
|
|
168
|
+
HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
|
|
169
|
+
HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
|
|
170
|
+
HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
|
|
171
|
+
INVALID_PATH: "文件路径无效。",
|
|
172
|
+
LAST_ADMIN: "系统必须保留至少一名可用管理员。",
|
|
173
|
+
LATEXMKRC_DISABLED: "管理员已禁用项目级 latexmkrc。",
|
|
174
|
+
LATEXMKRC_INVALID: "所选 latexmkrc 文件无效。",
|
|
175
|
+
LATEXMKRC_NOT_FOUND: "所选 latexmkrc 文件不存在。",
|
|
176
|
+
MAIN_DOCUMENT_CHANGED: "编译排队期间主文档已变化,请重新编译。",
|
|
177
|
+
MAIN_DOCUMENT_INVALID: "所选文件不是有效的 LaTeX 主文档。",
|
|
178
|
+
MAIN_FILE_DELETE_FORBIDDEN: "不能删除主文档。",
|
|
179
|
+
MAIN_FILE_INVALID: "主文档必须是存在的 .tex 文件。",
|
|
180
|
+
MAIN_FILE_NOT_FOUND: "所选主文档不存在。",
|
|
181
|
+
MEMBERS_MANAGE_FORBIDDEN: "只有项目所有者可以管理成员。",
|
|
182
|
+
MOVE_INTO_SELF: "不能把文件夹移动到自身内部。",
|
|
183
|
+
OWNER_MEMBER_FORBIDDEN: "项目所有者不能作为成员添加。",
|
|
184
|
+
PASSWORD_TOO_SHORT: "密码至少需要 {{minLength}} 个字符。",
|
|
185
|
+
PATH_EXISTS: "目标路径已被文件或文件夹占用。",
|
|
186
|
+
PATH_NOT_FOUND: "文件或文件夹不存在。",
|
|
187
|
+
PDF_NOT_FOUND: "尚未生成 PDF。",
|
|
188
|
+
PROJECT_BUSY: "项目正在执行源文件操作,请稍后重试。",
|
|
189
|
+
PROJECT_CREATE_FORBIDDEN: "管理员尚未授予你创建项目的权限。",
|
|
190
|
+
PROJECT_DELETE_FORBIDDEN: "只有项目所有者可以删除项目。",
|
|
191
|
+
PROJECT_EDIT_FORBIDDEN: "你没有此项目的编辑权限。",
|
|
192
|
+
PROJECT_NOT_FOUND: "项目不存在或你已无法访问。",
|
|
193
|
+
PROJECT_OWNER_ONLY: "只有项目所有者可以执行此操作。",
|
|
194
|
+
PROJECT_TRANSFER_FORBIDDEN: "只有当前项目所有者可以转让项目。",
|
|
195
|
+
PROJECT_TRANSFER_SELF: "你已经是该项目的所有者。",
|
|
196
|
+
PROJECT_TRANSFER_TARGET_INVALID: "请选择新的项目所有者。",
|
|
197
|
+
REPLY_DELETE_FORBIDDEN: "只能删除自己的回复。",
|
|
198
|
+
REPLY_EDIT_FORBIDDEN: "只能编辑自己的回复。",
|
|
199
|
+
REPLY_NOT_FOUND: "回复不存在。",
|
|
200
|
+
REQUEST_INVALID: "请求格式无效。",
|
|
201
|
+
RESERVED_PATH: "此路径由 TexLite 保留。",
|
|
202
|
+
SEARCH_QUERY_INVALID: "搜索或替换内容格式不正确。",
|
|
203
|
+
SELF_DELETE_FORBIDDEN: "不能删除当前登录的管理员。",
|
|
204
|
+
SERVER_ERROR: "服务器内部错误",
|
|
205
|
+
SOURCE_FILE_NOT_FOUND: "源码文件不存在。",
|
|
206
|
+
SOURCE_FLUSH_FAILED: "部分源码未能保存至磁盘,项目操作已取消。",
|
|
207
|
+
SPELLCHECK_SOURCE_INVALID: "待检查源码格式不正确。",
|
|
208
|
+
SPELLCHECK_SOURCE_TOO_LARGE: "待检查源码过大。",
|
|
209
|
+
SYNCTEX_EXTERNAL_PATH: "SyncTeX 返回了项目外部的源码路径。",
|
|
210
|
+
SYNCTEX_NOT_AVAILABLE: "尚无可用的 SyncTeX 数据,请重新编译。",
|
|
211
|
+
SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
|
|
212
|
+
SYNC_PDF_INVALID: "PDF 位置无效。",
|
|
213
|
+
SYNC_SOURCE_INVALID: "源码位置无效。",
|
|
214
|
+
TAG_NOT_FOUND: "所选标签不存在。",
|
|
215
|
+
UPLOAD_EMPTY: "没有选择要上传的文件。",
|
|
216
|
+
USERNAME_INVALID: "用户名只能包含字母、数字、点、横线或下划线。",
|
|
217
|
+
USER_NOT_FOUND: "用户不存在或已被禁用。",
|
|
218
|
+
ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
|
|
219
|
+
ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
|
|
220
|
+
ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",
|
|
221
|
+
ZIP_INVALID: "无法导入 ZIP 压缩包。",
|
|
222
|
+
ZIP_INVALID_PATH: "ZIP 压缩包包含无效路径。",
|
|
223
|
+
ZIP_MAIN_DOCUMENT_NOT_FOUND: "压缩包中没有找到 LaTeX 主文档(.tex)。",
|
|
224
|
+
ZIP_ONLY: "请选择 ZIP 压缩包。",
|
|
225
|
+
ZIP_READ_FAILED: "无法读取 ZIP 压缩包。",
|
|
226
|
+
ZIP_SYMLINK_FORBIDDEN: "ZIP 压缩包不允许包含符号链接。",
|
|
227
|
+
ZIP_TOO_MANY_ENTRIES: "ZIP 压缩包中的文件数量超过限制。",
|
|
228
|
+
ZIP_TOTAL_TOO_LARGE: "ZIP 压缩包解压后的总体积超过限制。",
|
|
229
|
+
SYNCTEX_FAILED: "SyncTeX 无法定位该位置,请重新编译项目。",
|
|
230
|
+
GIT_INVALID: "Git 请求格式无效。",
|
|
231
|
+
GIT_REVISION_NOT_FOUND: "所请求的 Git 版本不存在。",
|
|
232
|
+
GIT_CONFLICT: "当前仓库状态下无法完成 Git 操作。",
|
|
233
|
+
GIT_NO_CHANGES: "当前没有可以提交的修改。",
|
|
234
|
+
GIT_NO_COMMIT: "该项目没有可用于此操作的提交。",
|
|
235
|
+
GIT_OUTPUT_TOO_LARGE: "Git 输出过大,无法安全显示。",
|
|
236
|
+
GIT_REMOTE_ALREADY_CONFIGURED: "该项目已经配置远程仓库。",
|
|
237
|
+
GIT_REMOTE_NOT_CONFIGURED: "请先创建 GitHub 仓库。",
|
|
238
|
+
GIT_TIMEOUT: "Git 操作超时。",
|
|
239
|
+
GIT_OPERATION_FAILED: "无法完成 Git 操作。"
|
|
240
|
+
};
|
|
241
|
+
const messages = {
|
|
242
|
+
en: enMessages,
|
|
243
|
+
zh: zhMessages
|
|
244
|
+
};
|
|
245
|
+
function headerValue(value) {
|
|
246
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
247
|
+
}
|
|
248
|
+
/** Use the explicit app language first, then a standard Accept-Language header. */
|
|
249
|
+
export function serverLocale(headers) {
|
|
250
|
+
const requested = headerValue(headers?.["x-texlite-language"])
|
|
251
|
+
|| headerValue(headers?.["accept-language"]);
|
|
252
|
+
if (/\bzh(?:[-_]|\b)/i.test(requested))
|
|
253
|
+
return "zh";
|
|
254
|
+
if (/\ben(?:[-_]|\b)/i.test(requested))
|
|
255
|
+
return "en";
|
|
256
|
+
// API clients without a language header follow the same fallback as the web
|
|
257
|
+
// application: English. Browser clients always send Accept-Language.
|
|
258
|
+
return "en";
|
|
259
|
+
}
|
|
260
|
+
function stringValue(value) {
|
|
261
|
+
if (Array.isArray(value))
|
|
262
|
+
return value.map(stringValue).join(", ");
|
|
263
|
+
if (value === null || value === undefined)
|
|
264
|
+
return "";
|
|
265
|
+
return String(value);
|
|
266
|
+
}
|
|
267
|
+
function isServerErrorCode(code) {
|
|
268
|
+
return Object.hasOwn(enMessages, code);
|
|
269
|
+
}
|
|
270
|
+
/** Translate a stable API error code without making routing code locale-aware. */
|
|
271
|
+
export function serverErrorMessage(locale, code, params = {}) {
|
|
272
|
+
const template = isServerErrorCode(code)
|
|
273
|
+
? messages[locale][code]
|
|
274
|
+
: (locale === "zh" ? "请求无法完成。" : "The request could not be completed.");
|
|
275
|
+
return template.replace(/{{\s*([\w.-]+)\s*}}/g, (_match, key) => stringValue(params[key]));
|
|
276
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { listProjectFiles, listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
3
|
+
import { hasDocumentClass } from "./zip.js";
|
|
4
|
+
const rootDetectionCache = new Map();
|
|
5
|
+
function texFilePaths(files) {
|
|
6
|
+
return files
|
|
7
|
+
.filter((entry) => entry.type === "file" && /\.tex$/i.test(entry.path))
|
|
8
|
+
.map((entry) => entry.path)
|
|
9
|
+
.sort((left, right) => left.localeCompare(right));
|
|
10
|
+
}
|
|
11
|
+
async function fileHasDocumentClass(config, projectId, filePath) {
|
|
12
|
+
try {
|
|
13
|
+
const absolute = resolveSourcePath(config, projectId, filePath);
|
|
14
|
+
const stat = await fs.promises.stat(absolute);
|
|
15
|
+
const cached = rootDetectionCache.get(absolute);
|
|
16
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs) {
|
|
17
|
+
return cached.result;
|
|
18
|
+
}
|
|
19
|
+
const result = hasDocumentClass(await fs.promises.readFile(absolute, "utf8"));
|
|
20
|
+
cacheRootDetection(absolute, stat, result);
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code === "ENOENT")
|
|
25
|
+
return false;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function cacheRootDetection(absolute, stat, result) {
|
|
30
|
+
if (rootDetectionCache.size >= 2_000 && !rootDetectionCache.has(absolute))
|
|
31
|
+
rootDetectionCache.clear();
|
|
32
|
+
rootDetectionCache.set(absolute, { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, result });
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Return selectable project roots. A single fragmentary .tex file remains a
|
|
36
|
+
* compatibility fallback for imported projects; once a project contains more
|
|
37
|
+
* than one .tex file, every candidate must declare \documentclass itself.
|
|
38
|
+
*/
|
|
39
|
+
export async function mainDocumentCandidates(config, projectId, files) {
|
|
40
|
+
const texFiles = texFilePaths(files ?? await listProjectFilesAsync(config, projectId));
|
|
41
|
+
const mainFiles = [];
|
|
42
|
+
for (const filePath of texFiles) {
|
|
43
|
+
if (await fileHasDocumentClass(config, projectId, filePath))
|
|
44
|
+
mainFiles.push(filePath);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
texFiles,
|
|
48
|
+
mainFiles: mainFiles.length > 0 ? mainFiles : texFiles.length === 1 ? texFiles : []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function isMainDocumentCandidate(config, projectId, filePath) {
|
|
52
|
+
if (await fileHasDocumentClass(config, projectId, filePath))
|
|
53
|
+
return true;
|
|
54
|
+
const texFiles = texFilePaths(await listProjectFilesAsync(config, projectId));
|
|
55
|
+
return texFiles.length === 1 && texFiles[0] === filePath;
|
|
56
|
+
}
|
|
57
|
+
export function isMainDocumentCandidateSync(config, projectId, filePath) {
|
|
58
|
+
try {
|
|
59
|
+
const absolute = resolveSourcePath(config, projectId, filePath);
|
|
60
|
+
const stat = fs.statSync(absolute);
|
|
61
|
+
const cached = rootDetectionCache.get(absolute);
|
|
62
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs && cached.ctimeMs === stat.ctimeMs) {
|
|
63
|
+
if (cached.result)
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const result = hasDocumentClass(fs.readFileSync(absolute, "utf8"));
|
|
68
|
+
cacheRootDetection(absolute, stat, result);
|
|
69
|
+
if (result)
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const texFiles = texFilePaths(listProjectFiles(config, projectId));
|
|
77
|
+
return texFiles.length === 1 && texFiles[0] === filePath;
|
|
78
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard server-side limits shared by multiple features.
|
|
3
|
+
*
|
|
4
|
+
* These are deliberately not configuration settings: they cap memory used by
|
|
5
|
+
* text previews and individual citation records independently of the project
|
|
6
|
+
* upload limit.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
export const MAX_CITATION_BIBTEX_BYTES = 512 * 1024;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
4
|
+
import { httpError } from "./http.js";
|
|
4
5
|
const textExtension = /(?:\.(?:tex|bib|sty|cls|txt|md)|latexmkrc)$/i;
|
|
5
6
|
const maxFiles = 500;
|
|
6
7
|
const maxFileBytes = 3 * 1024 * 1024;
|
|
@@ -77,9 +78,7 @@ export async function replaceProject(config, projectId, options, replacement) {
|
|
|
77
78
|
continue;
|
|
78
79
|
if (options.maxFileBytes !== undefined && Buffer.byteLength(content, "utf8") > options.maxFileBytes) {
|
|
79
80
|
await Promise.allSettled(staged.map((entry) => fs.promises.rm(entry.temporary, { force: true })));
|
|
80
|
-
throw
|
|
81
|
-
code: "FILE_TOO_LARGE", statusCode: 413
|
|
82
|
-
});
|
|
81
|
+
throw httpError(413, "FILE_TOO_LARGE", { size: Math.floor(options.maxFileBytes / 1024 / 1024) });
|
|
83
82
|
}
|
|
84
83
|
const temporary = `${absolute}.search-${process.pid}-${Date.now()}-${staged.length}.tmp`;
|
|
85
84
|
await fs.promises.writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
@@ -108,7 +107,7 @@ export async function replaceProject(config, projectId, options, replacement) {
|
|
|
108
107
|
function searchPattern(options) {
|
|
109
108
|
const query = options.query.trim();
|
|
110
109
|
if (!query || query.length > 500)
|
|
111
|
-
throw
|
|
110
|
+
throw httpError(400, "SEARCH_QUERY_INVALID");
|
|
112
111
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
113
112
|
const source = options.wholeWord ? `(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])` : escaped;
|
|
114
113
|
return new RegExp(source, `${options.caseSensitive ? "" : "i"}gu`);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { publicUser, requireUser } from "../auth.js";
|
|
2
|
+
import { createSessionToken, digestToken, hashPassword, verifyPassword } from "../security.js";
|
|
3
|
+
import { apiError, ValidationError } from "../http.js";
|
|
4
|
+
const now = () => new Date().toISOString();
|
|
5
|
+
function text(value, max = 200) {
|
|
6
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
7
|
+
throw new ValidationError();
|
|
8
|
+
}
|
|
9
|
+
return value.trim();
|
|
10
|
+
}
|
|
11
|
+
/** Register login, logout, session introspection, and password routes. */
|
|
12
|
+
export function registerAuthRoutes(app, context) {
|
|
13
|
+
const { config, db, loginLimiter } = context;
|
|
14
|
+
app.post("/api/auth/login", async (request, reply) => {
|
|
15
|
+
const body = request.body;
|
|
16
|
+
const username = text(body?.username, 64);
|
|
17
|
+
const ip = request.ip || "127.0.0.1";
|
|
18
|
+
const rateLimitKey = `${ip}:${username.toLowerCase()}`;
|
|
19
|
+
if (loginLimiter.isLocked(rateLimitKey)) {
|
|
20
|
+
return apiError(reply, 429, "AUTH_RATE_LIMITED");
|
|
21
|
+
}
|
|
22
|
+
const password = typeof body?.password === "string" ? body.password : "";
|
|
23
|
+
const user = db.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE").get(username);
|
|
24
|
+
if (!user || user.disabled || !(await verifyPassword(password, user.password_hash))) {
|
|
25
|
+
const result = loginLimiter.recordFailure(rateLimitKey);
|
|
26
|
+
if (result.locked) {
|
|
27
|
+
return apiError(reply, 429, "AUTH_RATE_LIMITED");
|
|
28
|
+
}
|
|
29
|
+
return apiError(reply, 401, "AUTH_INVALID");
|
|
30
|
+
}
|
|
31
|
+
loginLimiter.reset(rateLimitKey);
|
|
32
|
+
const session = createSessionToken();
|
|
33
|
+
const createdAt = now();
|
|
34
|
+
const expires = new Date(Date.now() + config.sessionDays * 86_400_000);
|
|
35
|
+
db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
36
|
+
.run(session.digest, user.id, expires.toISOString(), createdAt);
|
|
37
|
+
const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
|
|
38
|
+
reply.setCookie("texlite_session", session.token, {
|
|
39
|
+
path: "/",
|
|
40
|
+
httpOnly: true,
|
|
41
|
+
sameSite: "strict",
|
|
42
|
+
secure: isSecure,
|
|
43
|
+
expires
|
|
44
|
+
});
|
|
45
|
+
return { user: publicUser(user) };
|
|
46
|
+
});
|
|
47
|
+
app.post("/api/auth/logout", async (request, reply) => {
|
|
48
|
+
const token = request.cookies.texlite_session;
|
|
49
|
+
if (token)
|
|
50
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
|
|
51
|
+
reply.clearCookie("texlite_session", { path: "/" });
|
|
52
|
+
return { ok: true };
|
|
53
|
+
});
|
|
54
|
+
app.get("/api/me", async (request, reply) => {
|
|
55
|
+
const user = requireUser(request, reply, db);
|
|
56
|
+
if (!user)
|
|
57
|
+
return;
|
|
58
|
+
return { user: publicUser(user) };
|
|
59
|
+
});
|
|
60
|
+
app.put("/api/me/password", async (request, reply) => {
|
|
61
|
+
const user = requireUser(request, reply, db);
|
|
62
|
+
if (!user)
|
|
63
|
+
return;
|
|
64
|
+
const body = request.body;
|
|
65
|
+
const currentPassword = typeof body?.currentPassword === "string" ? body.currentPassword : "";
|
|
66
|
+
const newPassword = typeof body?.newPassword === "string" ? body.newPassword : "";
|
|
67
|
+
if (!(await verifyPassword(currentPassword, user.password_hash))) {
|
|
68
|
+
return apiError(reply, 400, "CURRENT_PASSWORD_INVALID");
|
|
69
|
+
}
|
|
70
|
+
const passwordHash = await hashPassword(newPassword);
|
|
71
|
+
db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?")
|
|
72
|
+
.run(passwordHash, user.id);
|
|
73
|
+
db.prepare("DELETE FROM sessions WHERE user_id = ? AND id != ?")
|
|
74
|
+
.run(user.id, digestToken(request.cookies.texlite_session ?? ""));
|
|
75
|
+
return { ok: true };
|
|
76
|
+
});
|
|
77
|
+
}
|