Files
EpicNext-Cms/src/lib/services/catalog-git-config.ts
T
Simo 9f791ba284
CI / check (push) Successful in 1m31s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 21s
Support Gitea configuration and safe Catalog Studio furniture completion
2026-09-05 13:33:40 +02:00

178 lines
5.2 KiB
TypeScript

import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from "node:crypto";
import { promises as fs, readFileSync } from "node:fs";
import path from "node:path";
import { CATALOG_BRANCH, CATALOG_REMOTE } from "./catalog-git-core";
export interface ManagedCatalogConfig {
enabled: boolean;
remote: string;
branch: string;
username: string;
encryptedToken: string;
}
export function catalogStateRoot() {
return (
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git")
);
}
export function readManagedCatalogConfig(): ManagedCatalogConfig | null {
try {
return JSON.parse(
readFileSync(path.join(catalogStateRoot(), "config.json"), "utf8"),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error("Catalog Git configuration cannot be read");
}
}
function key() {
const secret = process.env.AUTH_SECRET;
if (!secret)
throw new Error("The application authentication secret is unavailable");
return createHash("sha256").update(secret).digest();
}
export function encryptCatalogToken(token: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const value = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), value]
.map((part) => part.toString("base64"))
.join(".");
}
export function decryptCatalogToken(value: string) {
if (!value) return "";
const [iv, tag, data] = value
.split(".")
.map((part) => Buffer.from(part, "base64"));
const cipher = createDecipheriv("aes-256-gcm", key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString("utf8");
}
export function validateCatalogConfig(
input: unknown,
previous: ManagedCatalogConfig | null,
): ManagedCatalogConfig {
const value = input as Record<string, unknown>;
if (
!value ||
typeof value.remote !== "string" ||
typeof value.branch !== "string" ||
typeof value.username !== "string" ||
typeof value.enabled !== "boolean"
)
throw new Error(
"Repository, branch, username and enabled state are required",
);
let url: URL;
try {
url = new URL(value.remote.trim());
} catch {
throw new Error("Enter a valid HTTPS Gitea repository URL");
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
)
throw new Error(
"Use an HTTPS repository URL without credentials or query parameters",
);
const remote = `${url
.toString()
.replace(/\/$/, "")
.replace(/\.git$/, "")}.git`;
const branch = value.branch.trim(),
username = value.username.trim();
if (
!branch ||
branch.startsWith("-") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.endsWith(".") ||
branch.includes("..") ||
branch.includes("//") ||
branch.includes("@{") ||
/[\s~^:?*[\\]/.test(branch) ||
[...branch].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
branch
.split("/")
.some((part) => part.startsWith(".") || part.endsWith(".lock")) ||
branch === "@"
)
throw new Error("Enter a valid Git branch");
if (!username || /[\r\n:]/.test(username))
throw new Error("Enter a valid Gitea username");
const token = typeof value.token === "string" ? value.token.trim() : "";
if (/[\r\n]/.test(token)) throw new Error("Invalid access token");
const same = previous?.remote === remote && previous.username === username;
const encryptedToken = token
? encryptCatalogToken(token)
: same
? previous.encryptedToken
: "";
if (value.enabled && !encryptedToken)
throw new Error("Provide a Gitea token before enabling export");
return { enabled: value.enabled, remote, branch, username, encryptedToken };
}
export function publicCatalogConfig(config = readManagedCatalogConfig()) {
return {
provider: "gitea",
enabled: config?.enabled ?? false,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
username: config?.username ?? "",
hasToken: !!config?.encryptedToken,
};
}
export async function saveCatalogConfig(config: ManagedCatalogConfig) {
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const temp = path.join(root, `config-${randomUUID()}.tmp`);
try {
await fs.writeFile(temp, JSON.stringify(config), {
mode: 0o600,
flag: "wx",
});
await fs.rename(temp, path.join(root, "config.json"));
} finally {
await fs.rm(temp, { force: true });
}
}
export function catalogGitEnvironment(
config: ManagedCatalogConfig,
): NodeJS.ProcessEnv {
const token = decryptCatalogToken(config.encryptedToken);
return {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "2",
GIT_CONFIG_KEY_0: `http.${new URL(config.remote).origin}/.extraHeader`,
GIT_CONFIG_VALUE_0:
"Authorization: Basic " +
Buffer.from(`${config.username}:${token}`).toString("base64"),
GIT_CONFIG_KEY_1: "http.followRedirects",
GIT_CONFIG_VALUE_1: "false",
};
}
export function managedCatalogCheckout(config: ManagedCatalogConfig) {
return path.join(
catalogStateRoot(),
"checkouts",
createHash("sha256")
.update(`${config.remote}\n${config.branch}`)
.digest("hex")
.slice(0, 24),
);
}