Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-02-01 15:43:35 +00:00
parent 3cf7e76d21
commit d306267d58
16 changed files with 286 additions and 42 deletions

View File

@@ -8,3 +8,10 @@ DB_NAME=siti_plugin_repo
# Authentication
JWT_SECRET=please-change-me
JWT_EXPIRES_IN=7d
# Gitea tokens
# GITEA_TOKEN wordt gebruikt als default token voor alle Gitea servers.
GITEA_TOKEN=
# Optioneel: override per host via GITEA_TOKENS (JSON of comma-separated baseUrl=token)
# Voorbeeld: GITEA_TOKENS=git.robert.ooo=my-token,https://git.example.com=another-token
GITEA_TOKENS=

3
.gitignore vendored
View File

@@ -1 +1,2 @@
/node_modules
/node_modules
.env

1
dist/assets/index-BOPMLc5y.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Siti Plugin Repo</title>
<script type="module" crossorigin src="/assets/index-DDjGuoSe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CFgb3VsA.css">
<script type="module" crossorigin src="/assets/index-CfHTe5gv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BOPMLc5y.css">
</head>
<body>

View File

@@ -17,4 +17,5 @@ services:
DB_NAME: "${DB_NAME:-siti_plugin_repo}"
JWT_SECRET: "${JWT_SECRET:-change-me}"
JWT_EXPIRES_IN: "${JWT_EXPIRES_IN:-7d}"
GITEA_TOKEN: "${GITEA_TOKEN:-change-me}"
restart: unless-stopped

12
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2",
@@ -1354,6 +1355,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.2.3",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2",

View File

