Support Gitea configuration and safe Catalog Studio furniture completion
This commit is contained in:
1 parent
26f071117b
commit
9f791ba284
22 files changed
+1120
-166
No files matched your search
@@ -0,0 +1,177 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user