import express from "express"; import path from "path"; import { fileURLToPath } from "url"; import fs from "fs/promises"; const app = express(); const PORT = process.env.PORT || 3001; const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 10 * 60 * 1000); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const distDir = path.join(rootDir, "dist"); const reposFile = path.join(__dirname, "repos.json"); const cache = new Map(); function parseRepoEntry(entry) { // support legacy string entries and object entries if (typeof entry === "string") { return { provider: "github", ownerRepo: entry, baseUrl: "https://github.com" }; } const provider = (entry.provider || "github").toLowerCase(); const ownerRepo = entry.repo || entry.ownerRepo || ""; const baseUrl = entry.baseUrl || (provider === "github" ? "https://github.com" : entry.baseUrl || ""); return { provider, ownerRepo, baseUrl }; } async function readRepos() { const content = await fs.readFile(reposFile, "utf-8"); const parsed = JSON.parse(content); return Array.isArray(parsed) ? parsed : []; } function getCached(key) { const entry = cache.get(key); if (!entry) return null; if (Date.now() > entry.expiresAt) { cache.delete(key); return null; } return entry.value; } function setCached(key, value) { cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); } async function fetchJson(url, cacheKey, opts = {}) { const cached = getCached(cacheKey); if (cached) return cached; const headers = { "User-Agent": "siti-plugin-repo", Accept: "application/json" }; // prefer GitHub API accept header when talking to github.com/api if (!opts.provider || opts.provider === "github") { headers.Accept = "application/vnd.github+json"; } const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`${opts.provider || "git"} request failed for ${url}`); } const data = await response.json().catch(async () => { // attempt to read text for non-json responses const text = await response.text(); try { return JSON.parse(text); } catch { return null; } }); setCached(cacheKey, data); return data; } async function fetchRepo(entry) { const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry); const cacheKey = `repo:${provider}:${ownerRepo}`; const cached = getCached(cacheKey); if (cached) return cached; let data; if (provider === "gitea") { const url = `${baseUrl.replace(/\/$/, "")}/api/v1/repos/${ownerRepo}`; data = await fetchJson(url, `repo-raw:${provider}:${ownerRepo}`, { provider }); const mapped = { fullName: data.full_name || `${ownerRepo}`, name: data.name || ownerRepo.split("/")[1] || ownerRepo, description: data.description || null, repoUrl: `${baseUrl.replace(/\/$/, "")}/${ownerRepo}`, defaultBranch: data.default_branch || data.default_branch || "main", stars: data.stargazers_count || data.watchers || 0, forks: data.forks_count || data.forks || 0, issues: data.open_issues_count || 0, updatedAt: data.updated_at || data.updated || null, topics: data.topics || [] }; setCached(cacheKey, mapped); return mapped; } // default: 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, description: data.description, repoUrl: data.html_url, defaultBranch: data.default_branch, stars: data.stargazers_count, forks: data.forks_count, issues: data.open_issues_count, updatedAt: data.updated_at, topics: data.topics || [] }; setCached(cacheKey, mapped); return mapped; } async function fetchManifest(entry, defaultBranch) { const { provider, ownerRepo, baseUrl } = parseRepoEntry(entry); const cacheKey = `manifest:${provider}:${ownerRepo}`; const cached = getCached(cacheKey); if (cached) return cached; const branches = [defaultBranch, "main", "master"].filter(Boolean); const [owner, repo] = ownerRepo.split("/"); for (const branch of branches) { let url; if (provider === "gitea") { url = `${baseUrl.replace(/\/$/, "")}/repos/${owner}/${repo}/raw/${branch}/manifest.json`; } else { url = `https://raw.githubusercontent.com/${ownerRepo}/${branch}/manifest.json`; } const response = await fetch(url, { headers: { "User-Agent": "siti-plugin-repo" } }); if (response.ok) { const manifest = await response.json().catch(() => null); setCached(cacheKey, manifest); return manifest; } } return null; } 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 }); return Array.isArray(data) ? data.map((release) => ({ tag: release.tag_name || release.name, name: release.name || release.tag_name, url: release.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/releases`, publishedAt: release.published_at || release.created_at })) : []; } const data = await fetchJson( `https://api.github.com/repos/${ownerRepo}/releases?per_page=5`, `releases:github:${ownerRepo}`, { provider: "github" } ); return Array.isArray(data) ? data.map((release) => ({ tag: release.tag_name, name: release.name || release.tag_name, url: release.html_url, publishedAt: release.published_at })) : []; } 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 }); return Array.isArray(data) ? data.map((commit) => ({ sha: commit.sha, message: commit.commit?.message || commit.message, author: commit.commit?.author?.name || commit.author?.name, date: commit.commit?.author?.date || commit.author?.date, url: commit.html_url || `${baseUrl.replace(/\/$/, "")}/${ownerRepo}/commit/${commit.sha}` })) : []; } const data = await fetchJson( `https://api.github.com/repos/${ownerRepo}/commits?per_page=5`, `commits:github:${ownerRepo}`, { provider: "github" } ); return Array.isArray(data) ? data.map((commit) => ({ sha: commit.sha, message: commit.commit?.message, author: commit.commit?.author?.name, date: commit.commit?.author?.date, url: commit.html_url })) : []; } app.get("/api/plugins", async (_req, res) => { try { const repos = await readRepos(); const results = await Promise.all( repos.map(async (repo) => { try { const info = await fetchRepo(repo); const manifest = await fetchManifest(repo, info.defaultBranch || info.default_branch); return { ...info, manifest }; } catch { const parsed = parseRepoEntry(repo); return { fullName: parsed.ownerRepo, name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo, description: "Kon gegevens niet ophalen.", repoUrl: parsed.provider === "gitea" ? `${parsed.baseUrl.replace(/\/$/, "")}/${parsed.ownerRepo}` : `https://github.com/${parsed.ownerRepo}`, stars: 0, forks: 0, issues: 0, updatedAt: null, topics: [], manifest: null }; } }) ); res.json({ count: results.length, updatedAt: new Date().toISOString(), items: results }); } catch (error) { res.status(500).json({ error: "Kon plugins niet laden." }); } }); app.get("/api/plugins/:owner/:repo", async (req, res) => { const ownerRepo = `${req.params.owner}/${req.params.repo}`; try { const provider = (req.query.provider || "github").toLowerCase(); const baseUrl = req.query.baseUrl || (provider === "github" ? "https://github.com" : ""); const entry = provider === "github" ? ownerRepo : { provider, repo: ownerRepo, baseUrl }; const info = await fetchRepo(entry); const [manifest, releases, commits] = await Promise.all([ fetchManifest(entry, info.defaultBranch).catch(() => null), fetchReleases(entry).catch(() => []), fetchCommits(entry).catch(() => []) ]); res.json({ ...info, manifest, releases, commits }); } catch (error) { res.status(500).json({ error: "Kon plugin details niet laden." }); } }); app.use(express.static(distDir)); app.get("*", (_req, res) => { res.sendFile(path.join(distDir, "index.html")); }); const HOST = process.env.HOST || "::"; app.listen(PORT, HOST, () => { console.log(`Server draait op http://${HOST}:${PORT}`); });