@@ -1,3 +1,4 @@
import "dotenv/config";
import express from "express";
import path from "path";
import { Readable } from "node:stream";
@@ -328,7 +329,8 @@ app.get("/api/plugins", async (_req, res) => {
ownerRepo: repo.ownerRepo,
baseUrl: repo.baseUrl,
repoId: repo.id,
label: repo.label
label: repo.label,
isPrivate: false
};
}
})
@@ -375,23 +377,77 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
repoRecord = await findRepoByOwnerRepo(ownerRepo);
}
const provider = (req.query.provider || repoRecord?.provider || "github").toLowerCase();
const baseUrlQuery =
req.query.baseUrl || repoRecord?.baseUrl || (provider === "github" ? "https://github.com" : "");
let provider = (req.query.provider || repoRecord?.provider || "github").toLowerCase();
if (repoRecord?.provider) {
provider = repoRecord.provider;
}
const baseUrlFallback = provider === "github" ? "https://github.com" : "";
let baseUrlQuery = req.query.baseUrl || repoRecord?.baseUrl || baseUrlFallback;
if (repoRecord?.baseUrl) {
baseUrlQuery = repoRecord.baseUrl;
}
const entry =
repoRecord && repoRecord.ownerRepo
? { provider: repoRecord.provider, repo: repoRecord.ownerRepo, baseUrl: repoRecord.baseUrl }
? {
provider: repoRecord.provider,
repo: repoRecord.ownerRepo,
baseUrl: repoRecord.baseUrl || baseUrlQuery || undefined
}
: provider === "github"
? ownerRepo
: { provider, repo: ownerRepo, baseUrl: baseUrlQuery };
const normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery });
const normalizedEntry =
normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery }) ||
null;
const info = await fetchRepo(entry);
const [manifest, releases, commits] = await Promise.all([
fetchManifest(entry, info.defaultBranch).catch(() => null),
fetchReleases(entry).catch(() => []),
fetchCommits(entry).catch(() => [])
]);
if (!normalizedEntry) {
return res.status(400).json({ error: "Ongeldige repository." });
}
let info;
let manifest = null;
let releases = [];
let commits = [];
try {
info = await fetchRepo(normalizedEntry);
[manifest, releases, commits] = await Promise.all([
fetchManifest(normalizedEntry, info.defaultBranch).catch(() => null),
fetchReleases(normalizedEntry).catch(() => []),
fetchCommits(normalizedEntry).catch(() => [])
]);
} catch (fetchError) {
console.warn("Kon plugin info niet ophalen:", fetchError.message);
const parsed = parseRepoEntry(normalizedEntry);
const fallbackRepo = {
provider: repoRecord?.provider || parsed.provider,
baseUrl: repoRecord?.baseUrl || parsed.baseUrl,
repo: repoRecord?.ownerRepo || parsed.ownerRepo
};
const repoUrl = buildRepoUrl(fallbackRepo);
const name = repoRecord?.label || parsed.ownerRepo.split("/")[1] || parsed.ownerRepo;
return res.json({
fullName: fallbackRepo.repo,
name,
description: repoRecord?.label ? `Plugin: ${repoRecord.label}` : "Kon gegevens niet ophalen.",
repoUrl,
provider: fallbackRepo.provider,
ownerRepo: fallbackRepo.repo,
baseUrl: fallbackRepo.baseUrl,
repoId: repoRecord?.id || null,
label: repoRecord?.label || null,
manifest: null,
releases: [],
commits: [],
download: null,
version: null,
versionSource: null,
isPrivate: false
});
}
const isPrivate = Boolean(info?.isPrivate);
const allowDownloads = normalizedEntry && !isPrivate;
const effectiveBaseUrl =
normalizedEntry?.baseUrl ||
@@ -400,7 +456,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
(provider === "github" ? "https://github.com" : "");
const releasesWithDownload =
normalizedEntry && releases.length > 0
allowDownloads && releases.length > 0
? releases.map((release) => {
const tagOrName = release.tag || release.name;
if (!tagOrName) {
@@ -420,7 +476,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
: releases;
let downloadMeta = null;
if (normalizedEntry) {
if (allowDownloads) {
const latestRelease = releasesWithDownload[0];
const sourceType = latestRelease ? "release" : "branch";
const version = latestRelease?.tag || latestRelease?.name || info.defaultBranch || "main";
@@ -454,6 +510,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
manifest,
releases: releasesWithDownload,
download: downloadMeta,
isPrivate,
commits
});
} catch (error) {
@@ -502,6 +559,9 @@ app.get("/api/plugins/:owner/:repo/download", async (req, res) => {
});
stream.pipe(res);
} catch (error) {
if (error?.meta === "PRIVATE_REPO") {
return res.status(403).json({ error: "Downloads voor private plugins zijn uitgeschakeld." });
}
console.error("Plugin download endpoint error:", error);
if (!res.headersSent) {
res.status(500).json({ error: "Download mislukt." });
@@ -543,20 +603,34 @@ app.post("/api/licenses", requireAuth, async (req, res) => {
if (body.repoId && Number.isNaN(repoId)) {
return res.status(400).json({ error: "Ongeldig repo id." });
}
let repoRecord = null;
if (repoId) {
repoRecord = await getRepoById(repoId);
if (!repoRecord) {
return res.status(404).json({ error: "Plugin niet gevonden." });
}
}
const repoInput =
typeof body.repo === "object"
? body.repo
: typeof body.plugin === "object"
? body.plugin
: body.repo || body.ownerRepo || body.fullName || body.plugin;
const repoFromRecord = repoRecord
? {
repo: repoRecord.ownerRepo,
provider: repoRecord.provider,
baseUrl: repoRecord.baseUrl
}
: null;
const repoEntry =
normalizeRepoInput(repoInput, {
repo: body.ownerRepo || body.fullName || body.repo,
provider: body.provider,
baseUrl: body.baseUrl
}) || null;
repo: body.ownerRepo || body.fullName || body.repo || repoFromRecord?.repo,
provider: body.provider || repoFromRecord?.provider,
baseUrl: body.baseUrl || repoFromRecord?.baseUrl
}) || repoFromRecord;
if (!repoId && !repoEntry) {
if (!repoEntry) {
return res.status(400).json({ error: "Kies een plugin om de licentie aan te koppelen." });
}
@@ -677,6 +751,12 @@ async function handleLicenseDownload(res, { key, hostname, version = "latest" })
});
stream.pipe(res);
} catch (error) {
if (error?.meta === "PRIVATE_REPO") {
if (!res.headersSent) {
return res.status(403).json({ error: "Downloads voor private plugins zijn uitgeschakeld." });
}
return res.end();
}
console.error("Download endpoint error:", error);
if (!res.headersSent) {
res.status(500).json({ error: "Download mislukt." });

View File

@@ -27,3 +27,63 @@ export const DB_CONFIG = {
export const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
function normalizeHostKey(value) {
if (!value) return null;
try {
const url = new URL(value);
return url.host.toLowerCase();
} catch {
return value.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").trim().toLowerCase() || null;
}
}
function parseGiteaTokenMap(rawValue) {
if (!rawValue) return {};
const map = {};
try {
const parsed = typeof rawValue === "string" ? JSON.parse(rawValue) : rawValue;
if (parsed && typeof parsed === "object") {
for (const [key, token] of Object.entries(parsed)) {
const host = normalizeHostKey(key);
if (host && typeof token === "string" && token.trim()) {
map[host] = token.trim();
}
}
return map;
}
} catch {
// Fall through to comma-separated parsing.
}
const segments = String(rawValue)
.split(",")
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
const [key, token] = segment.split("=").map((part) => part.trim());
const host = normalizeHostKey(key);
if (host && token) {
map[host] = token;
}
}
return map;
}
const DEFAULT_GITEA_TOKEN = process.env.GITEA_TOKEN?.trim() || null;
const GITEA_TOKENS = parseGiteaTokenMap(process.env.GITEA_TOKENS);
function getEnvTokenForHost(host) {
if (!host) return null;
const envKey = `GITEA_TOKEN_${host.replace(/[^A-Z0-9]/gi, "_").toUpperCase()}`;
return process.env[envKey]?.trim() || null;
}
export function getGiteaToken(baseUrl) {
const host = normalizeHostKey(baseUrl);
return (
(host && (GITEA_TOKENS[host] || getEnvTokenForHost(host))) ||
DEFAULT_GITEA_TOKEN ||
null
);
}

View File

@@ -22,6 +22,11 @@ export async function resolveRepoDownload(repoEntry, requestedVersion = "latest"
}
const repoInfo = await fetchRepo(normalizedEntry);
if (repoInfo.isPrivate) {
const error = new Error("Downloads niet beschikbaar voor private repositories.");
error.meta = "PRIVATE_REPO";
throw error;
}
const releases = await fetchReleases(normalizedEntry).catch(() => []);
let targetVersion = requestedVersion || "latest";

View File

@@ -1,5 +1,6 @@
import { readJsonFile } from "./storage.js";
import { getCached, setCached } from "./cache.js";
import { getGiteaToken } from "./config.js";
export function parseRepoEntry(entry) {
if (typeof entry === "string") {
@@ -51,6 +52,12 @@ async function fetchJson(url, cacheKey, opts = {}) {
if (!opts.provider || opts.provider === "github") {
headers.Accept = "application/vnd.github+json";
}
if (opts.provider === "gitea") {
const token = opts.token || getGiteaToken(opts.baseUrl || url);
if (token) {
headers.Authorization = `token ${token}`;
}
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`${opts.provider || "git"} request failed for ${url}`);
@@ -76,7 +83,7 @@ export async function fetchRepo(entry) {
let data;
if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`;
data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider });
data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider, baseUrl });
const mapped = {
fullName: data.full_name || `${ownerRepo}`,
name: data.name || ownerRepo.split("/")[1] || ownerRepo,
@@ -90,13 +97,16 @@ export async function fetchRepo(entry) {
topics: data.topics || [],
provider,
ownerRepo,
baseUrl
baseUrl,
isPrivate: Boolean(data.private)
};
setCached(cacheKey, mapped);
return mapped;
}
data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, { provider: "github" });
data = await fetchJson(`https://api.github.com/repos/${ownerRepo}`, `repo-raw:github:${ownerRepo}`, {
provider: "github"
});
const mapped = {
fullName: data.full_name,
name: data.name,
@@ -110,7 +120,8 @@ export async function fetchRepo(entry) {
topics: data.topics || [],
provider,
ownerRepo,
baseUrl: "https://github.com"
baseUrl: "https://github.com",
isPrivate: Boolean(data.private)
};
setCached(cacheKey, mapped);
return mapped;
@@ -131,7 +142,14 @@ export async function fetchManifest(entry, defaultBranch) {
} else {
url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`;
}
const response = await fetch(url, { headers: { "User-Agent": "siti-plugin-repo" } });
const headers = { "User-Agent": "siti-plugin-repo" };
if (provider === "gitea") {
const token = getGiteaToken(baseUrl);
if (token) {
headers.Authorization = `token ${token}`;
}
}
const response = await fetch(url, { headers });
if (response.ok) {
const manifest = await response.json().catch(() => null);
setCached(cacheKey, manifest);
@@ -145,7 +163,7 @@ export async function fetchReleases(entry) {
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/releases?limit=5`;
const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider });
const data = await fetchJson(url, `releases:${provider}:${ownerRepo}`, { provider, baseUrl });
return Array.isArray(data)
? data.map((release) => ({
tag: release.tag_name || release.name,
@@ -175,7 +193,7 @@ export async function fetchCommits(entry) {
const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry);
if (provider === "gitea") {
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}/commits?limit=5`;
const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider });
const data = await fetchJson(url, `commits:${provider}:${ownerRepo}`, { provider, baseUrl });
return Array.isArray(data)
? data.map((commit) => ({
sha: commit.sha,

View File

@@ -90,6 +90,10 @@
.hero h1 {
font-size: clamp(2.4rem, 4vw, 3.4rem);
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.subtitle {
@@ -180,6 +184,10 @@
.card h2 {
margin: 0;
font-size: 1.3rem;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card p {
@@ -241,6 +249,11 @@
color: #166534;
}
.state.warning {
background: #fef9c3;
color: #92400e;
}
.state.inline {
margin-top: 16px;
padding: 16px;
@@ -621,3 +634,25 @@
color: #94a3b8;
}
}
.privacy-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}

View File

@@ -49,6 +49,7 @@ export default function Home() {
{plugins.map((plugin) => {
const displayName = plugin.label || plugin.manifest?.plugin_name || plugin.name;
const description = plugin.manifest?.description || plugin.description;
const isPrivate = Boolean(plugin.isPrivate);
const searchParams = new URLSearchParams();
if (plugin.provider) {
searchParams.set("provider", plugin.provider);
@@ -65,7 +66,15 @@ export default function Home() {
return (
<article className="card" key={plugin.fullName}>
<div className="card-header">
<h2>{displayName}</h2>
<h2>
{displayName}
{isPrivate && (
<span className="privacy-indicator" title="Privé plugin">
<span aria-hidden="true">🔒</span>
<span className="sr-only">Privé plugin</span>
</span>
)}
</h2>
<span className="pill">{plugin.fullName}</span>
</div>
<p>{description}</p>

View File

@@ -49,7 +49,8 @@ export default function PluginDetail() {
const author = manifest?.author || "-";
const version = manifest?.version || "-";
const repositoryLabel = data?.provider === "gitea" ? "Gitea" : "GitHub";
const latestDownload = data?.download;
const isPrivate = Boolean(data?.isPrivate);
const latestDownload = isPrivate ? null : data?.download;
const releases = useMemo(() => data?.releases || [], [data]);
const commits = useMemo(() => data?.commits || [], [data]);
@@ -59,7 +60,15 @@ export default function PluginDetail() {
<header className="detail-hero">
<div>
<p className="eyebrow">Plugin details</p>
<h1>{displayName}</h1>
<h1>
{displayName}
{isPrivate && (
<span className="privacy-indicator" title="Privé plugin">
<span aria-hidden="true">🔒</span>
<span className="sr-only">Privé plugin</span>
</span>
)}
</h1>
<p className="subtitle">{description}</p>
</div>
<div className="detail-actions">
@@ -77,6 +86,12 @@ export default function PluginDetail() {
</div>
</header>
{isPrivate && (
<div className="state warning">
<strong>Privé plugin.</strong> Downloads via deze repo zijn niet beschikbaar.
</div>
)}
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}