diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c978e92
--- /dev/null
+++ b/README.md
@@ -0,0 +1,289 @@
+# Siti Plugin Repo
+
+Deze repository bevat een Vite/React frontend en een Express backend die samen een centrale licentie- en pluginbeheeromgeving vormen. Je beheert hier WordPress-plugins, gekoppelde licenties en gebruikers (inclusief admin-rollen), en biedt een HTTP API waar je eigen plugins tegenaan kunnen praten voor licentievalidatie en update-informatie.
+
+## Inhoud
+
+1. [Architectuuroverzicht](#architectuuroverzicht)
+2. [Installatie & configuratie](#installatie--configuratie)
+3. [Gebruikers & rollen](#gebruikers--rollen)
+4. [API-overzicht](#api-overzicht)
+5. [Integratie met je WordPress-plugin](#integratie-met-je-wordpress-plugin)
+6. [Handige tips](#handige-tips)
+
+---
+
+## Architectuuroverzicht
+
+| Component | Omschrijving |
+| --- | --- |
+| `server/` | Express-app die licenties, gebruikers en plugin metadata serveert. Praat met MariaDB en Git providers. |
+| `src/` | React frontend voor licentiebeheer, inclusief admin flows en in-app authenticatie. |
+| MariaDB | Externe database met tabellen `users`, `licenses`, `license_hostnames`. Licentiegegevens staan **niet** meer in JSON-bestanden. |
+| Git providers | Manifest-/release-/commitdata wordt opgehaald bij GitHub of Gitea, afhankelijk van entries in `server/repos.json`. |
+
+Belangrijke eigenschappen:
+
+- Vanaf de eerste run wordt het schema automatisch gecreëerd in MariaDB.
+- De **allereerste gebruiker** die zich registreert wordt automatisch admin.
+- Admins kunnen gebruikers aanmaken en licenties namens andere gebruikers genereren.
+- Een licentie hoort bij de **eerste hostname** die zich meldt; elke volgende hostname wordt geweigerd.
+
+## Installatie & configuratie
+
+### Vereisten
+
+- Node.js 18+
+- MariaDB 10.6+ (extern of lokaal)
+- Optioneel Docker (voor containerized deployment)
+
+### Stap 1 – `.env`
+
+Gebruik `.env.example` als startpunt en zet hem om naar `.env`:
+
+```bash
+cp .env.example .env
+```
+
+Stel daarna minimaal de volgende variabelen in:
+
+```dotenv
+DB_HOST=127.0.0.1
+DB_PORT=3306
+DB_USER=sitiapp
+DB_PASSWORD=supergeheim
+DB_NAME=siti_plugin_repo
+JWT_SECRET=een-lang-en-random-geheim
+JWT_EXPIRES_IN=7d
+```
+
+> Let op: geef de backend toegang tot een **bestaande database**. Het schema wordt automatisch ingericht, maar de database zelf moet bestaan.
+
+### Stap 2 – Dependencies installeren
+
+```bash
+npm install
+```
+
+### Stap 3 – Ontwikkelservers starten
+
+```bash
+# Backend API + licentiebeheer UI
+npm run dev:server
+
+# Frontend (Vite dev server)
+npm run dev
+```
+
+Vite proxy’t `/api/*` naar `localhost:3001`, dus beide processen moeten draaien.
+
+### Docker Compose
+
+Wil je alles containerizen, gebruik dan `docker-compose.yml`. De Compose configuratie bevat alleen de applicatiecontainer; MariaDB blijft een externe service. Voorbeeld:
+
+```bash
+DB_HOST=database.internal
+DB_PORT=3306
+DB_USER=sitiapp
+DB_PASSWORD=my-secret
+DB_NAME=siti_plugin_repo
+JWT_SECRET=prod-secret
+docker compose up --build
+```
+
+## Gebruikers & rollen
+
+- **Eerste gebruiker** → admin (`is_admin = 1`).
+- Admins kunnen:
+ - Overzicht van alle gebruikers downloaden (`GET /api/admin/users`).
+ - Nieuwe gebruikers aanmaken (`POST /api/admin/users`).
+ - Licenties koppelen aan andere gebruikers door `userId` mee te geven aan `POST /api/licenses`.
+- Niet-admins:
+ - Kunnen alleen hun eigen licenties zien en aanmaken.
+ - Kunnen geen andere gebruikers zien of licenties voor anderen beheren.
+
+Authenticatie:
+
+- JWT’s worden uitgegeven door `/api/auth/login` of `/api/auth/register`.
+- Het frontend bewaart de token in `localStorage` en stuurt `Authorization: Bearer …`.
+- `requireAdmin` middleware blokkeert niet-admin gebruikers automatisch voor admin-routes.
+
+## API-overzicht
+
+| Methode & pad | Rol | Beschrijving |
+| --- | --- | --- |
+| `POST /api/auth/register` | Publiek | Maakt gebruiker aan (eerste gebruiker wordt admin). |
+| `POST /api/auth/login` | Publiek | Verstrekt JWT. |
+| `GET /api/auth/me` | Auth | Retourneert huidige gebruiker. |
+| `GET /api/admin/users` | Admin | Geeft lijst met alle gebruikers. |
+| `POST /api/admin/users` | Admin | Maakt gebruiker aan (met optionele admin-flag). |
+| `GET /api/plugins` | Publiek | Lijst met plugins uit `server/repos.json`. |
+| `GET /api/plugins/:owner/:repo` | Publiek | Details van één plugin. |
+| `GET /api/licenses` | Auth | Licenties van ingelogde gebruiker. Admins kunnen `?userId=…` meegeven. |
+| `POST /api/licenses` | Auth | Licentie aanmaken. Admins: `userId` om een andere eigenaar te kiezen. |
+| `POST /api/licenses/verify` | Publiek | Valideert licentiekey + hostname en geeft huidige versie terug. |
+| `POST /api/licenses/download` | Publiek (met geldige licentie) | Verifieert licentie + hostname en streamt een ZIP (versie of latest). |
+
+**`POST /api/licenses/verify`** is het belangrijkste eindpunt voor WordPress-plugins. Request body:
+
+```json
+{
+ "key": "SITI-XXXX-XXXX",
+ "hostname": "example.com"
+}
+```
+
+Response (succes):
+
+```json
+{
+ "valid": true,
+ "hostname": "example.com",
+ "boundNow": false,
+ "license": {
+ "pluginName": "Mijn Plugin",
+ "pluginVersion": "1.2.3",
+ "primaryHostname": "example.com",
+ "key": "SITI-XXXX-XXXX",
+ "hostnames": [
+ { "hostname": "example.com", "hits": 12, "lastSeenAt": "2026-02-01T11:22:33.000Z" }
+ ]
+ }
+}
+```
+
+Als de hostname nog niet eerder gekoppeld was, wordt `boundNow: true`. Als een andere site al gekoppeld is, krijg je HTTP 403 met de melding `Licentie hoort bij …`.
+
+## Integratie met je WordPress-plugin
+
+### 1. Bewaar licentie en API-basis-URL
+
+Maak in je plugin een settingspagina waar de klant:
+
+- De licentiecode invult (bijv. `SITI-ABCD-1234`).
+- De URL van je licentie-API configureert (`https://repo.jouwdomein.nl`).
+
+Sla dit op in `wp_options`, bijvoorbeeld:
+
+```php
+update_option( 'siti_license_key', sanitize_text_field( $_POST['siti_license_key'] ) );
+update_option( 'siti_license_api', esc_url_raw( $_POST['siti_license_api'] ) );
+```
+
+### 2. License check helper
+
+```php
+function siti_verify_license() {
+ $license = get_option( 'siti_license_key' );
+ $api = untrailingslashit( get_option( 'siti_license_api' ) );
+
+ if ( empty( $license ) || empty( $api ) ) {
+ return new WP_Error( 'missing', 'Geen licentie ingesteld.' );
+ }
+
+ $hostname = parse_url( home_url(), PHP_URL_HOST );
+ $response = wp_remote_post( "{$api}/api/licenses/verify", array(
+ 'headers' => array( 'Content-Type' => 'application/json' ),
+ 'body' => wp_json_encode( array(
+ 'key' => $license,
+ 'hostname' => $hostname,
+ ) ),
+ 'timeout' => 15,
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $code = wp_remote_retrieve_response_code( $response );
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( 200 !== $code || empty( $body['valid'] ) ) {
+ return new WP_Error( 'invalid', $body['error'] ?? 'Licentie ongeldig.' );
+ }
+
+ update_option( 'siti_license_last_check', current_time( 'mysql' ) );
+ update_option( 'siti_license_version', $body['license']['pluginVersion'] ?? null );
+
+ return $body['license'];
+}
+```
+
+Gebruik deze helper in:
+
+- `admin_init` (om bij het openen van de plugin-instellingen te valideren).
+- Een WP-Cron job (bijv. dagelijks) om de licentie actief te houden.
+
+### 3. Updates / downloads doorgeven aan WordPress
+
+Omdat je backend bij `verify` de `pluginVersion` retourneert, kun je dat gebruiken om WP te vertellen dat er een update is. Een simpele aanpak:
+
+```php
+add_filter( 'pre_set_site_transient_update_plugins', function ( $transient ) {
+ $license = siti_verify_license();
+ if ( is_wp_error( $license ) ) {
+ return $transient;
+ }
+
+ $current_version = '1.2.0'; // huidige pluginversie
+ $remote_version = $license['pluginVersion'];
+
+ if ( version_compare( $remote_version, $current_version, '>' ) ) {
+ $transient->response['jouw-plugin/jouw-plugin.php'] = (object) array(
+ 'slug' => 'jouw-plugin',
+ 'new_version' => $remote_version,
+ 'package' => 'https://jouw-download-url/zip', // eigen distributie
+ 'tested' => get_bloginfo( 'version' ),
+ 'url' => 'https://jouwwebsite.nl/plugin',
+ );
+ }
+
+ return $transient;
+});
+```
+
+> De API levert geen ZIP-bestand; je blijft zelf verantwoordelijk voor het aanbieden van downloads (bijv. via GitHub releases of Gitea). Gebruik `package` hierboven om naar de daadwerkelijke download te verwijzen.
+
+#### Download endpoint gebruiken
+
+Wanneer je liever via jouw eigen endpoint distribueert, roep dan `POST /api/licenses/download` aan vanaf je plugin of update-server. Body:
+
+```json
+{
+ "key": "SITI-XXXX-XXXX",
+ "hostname": "example.com",
+ "version": "v1.2.3" // of "latest"
+}
+```
+
+Bij succes streamt de API direct een ZIP-bestand. Tip:
+
+1. Laat WordPress jouw eigen mini-proxy aanspreken (omdat WP_UPgrader meestal een URL verwacht). Je proxy kan de POST doen, headers controleren en de stream doorgeven.
+2. Of gebruik `download_url()` in WordPress met `wp_remote_post` en sla de response body tijdelijk op in upload-dir.
+3. Versie `latest` pakt de nieuwste release (via GitHub/Gitea). Specifieke tags vallen terug op `refs/tags/{tag}`. Als een tag ontbreekt, wordt de branch/commitnaam gebruikt.
+
+### 4. Fouten tonen
+
+Laat admins weten wanneer een licentie ongeldig is:
+
+```php
+add_action( 'admin_notices', function () {
+ $license = siti_verify_license();
+ if ( is_wp_error( $license ) ) {
+ printf(
+ '
',
+ esc_html( $license->get_error_message() )
+ );
+ }
+} );
+```
+
+## Handige tips
+
+- Houd `server/repos.json` up-to-date met alle Git repositories waarvan je manifest wilt tonen.
+- Wanneer je licenties migreert vanuit `server/licenses.json`, schrijf een kleine scriptje dat records in de nieuwe MariaDB-tabellen importeert.
+- Gebruik HTTPS op productie; licentiekeys en hostnames gaan over het netwerk.
+- Voor staging of development kun je `JWT_SECRET` kort houden, maar in productie moet het lang en random zijn.
+- Log server-output (`server.log`) in een centralized logging stack om snel te zien wanneer licenties worden geweigerd.
+
+Veel succes met het integreren! Mocht je verdere automatisering willen (bijvoorbeeld automatische updateserver voor ZIP’s), breid dit project dan uit met een downloadendpoint dat controleert of een licentie geldig is voordat hij een ZIP serveert.
diff --git a/server/index.js b/server/index.js
index 513d388..a9fa4ef 100644
--- a/server/index.js
+++ b/server/index.js
@@ -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"));
diff --git a/server/lib/downloadService.js b/server/lib/downloadService.js
new file mode 100644
index 0000000..36854d1
--- /dev/null
+++ b/server/lib/downloadService.js
@@ -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);
+}
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
index 4eae28a..75f472c 100644
--- a/src/pages/Home.jsx
+++ b/src/pages/Home.jsx
@@ -18,7 +18,7 @@ export default function Home() {
setPlugins(data.items || []);
setLastSync(data.updatedAt);
} catch (err) {
- setError("Laden van GitHub data is mislukt.");
+ setError("Laden van repository data is mislukt.");
} finally {
setLoading(false);
}
@@ -49,6 +49,16 @@ export default function Home() {
{plugins.map((plugin) => {
const displayName = plugin.manifest?.plugin_name || plugin.name;
const description = plugin.manifest?.description || plugin.description;
+ const searchParams = new URLSearchParams();
+ if (plugin.provider) {
+ searchParams.set("provider", plugin.provider);
+ }
+ if (plugin.baseUrl) {
+ searchParams.set("baseUrl", plugin.baseUrl);
+ }
+ const repoLabel = plugin.provider === "gitea" ? "Gitea" : "GitHub";
+ const detailUrl =
+ `/plugin/${plugin.fullName}` + (searchParams.toString() ? `?${searchParams.toString()}` : "");
return (
@@ -69,11 +79,11 @@ export default function Home() {
)}
diff --git a/src/pages/PluginDetail.jsx b/src/pages/PluginDetail.jsx
index bd50627..a7ab5b4 100644
--- a/src/pages/PluginDetail.jsx
+++ b/src/pages/PluginDetail.jsx
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState } from "react";
-import { Link, useParams } from "react-router-dom";
+import { Link, useParams, useSearchParams } from "react-router-dom";
export default function PluginDetail() {
const { owner, repo } = useParams();
+ const [searchParams] = useSearchParams();
+ const provider = (searchParams.get("provider") || "github").toLowerCase();
+ const baseUrl = searchParams.get("baseUrl") || "";
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -10,7 +13,17 @@ export default function PluginDetail() {
useEffect(() => {
async function loadDetail() {
try {
- const response = await fetch(`/api/plugins/${owner}/${repo}`);
+ const query = new URLSearchParams();
+ if (provider) {
+ query.set("provider", provider);
+ }
+ if (baseUrl) {
+ query.set("baseUrl", baseUrl);
+ }
+ const search = query.toString();
+ const response = await fetch(
+ `/api/plugins/${owner}/${repo}${search.length > 0 ? `?${search}` : ""}`
+ );
if (!response.ok) {
throw new Error("Kon details niet laden");
}
@@ -24,13 +37,15 @@ export default function PluginDetail() {
}
loadDetail();
- }, [owner, repo]);
+ }, [owner, repo, provider, baseUrl]);
const manifest = data?.manifest;
const displayName = manifest?.plugin_name || data?.name || repo;
const description = manifest?.description || data?.description;
const author = manifest?.author || "-";
const version = manifest?.version || "-";
+ const repositoryLabel = data?.provider === "gitea" ? "Gitea" : "GitHub";
+ const latestDownload = data?.download;
const releases = useMemo(() => data?.releases || [], [data]);
const commits = useMemo(() => data?.commits || [], [data]);
@@ -46,8 +61,13 @@ export default function PluginDetail() {
@@ -89,18 +109,28 @@ export default function PluginDetail() {
Releases
{releases.length === 0 && Geen releases gevonden.
}
- {releases.map((release) => (
- -
-
- {release.name}
-
-
- {release.publishedAt
- ? new Date(release.publishedAt).toLocaleDateString("nl-NL")
- : "-"}
-
-
- ))}
+ {releases.map((release) => {
+ const key = release.tag || release.name || release.url;
+ return (
+ -
+
+
+ {release.name}
+
+
+ {release.publishedAt
+ ? new Date(release.publishedAt).toLocaleDateString("nl-NL")
+ : "-"}
+
+
+ {release.downloadUrl && (
+
+ Download
+
+ )}
+
+ );
+ })}