feat: add download functionality for licenses and enhance plugin detail view

This commit is contained in:
2026-02-01 02:44:02 +00:00
parent 73025c84c5
commit 65b0de63a9
5 changed files with 489 additions and 21 deletions

View File

@@ -1,5 +1,6 @@
import express from "express";
import path from "path";
import { Readable } from "node:stream";
import {
fetchCommits,
fetchManifest,
@@ -21,6 +22,7 @@ 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());
@@ -159,6 +161,7 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
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 normalizedEntry = normalizeRepoInput(entry, { repo: ownerRepo, provider, baseUrl });
const info = await fetchRepo(entry);
const [manifest, releases, commits] = await Promise.all([
@@ -167,10 +170,36 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
fetchCommits(entry).catch(() => [])
]);
const releasesWithDownload =
normalizedEntry && releases.length > 0
? releases.map((release) => {
const tagOrName = release.tag || release.name;
return {
...release,
downloadUrl: tagOrName ? buildDownloadUrl(normalizedEntry, tagOrName, "release") : null
};
})
: releases;
let downloadMeta = null;
if (normalizedEntry) {
const latestRelease = releasesWithDownload[0];
const sourceType = latestRelease ? "release" : "branch";
const version = latestRelease?.tag || latestRelease?.name || info.defaultBranch || "main";
if (version) {
downloadMeta = {
url: buildDownloadUrl(normalizedEntry, version, sourceType),
version,
sourceType
};
}
}
res.json({
...info,
manifest,
releases,
releases: releasesWithDownload,
download: downloadMeta,
commits
});
} catch (error) {
@@ -283,6 +312,51 @@ app.post("/api/licenses/verify", async (req, res) => {
}
});
app.post("/api/licenses/download", async (req, res) => {
try {
const { key, hostname, version = "latest" } = req.body || {};
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) {
console.error("Download endpoint error:", error);
if (!res.headersSent) {
res.status(500).json({ error: "Download mislukt." });
} else {
res.end();
}
}
});
app.use(express.static(PATHS.distDir));
app.get("*", (_req, res) => {
res.sendFile(path.join(PATHS.distDir, "index.html"));

View File

@@ -0,0 +1,65 @@
import { fetchRepo, fetchReleases, normalizeRepoInput } from "./pluginService.js";
export function buildDownloadUrl(repoEntry, version, sourceType = "release") {
const ownerRepo = repoEntry.repo;
if (repoEntry.provider === "gitea") {
const sanitizedBase = (repoEntry.baseUrl || "").replace(/\/$/, "");
const [owner, repo] = ownerRepo.split("/");
return `${sanitizedBase}/repos/${owner}/${repo}/archive/${version}.zip`;
}
const refType = sourceType === "release" ? "tags" : "heads";
return `https://codeload.github.com/${ownerRepo}/zip/refs/${refType}/${version}`;
}
export async function resolveRepoDownload(repoEntry, requestedVersion = "latest") {
const normalizedEntry = normalizeRepoInput(repoEntry, {
repo: repoEntry.repo,
provider: repoEntry.provider,
baseUrl: repoEntry.baseUrl
});
if (!normalizedEntry) {
throw new Error("Kon repository gegevens niet bepalen.");
}
const repoInfo = await fetchRepo(normalizedEntry);
const releases = await fetchReleases(normalizedEntry).catch(() => []);
let targetVersion = requestedVersion || "latest";
let sourceType = "release";
if (targetVersion === "latest") {
if (releases.length > 0) {
targetVersion = releases[0].tag;
} else {
targetVersion = repoInfo.defaultBranch || "main";
sourceType = "branch";
}
} else {
const found = releases.find((release) => release.tag === targetVersion || release.name === targetVersion);
if (!found) {
sourceType = "branch";
} else {
targetVersion = found.tag;
}
}
const downloadUrl = buildDownloadUrl(normalizedEntry, targetVersion, sourceType);
const filenameBase = repoInfo.name || normalizedEntry.repo.split("/").pop() || "plugin";
return {
url: downloadUrl,
version: targetVersion,
sourceType,
filename: `${filenameBase}-${targetVersion}.zip`,
repoEntry: normalizedEntry,
repoInfo
};
}
export async function getDownloadSourceForLicense(licenseRow, requestedVersion = "latest") {
const repoEntry = {
repo: licenseRow.repo_name,
provider: licenseRow.repo_provider,
baseUrl: licenseRow.repo_base_url
};
return resolveRepoDownload(repoEntry, requestedVersion);
}