import "dotenv/config"; import express from "express"; import path from "path"; import { Readable } from "node:stream"; import { fetchCommits, fetchManifest, fetchRepo, fetchReleases, normalizeRepoInput, parseRepoEntry, readRepos } from "./lib/pluginService.js"; import { buildLicensePayload, createLicense, findLicenseByKey, getLicenseById, listLicensesByUser, touchLicenseHostname } from "./lib/licenseService.js"; import { countRepos, createRepo as createRepoRecord, deleteRepo as deleteRepoRecord, findRepoByOwnerRepo, getRepoById, listRepos, updateRepo as updateRepoRecord } from "./lib/repoService.js"; import { HOST, PATHS, PORT } from "./lib/config.js"; import { ensureSchema } from "./lib/schema.js"; import { authenticateUser, registerUser, adminCreateUser, listUsers, getUserById } from "./lib/userService.js"; import { requireAuth, requireAdmin } from "./middleware/auth.js"; import { getDownloadSourceForLicense, resolveRepoDownload, buildDownloadUrl } from "./lib/downloadService.js"; const app = express(); app.use(express.json()); try { await ensureSchema(); await seedLegacyRepos(); } catch (error) { console.error("Kon database schema niet initialiseren:", error); process.exit(1); } function buildRepoUrl(repoEntry) { if (!repoEntry) return null; if (repoEntry.provider === "gitea") { const base = (repoEntry.baseUrl || "").replace(/\/$/, ""); return base && repoEntry.repo ? `${base}/${repoEntry.repo}` : null; } return `https://github.com/${repoEntry.repo}`; } async function resolveRepoMetaFromRequest(body = {}) { const owner = typeof body.owner === "string" ? body.owner.trim() : ""; const repository = typeof body.repository === "string" ? body.repository.trim() : ""; if (!owner || !repository) { return null; } const requestedProvider = (body.provider || body.repoProvider || "").toLowerCase() || null; const requestedBaseUrl = body.baseUrl || body.repoBaseUrl || null; const ownerRepo = `${owner}/${repository}`; const existing = await findRepoByOwnerRepo(ownerRepo, requestedProvider || undefined); const provider = existing?.provider || requestedProvider || "github"; const baseUrl = existing?.baseUrl || requestedBaseUrl || (provider === "github" ? "https://github.com" : null); const repoEntry = normalizeRepoInput( { repo: ownerRepo, provider, baseUrl }, { repo: ownerRepo, provider, baseUrl } ); if (!repoEntry) { return null; } try { const info = await fetchRepo(repoEntry); const manifest = await fetchManifest(repoEntry, info.defaultBranch).catch(() => null); const releases = await fetchReleases(repoEntry).catch(() => []); const latestRelease = releases[0]; const version = manifest?.version || latestRelease?.tag || latestRelease?.name || info.defaultBranch || null; const pluginName = manifest?.plugin_name || existing?.label || info.name || repository; return { repoId: existing?.id || null, repoEntry, pluginName, version }; } catch (error) { return null; } } async function seedLegacyRepos() { try { const repoCount = await countRepos(); if (repoCount > 0) { return; } const legacy = await readRepos(PATHS.reposFile); if (!Array.isArray(legacy) || legacy.length === 0) { return; } for (const entry of legacy) { try { await createRepoRecord(entry); } catch (error) { if (error?.meta !== "DUPLICATE") { console.warn("Kon legacy repo niet importeren:", error.message); } } } } catch (error) { console.warn("Legacy repos importeren mislukt:", error.message); } } app.post("/api/auth/register", async (req, res) => { try { const { username, name, email, password } = req.body || {}; if (!username || !name || !email || !password) { return res.status(400).json({ error: "Vul gebruikersnaam, naam, e-mail en wachtwoord in." }); } if (password.length < 8) { return res.status(400).json({ error: "Wachtwoord moet minimaal 8 karakters zijn." }); } const { user, token } = await registerUser({ username: String(username).trim(), name: String(name).trim(), email: String(email).trim().toLowerCase(), password: String(password) }); res.status(201).json({ token, user }); } catch (error) { if (error?.code === "ER_DUP_ENTRY") { const field = error.meta === "EMAIL" ? "e-mailadres" : "gebruikersnaam"; return res.status(409).json({ error: `Dit ${field} is al in gebruik.` }); } res.status(500).json({ error: "Registratie mislukt." }); } }); app.post("/api/auth/login", async (req, res) => { try { const { identifier, password } = req.body || {}; if (!identifier || !password) { return res.status(400).json({ error: "Vul gebruikersnaam/e-mail en wachtwoord in." }); } const { user, token } = await authenticateUser(String(identifier).trim(), String(password)); res.json({ token, user }); } catch (error) { const message = error?.meta === "INVALID_CREDENTIALS" ? "Onjuiste inloggegevens." : "Login mislukt."; res.status(401).json({ error: message }); } }); app.get("/api/auth/me", requireAuth, (req, res) => { res.json({ user: req.user }); }); app.get("/api/admin/users", requireAuth, requireAdmin, async (_req, res) => { try { const users = await listUsers(); res.json({ count: users.length, items: users }); } catch (error) { res.status(500).json({ error: "Kon gebruikers niet laden." }); } }); app.post("/api/admin/users", requireAuth, requireAdmin, async (req, res) => { try { const { username, name, email, password, isAdmin } = req.body || {}; if (!username || !name || !email || !password) { return res.status(400).json({ error: "Alle velden zijn verplicht." }); } if (String(password).length < 8) { return res.status(400).json({ error: "Wachtwoord moet minimaal 8 karakters zijn." }); } const user = await adminCreateUser({ username: String(username).trim(), name: String(name).trim(), email: String(email).trim().toLowerCase(), password: String(password), isAdmin: Boolean(isAdmin) }); res.status(201).json({ user }); } catch (error) { if (error?.code === "ER_DUP_ENTRY") { const field = error.meta === "EMAIL" ? "e-mailadres" : "gebruikersnaam"; return res.status(409).json({ error: `Dit ${field} is al in gebruik.` }); } res.status(500).json({ error: "Gebruiker aanmaken mislukt." }); } }); function resolveOwnerRepoFromBody(body = {}) { if (body.ownerRepo) { return String(body.ownerRepo).trim(); } const owner = typeof body.owner === "string" ? body.owner.trim() : ""; const repo = typeof body.repo === "string" ? body.repo.trim() : ""; if (owner && repo) { return `${owner}/${repo}`; } return ""; } app.get("/api/repos", requireAuth, requireAdmin, async (_req, res) => { try { const repos = await listRepos(); res.json({ count: repos.length, items: repos }); } catch (error) { res.status(500).json({ error: "Kon repos niet laden." }); } }); app.post("/api/repos", requireAuth, requireAdmin, async (req, res) => { try { const ownerRepo = resolveOwnerRepoFromBody(req.body); if (!ownerRepo) { return res.status(400).json({ error: "Geef een owner en repo op." }); } const provider = (req.body?.provider || "github").toLowerCase(); const baseUrl = req.body?.baseUrl || null; const label = req.body?.label || null; const repo = await createRepoRecord({ provider, ownerRepo, baseUrl, label }); res.status(201).json({ repo }); } catch (error) { if (error?.meta === "DUPLICATE") { return res.status(409).json({ error: error.message }); } res.status(500).json({ error: "Kon repo niet opslaan." }); } }); app.patch("/api/repos/:id", requireAuth, requireAdmin, async (req, res) => { try { const repoId = Number(req.params.id); if (Number.isNaN(repoId)) { return res.status(400).json({ error: "Ongeldige repo id." }); } const ownerRepo = resolveOwnerRepoFromBody(req.body); const payload = { provider: req.body?.provider, ownerRepo: ownerRepo || undefined, baseUrl: req.body?.baseUrl, label: req.body?.label }; const repo = await updateRepoRecord(repoId, payload); res.json({ repo }); } catch (error) { if (error?.meta === "NOT_FOUND") { return res.status(404).json({ error: error.message }); } if (error?.meta === "DUPLICATE") { return res.status(409).json({ error: error.message }); } res.status(500).json({ error: "Kon repo niet bijwerken." }); } }); app.delete("/api/repos/:id", requireAuth, requireAdmin, async (req, res) => { try { const repoId = Number(req.params.id); if (Number.isNaN(repoId)) { return res.status(400).json({ error: "Ongeldige repo id." }); } await deleteRepoRecord(repoId); res.status(204).end(); } catch (error) { if (error?.meta === "NOT_FOUND") { return res.status(404).json({ error: error.message }); } if (error?.meta === "IN_USE") { return res.status(409).json({ error: error.message }); } res.status(500).json({ error: "Kon repo niet verwijderen." }); } }); app.get("/api/plugins", async (_req, res) => { try { const repos = await listRepos(); const results = await Promise.all( repos.map(async (repo) => { const entry = { provider: repo.provider, repo: repo.ownerRepo, baseUrl: repo.baseUrl }; try { const info = await fetchRepo(entry); const manifest = await fetchManifest(entry, info.defaultBranch || info.default_branch); return { ...info, manifest, provider: repo.provider, baseUrl: repo.baseUrl, repoId: repo.id, label: repo.label, ownerRepo: repo.ownerRepo }; } catch { const parsed = parseRepoEntry(entry); return { fullName: parsed.ownerRepo, name: parsed.ownerRepo.split("/")[1] || parsed.ownerRepo, description: "Kon gegevens niet ophalen.", repoUrl: buildRepoUrl({ provider: repo.provider, baseUrl: repo.baseUrl, repo: repo.ownerRepo }), stars: 0, forks: 0, issues: 0, updatedAt: null, topics: [], manifest: null, provider: repo.provider, ownerRepo: repo.ownerRepo, baseUrl: repo.baseUrl, repoId: repo.id, label: repo.label, isPrivate: false }; } }) ); res.json({ count: results.length, updatedAt: new Date().toISOString(), items: results }); } catch (error) { res.status(500).json({ error: "Kon plugins niet laden." }); } }); function buildPluginDownloadEndpoint(ownerRepo, { provider, baseUrl, version } = {}) { const [owner, repo] = ownerRepo.split("/"); if (!owner || !repo) { return null; } const params = new URLSearchParams(); if (provider) { params.set("provider", provider); } if (baseUrl) { params.set("baseUrl", baseUrl); } if (version) { params.set("version", version); } const search = params.toString(); return `/api/plugins/${owner}/${repo}/download${search ? `?${search}` : ""}`; } app.get("/api/plugins/:owner/:repo", async (req, res) => { const ownerRepo = `${req.params.owner}/${req.params.repo}`; try { const repoIdQuery = Number(req.query.repoId); let repoRecord = null; if (!Number.isNaN(repoIdQuery)) { repoRecord = await getRepoById(repoIdQuery); } if (!repoRecord) { repoRecord = await findRepoByOwnerRepo(ownerRepo); } 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 || baseUrlQuery || undefined } : provider === "github" ? ownerRepo : { provider, repo: ownerRepo, baseUrl: baseUrlQuery }; const normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl: baseUrlQuery }) || null; 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 || baseUrlQuery || info.baseUrl || (provider === "github" ? "https://github.com" : ""); const releasesWithDownload = allowDownloads && releases.length > 0 ? releases.map((release) => { const tagOrName = release.tag || release.name; if (!tagOrName) { return release; } const endpoint = buildPluginDownloadEndpoint(ownerRepo, { provider, baseUrl: effectiveBaseUrl, version: tagOrName }); return { ...release, downloadUrl: endpoint, sourceDownloadUrl: buildDownloadUrl(normalizedEntry, tagOrName, "release") }; }) : releases; let downloadMeta = null; if (allowDownloads) { const latestRelease = releasesWithDownload[0]; const sourceType = latestRelease ? "release" : "branch"; const version = latestRelease?.tag || latestRelease?.name || info.defaultBranch || "main"; if (version) { const endpoint = buildPluginDownloadEndpoint(ownerRepo, { provider, baseUrl: effectiveBaseUrl, version }); downloadMeta = { url: endpoint, sourceUrl: buildDownloadUrl(normalizedEntry, version, sourceType), version, sourceType }; } } const versionFromManifest = manifest?.version; const versionFromDownload = downloadMeta?.version; const versionFromReleases = releasesWithDownload?.[0]?.tag || releasesWithDownload?.[0]?.name; const resolvedVersion = versionFromManifest || versionFromDownload || versionFromReleases || null; const versionSource = versionFromManifest ? "manifest" : versionFromDownload ? "release" : versionFromReleases ? "releases" : null; res.json({ ...info, repoId: repoRecord?.id || null, label: repoRecord?.label || null, version: resolvedVersion, versionSource, manifest, releases: releasesWithDownload, download: downloadMeta, isPrivate, commits }); } catch (error) { res.status(500).json({ error: "Kon plugin details niet laden." }); } }); app.get("/api/plugins/:owner/:repo/download", async (req, res) => { const ownerRepo = `${req.params.owner}/${req.params.repo}`; try { const provider = (req.query.provider || "github").toLowerCase(); const baseUrl = req.query.baseUrl || undefined; const version = req.query.version || "latest"; if (provider === "gitea" && !baseUrl) { return res.status(400).json({ error: "Gitea downloads vereisen een baseUrl." }); } const repoEntry = normalizeRepoInput( { repo: ownerRepo, provider, baseUrl }, { repo: ownerRepo, provider, baseUrl } ); if (!repoEntry) { return res.status(400).json({ error: "Ongeldige repository." }); } const source = await resolveRepoDownload(repoEntry, version); const remoteResponse = await fetch(source.url); if (!remoteResponse.ok || !remoteResponse.body) { return res.status(502).json({ error: "Kon plugin versie niet downloaden." }); } res.setHeader("Content-Type", remoteResponse.headers.get("content-type") || "application/zip"); const length = remoteResponse.headers.get("content-length"); if (length) { res.setHeader("Content-Length", length); } res.setHeader("Content-Disposition", `attachment; filename="${source.filename}"`); res.setHeader("X-Plugin-Version", source.version); const stream = Readable.fromWeb(remoteResponse.body); stream.on("error", (err) => { console.error("Plugin download stream error:", err); res.destroy(err); }); 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." }); } else { res.end(); } } }); app.get("/api/licenses", requireAuth, async (req, res) => { try { let targetUserId = req.user.id; if (req.user.isAdmin && req.query.userId) { const parsed = Number(req.query.userId); if (Number.isNaN(parsed)) { return res.status(400).json({ error: "Ongeldige userId." }); } const targetUser = await getUserById(parsed); if (!targetUser) { return res.status(404).json({ error: "Gebruiker niet gevonden." }); } targetUserId = parsed; } const payload = await listLicensesByUser(targetUserId); res.json({ count: payload.length, updatedAt: new Date().toISOString(), items: payload }); } catch (error) { res.status(500).json({ error: "Kon licenties niet laden." }); } }); app.post("/api/licenses", requireAuth, async (req, res) => { try { const body = req.body || {}; const repoId = body.repoId ? Number(body.repoId) : null; 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 || repoFromRecord?.repo, provider: body.provider || repoFromRecord?.provider, baseUrl: body.baseUrl || repoFromRecord?.baseUrl }) || repoFromRecord; if (!repoEntry) { return res.status(400).json({ error: "Kies een plugin om de licentie aan te koppelen." }); } try { await fetchRepo(repoEntry); } catch (error) { return res.status(400).json({ error: "Kon plugin gegevens niet ophalen." }); } let ownerUserId = req.user.id; if (req.user.isAdmin && body.userId) { const parsed = Number(body.userId); if (Number.isNaN(parsed)) { return res.status(400).json({ error: "Ongeldige gebruiker." }); } const target = await getUserById(parsed); if (!target) { return res.status(404).json({ error: "Gebruiker niet gevonden." }); } ownerUserId = parsed; } const payload = await createLicense(ownerUserId, { label: body.label?.trim(), note: body.note?.trim(), repo: repoEntry, repoId: repoId || undefined }); res.status(201).json(payload); } catch (error) { res.status(500).json({ error: "Kon licentie niet aanmaken." }); } }); app.post("/api/licenses/verify", async (req, res) => { try { const { key, hostname } = req.body || {}; if (!key || !hostname) { return res.status(400).json({ valid: false, error: "Licentiecode en hostname zijn verplicht." }); } const license = await findLicenseByKey(String(key).trim()); if (!license) { const repoMeta = await resolveRepoMetaFromRequest(req.body); const fallbackPayload = repoMeta ? { pluginVersion: repoMeta.version, license: { pluginName: repoMeta.pluginName, pluginVersion: repoMeta.version, repoFullName: repoMeta.repoEntry.repo, repoUrl: buildRepoUrl(repoMeta.repoEntry), repoId: repoMeta.repoId || null, repo: repoMeta.repoEntry } } : {}; return res.status(404).json({ valid: false, error: "Licentie niet gevonden.", ...fallbackPayload }); } const rawVersion = req.body?.currentVersion ?? req.body?.pluginVersion ?? null; const pluginVersion = rawVersion && String(rawVersion).trim().length > 0 ? String(rawVersion).trim().slice(0, 64) : null; const result = await touchLicenseHostname(license, hostname, { pluginVersion }); if (!result.ok) { const status = result.conflict ? 403 : 400; const payload = await buildLicensePayload(license); return res .status(status) .json({ valid: false, error: result.error, boundHostname: license.primary_hostname, license: payload }); } const freshLicense = await getLicenseById(license.id); const payload = await buildLicensePayload(freshLicense); res.json({ valid: true, hostname: payload.primaryHostname, boundNow: !!result.boundNow, license: payload }); } catch (error) { res.status(500).json({ valid: false, error: "Validatie mislukt." }); } }); async function handleLicenseDownload(res, { key, hostname, version = "latest" }) { try { if (!key || !hostname) { return res.status(400).json({ error: "Licentiecode en hostname zijn verplicht." }); } const license = await findLicenseByKey(String(key).trim()); if (!license) { return res.status(404).json({ error: "Licentie niet gevonden." }); } const result = await touchLicenseHostname(license, hostname); if (!result.ok) { const status = result.conflict ? 403 : 400; return res.status(status).json({ error: result.error, boundHostname: license.primary_hostname }); } const source = await getDownloadSourceForLicense(license, version); const remoteResponse = await fetch(source.url); if (!remoteResponse.ok || !remoteResponse.body) { return res.status(502).json({ error: "Kon plugin versie niet ophalen." }); } res.setHeader("Content-Type", remoteResponse.headers.get("content-type") || "application/zip"); const length = remoteResponse.headers.get("content-length"); if (length) { res.setHeader("Content-Length", length); } res.setHeader("Content-Disposition", `attachment; filename=\"${source.filename}\"`); res.setHeader("X-Plugin-Version", source.version); const stream = Readable.fromWeb(remoteResponse.body); stream.on("error", (err) => { console.error("Download stream error", err); res.destroy(err); }); 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." }); } else { res.end(); } } } app.post("/api/licenses/download", async (req, res) => { await handleLicenseDownload(res, { key: req.body?.key, hostname: req.body?.hostname, version: req.body?.version || "latest" }); }); app.get("/api/licenses/download", async (req, res) => { await handleLicenseDownload(res, { key: req.query.key, hostname: req.query.hostname, version: req.query.version || "latest" }); }); app.use(express.static(PATHS.distDir)); app.get("*", (_req, res) => { res.sendFile(path.join(PATHS.distDir, "index.html")); }); app.listen(PORT, HOST, () => { console.log(`Server draait op http://${HOST}:${PORT}`); });