Files
siti-plugin-repo/server/index.js

793 lines
29 KiB
JavaScript

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}`);
});