This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { cachedRepositoryPath, listRepositoryFiles } from "../gitOperations";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/style-packages", (_req, res) => {
|
||||
res.json(store.stylePackages);
|
||||
});
|
||||
|
||||
router.get("/style-instances", (_req, res) => {
|
||||
res.json(store.styleInstances);
|
||||
});
|
||||
|
||||
router.post("/style-instances", (req, res) => {
|
||||
const instance = req.body;
|
||||
store.styleInstances.push(instance);
|
||||
store.addAudit(
|
||||
"Add Style Instance",
|
||||
"config",
|
||||
`Created style instance '${instance.id}' using '${instance.uses}'`,
|
||||
);
|
||||
res.json({ success: true, instance });
|
||||
});
|
||||
|
||||
router.put("/style-instances/:id", (req, res) => {
|
||||
const index = store.styleInstances.findIndex(
|
||||
(instance) => instance.id === req.params.id,
|
||||
);
|
||||
if (index === -1) return res.status(404).json({ error: "Instance not found" });
|
||||
store.styleInstances[index] = { ...store.styleInstances[index], ...req.body };
|
||||
store.addAudit(
|
||||
"Update Style Instance",
|
||||
"config",
|
||||
`Updated style instance '${req.params.id}'`,
|
||||
);
|
||||
return res.json({ success: true, instance: store.styleInstances[index] });
|
||||
});
|
||||
|
||||
router.get("/content", (_req, res) => {
|
||||
res.json(store.contentItems);
|
||||
});
|
||||
|
||||
router.post("/content/discover", (_req, res) => {
|
||||
store.addAudit(
|
||||
"Discover Content",
|
||||
"config",
|
||||
"Executed content discovery scan across all Git sources",
|
||||
);
|
||||
const discovered = [];
|
||||
|
||||
for (const source of store.gitSources) {
|
||||
if (source.health !== "Healthy" || !source.projectionEnabled) continue;
|
||||
let root: string;
|
||||
try {
|
||||
root = cachedRepositoryPath(source.id);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(root)) continue;
|
||||
|
||||
const markdownFiles = listRepositoryFiles(
|
||||
source.id,
|
||||
(relative) => relative.toLowerCase().endsWith(".md"),
|
||||
10_000,
|
||||
);
|
||||
for (const [index, file] of markdownFiles.entries()) {
|
||||
const title = path.posix.basename(file.path, ".md").trim() || "Untitled";
|
||||
const declaredModels = source.produces || [];
|
||||
const defaultModel = store.siteConfig.contentModels[0]?.id ?? "page";
|
||||
const artifactType = declaredModels[0] ?? defaultModel;
|
||||
const slug = title.toLowerCase();
|
||||
const model = store.siteConfig.contentModels.find(
|
||||
(candidate) => candidate.id === artifactType,
|
||||
);
|
||||
const navigation = store.siteConfig.navigation.find(
|
||||
(entry) => entry.contentModel === artifactType,
|
||||
);
|
||||
const routePattern =
|
||||
model?.routing?.detail ??
|
||||
(navigation
|
||||
? `${navigation.route}/{slug}/`
|
||||
: `/${artifactType}s/{slug}/`);
|
||||
discovered.push({
|
||||
id: `art-${source.id}-${index}`,
|
||||
title: title
|
||||
.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase()),
|
||||
slug,
|
||||
published: new Date().toISOString(),
|
||||
status: "published" as const,
|
||||
artifactType,
|
||||
summary: `Discovered artifact from ${file.path}`,
|
||||
tags: [source.id],
|
||||
aliases: [],
|
||||
sourceRepo: source.id,
|
||||
path: file.path,
|
||||
contentMarkdown: fs.readFileSync(
|
||||
path.join(root, ...file.path.split("/")),
|
||||
"utf8",
|
||||
),
|
||||
mediaReferences: [],
|
||||
youtubeDirectives: [],
|
||||
wikipediaLinks: [],
|
||||
validationStatus: "valid" as const,
|
||||
validationMessages: [],
|
||||
route: routePattern.replace("{slug}", slug).replace("//", "/"),
|
||||
styleInstanceId: "default",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
store.contentItems = discovered;
|
||||
res.json({
|
||||
success: true,
|
||||
discoveredCount: discovered.length,
|
||||
mediaCount: store.mediaAssets.length,
|
||||
items: discovered,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/media", (_req, res) => {
|
||||
res.json(store.mediaAssets);
|
||||
});
|
||||
|
||||
export { router as contentRouter };
|
||||
@@ -0,0 +1,124 @@
|
||||
import express from "express";
|
||||
import { CredentialRef } from "../../types";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok", service: "labyricorn-site-builder" });
|
||||
});
|
||||
|
||||
router.get("/site-config", (_req, res) => {
|
||||
res.json(store.siteConfig);
|
||||
});
|
||||
|
||||
router.put("/site-config", (_req, res) => {
|
||||
res.status(405).json({
|
||||
code: "E_CONFIG_READ_ONLY",
|
||||
message: "Site configuration is Git-owned and read-only in v1.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/theme", (_req, res) => {
|
||||
res.json(store.themeConfig);
|
||||
});
|
||||
|
||||
router.post("/theme/validate", (_req, res) => {
|
||||
const theme = store.validateTheme();
|
||||
res.status(theme.status === "valid" ? 200 : 422).json({
|
||||
success: theme.status === "valid",
|
||||
theme,
|
||||
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/builds", (_req, res) => {
|
||||
res.json(store.builds);
|
||||
});
|
||||
|
||||
router.post("/builds/trigger", (_req, res) => {
|
||||
res.json(store.runBuild());
|
||||
});
|
||||
|
||||
router.get("/nginx", (_req, res) => {
|
||||
res.json(store.nginxStatus);
|
||||
});
|
||||
|
||||
router.post("/nginx/test", (_req, res) => {
|
||||
store.nginxStatus.configValid = true;
|
||||
store.addAudit(
|
||||
"Test Nginx Config",
|
||||
"nginx",
|
||||
"Control-plane simulation reported a valid Nginx configuration.",
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
simulated: true,
|
||||
message: "Nginx validation is simulated by the control plane.",
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/nginx/activate", (req, res) => {
|
||||
res.json(store.activateReleaseLocally(req.body.buildId));
|
||||
});
|
||||
|
||||
router.get("/push-targets", (_req, res) => {
|
||||
res.json(store.pushTargets);
|
||||
});
|
||||
|
||||
router.post("/push-targets/:id/push", (req, res) => {
|
||||
const result = store.pushToTarget(req.params.id, req.body.buildId);
|
||||
res.status(result.success ? 200 : 501).json(result);
|
||||
});
|
||||
|
||||
router.post("/push-targets/:id/test", (_req, res) => {
|
||||
res.status(501).json({
|
||||
success: false,
|
||||
code: "E_REMOTE_PUBLICATION_NOT_IMPLEMENTED",
|
||||
message: "Remote SSH and rsync checks are not implemented.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/credentials", (_req, res) => {
|
||||
res.json(store.credentials);
|
||||
});
|
||||
|
||||
router.post("/credentials", (req, res) => {
|
||||
const { name, type, secretValue } = req.body;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!["ssh_key", "token", "password"].includes(type) ||
|
||||
typeof secretValue !== "string"
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "name, type, and secretValue are required.",
|
||||
});
|
||||
}
|
||||
const id = `secret:${name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
|
||||
const masked =
|
||||
type === "ssh_key"
|
||||
? `${secretValue.substring(0, 20)}...`
|
||||
: `${secretValue.substring(0, 4)}********************`;
|
||||
const credential: CredentialRef = {
|
||||
id,
|
||||
name,
|
||||
type: type as CredentialRef["type"],
|
||||
maskedValue: masked,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
usageCount: 0,
|
||||
};
|
||||
store.credentials.push(credential);
|
||||
store.addAudit(
|
||||
"Add Credential",
|
||||
"credential",
|
||||
`Stored credential reference ${id}`,
|
||||
);
|
||||
return res.json({ success: true, credential });
|
||||
});
|
||||
|
||||
router.get("/audit-logs", (_req, res) => {
|
||||
res.json(store.auditLogs);
|
||||
});
|
||||
|
||||
export { router as controlRouter };
|
||||
@@ -0,0 +1,230 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import { GitSource } from "../../types";
|
||||
import {
|
||||
cachedRepositoryPath,
|
||||
listRepositoryFiles,
|
||||
resolveRemote,
|
||||
synchronizeRepository,
|
||||
validateRepository,
|
||||
} from "../gitOperations";
|
||||
import { store } from "../store";
|
||||
import {
|
||||
fetchGiteaRepositories,
|
||||
FixedWindowRateLimiter,
|
||||
loadGiteaDiscoveryConfig,
|
||||
toGitSource,
|
||||
} from "./giteaDiscovery";
|
||||
|
||||
const router = express.Router();
|
||||
const discoveryLimiter = new FixedWindowRateLimiter();
|
||||
let discoveryInFlight: Promise<ReturnType<typeof toGitSource>[]> | null = null;
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
router.post("/gitea/discover", async (req, res) => {
|
||||
const limit = discoveryLimiter.take(req.ip || req.socket.remoteAddress || "unknown");
|
||||
if (!limit.allowed) {
|
||||
res.setHeader("Retry-After", String(limit.retryAfter));
|
||||
return res.status(429).json({
|
||||
code: "E_GITEA_DISCOVERY_RATE_LIMIT",
|
||||
error: "Gitea discovery rate limit exceeded.",
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
let config: ReturnType<typeof loadGiteaDiscoveryConfig>;
|
||||
try {
|
||||
config = loadGiteaDiscoveryConfig();
|
||||
} catch (error) {
|
||||
return res.status(503).json({
|
||||
code: "E_GITEA_DISCOVERY_CONFIG",
|
||||
error: errorMessage(error),
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
store.addAudit(
|
||||
"Gitea Discovery",
|
||||
"config",
|
||||
`Initiating repository discovery from ${config.apiUrl.origin}`,
|
||||
);
|
||||
try {
|
||||
discoveryInFlight ??= fetchGiteaRepositories(config)
|
||||
.then((repositories) => repositories.map(toGitSource))
|
||||
.finally(() => {
|
||||
discoveryInFlight = null;
|
||||
});
|
||||
const discovered = await discoveryInFlight;
|
||||
const added = store.mergeDiscoveredGitSources(discovered);
|
||||
return res.json({
|
||||
success: true,
|
||||
discoveredCount: discovered.length,
|
||||
addedCount: added,
|
||||
message: `Successfully discovered ${discovered.length} repositories from Gitea.`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
store.addAudit("Gitea Discovery Failed", "config", message, "failure");
|
||||
return res.status(502).json({ error: message, success: false });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/git-sources", (_req, res) => {
|
||||
res.json(store.gitSources);
|
||||
});
|
||||
|
||||
router.post("/git-sources", async (req, res) => {
|
||||
const body = req.body as Partial<GitSource>;
|
||||
if (
|
||||
typeof body.id !== "string" ||
|
||||
typeof body.repository !== "string" ||
|
||||
typeof body.ref !== "string"
|
||||
) {
|
||||
return res.status(400).json({ error: "id, repository, and ref are required." });
|
||||
}
|
||||
|
||||
try {
|
||||
validateRepository(body.repository);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
const source: GitSource = {
|
||||
id: body.id,
|
||||
repository: body.repository,
|
||||
ref: body.ref,
|
||||
branch: body.branch || body.ref,
|
||||
type: body.type || "hybrid",
|
||||
required: body.required ?? false,
|
||||
credential: body.credential,
|
||||
health: "Warning",
|
||||
reachability: "Unreachable",
|
||||
configurationState: "Not Configured",
|
||||
visibility: "Private in Gitea",
|
||||
projectionEnabled: false,
|
||||
produces: [],
|
||||
explainWhy: "Awaiting initial synchronization.",
|
||||
status: "syncing",
|
||||
};
|
||||
|
||||
try {
|
||||
const commit = await resolveRemote(source.repository, source.ref);
|
||||
if (commit) {
|
||||
source.status = "connected";
|
||||
source.reachability = "Healthy";
|
||||
source.lastResolvedCommit = commit;
|
||||
source.lastSyncedAt = new Date().toISOString();
|
||||
source.explainWhy =
|
||||
"Connected to repository, but missing .labyricorn/site.yml metadata.";
|
||||
} else {
|
||||
source.status = "error";
|
||||
source.explainWhy = "Ref not found in repository.";
|
||||
}
|
||||
} catch (error) {
|
||||
source.status = "error";
|
||||
source.explainWhy = `Git ls-remote failed: ${errorMessage(error)}`;
|
||||
}
|
||||
|
||||
store.gitSources.push(source);
|
||||
store.addAudit(
|
||||
"Add Git Source",
|
||||
"source",
|
||||
`Added Git source ${source.id} (${source.repository})`,
|
||||
);
|
||||
return res.json({ success: true, source });
|
||||
});
|
||||
|
||||
router.put("/git-sources/:id", (req, res) => {
|
||||
const index = store.gitSources.findIndex((source) => source.id === req.params.id);
|
||||
if (index === -1) return res.status(404).json({ error: "Source not found" });
|
||||
store.gitSources[index] = { ...store.gitSources[index], ...req.body };
|
||||
store.addAudit(
|
||||
"Update Git Source",
|
||||
"source",
|
||||
`Updated Git source ${req.params.id}`,
|
||||
);
|
||||
return res.json({ success: true, source: store.gitSources[index] });
|
||||
});
|
||||
|
||||
router.delete("/git-sources/:id", (req, res) => {
|
||||
store.gitSources = store.gitSources.filter(
|
||||
(source) => source.id !== req.params.id,
|
||||
);
|
||||
store.addAudit(
|
||||
"Delete Git Source",
|
||||
"source",
|
||||
`Removed Git source ${req.params.id}`,
|
||||
);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post("/git-sources/:id/test", async (req, res) => {
|
||||
const source = store.gitSources.find((item) => item.id === req.params.id);
|
||||
if (!source) return res.status(404).json({ error: "Source not found" });
|
||||
source.status = "syncing";
|
||||
source.explainWhy = "Synchronization running...";
|
||||
|
||||
try {
|
||||
const commit = await resolveRemote(source.repository, source.ref || "HEAD");
|
||||
if (!commit) {
|
||||
source.status = "error";
|
||||
source.explainWhy = "Ref not found in repository.";
|
||||
return res.status(400).json({ error: source.explainWhy });
|
||||
}
|
||||
|
||||
source.status = "connected";
|
||||
source.reachability = "Healthy";
|
||||
source.lastResolvedCommit = commit;
|
||||
source.lastSyncedAt = new Date().toISOString();
|
||||
const target = await synchronizeRepository(
|
||||
source.id,
|
||||
source.repository,
|
||||
source.ref || "main",
|
||||
);
|
||||
if (fs.existsSync(`${target}/.labyricorn/site.yml`)) {
|
||||
source.configurationState = "Valid";
|
||||
source.projectionEnabled = true;
|
||||
source.explainWhy =
|
||||
"Repository synchronized and valid site.yml detected.";
|
||||
source.health = "Healthy";
|
||||
} else {
|
||||
source.configurationState = "Not Configured";
|
||||
source.projectionEnabled = false;
|
||||
source.explainWhy =
|
||||
"Repository synchronized, but missing .labyricorn/site.yml metadata.";
|
||||
source.health = "Warning";
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Successfully connected and synchronized ${source.repository}!`,
|
||||
resolvedCommit: source.lastResolvedCommit,
|
||||
syncedAt: source.lastSyncedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
source.status = "error";
|
||||
source.health = "Invalid";
|
||||
source.explainWhy = `Git sync failed: ${errorMessage(error)}`;
|
||||
return res.status(400).json({ error: source.explainWhy });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/git-sources/:id/tree", (req, res) => {
|
||||
if (!store.gitSources.some((source) => source.id === req.params.id)) {
|
||||
return res.status(404).json({ error: "Source not found" });
|
||||
}
|
||||
try {
|
||||
cachedRepositoryPath(req.params.id);
|
||||
const files = listRepositoryFiles(req.params.id).map((file) => ({
|
||||
path: file.path,
|
||||
type: "file",
|
||||
size: `${file.sizeBytes} B`,
|
||||
}));
|
||||
return res.json({ sourceId: req.params.id, files });
|
||||
} catch {
|
||||
return res.json({ sourceId: req.params.id, files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as gitSourceRouter };
|
||||
@@ -0,0 +1,198 @@
|
||||
import { GitSource } from "../../types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_RATE_LIMIT = 5;
|
||||
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
||||
|
||||
export interface GiteaDiscoveryConfig {
|
||||
apiUrl: URL;
|
||||
token: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface GiteaRepository {
|
||||
clone_url?: unknown;
|
||||
default_branch?: unknown;
|
||||
full_name?: unknown;
|
||||
name?: unknown;
|
||||
private?: unknown;
|
||||
ssh_url?: unknown;
|
||||
}
|
||||
|
||||
export interface DiscoveredRepository {
|
||||
cloneUrl: string;
|
||||
defaultBranch: string;
|
||||
fullName: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
sshUrl?: string;
|
||||
}
|
||||
|
||||
const positiveInteger = (value: string | undefined, fallback: number): number => {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`Invalid positive integer configuration value '${value}'.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const allowedOrigins = (value: string | undefined): Set<string> =>
|
||||
new Set(
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => new URL(entry).origin),
|
||||
);
|
||||
|
||||
export const loadGiteaDiscoveryConfig = (
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): GiteaDiscoveryConfig => {
|
||||
const token = env.GITEA_TOKEN?.trim();
|
||||
const baseUrl = env.GITEA_BASE_URL?.trim();
|
||||
if (!token || !baseUrl) {
|
||||
throw new Error("Gitea discovery requires GITEA_TOKEN and GITEA_BASE_URL.");
|
||||
}
|
||||
|
||||
const base = new URL(baseUrl);
|
||||
if (base.protocol !== "https:" || base.username || base.password) {
|
||||
throw new Error("GITEA_BASE_URL must be an HTTPS origin without credentials.");
|
||||
}
|
||||
|
||||
const allowlist = allowedOrigins(env.GITEA_ALLOWED_ORIGINS);
|
||||
if (!allowlist.has(base.origin)) {
|
||||
throw new Error(
|
||||
`GITEA_BASE_URL origin '${base.origin}' is not in GITEA_ALLOWED_ORIGINS.`,
|
||||
);
|
||||
}
|
||||
|
||||
const apiUrl = new URL("/api/v1/repos/search", base);
|
||||
apiUrl.searchParams.set("limit", "50");
|
||||
return {
|
||||
apiUrl,
|
||||
token,
|
||||
timeoutMs: positiveInteger(
|
||||
env.GITEA_DISCOVERY_TIMEOUT_MS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const repository = (value: GiteaRepository): DiscoveredRepository | null => {
|
||||
if (
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.clone_url !== "string" ||
|
||||
value.name.length === 0 ||
|
||||
value.clone_url.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const fullName =
|
||||
typeof value.full_name === "string" && value.full_name.length > 0
|
||||
? value.full_name
|
||||
: value.name;
|
||||
return {
|
||||
cloneUrl: value.clone_url,
|
||||
defaultBranch:
|
||||
typeof value.default_branch === "string" && value.default_branch.length > 0
|
||||
? value.default_branch
|
||||
: "main",
|
||||
fullName,
|
||||
name: value.name,
|
||||
private: value.private === true,
|
||||
sshUrl: typeof value.ssh_url === "string" ? value.ssh_url : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchGiteaRepositories = async (
|
||||
config: GiteaDiscoveryConfig,
|
||||
fetchImplementation: typeof fetch = fetch,
|
||||
): Promise<DiscoveredRepository[]> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
||||
try {
|
||||
const response = await fetchImplementation(config.apiUrl, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${config.token}`,
|
||||
},
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gitea returned HTTP ${response.status}.`);
|
||||
const document = (await response.json()) as { data?: unknown };
|
||||
if (!Array.isArray(document.data)) {
|
||||
throw new Error("Gitea returned an invalid repository search response.");
|
||||
}
|
||||
return document.data
|
||||
.map((entry) => repository(entry as GiteaRepository))
|
||||
.filter((entry): entry is DiscoveredRepository => entry !== null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error(`Gitea discovery timed out after ${config.timeoutMs}ms.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceId = (repo: DiscoveredRepository): string =>
|
||||
repo.fullName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "--")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 128) || repo.name.toLowerCase();
|
||||
|
||||
export const toGitSource = (repo: DiscoveredRepository): GitSource => ({
|
||||
id: sourceId(repo),
|
||||
repository: repo.cloneUrl,
|
||||
ref: repo.defaultBranch,
|
||||
branch: repo.defaultBranch,
|
||||
type: "hybrid",
|
||||
health: "Warning",
|
||||
reachability: "Unreachable",
|
||||
configurationState: "Not Configured",
|
||||
visibility: repo.private ? "Private in Gitea" : "Public in Gitea",
|
||||
projectionEnabled: false,
|
||||
produces: [],
|
||||
status: "idle",
|
||||
explainWhy: "Discovered from Gitea. Awaiting initial synchronization.",
|
||||
required: false,
|
||||
credential: "none",
|
||||
});
|
||||
|
||||
export class FixedWindowRateLimiter {
|
||||
private readonly clients = new Map<
|
||||
string,
|
||||
{ count: number; startedAt: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly maximum = positiveInteger(
|
||||
process.env.GITEA_DISCOVERY_RATE_LIMIT,
|
||||
DEFAULT_RATE_LIMIT,
|
||||
),
|
||||
private readonly windowMs = positiveInteger(
|
||||
process.env.GITEA_DISCOVERY_RATE_WINDOW_MS,
|
||||
DEFAULT_RATE_WINDOW_MS,
|
||||
),
|
||||
) {}
|
||||
|
||||
take(client: string, now = Date.now()): { allowed: boolean; retryAfter: number } {
|
||||
const current = this.clients.get(client);
|
||||
if (!current || now - current.startedAt >= this.windowMs) {
|
||||
this.clients.set(client, { count: 1, startedAt: now });
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
if (current.count >= this.maximum) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfter: Math.ceil((this.windowMs - (now - current.startedAt)) / 1_000),
|
||||
};
|
||||
}
|
||||
current.count += 1;
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import express from "express";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { store } from "../store";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/live-site/html", (req, res) => {
|
||||
const rootDir = store.getPreviewDirectory();
|
||||
if (!rootDir) {
|
||||
return res.status(503).json({
|
||||
code: "E_RELEASE_UNAVAILABLE",
|
||||
message: "No verified staging or live release is selected.",
|
||||
});
|
||||
}
|
||||
|
||||
let requested: string;
|
||||
try {
|
||||
requested = decodeURIComponent(
|
||||
String(req.query.route ?? "/").split(/[?#]/, 1)[0],
|
||||
);
|
||||
} catch {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
if (
|
||||
!requested.startsWith("/") ||
|
||||
requested.includes("\\") ||
|
||||
requested.split("/").includes("..")
|
||||
) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
const relative = requested.replace(/^\/+/, "");
|
||||
const outputFile =
|
||||
relative && path.posix.extname(relative)
|
||||
? relative
|
||||
: path.posix.join(relative, "index.html");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const target = path.resolve(rootDir, ...outputFile.split("/"));
|
||||
if (!target.startsWith(rootPath)) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
);
|
||||
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
||||
return res.type(path.extname(target)).send(fs.readFileSync(target));
|
||||
}
|
||||
const notFound = path.join(rootDir, "404.html");
|
||||
if (fs.existsSync(notFound)) {
|
||||
return res.status(404).type("html").send(fs.readFileSync(notFound));
|
||||
}
|
||||
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
export { router as previewRouter };
|
||||
Reference in New Issue
Block a user