texlite 0.2.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/LICENSE +661 -0
- package/README.md +403 -0
- package/README.zh-CN.md +391 -0
- package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
- package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
- package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
- package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
- package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
- package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
- package/dist/client/assets/editor-JxT6uSYX.js +14 -0
- package/dist/client/assets/index-Bc5CmFMl.js +15 -0
- package/dist/client/assets/index-CMqYAbDx.css +1 -0
- package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
- package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
- package/dist/client/assets/plus-DzbLkp6l.js +1 -0
- package/dist/client/assets/react-CsO_xvbl.js +49 -0
- package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
- package/dist/client/index.html +20 -0
- package/dist/client/logo.svg +4 -0
- package/dist/server/anchors.js +95 -0
- package/dist/server/app.js +1624 -0
- package/dist/server/archive.js +26 -0
- package/dist/server/auth.js +42 -0
- package/dist/server/cli.js +318 -0
- package/dist/server/collaboration.js +635 -0
- package/dist/server/compileArtifacts.js +86 -0
- package/dist/server/compileDiagnostics.js +227 -0
- package/dist/server/compiler.js +514 -0
- package/dist/server/config.js +336 -0
- package/dist/server/db.js +280 -0
- package/dist/server/environment.js +57 -0
- package/dist/server/files.js +121 -0
- package/dist/server/git.js +414 -0
- package/dist/server/history.js +379 -0
- package/dist/server/http.js +7 -0
- package/dist/server/index.js +75 -0
- package/dist/server/latexCompletion.js +296 -0
- package/dist/server/metrics.js +34 -0
- package/dist/server/pm2.js +234 -0
- package/dist/server/projectOutline.js +101 -0
- package/dist/server/projectSearch.js +112 -0
- package/dist/server/projects.js +29 -0
- package/dist/server/routes/compile.js +343 -0
- package/dist/server/runtimePaths.js +42 -0
- package/dist/server/security.js +29 -0
- package/dist/server/spellCheck.js +195 -0
- package/dist/server/synctex.js +56 -0
- package/dist/server/zip.js +185 -0
- package/package.json +110 -0
- package/preview.png +0 -0
- package/texlite.config.example.json +33 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultDataDirectory, packageClientDirectory, resolveConfigPath } from "./runtimePaths.js";
|
|
4
|
+
export const LATEX_ENGINES = ["pdflatex", "xelatex", "lualatex"];
|
|
5
|
+
/** Effective values used when the corresponding file/env setting is omitted. */
|
|
6
|
+
export const CONFIG_DEFAULTS = {
|
|
7
|
+
siteName: "TexLite",
|
|
8
|
+
adminEmail: "",
|
|
9
|
+
host: "127.0.0.1",
|
|
10
|
+
port: 3000,
|
|
11
|
+
dataDir: defaultDataDirectory(),
|
|
12
|
+
clientDir: packageClientDirectory(),
|
|
13
|
+
sessionDays: 14,
|
|
14
|
+
compileTimeoutSeconds: 600,
|
|
15
|
+
maxCompileJobs: 10,
|
|
16
|
+
latexmk: "latexmk",
|
|
17
|
+
defaultEngine: "xelatex",
|
|
18
|
+
allowedEngines: [...LATEX_ENGINES],
|
|
19
|
+
extraArgs: [],
|
|
20
|
+
allowProjectLatexmkrc: true,
|
|
21
|
+
maxFileSizeMB: 50,
|
|
22
|
+
historyMaxVersions: 200,
|
|
23
|
+
historyMaxStorageMB: 512,
|
|
24
|
+
git: "git",
|
|
25
|
+
gitOperationTimeoutSeconds: 120,
|
|
26
|
+
githubApiBaseUrl: "https://api.github.com"
|
|
27
|
+
};
|
|
28
|
+
const CONFIG_LIMITS = {
|
|
29
|
+
port: [1, 65_535],
|
|
30
|
+
sessionDays: [1, 3_650],
|
|
31
|
+
compileTimeoutSeconds: [1, 3_600],
|
|
32
|
+
maxCompileJobs: [1, 32],
|
|
33
|
+
maxFileSizeMB: [1, 2_048],
|
|
34
|
+
historyMaxVersions: [10, 5_000],
|
|
35
|
+
historyMaxStorageMB: [16, 102_400],
|
|
36
|
+
gitOperationTimeoutSeconds: [1, 3_600]
|
|
37
|
+
};
|
|
38
|
+
export function loadConfig(configPathOverride) {
|
|
39
|
+
const configPath = resolveConfigPath(configPathOverride);
|
|
40
|
+
const fileConfig = readConfigFile(configPath);
|
|
41
|
+
validateFileConfig(fileConfig);
|
|
42
|
+
const configDirectory = path.dirname(configPath);
|
|
43
|
+
const configuredDataDir = setting("storage.dataDir", process.env.TEXLITE_DATA_DIR, fileConfig.storage?.dataDir, CONFIG_DEFAULTS.dataDir);
|
|
44
|
+
const dataDir = resolveConfiguredPath("storage.dataDir", configuredDataDir, configDirectory);
|
|
45
|
+
const configuredClientDir = setting("TEXLITE_CLIENT_DIR", process.env.TEXLITE_CLIENT_DIR, undefined, CONFIG_DEFAULTS.clientDir);
|
|
46
|
+
const clientDir = resolveConfiguredPath("TEXLITE_CLIENT_DIR", configuredClientDir, configDirectory);
|
|
47
|
+
const configuredEngine = setting("latex.defaultEngine", process.env.TEXLITE_DEFAULT_ENGINE, fileConfig.latex?.defaultEngine, CONFIG_DEFAULTS.defaultEngine);
|
|
48
|
+
if (!isEngine(configuredEngine)) {
|
|
49
|
+
throw configurationError("latex.defaultEngine", `must be one of ${LATEX_ENGINES.join(", ")}; received ${displayValue(configuredEngine)}`);
|
|
50
|
+
}
|
|
51
|
+
const defaultEngine = configuredEngine;
|
|
52
|
+
const configuredAllowed = fileConfig.latex?.allowedEngines;
|
|
53
|
+
const allowedEngines = configuredAllowed === undefined
|
|
54
|
+
? [...LATEX_ENGINES]
|
|
55
|
+
: configuredAllowed.map((engine) => engine);
|
|
56
|
+
if (!allowedEngines.includes(defaultEngine)) {
|
|
57
|
+
throw configurationError("latex.allowedEngines", `must include the configured default engine “${defaultEngine}”`);
|
|
58
|
+
}
|
|
59
|
+
const compileTimeoutSeconds = integerSetting("latex.compileTimeoutSeconds", process.env.TEXLITE_COMPILE_TIMEOUT, fileConfig.latex?.compileTimeoutSeconds, CONFIG_DEFAULTS.compileTimeoutSeconds, CONFIG_LIMITS.compileTimeoutSeconds);
|
|
60
|
+
const maxCompileJobs = integerSetting("latex.maxCompileJobs", process.env.TEXLITE_MAX_COMPILE_JOBS, fileConfig.latex?.maxCompileJobs, CONFIG_DEFAULTS.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
|
|
61
|
+
const maxFileSizeMB = integerSetting("uploads.maxFileSizeMB", process.env.TEXLITE_MAX_UPLOAD_SIZE_MB, fileConfig.uploads?.maxFileSizeMB, CONFIG_DEFAULTS.maxFileSizeMB, CONFIG_LIMITS.maxFileSizeMB);
|
|
62
|
+
const historyMaxVersions = integerSetting("history.maxVersions", process.env.TEXLITE_HISTORY_MAX_VERSIONS, fileConfig.history?.maxVersions, CONFIG_DEFAULTS.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
63
|
+
const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
|
|
64
|
+
const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
65
|
+
const config = {
|
|
66
|
+
configPath,
|
|
67
|
+
siteName: stringSetting("siteName", process.env.TEXLITE_SITE_NAME, fileConfig.siteName, CONFIG_DEFAULTS.siteName, { min: 1, max: 120 }),
|
|
68
|
+
adminEmail: stringSetting("adminEmail", process.env.TEXLITE_ADMIN_EMAIL, fileConfig.adminEmail, CONFIG_DEFAULTS.adminEmail, { min: 0, max: 320 }),
|
|
69
|
+
host: stringSetting("server.host", process.env.TEXLITE_HOST, fileConfig.server?.host, CONFIG_DEFAULTS.host, { min: 1, max: 255 }),
|
|
70
|
+
port: integerSetting("server.port", process.env.TEXLITE_PORT, fileConfig.server?.port, CONFIG_DEFAULTS.port, CONFIG_LIMITS.port),
|
|
71
|
+
dataDir,
|
|
72
|
+
databasePath: path.join(dataDir, "texlite.db"),
|
|
73
|
+
projectsDir: path.join(dataDir, "projects"),
|
|
74
|
+
clientDir,
|
|
75
|
+
sessionDays: integerSetting("sessionDays", process.env.TEXLITE_SESSION_DAYS, fileConfig.sessionDays, CONFIG_DEFAULTS.sessionDays, CONFIG_LIMITS.sessionDays),
|
|
76
|
+
compileTimeoutMs: compileTimeoutSeconds * 1000,
|
|
77
|
+
maxCompileJobs,
|
|
78
|
+
latexmk: stringSetting("latex.latexmk", process.env.TEXLITE_LATEXMK, fileConfig.latex?.latexmk, CONFIG_DEFAULTS.latexmk, { min: 1, max: 256 }),
|
|
79
|
+
defaultEngine,
|
|
80
|
+
allowedEngines,
|
|
81
|
+
extraArgs: fileConfig.latex?.extraArgs === undefined ? [] : [...fileConfig.latex.extraArgs],
|
|
82
|
+
allowProjectLatexmkrc: fileConfig.latex?.allowProjectLatexmkrc ?? CONFIG_DEFAULTS.allowProjectLatexmkrc,
|
|
83
|
+
maxUploadBytes: maxFileSizeMB * 1024 * 1024,
|
|
84
|
+
historyMaxVersions,
|
|
85
|
+
historyMaxStorageBytes: historyMaxStorageMB * 1024 * 1024,
|
|
86
|
+
git: stringSetting("git.binary", process.env.TEXLITE_GIT, fileConfig.git?.binary, CONFIG_DEFAULTS.git, { min: 1, max: 256 }),
|
|
87
|
+
gitOperationTimeoutMs: gitOperationTimeoutSeconds * 1000,
|
|
88
|
+
githubApiBaseUrl: stringSetting("git.githubApiBaseUrl", process.env.TEXLITE_GITHUB_API_URL, fileConfig.git?.githubApiBaseUrl, CONFIG_DEFAULTS.githubApiBaseUrl, { min: 1, max: 2_048 }).replace(/\/+$/, "")
|
|
89
|
+
};
|
|
90
|
+
validateConfig(config);
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
/** Validate an already materialized configuration (useful for startup/tests). */
|
|
94
|
+
export function validateConfig(config) {
|
|
95
|
+
if (!config.dataDir || path.parse(config.dataDir).root === config.dataDir) {
|
|
96
|
+
throw configurationError("storage.dataDir", "must be a non-root directory where TexLite can store data");
|
|
97
|
+
}
|
|
98
|
+
validateDirectoryTarget("storage.dataDir", config.dataDir, true);
|
|
99
|
+
validateDirectoryTarget("projects directory", config.projectsDir, true);
|
|
100
|
+
validateFileTarget("database path", config.databasePath);
|
|
101
|
+
validateDirectoryTarget("client directory", config.clientDir, false);
|
|
102
|
+
validateUrl("git.githubApiBaseUrl", config.githubApiBaseUrl);
|
|
103
|
+
if (!config.allowedEngines.length || new Set(config.allowedEngines).size !== config.allowedEngines.length) {
|
|
104
|
+
throw configurationError("latex.allowedEngines", "must contain at least one unique engine");
|
|
105
|
+
}
|
|
106
|
+
for (const engine of config.allowedEngines) {
|
|
107
|
+
if (!isEngine(engine))
|
|
108
|
+
throw configurationError("latex.allowedEngines", `contains unsupported engine ${displayValue(engine)}`);
|
|
109
|
+
}
|
|
110
|
+
if (!isEngine(config.defaultEngine) || !config.allowedEngines.includes(config.defaultEngine)) {
|
|
111
|
+
throw configurationError("latex.defaultEngine", "must be one of the engines listed in latex.allowedEngines");
|
|
112
|
+
}
|
|
113
|
+
validateInteger("server.port", config.port, CONFIG_LIMITS.port);
|
|
114
|
+
validateInteger("sessionDays", config.sessionDays, CONFIG_LIMITS.sessionDays);
|
|
115
|
+
validateInteger("latex.compileTimeoutSeconds", config.compileTimeoutMs / 1000, CONFIG_LIMITS.compileTimeoutSeconds);
|
|
116
|
+
validateInteger("latex.maxCompileJobs", config.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
|
|
117
|
+
validateInteger("uploads.maxFileSizeMB", config.maxUploadBytes / (1024 * 1024), CONFIG_LIMITS.maxFileSizeMB);
|
|
118
|
+
validateInteger("history.maxVersions", config.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
|
|
119
|
+
validateInteger("history.maxStorageMB", config.historyMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.historyMaxStorageMB);
|
|
120
|
+
validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
121
|
+
}
|
|
122
|
+
function isEngine(value) {
|
|
123
|
+
return typeof value === "string" && LATEX_ENGINES.includes(value);
|
|
124
|
+
}
|
|
125
|
+
function readConfigFile(configPath) {
|
|
126
|
+
if (!fs.existsSync(configPath))
|
|
127
|
+
return {};
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
130
|
+
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
|
131
|
+
throw configurationError("configuration file", "must contain a JSON object at the top level");
|
|
132
|
+
}
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof Error && error.message.startsWith("Invalid configuration"))
|
|
137
|
+
throw error;
|
|
138
|
+
throw new Error(`Unable to read configuration file ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function validateFileConfig(config) {
|
|
142
|
+
optionalString(config.siteName, "siteName", { min: 1, max: 120 });
|
|
143
|
+
optionalString(config.adminEmail, "adminEmail", { min: 0, max: 320 });
|
|
144
|
+
optionalInteger(config.sessionDays, "sessionDays", CONFIG_LIMITS.sessionDays);
|
|
145
|
+
const server = optionalSection(config.server, "server");
|
|
146
|
+
optionalString(server?.host, "server.host", { min: 1, max: 255 });
|
|
147
|
+
optionalInteger(server?.port, "server.port", CONFIG_LIMITS.port);
|
|
148
|
+
const storage = optionalSection(config.storage, "storage");
|
|
149
|
+
optionalString(storage?.dataDir, "storage.dataDir", { min: 1, max: 4_096 });
|
|
150
|
+
const uploads = optionalSection(config.uploads, "uploads");
|
|
151
|
+
optionalInteger(uploads?.maxFileSizeMB, "uploads.maxFileSizeMB", CONFIG_LIMITS.maxFileSizeMB);
|
|
152
|
+
const history = optionalSection(config.history, "history");
|
|
153
|
+
optionalInteger(history?.maxVersions, "history.maxVersions", CONFIG_LIMITS.historyMaxVersions);
|
|
154
|
+
optionalInteger(history?.maxStorageMB, "history.maxStorageMB", CONFIG_LIMITS.historyMaxStorageMB);
|
|
155
|
+
const latex = optionalSection(config.latex, "latex");
|
|
156
|
+
optionalString(latex?.latexmk, "latex.latexmk", { min: 1, max: 256 });
|
|
157
|
+
if (latex && Object.prototype.hasOwnProperty.call(latex, "defaultEngine") && !isEngine(latex.defaultEngine)) {
|
|
158
|
+
throw configurationError("latex.defaultEngine", `must be one of ${LATEX_ENGINES.join(", ")}; received ${displayValue(latex.defaultEngine)}`);
|
|
159
|
+
}
|
|
160
|
+
optionalInteger(latex?.compileTimeoutSeconds, "latex.compileTimeoutSeconds", CONFIG_LIMITS.compileTimeoutSeconds);
|
|
161
|
+
optionalInteger(latex?.maxCompileJobs, "latex.maxCompileJobs", CONFIG_LIMITS.maxCompileJobs);
|
|
162
|
+
if (latex && Object.prototype.hasOwnProperty.call(latex, "allowedEngines")) {
|
|
163
|
+
if (!Array.isArray(latex.allowedEngines) || latex.allowedEngines.length === 0) {
|
|
164
|
+
throw configurationError("latex.allowedEngines", `must be a non-empty list containing only ${LATEX_ENGINES.join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
const engines = latex.allowedEngines;
|
|
167
|
+
if (engines.some((engine) => !isEngine(engine))) {
|
|
168
|
+
throw configurationError("latex.allowedEngines", `must contain only ${LATEX_ENGINES.join(", ")}`);
|
|
169
|
+
}
|
|
170
|
+
if (new Set(engines).size !== engines.length) {
|
|
171
|
+
throw configurationError("latex.allowedEngines", "must not contain duplicate engines");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (latex && Object.prototype.hasOwnProperty.call(latex, "extraArgs")) {
|
|
175
|
+
if (!Array.isArray(latex.extraArgs) || latex.extraArgs.some((item) => typeof item !== "string" || item.length > 512)) {
|
|
176
|
+
throw configurationError("latex.extraArgs", "must be an array of strings, each no longer than 512 characters");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (latex && Object.prototype.hasOwnProperty.call(latex, "allowProjectLatexmkrc") && typeof latex.allowProjectLatexmkrc !== "boolean") {
|
|
180
|
+
throw configurationError("latex.allowProjectLatexmkrc", "must be true or false");
|
|
181
|
+
}
|
|
182
|
+
const git = optionalSection(config.git, "git");
|
|
183
|
+
optionalString(git?.binary, "git.binary", { min: 1, max: 256 });
|
|
184
|
+
optionalInteger(git?.operationTimeoutSeconds, "git.operationTimeoutSeconds", CONFIG_LIMITS.gitOperationTimeoutSeconds);
|
|
185
|
+
if (git && Object.prototype.hasOwnProperty.call(git, "githubApiBaseUrl")) {
|
|
186
|
+
optionalString(git.githubApiBaseUrl, "git.githubApiBaseUrl", { min: 1, max: 2_048 });
|
|
187
|
+
if (typeof git.githubApiBaseUrl === "string")
|
|
188
|
+
validateUrl("git.githubApiBaseUrl", git.githubApiBaseUrl);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function optionalSection(value, name) {
|
|
192
|
+
if (value === undefined)
|
|
193
|
+
return undefined;
|
|
194
|
+
if (!isRecord(value) || Array.isArray(value))
|
|
195
|
+
throw configurationError(name, "must be a JSON object");
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
function optionalString(value, name, limits) {
|
|
199
|
+
if (value === undefined)
|
|
200
|
+
return;
|
|
201
|
+
if (typeof value !== "string" || value.length < limits.min || value.length > limits.max || (limits.min > 0 && !value.trim())) {
|
|
202
|
+
throw configurationError(name, `must be a ${limits.min === 0 ? "string" : `non-empty string`} of at most ${limits.max} characters`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function optionalInteger(value, name, limits) {
|
|
206
|
+
if (value === undefined)
|
|
207
|
+
return;
|
|
208
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
209
|
+
throw configurationError(name, `must be an integer from ${limits[0]} to ${limits[1]}; received ${displayValue(value)}`);
|
|
210
|
+
}
|
|
211
|
+
validateInteger(name, value, limits);
|
|
212
|
+
}
|
|
213
|
+
function integerSetting(name, envValue, fileValue, fallback, limits) {
|
|
214
|
+
if (envValue !== undefined) {
|
|
215
|
+
if (!/^\d+$/.test(envValue)) {
|
|
216
|
+
throw configurationError(name, `must be an integer from ${limits[0]} to ${limits[1]}; received ${displayValue(envValue)}`);
|
|
217
|
+
}
|
|
218
|
+
const value = Number(envValue);
|
|
219
|
+
validateInteger(name, value, limits);
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
if (fileValue === undefined)
|
|
223
|
+
return fallback;
|
|
224
|
+
validateInteger(name, fileValue, limits);
|
|
225
|
+
return fileValue;
|
|
226
|
+
}
|
|
227
|
+
function stringSetting(name, envValue, fileValue, fallback, limits) {
|
|
228
|
+
const value = envValue ?? fileValue ?? fallback;
|
|
229
|
+
optionalString(value, name, limits);
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
function setting(name, envValue, fileValue, fallback) {
|
|
233
|
+
if (envValue !== undefined) {
|
|
234
|
+
if (typeof envValue === "string" && !envValue.trim())
|
|
235
|
+
throw configurationError(name, "must not be empty");
|
|
236
|
+
return envValue;
|
|
237
|
+
}
|
|
238
|
+
return fileValue ?? fallback;
|
|
239
|
+
}
|
|
240
|
+
function resolveConfiguredPath(name, value, baseDirectory) {
|
|
241
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
242
|
+
throw configurationError(name, "must be a non-empty path without NUL characters");
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
return path.resolve(baseDirectory, value);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
throw configurationError(name, `is not a valid path: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function validateDirectoryTarget(name, target, checkWritableParent) {
|
|
252
|
+
if (fs.existsSync(target)) {
|
|
253
|
+
let stat;
|
|
254
|
+
try {
|
|
255
|
+
stat = fs.statSync(target);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw configurationError(name, `cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
259
|
+
}
|
|
260
|
+
if (!stat.isDirectory())
|
|
261
|
+
throw configurationError(name, `points to a file, not a directory: ${target}`);
|
|
262
|
+
if (checkWritableParent) {
|
|
263
|
+
try {
|
|
264
|
+
fs.accessSync(target, fs.constants.W_OK);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw configurationError(name, `is not writable: ${target}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const parent = nearestExistingParent(target);
|
|
273
|
+
if (!parent)
|
|
274
|
+
throw configurationError(name, `has no existing parent directory; create one before starting: ${target}`);
|
|
275
|
+
if (checkWritableParent) {
|
|
276
|
+
try {
|
|
277
|
+
fs.accessSync(parent, fs.constants.W_OK);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
throw configurationError(name, `cannot be created because its parent is not writable: ${parent}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function validateFileTarget(name, target) {
|
|
285
|
+
if (!fs.existsSync(target))
|
|
286
|
+
return;
|
|
287
|
+
try {
|
|
288
|
+
if (fs.statSync(target).isDirectory())
|
|
289
|
+
throw configurationError(name, `points to a directory, but must be a file: ${target}`);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (error instanceof Error && error.message.startsWith("Invalid configuration"))
|
|
293
|
+
throw error;
|
|
294
|
+
throw configurationError(name, `cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function nearestExistingParent(target) {
|
|
298
|
+
let current = path.dirname(target);
|
|
299
|
+
while (current !== path.dirname(current)) {
|
|
300
|
+
if (fs.existsSync(current))
|
|
301
|
+
return current;
|
|
302
|
+
current = path.dirname(current);
|
|
303
|
+
}
|
|
304
|
+
return fs.existsSync(current) ? current : null;
|
|
305
|
+
}
|
|
306
|
+
function validateUrl(name, value) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = new URL(value);
|
|
309
|
+
if (!["http:", "https:"].includes(parsed.protocol))
|
|
310
|
+
throw new Error("only http:// and https:// are supported");
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
throw configurationError(name, `must be a valid http(s) URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function validateInteger(name, value, limits) {
|
|
317
|
+
if (!Number.isInteger(value) || value < limits[0] || value > limits[1]) {
|
|
318
|
+
throw configurationError(name, `must be an integer from ${limits[0]} to ${limits[1]}; received ${displayValue(value)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function displayValue(value) {
|
|
322
|
+
if (typeof value === "string")
|
|
323
|
+
return `“${value}”`;
|
|
324
|
+
try {
|
|
325
|
+
return JSON.stringify(value);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return String(value);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function configurationError(name, detail) {
|
|
332
|
+
return new Error(`Invalid configuration (${name}): ${detail}. Update texlite.config.json or the corresponding TEXLITE_* environment variable and try again.`);
|
|
333
|
+
}
|
|
334
|
+
function isRecord(value) {
|
|
335
|
+
return typeof value === "object" && value !== null;
|
|
336
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
export function openDatabase(config) {
|
|
4
|
+
fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
5
|
+
fs.mkdirSync(config.projectsDir, { recursive: true, mode: 0o700 });
|
|
6
|
+
const db = new Database(config.databasePath, { timeout: 5000 });
|
|
7
|
+
db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
|
|
8
|
+
migrate(db);
|
|
9
|
+
return db;
|
|
10
|
+
}
|
|
11
|
+
function migrate(db) {
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
16
|
+
display_name TEXT NOT NULL,
|
|
17
|
+
password_hash TEXT NOT NULL,
|
|
18
|
+
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
|
|
19
|
+
disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)),
|
|
20
|
+
must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
|
21
|
+
can_create_projects INTEGER NOT NULL DEFAULT 0 CHECK (can_create_projects IN (0, 1)),
|
|
22
|
+
created_at TEXT NOT NULL
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
28
|
+
expires_at TEXT NOT NULL,
|
|
29
|
+
created_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
|
36
|
+
last_modified_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
37
|
+
name TEXT NOT NULL,
|
|
38
|
+
main_file TEXT NOT NULL DEFAULT 'main.tex',
|
|
39
|
+
latexmkrc TEXT,
|
|
40
|
+
engine TEXT NOT NULL DEFAULT 'xelatex' CHECK (engine IN ('pdflatex', 'xelatex', 'lualatex')),
|
|
41
|
+
created_at TEXT NOT NULL,
|
|
42
|
+
updated_at TEXT NOT NULL
|
|
43
|
+
);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS projects_owner_id ON projects(owner_id);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS project_tags (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
49
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
50
|
+
color TEXT NOT NULL CHECK (color IN ('red', 'orange', 'yellow', 'green', 'blue', 'purple', 'gray')),
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
UNIQUE (project_id, name)
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS project_tags_project_id ON project_tags(project_id);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
59
|
+
color TEXT NOT NULL CHECK (color IN ('red', 'orange', 'yellow', 'green', 'blue', 'purple', 'gray')),
|
|
60
|
+
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
61
|
+
created_at TEXT NOT NULL,
|
|
62
|
+
updated_at TEXT NOT NULL
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS project_tag_links (
|
|
66
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
67
|
+
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
68
|
+
created_at TEXT NOT NULL,
|
|
69
|
+
PRIMARY KEY (project_id, tag_id)
|
|
70
|
+
);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS project_tag_links_tag_id ON project_tag_links(tag_id);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS user_tags (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
76
|
+
name TEXT NOT NULL COLLATE NOCASE,
|
|
77
|
+
color TEXT NOT NULL CHECK (color IN ('red', 'orange', 'yellow', 'green', 'blue', 'purple', 'gray')),
|
|
78
|
+
created_at TEXT NOT NULL,
|
|
79
|
+
updated_at TEXT NOT NULL,
|
|
80
|
+
UNIQUE (user_id, name)
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS user_tags_user_id ON user_tags(user_id);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS user_project_tag_links (
|
|
85
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
86
|
+
tag_id TEXT NOT NULL REFERENCES user_tags(id) ON DELETE CASCADE,
|
|
87
|
+
created_at TEXT NOT NULL,
|
|
88
|
+
PRIMARY KEY (project_id, tag_id)
|
|
89
|
+
);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS user_project_tag_links_tag_id ON user_project_tag_links(tag_id);
|
|
91
|
+
|
|
92
|
+
CREATE TABLE IF NOT EXISTS user_project_archives (
|
|
93
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
94
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
95
|
+
archived_at TEXT NOT NULL,
|
|
96
|
+
PRIMARY KEY (user_id, project_id)
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS user_project_archives_project_id ON user_project_archives(project_id);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS project_members (
|
|
101
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
102
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
103
|
+
permission TEXT NOT NULL CHECK (permission IN ('read', 'edit')),
|
|
104
|
+
created_at TEXT NOT NULL,
|
|
105
|
+
PRIMARY KEY (project_id, user_id)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
109
|
+
id TEXT PRIMARY KEY,
|
|
110
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
111
|
+
file_path TEXT NOT NULL,
|
|
112
|
+
author_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
113
|
+
selected_text TEXT NOT NULL DEFAULT '',
|
|
114
|
+
start_offset INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
end_offset INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
context_before TEXT NOT NULL DEFAULT '',
|
|
117
|
+
context_after TEXT NOT NULL DEFAULT '',
|
|
118
|
+
orphaned INTEGER NOT NULL DEFAULT 0 CHECK (orphaned IN (0, 1)),
|
|
119
|
+
start_line INTEGER NOT NULL DEFAULT 1,
|
|
120
|
+
end_line INTEGER NOT NULL DEFAULT 1,
|
|
121
|
+
content TEXT NOT NULL,
|
|
122
|
+
resolved INTEGER NOT NULL DEFAULT 0 CHECK (resolved IN (0, 1)),
|
|
123
|
+
created_at TEXT NOT NULL,
|
|
124
|
+
updated_at TEXT NOT NULL,
|
|
125
|
+
edited_at TEXT
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS comments_project_file ON comments(project_id, file_path);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS comment_replies (
|
|
130
|
+
id TEXT PRIMARY KEY,
|
|
131
|
+
comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
|
132
|
+
author_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
133
|
+
content TEXT NOT NULL,
|
|
134
|
+
created_at TEXT NOT NULL,
|
|
135
|
+
updated_at TEXT NOT NULL,
|
|
136
|
+
edited_at TEXT
|
|
137
|
+
);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS comment_replies_comment_id ON comment_replies(comment_id, created_at);
|
|
139
|
+
|
|
140
|
+
CREATE TABLE IF NOT EXISTS compile_runs (
|
|
141
|
+
id TEXT PRIMARY KEY,
|
|
142
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
143
|
+
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
144
|
+
main_file TEXT NOT NULL DEFAULT '',
|
|
145
|
+
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
|
146
|
+
log TEXT NOT NULL DEFAULT '',
|
|
147
|
+
created_at TEXT NOT NULL,
|
|
148
|
+
finished_at TEXT
|
|
149
|
+
);
|
|
150
|
+
CREATE INDEX IF NOT EXISTS compile_runs_project_created ON compile_runs(project_id, created_at DESC);
|
|
151
|
+
|
|
152
|
+
CREATE TABLE IF NOT EXISTS project_history_versions (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
155
|
+
author_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
156
|
+
reason TEXT NOT NULL CHECK (reason IN ('initial', 'autosave', 'file', 'settings', 'git', 'restore', 'checkpoint')),
|
|
157
|
+
label TEXT,
|
|
158
|
+
manifest_json TEXT NOT NULL,
|
|
159
|
+
changed_paths_json TEXT NOT NULL DEFAULT '[]',
|
|
160
|
+
created_at TEXT NOT NULL
|
|
161
|
+
);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS project_history_versions_project_created
|
|
163
|
+
ON project_history_versions(project_id, created_at DESC);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS project_history_state (
|
|
166
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
167
|
+
manifest_json TEXT NOT NULL,
|
|
168
|
+
updated_at TEXT NOT NULL
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS project_dictionary_words (
|
|
172
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
173
|
+
word TEXT NOT NULL COLLATE NOCASE,
|
|
174
|
+
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
175
|
+
created_at TEXT NOT NULL,
|
|
176
|
+
PRIMARY KEY (project_id, word)
|
|
177
|
+
);
|
|
178
|
+
CREATE INDEX IF NOT EXISTS project_dictionary_words_project_id ON project_dictionary_words(project_id);
|
|
179
|
+
|
|
180
|
+
CREATE TABLE IF NOT EXISTS project_git_settings (
|
|
181
|
+
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
|
|
182
|
+
token_ciphertext TEXT,
|
|
183
|
+
github_login TEXT,
|
|
184
|
+
remote_url TEXT,
|
|
185
|
+
repository_name TEXT,
|
|
186
|
+
repository_html_url TEXT,
|
|
187
|
+
default_branch TEXT NOT NULL DEFAULT 'main',
|
|
188
|
+
created_at TEXT NOT NULL,
|
|
189
|
+
updated_at TEXT NOT NULL
|
|
190
|
+
);
|
|
191
|
+
`);
|
|
192
|
+
const projectColumns = db.prepare("PRAGMA table_info(projects)").all();
|
|
193
|
+
if (!projectColumns.some((column) => column.name === "latexmkrc")) {
|
|
194
|
+
db.exec("ALTER TABLE projects ADD COLUMN latexmkrc TEXT");
|
|
195
|
+
}
|
|
196
|
+
if (!projectColumns.some((column) => column.name === "last_modified_by")) {
|
|
197
|
+
db.exec("ALTER TABLE projects ADD COLUMN last_modified_by TEXT REFERENCES users(id) ON DELETE SET NULL");
|
|
198
|
+
}
|
|
199
|
+
const commentColumns = db.prepare("PRAGMA table_info(comments)").all();
|
|
200
|
+
const additions = [
|
|
201
|
+
["start_offset", "INTEGER NOT NULL DEFAULT 0"],
|
|
202
|
+
["end_offset", "INTEGER NOT NULL DEFAULT 0"],
|
|
203
|
+
["context_before", "TEXT NOT NULL DEFAULT ''"],
|
|
204
|
+
["context_after", "TEXT NOT NULL DEFAULT ''"],
|
|
205
|
+
["orphaned", "INTEGER NOT NULL DEFAULT 0"],
|
|
206
|
+
["edited_at", "TEXT"]
|
|
207
|
+
];
|
|
208
|
+
for (const [name, definition] of additions) {
|
|
209
|
+
if (!commentColumns.some((column) => column.name === name)) {
|
|
210
|
+
db.exec(`ALTER TABLE comments ADD COLUMN ${name} ${definition}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const replyColumns = db.prepare("PRAGMA table_info(comment_replies)").all();
|
|
214
|
+
if (!replyColumns.some((column) => column.name === "edited_at")) {
|
|
215
|
+
db.exec("ALTER TABLE comment_replies ADD COLUMN edited_at TEXT");
|
|
216
|
+
}
|
|
217
|
+
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
|
218
|
+
if (!userColumns.some((column) => column.name === "can_create_projects")) {
|
|
219
|
+
db.exec("ALTER TABLE users ADD COLUMN can_create_projects INTEGER NOT NULL DEFAULT 0");
|
|
220
|
+
}
|
|
221
|
+
const compileRunColumns = db.prepare("PRAGMA table_info(compile_runs)").all();
|
|
222
|
+
if (!compileRunColumns.some((column) => column.name === "main_file")) {
|
|
223
|
+
db.exec("ALTER TABLE compile_runs ADD COLUMN main_file TEXT NOT NULL DEFAULT ''");
|
|
224
|
+
}
|
|
225
|
+
db.exec(`
|
|
226
|
+
UPDATE users SET can_create_projects = 1 WHERE role = 'admin';
|
|
227
|
+
UPDATE projects SET last_modified_by = owner_id WHERE last_modified_by IS NULL;
|
|
228
|
+
UPDATE compile_runs SET main_file = COALESCE(
|
|
229
|
+
(SELECT project.main_file FROM projects project WHERE project.id = compile_runs.project_id), ''
|
|
230
|
+
) WHERE main_file = '';
|
|
231
|
+
CREATE INDEX IF NOT EXISTS compile_runs_project_main_created
|
|
232
|
+
ON compile_runs(project_id, main_file, created_at DESC);
|
|
233
|
+
|
|
234
|
+
INSERT OR IGNORE INTO tags (id, name, color, created_by, created_at, updated_at)
|
|
235
|
+
SELECT legacy.id, legacy.name, legacy.color, p.owner_id, legacy.created_at, legacy.created_at
|
|
236
|
+
FROM project_tags legacy
|
|
237
|
+
JOIN projects p ON p.id = legacy.project_id
|
|
238
|
+
WHERE legacy.id = (
|
|
239
|
+
SELECT first_tag.id FROM project_tags first_tag
|
|
240
|
+
WHERE first_tag.name = legacy.name COLLATE NOCASE
|
|
241
|
+
ORDER BY first_tag.created_at, first_tag.id LIMIT 1
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
INSERT OR IGNORE INTO project_tag_links (project_id, tag_id, created_at)
|
|
245
|
+
SELECT legacy.project_id, tag.id, legacy.created_at
|
|
246
|
+
FROM project_tags legacy
|
|
247
|
+
JOIN tags tag ON tag.name = legacy.name COLLATE NOCASE;
|
|
248
|
+
|
|
249
|
+
INSERT OR IGNORE INTO user_tags (id, user_id, name, color, created_at, updated_at)
|
|
250
|
+
SELECT legacy.id, project.owner_id, legacy.name, legacy.color, legacy.created_at, legacy.created_at
|
|
251
|
+
FROM project_tags legacy
|
|
252
|
+
JOIN projects project ON project.id = legacy.project_id
|
|
253
|
+
WHERE legacy.id = (
|
|
254
|
+
SELECT first_tag.id FROM project_tags first_tag
|
|
255
|
+
JOIN projects first_project ON first_project.id = first_tag.project_id
|
|
256
|
+
WHERE first_project.owner_id = project.owner_id
|
|
257
|
+
AND first_tag.name = legacy.name COLLATE NOCASE
|
|
258
|
+
ORDER BY first_tag.created_at, first_tag.id LIMIT 1
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at)
|
|
262
|
+
SELECT legacy.project_id, user_tag.id, legacy.created_at
|
|
263
|
+
FROM project_tags legacy
|
|
264
|
+
JOIN projects project ON project.id = legacy.project_id
|
|
265
|
+
JOIN user_tags user_tag ON user_tag.user_id = project.owner_id
|
|
266
|
+
AND user_tag.name = legacy.name COLLATE NOCASE;
|
|
267
|
+
|
|
268
|
+
INSERT OR IGNORE INTO user_tags (id, user_id, name, color, created_at, updated_at)
|
|
269
|
+
SELECT tag.id, tag.created_by, tag.name, tag.color, tag.created_at, tag.updated_at
|
|
270
|
+
FROM tags tag WHERE tag.created_by IS NOT NULL;
|
|
271
|
+
|
|
272
|
+
INSERT OR IGNORE INTO user_project_tag_links (project_id, tag_id, created_at)
|
|
273
|
+
SELECT link.project_id, link.tag_id, link.created_at
|
|
274
|
+
FROM project_tag_links link JOIN user_tags user_tag ON user_tag.id = link.tag_id;
|
|
275
|
+
`);
|
|
276
|
+
}
|
|
277
|
+
export function activeAdminCount(db) {
|
|
278
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND disabled = 0").get();
|
|
279
|
+
return Number(row.count);
|
|
280
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export async function assertEnvironment(config) {
|
|
3
|
+
const commands = [
|
|
4
|
+
{ name: "latexmk", command: config.latexmk },
|
|
5
|
+
...config.allowedEngines.map((command) => ({ name: command, command }))
|
|
6
|
+
];
|
|
7
|
+
const checked = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
for (const item of commands) {
|
|
10
|
+
if (seen.has(item.command))
|
|
11
|
+
continue;
|
|
12
|
+
seen.add(item.command);
|
|
13
|
+
const result = await commandVersion(item.command, 10_000);
|
|
14
|
+
if (result.error || result.status !== 0) {
|
|
15
|
+
const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
16
|
+
throw new Error(`Environment check failed: could not run the ${item.name} command "${item.command}" (${detail}). Initialization/startup has been stopped.`);
|
|
17
|
+
}
|
|
18
|
+
const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
|
|
19
|
+
checked.push({ ...item, version });
|
|
20
|
+
}
|
|
21
|
+
return checked;
|
|
22
|
+
}
|
|
23
|
+
export async function assertGitAvailable(config) {
|
|
24
|
+
const item = { name: "Git", command: config.git };
|
|
25
|
+
const result = await commandVersion(item.command, Math.min(config.gitOperationTimeoutMs, 10_000));
|
|
26
|
+
if (result.error || result.status !== 0) {
|
|
27
|
+
const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
28
|
+
throw Object.assign(new Error(`Git integration is unavailable: could not run the configured command "${item.command}" (${detail}). Install Git or correct git.binary and try again.`), { statusCode: 503, code: "GIT_UNAVAILABLE" });
|
|
29
|
+
}
|
|
30
|
+
const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
|
|
31
|
+
return { ...item, version };
|
|
32
|
+
}
|
|
33
|
+
function commandVersion(command, timeoutMs) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const child = spawn(command, ["--version"], {
|
|
36
|
+
env: { ...process.env, LC_ALL: "C" }, shell: false, stdio: ["ignore", "pipe", "pipe"]
|
|
37
|
+
});
|
|
38
|
+
const stdout = [];
|
|
39
|
+
const stderr = [];
|
|
40
|
+
let settled = false;
|
|
41
|
+
let timedOut = false;
|
|
42
|
+
const finish = (result) => {
|
|
43
|
+
if (settled)
|
|
44
|
+
return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve({ ...result, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
|
|
50
|
+
child.stdout.on("data", (chunk) => { if (Buffer.concat(stdout).length < 64 * 1024)
|
|
51
|
+
stdout.push(chunk); });
|
|
52
|
+
child.stderr.on("data", (chunk) => { if (Buffer.concat(stderr).length < 64 * 1024)
|
|
53
|
+
stderr.push(chunk); });
|
|
54
|
+
child.once("error", (error) => finish({ status: null, error: error.message }));
|
|
55
|
+
child.once("close", (status) => finish({ status, error: timedOut ? "command timed out" : null }));
|
|
56
|
+
});
|
|
57
|
+
}
|