feat: implement user authentication and license management system

- Added schema for users, licenses, and license hostnames in the database.
- Created storage utility for reading and writing JSON files.
- Developed user service for user registration, authentication, and retrieval.
- Implemented authentication middleware to protect routes.
- Built LicenseCard component to display license details.
- Created SiteNav component for navigation with user authentication status.
- Established AuthContext for managing authentication state and actions.
- Developed Home page to display available plugins.
- Created LicenseManager page for managing licenses with forms for creation and verification.
- Implemented PluginDetail page to show detailed information about a specific plugin.
- Added utility functions for date formatting.
This commit is contained in:
2026-02-01 02:20:28 +00:00
parent f4411ffd88
commit 7b0ca40c4f
27 changed files with 2344 additions and 428 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# MariaDB / MySQL connection
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=secret
DB_NAME=siti_plugin_repo
# Authentication
JWT_SECRET=please-change-me
JWT_EXPIRES_IN=7d

68
dist/assets/index-BlY_enN1.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-CgskK80a.css vendored Normal file

File diff suppressed because one or more lines are too long

16
dist/index.html vendored Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Siti Plugin Repo</title>
<script type="module" crossorigin src="/assets/index-BlY_enN1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CgskK80a.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,11 +1,20 @@
services:
siti-plugin-repo:
image: siti-plugin-repo:latest
build: .
image: siti-plugin-repo:local
build:
context: .
pull_policy: never
container_name: siti-plugin-repo
ports:
- "${HOST_PORT:-8080}:${PORT:-3001}"
environment:
PORT: "${PORT:-3001}"
CACHE_TTL_MS: "${CACHE_TTL_MS:-600000}"
DB_HOST: "${DB_HOST:-127.0.0.1}"
DB_PORT: "${DB_PORT:-3306}"
DB_USER: "${DB_USER:-sitiapp}"
DB_PASSWORD: "${DB_PASSWORD:-sitiapp}"
DB_NAME: "${DB_NAME:-siti_plugin_repo}"
JWT_SECRET: "${JWT_SECRET:-change-me}"
JWT_EXPIRES_IN: "${JWT_EXPIRES_IN:-7d}"
restart: unless-stopped

216
package-lock.json generated
View File

@@ -8,7 +8,10 @@
"name": "siti-plugin-repo",
"version": "0.1.0",
"dependencies": {
"bcryptjs": "^3.0.3",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.23.1"
@@ -1117,6 +1120,14 @@
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -1126,6 +1137,14 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -1195,6 +1214,11 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1305,6 +1329,14 @@
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1335,6 +1367,14 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1567,6 +1607,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1687,6 +1735,11 @@
"node": ">= 0.10"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1716,6 +1769,97 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1736,6 +1880,20 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz",
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1803,6 +1961,51 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mysql2": {
"version": "3.16.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.3",
"named-placeholders": "^1.1.6",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.3"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2135,6 +2338,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@@ -2231,6 +2439,14 @@
"node": ">=0.10.0"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -11,7 +11,10 @@
"start": "node server/index.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.23.1"

View File

@@ -1,218 +1,83 @@
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs/promises";
import {
fetchCommits,
fetchManifest,
fetchRepo,
fetchReleases,
normalizeRepoInput,
parseRepoEntry,
readRepos
} from "./lib/pluginService.js";
import {
buildLicensePayload,
createLicense,
findLicenseByKey,
getLicenseById,
listLicensesByUser,
touchLicenseHostname
} from "./lib/licenseService.js";
import { HOST, PATHS, PORT } from "./lib/config.js";
import { ensureSchema } from "./lib/schema.js";
import { authenticateUser, registerUser } from "./lib/userService.js";
import { requireAuth } from "./middleware/auth.js";
const app = express();
const PORT = process.env.PORT || 3001;
const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 10 * 60 * 1000);
app.use(express.json());
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;
await ensureSchema();
} catch (error) {
console.error("Kon database schema niet initialiseren:", error);
process.exit(1);
}
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." });
}
});
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;
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 });
}
});
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/auth/me", requireAuth, (req, res) => {
res.json({ user: req.user });
});
app.get("/api/plugins", async (_req, res) => {
try {
const repos = await readRepos();
const repos = await readRepos(PATHS.reposFile);
const results = await Promise.all(
repos.map(async (repo) => {
try {
@@ -225,13 +90,19 @@ app.get("/api/plugins", async (_req, res) => {
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}`,
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
manifest: null,
provider: parsed.provider,
ownerRepo: parsed.ownerRepo,
baseUrl: parsed.baseUrl
};
}
})
@@ -272,12 +143,91 @@ app.get("/api/plugins/:owner/:repo", async (req, res) => {
}
});
app.use(express.static(distDir));
app.get("*", (_req, res) => {
res.sendFile(path.join(distDir, "index.html"));
app.get("/api/licenses", requireAuth, async (req, res) => {
try {
const payload = await listLicensesByUser(req.user.id);
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 repoInput =
typeof body.repo === "object"
? body.repo
: typeof body.plugin === "object"
? body.plugin
: body.repo || body.ownerRepo || body.fullName || body.plugin;
const repoEntry =
normalizeRepoInput(repoInput, {
repo: body.ownerRepo || body.fullName || body.repo,
provider: body.provider,
baseUrl: body.baseUrl
}) || null;
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." });
}
const payload = await createLicense(req.user.id, {
label: body.label?.trim(),
note: body.note?.trim(),
repo: repoEntry
});
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) {
return res.status(404).json({ valid: false, error: "Licentie niet gevonden." });
}
const result = await touchLicenseHostname(license, hostname);
if (!result.ok) {
const status = result.conflict ? 403 : 400;
return res.status(status).json({ valid: false, error: result.error, boundHostname: license.primary_hostname });
}
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." });
}
});
app.use(express.static(PATHS.distDir));
app.get("*", (_req, res) => {
res.sendFile(path.join(PATHS.distDir, "index.html"));
});
const HOST = process.env.HOST || "::";
app.listen(PORT, HOST, () => {
console.log(`Server draait op http://${HOST}:${PORT}`);
});

21
server/lib/cache.js Normal file
View File

@@ -0,0 +1,21 @@
import { CACHE_TTL_MS } from "./config.js";
const cache = new Map();
export 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;
}
export function setCached(key, value, ttlMs = CACHE_TTL_MS) {
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
export function clearCached(key) {
cache.delete(key);
}

29
server/lib/config.js Normal file
View File

@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const serverDir = path.resolve(__dirname, "..");
const rootDir = path.resolve(serverDir, "..");
export const PATHS = {
serverDir,
rootDir,
distDir: path.join(rootDir, "dist"),
reposFile: path.join(serverDir, "repos.json")
};
export const PORT = process.env.PORT || 3001;
export const HOST = process.env.HOST || "::";
export const CACHE_TTL_MS = Number(process.env.CACHE_TTL_MS || 10 * 60 * 1000);
export const DB_CONFIG = {
host: process.env.DB_HOST || "127.0.0.1",
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "",
database: process.env.DB_NAME || "siti_plugin_repo"
};
export const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production";
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";

11
server/lib/db.js Normal file
View File

@@ -0,0 +1,11 @@
import mysql from "mysql2/promise";
import { DB_CONFIG } from "./config.js";
const pool = mysql.createPool({
...DB_CONFIG,
waitForConnections: true,
connectionLimit: Number(process.env.DB_POOL_SIZE || 10),
namedPlaceholders: false
});
export default pool;

View File

@@ -0,0 +1,192 @@
import crypto from "crypto";
import db from "./db.js";
import { fetchManifest, fetchRepo, normalizeRepoInput } from "./pluginService.js";
function toIso(value) {
return value ? new Date(value).toISOString() : null;
}
export function generateLicenseKey() {
const raw = crypto.randomBytes(8).toString("hex").toUpperCase();
const segments = raw.match(/.{1,4}/g) || [];
return `SITI-${segments.slice(0, 4).join("-")}`;
}
export function normalizeHostname(value) {
return value ? value.trim().toLowerCase() : null;
}
export async function listLicensesByUser(userId) {
const [rows] = await db.query(`SELECT * FROM licenses WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
return Promise.all(rows.map((row) => buildLicensePayload(row)));
}
export async function getLicenseById(id) {
const [rows] = await db.query(`SELECT * FROM licenses WHERE id = ? LIMIT 1`, [id]);
return rows[0] || null;
}
export async function findLicenseByKey(key) {
const [rows] = await db.query(`SELECT * FROM licenses WHERE license_key = ? LIMIT 1`, [key]);
return rows[0] || null;
}
export async function createLicense(userId, { label, note, repo }) {
const repoEntry = normalizeRepoInput(repo);
if (!repoEntry) {
const error = new Error("Ongeldige plugin referentie.");
error.meta = "INVALID_REPO";
throw error;
}
let licenseId = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
const id = crypto.randomUUID();
const licenseKey = generateLicenseKey();
try {
await db.query(
`INSERT INTO licenses (
id, user_id, license_key, label, note,
repo_provider, repo_name, repo_base_url,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
id,
userId,
licenseKey,
label || repoEntry.repo,
note || null,
repoEntry.provider || "github",
repoEntry.repo,
repoEntry.baseUrl || (repoEntry.provider === "github" ? "https://github.com" : null)
]
);
licenseId = id;
break;
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
continue;
}
throw error;
}
}
if (!licenseId) {
throw new Error("Kon licentie niet opslaan.");
}
return buildLicensePayload(await getLicenseById(licenseId));
}
export async function buildLicensePayload(row) {
if (!row) return null;
const repoEntry = normalizeRepoInput({
repo: row.repo_name,
provider: row.repo_provider,
baseUrl: row.repo_base_url
});
const [hostnameRows] = await db.query(
`SELECT hostname, normalized, first_seen_at, last_seen_at, hits
FROM license_hostnames WHERE license_id = ? ORDER BY first_seen_at ASC`,
[row.id]
);
const hostnames = hostnameRows.map((entry) => ({
hostname: entry.hostname,
normalized: entry.normalized,
firstSeenAt: toIso(entry.first_seen_at),
lastSeenAt: toIso(entry.last_seen_at),
hits: entry.hits
}));
if (!repoEntry) {
return {
id: row.id,
key: row.license_key,
label: row.label,
note: row.note,
hostnames,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
lastVersionCheckAt: toIso(row.last_version_check_at),
primaryHostname: row.primary_hostname,
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: null,
pluginName: row.label,
pluginVersion: null,
repo: null
};
}
try {
const info = await fetchRepo(repoEntry);
const manifest = await fetchManifest(repoEntry, info.defaultBranch).catch(() => null);
return {
id: row.id,
key: row.license_key,
label: row.label,
note: row.note,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
lastVersionCheckAt: toIso(row.last_version_check_at),
primaryHostname: row.primary_hostname,
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: info.fullName,
repoUrl: info.repoUrl,
pluginName: manifest?.plugin_name || info.name || row.label,
pluginVersion: manifest?.version || null,
repo: repoEntry,
hostnames
};
} catch (error) {
return {
id: row.id,
key: row.license_key,
label: row.label,
note: row.note,
createdAt: toIso(row.created_at),
updatedAt: toIso(row.updated_at),
lastVersionCheckAt: toIso(row.last_version_check_at),
primaryHostname: row.primary_hostname,
primaryHostnameNormalized: row.primary_hostname_normalized,
repoFullName: row.repo_name,
repoUrl: repoEntry?.baseUrl ? `${repoEntry.baseUrl.replace(/\/$/, "")}/${row.repo_name}` : null,
pluginName: row.label,
pluginVersion: null,
repo: repoEntry,
hostnames
};
}
}
export async function touchLicenseHostname(license, hostname) {
const normalizedHost = normalizeHostname(hostname);
if (!normalizedHost) {
return { ok: false, error: "Ongeldige hostname." };
}
const trimmed = hostname.trim();
if (!license.primary_hostname_normalized) {
await db.query(
`UPDATE licenses SET primary_hostname = ?, primary_hostname_normalized = ?, last_version_check_at = NOW(), updated_at = NOW()
WHERE id = ?`,
[trimmed, normalizedHost, license.id]
);
} else if (license.primary_hostname_normalized !== normalizedHost) {
return {
ok: false,
conflict: true,
error: `Licentie hoort bij ${license.primary_hostname || "een andere site"}.`
};
} else {
await db.query(`UPDATE licenses SET last_version_check_at = NOW(), updated_at = NOW() WHERE id = ?`, [license.id]);
}
await db.query(
`INSERT INTO license_hostnames (license_id, hostname, normalized, first_seen_at, last_seen_at, hits)
VALUES (?, ?, ?, NOW(), NOW(), 1)
ON DUPLICATE KEY UPDATE hostname = VALUES(hostname), last_seen_at = NOW(), hits = hits + 1`,
[license.id, trimmed, normalizedHost]
);
return { ok: true, boundNow: !license.primary_hostname_normalized, normalized: normalizedHost };
}

204
server/lib/pluginService.js Normal file
View File

@@ -0,0 +1,204 @@
import { readJsonFile } from "./storage.js";
import { getCached, setCached } from "./cache.js";
export function parseRepoEntry(entry) {
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" : "");
return { provider, ownerRepo, baseUrl };
}
export function normalizeRepoInput(input, extras = {}) {
if (typeof input === "string") {
return normalizeRepoInput({ repo: input }, extras);
}
const source = input && typeof input === "object" ? input : {};
const repo = source.repo || source.ownerRepo || source.fullName || extras.repo;
if (!repo) {
return null;
}
const provider = (source.provider || extras.provider || "github").toLowerCase();
const normalized = {
repo,
provider
};
const baseUrl = source.baseUrl || extras.baseUrl || (provider === "github" ? "https://github.com" : undefined);
if (baseUrl) {
normalized.baseUrl = baseUrl;
}
return normalized;
}
export async function readRepos(reposFile) {
const parsed = await readJsonFile(reposFile, []);
return Array.isArray(parsed) ? parsed : [];
}
async function fetchJson(url, cacheKey, opts = {}) {
const cached = getCached(cacheKey);
if (cached) return cached;
const headers = {
"User-Agent": "siti-plugin-repo",
Accept: "application/json"
};
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 () => {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return null;
}
});
setCached(cacheKey, data);
return data;
}
export 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 || "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 || [],
provider,
ownerRepo,
baseUrl
};
setCached(cacheKey, mapped);
return mapped;
}
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 || [],
provider,
ownerRepo,
baseUrl: "https://github.com"
};
setCached(cacheKey, mapped);
return mapped;
}
export 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;
}
export 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
}))
: [];
}
export 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
}))
: [];
}

57
server/lib/schema.js Normal file
View File

@@ -0,0 +1,57 @@
import db from "./db.js";
async function createUsersTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
async function createLicensesTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS licenses (
id CHAR(36) NOT NULL PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
license_key VARCHAR(64) NOT NULL UNIQUE,
label VARCHAR(255),
note TEXT,
repo_provider VARCHAR(32) NOT NULL,
repo_name VARCHAR(255) NOT NULL,
repo_base_url VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_version_check_at DATETIME NULL,
primary_hostname VARCHAR(255) NULL,
primary_hostname_normalized VARCHAR(255) NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
async function createLicenseHostnamesTable() {
await db.query(`
CREATE TABLE IF NOT EXISTS license_hostnames (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
license_id CHAR(36) NOT NULL,
hostname VARCHAR(255) NOT NULL,
normalized VARCHAR(255) NOT NULL,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
hits INT UNSIGNED NOT NULL DEFAULT 1,
UNIQUE KEY unique_license_host (license_id, normalized),
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
}
export async function ensureSchema() {
await createUsersTable();
await createLicensesTable();
await createLicenseHostnamesTable();
}

19
server/lib/storage.js Normal file
View File

@@ -0,0 +1,19 @@
import fs from "fs/promises";
export async function readJsonFile(filePath, fallback = []) {
try {
const content = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(content);
return parsed ?? fallback;
} catch (error) {
if (error.code === "ENOENT") {
await fs.writeFile(filePath, JSON.stringify(fallback, null, 2));
return fallback;
}
throw error;
}
}
export async function writeJsonFile(filePath, data) {
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
}

69
server/lib/userService.js Normal file
View File

@@ -0,0 +1,69 @@
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import db from "./db.js";
import { JWT_SECRET, JWT_EXPIRES_IN } from "./config.js";
function serializeUser(row) {
if (!row) return null;
return {
id: row.id,
username: row.username,
name: row.name,
email: row.email,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null
};
}
function signToken(userId) {
return jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
export async function registerUser({ username, name, email, password }) {
const passwordHash = await bcrypt.hash(password, 10);
try {
const [result] = await db.query(
`INSERT INTO users (username, name, email, password_hash) VALUES (?, ?, ?, ?)`,
[username, name, email, passwordHash]
);
const user = await getUserById(result.insertId);
const token = signToken(user.id);
return { user, token };
} catch (error) {
if (error?.code === "ER_DUP_ENTRY") {
const message = error.sqlMessage || "Duplicate";
if (message.includes("username")) {
error.meta = "USERNAME";
} else if (message.includes("email")) {
error.meta = "EMAIL";
}
}
throw error;
}
}
export async function authenticateUser(identifier, password) {
const [rows] = await db.query(
`SELECT * FROM users WHERE username = ? OR email = ? LIMIT 1`,
[identifier, identifier]
);
if (rows.length === 0) {
const err = new Error("Onjuiste inloggegevens.");
err.meta = "INVALID_CREDENTIALS";
throw err;
}
const row = rows[0];
const ok = await bcrypt.compare(password, row.password_hash);
if (!ok) {
const err = new Error("Onjuiste inloggegevens.");
err.meta = "INVALID_CREDENTIALS";
throw err;
}
const user = serializeUser(row);
const token = signToken(user.id);
return { user, token };
}
export async function getUserById(id) {
const [rows] = await db.query(`SELECT id, username, name, email, created_at FROM users WHERE id = ? LIMIT 1`, [id]);
return serializeUser(rows[0]);
}

23
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,23 @@
import jwt from "jsonwebtoken";
import { JWT_SECRET } from "../lib/config.js";
import { getUserById } from "../lib/userService.js";
export async function requireAuth(req, res, next) {
const authHeader = req.headers.authorization || "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
if (!token) {
return res.status(401).json({ error: "Inloggen vereist." });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
const user = await getUserById(payload.sub);
if (!user) {
return res.status(401).json({ error: "Gebruiker niet gevonden." });
}
req.user = user;
req.token = token;
next();
} catch (error) {
return res.status(401).json({ error: "Ongeldige of verlopen token." });
}
}

View File

@@ -10,6 +10,59 @@
padding: 48px 8vw 64px;
}
.site-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.nav-logo {
font-weight: 700;
color: inherit;
text-decoration: none;
font-size: 1.1rem;
}
.nav-links {
display: flex;
gap: 12px;
}
.nav-user {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: #475569;
}
.nav-user-name {
font-weight: 600;
}
.nav-user-guest {
color: #94a3b8;
font-size: 0.85rem;
}
.nav-link {
padding: 8px 16px;
border-radius: 999px;
border: 1px solid transparent;
text-decoration: none;
color: #475569;
font-weight: 600;
}
.nav-link.active {
background: #eef2ff;
color: #4338ca;
border-color: #c7d2fe;
}
.page {
display: flex;
flex-direction: column;
@@ -46,6 +99,12 @@
max-width: 520px;
}
.hint {
margin: 8px 0 0;
font-size: 0.9rem;
color: #64748b;
}
.cta {
background: #4f46e5;
color: #fff;
@@ -55,6 +114,11 @@
font-weight: 600;
box-shadow: 0 8px 24px rgba(79, 70, 229, 0.2);
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
}
.cta:hover {
@@ -62,6 +126,26 @@
box-shadow: 0 12px 30px rgba(79, 70, 229, 0.3);
}
.ghost {
border: 1px solid #c7d2fe;
color: #4338ca;
padding: 8px 14px;
border-radius: 999px;
text-decoration: none;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: transparent;
cursor: pointer;
}
.ghost-small {
padding: 6px 12px;
font-size: 0.85rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -139,15 +223,6 @@
text-decoration: none;
}
.ghost {
border: 1px solid #c7d2fe;
color: #4338ca;
padding: 8px 14px;
border-radius: 999px;
text-decoration: none;
font-weight: 600;
}
.state {
background: #fff;
border-radius: 16px;
@@ -161,6 +236,16 @@
color: #b91c1c;
}
.state.success {
background: #dcfce7;
color: #166534;
}
.state.inline {
margin-top: 16px;
padding: 16px;
}
.footer {
margin-top: 48px;
color: #94a3b8;
@@ -225,6 +310,144 @@
font-weight: 600;
}
.license-meta-bar {
display: flex;
gap: 24px;
flex-wrap: wrap;
color: #64748b;
font-size: 0.95rem;
}
.license-forms {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.9rem;
color: #475569;
}
.field input,
.field select,
.field textarea {
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 10px 14px;
font-size: 1rem;
font-family: inherit;
background: #fff;
color: inherit;
}
.license-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.license-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.license-card h3 {
margin: 0;
}
.license-subtitle {
margin: 4px 0 0;
color: #94a3b8;
font-size: 0.9rem;
}
.license-note {
margin-top: 4px;
font-size: 0.95rem;
color: #475569;
}
.license-detail-list {
margin-top: 0;
}
.host-list ul {
list-style: none;
padding: 0;
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.host-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
font-size: 0.9rem;
color: #64748b;
}
.host-list li strong {
color: inherit;
}
.license-links {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-top: auto;
}
.ghost-pill {
border: 1px solid #cbd5f5;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.85rem;
color: #475569;
}
.auth-card {
gap: 16px;
}
.auth-tabs {
display: flex;
gap: 8px;
}
.auth-tab {
flex: 1;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #f8fafc;
padding: 8px 12px;
font-weight: 600;
cursor: pointer;
color: #475569;
}
.auth-tab.active {
background: #eef2ff;
border-color: #c7d2fe;
color: #4338ca;
}
@media (max-width: 600px) {
.app {
padding: 40px 6vw 56px;
@@ -241,6 +464,28 @@
color: #e2e8f0;
}
.site-nav {
border-color: rgba(99, 102, 241, 0.4);
}
.nav-link {
color: #cbd5f5;
}
.nav-link.active {
background: rgba(79, 70, 229, 0.2);
border-color: rgba(79, 70, 229, 0.4);
color: #faf5ff;
}
.nav-user {
color: #cbd5f5;
}
.nav-user-guest {
color: #94a3b8;
}
.subtitle {
color: #cbd5f5;
}
@@ -277,6 +522,36 @@
color: #e0e7ff;
}
.auth-tab {
border-color: #312e81;
background: #1e1b4b;
color: #cbd5f5;
}
.auth-tab.active {
background: rgba(79, 70, 229, 0.2);
border-color: rgba(79, 70, 229, 0.6);
color: #faf5ff;
}
.hint,
.license-meta-bar,
.license-note {
color: #cbd5f5;
}
.field input,
.field select,
.field textarea {
background: #1e1b4b;
border-color: #312e81;
color: #e2e8f0;
}
.host-list li {
color: #cbd5f5;
}
.detail-list div {
color: #cbd5f5;
}
@@ -300,6 +575,16 @@
color: #fee2e2;
}
.state.success {
background: #14532d;
color: #bbf7d0;
}
.ghost-pill {
border-color: #4f46e5;
color: #cbd5f5;
}
.footer {
color: #94a3b8;
}

View File

@@ -1,221 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link, Route, Routes, useParams } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import SiteNav from "./components/SiteNav.jsx";
import Home from "./pages/Home.jsx";
import PluginDetail from "./pages/PluginDetail.jsx";
import LicenseManager from "./pages/LicenseManager.jsx";
import "./App.css";
function Home() {
const [plugins, setPlugins] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastSync, setLastSync] = useState(null);
useEffect(() => {
async function loadPlugins() {
try {
const response = await fetch("/api/plugins");
if (!response.ok) {
throw new Error("Kon plugins niet laden");
}
const data = await response.json();
setPlugins(data.items || []);
setLastSync(data.updatedAt);
} catch (err) {
setError("Laden van GitHub data is mislukt.");
} finally {
setLoading(false);
}
}
loadPlugins();
}, []);
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">WordPress plugin overzicht</p>
<h1>Siti Plugin Repo</h1>
<p className="subtitle">Al je publieke WordPress plugins op één plek.</p>
</div>
<a className="cta" href="https://github.com/SitiWeb" target="_blank" rel="noreferrer">
GitHub SitiWeb
</a>
</header>
<section className="grid">
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!loading && !error && plugins.length === 0 && (
<div className="state">Geen repositories gevonden.</div>
)}
{plugins.map((plugin) => {
const displayName = plugin.manifest?.plugin_name || plugin.name;
const description = plugin.manifest?.description || plugin.description;
return (
<article className="card" key={plugin.fullName}>
<div className="card-header">
<h2>{displayName}</h2>
<span className="pill">{plugin.fullName}</span>
</div>
<p>{description}</p>
<div className="meta">
<span> {plugin.stars}</span>
<span>Forks {plugin.forks}</span>
<span>Issues {plugin.issues}</span>
</div>
{plugin.topics.length > 0 && (
<div className="topics">
{plugin.topics.slice(0, 4).map((topic) => (
<span className="topic" key={topic}>{topic}</span>
))}
</div>
)}
<div className="actions">
<Link className="link" to={`/plugin/${plugin.fullName}`}>
Bekijk details
</Link>
<a className="ghost" href={plugin.repoUrl} target="_blank" rel="noreferrer">
GitHub
</a>
</div>
</article>
);
})}
</section>
<footer className="footer">
<span>
Laatste sync: {lastSync ? new Date(lastSync).toLocaleString("nl-NL") : "-"}
</span>
</footer>
</div>
);
}
function PluginDetail() {
const { owner, repo } = useParams();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadDetail() {
try {
const response = await fetch(`/api/plugins/${owner}/${repo}`);
if (!response.ok) {
throw new Error("Kon details niet laden");
}
const detail = await response.json();
setData(detail);
} catch (err) {
setError("Laden van plugin details is mislukt.");
} finally {
setLoading(false);
}
}
loadDetail();
}, [owner, repo]);
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 releases = useMemo(() => data?.releases || [], [data]);
const commits = useMemo(() => data?.commits || [], [data]);
return (
<div className="page">
<header className="detail-hero">
<div>
<p className="eyebrow">Plugin details</p>
<h1>{displayName}</h1>
<p className="subtitle">{description}</p>
</div>
<div className="detail-actions">
<Link className="ghost" to="/"> Terug</Link>
{data?.repoUrl && (
<a className="cta" href={data.repoUrl} target="_blank" rel="noreferrer">
GitHub
</a>
)}
</div>
</header>
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!loading && !error && data && (
<section className="detail-grid">
<div className="card">
<h2>Manifest</h2>
<div className="detail-list">
<div>
<span>Naam</span>
<strong>{displayName}</strong>
</div>
<div>
<span>Versie</span>
<strong>{version}</strong>
</div>
<div>
<span>Auteur</span>
<strong>{author}</strong>
</div>
<div>
<span>Repository</span>
<strong>{data.fullName}</strong>
</div>
</div>
{manifest?.author_url && (
<a className="link" href={manifest.author_url} target="_blank" rel="noreferrer">
Auteur website
</a>
)}
</div>
<div className="card">
<h2>Releases</h2>
{releases.length === 0 && <p>Geen releases gevonden.</p>}
<ul className="list">
{releases.map((release) => (
<li key={release.tag}>
<a href={release.url} target="_blank" rel="noreferrer">
{release.name}
</a>
<span>{release.publishedAt ? new Date(release.publishedAt).toLocaleDateString("nl-NL") : "-"}</span>
</li>
))}
</ul>
</div>
<div className="card">
<h2>Recente commits</h2>
{commits.length === 0 && <p>Geen commits gevonden.</p>}
<ul className="list">
{commits.map((commit) => (
<li key={commit.sha}>
<a href={commit.url} target="_blank" rel="noreferrer">
{commit.message?.split("\n")[0] || commit.sha.slice(0, 7)}
</a>
<span>{commit.author || "-"}</span>
</li>
))}
</ul>
</div>
</section>
)}
</div>
);
}
export default function App() {
return (
<div className="app">
<SiteNav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/plugin/:owner/:repo" element={<PluginDetail />} />
<Route path="/licenses" element={<LicenseManager />} />
</Routes>
</div>
);

View File

@@ -0,0 +1,65 @@
import { formatDate, formatDateTime } from "../utils/dates.js";
export default function LicenseCard({ license }) {
const hostnames = license.hostnames || [];
const primaryHostname = license.primaryHostname || "Nog niet gekoppeld";
return (
<article className="card license-card">
<div className="license-card-header">
<div>
<h3>{license.pluginName || license.label || "Licentie"}</h3>
<p className="license-subtitle">{license.repoFullName || "-"}</p>
</div>
<span className="pill">{license.key}</span>
</div>
<div className="detail-list license-detail-list">
<div>
<span>Versie</span>
<strong>{license.pluginVersion || "-"}</strong>
</div>
<div>
<span>Hostname</span>
<strong>{primaryHostname}</strong>
</div>
<div>
<span>Aangemaakt</span>
<strong>{formatDate(license.createdAt)}</strong>
</div>
<div>
<span>Laatste check</span>
<strong>{formatDateTime(license.lastVersionCheckAt)}</strong>
</div>
</div>
{license.note && <p className="license-note">Notitie: {license.note}</p>}
{hostnames.length > 0 && (
<div className="host-list">
<p className="hint">Hostnames</p>
<ul>
{hostnames.map((host) => (
<li key={`${host.hostname}-${host.firstSeenAt || host.lastSeenAt}`}>
<div>
<strong>{host.hostname}</strong>
<span>{host.hits || 0} checks</span>
</div>
<span>{formatDateTime(host.lastSeenAt)}</span>
</li>
))}
</ul>
</div>
)}
<div className="license-links">
{license.repoUrl && (
<a className="link" href={license.repoUrl} target="_blank" rel="noreferrer">
Repository
</a>
)}
{license.pluginName && <span className="ghost-pill">{license.pluginName}</span>}
</div>
</article>
);
}

View File

@@ -0,0 +1,29 @@
import { Link, NavLink } from "react-router-dom";
import { useAuth } from "../context/AuthContext.jsx";
export default function SiteNav() {
const linkClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
const { user, logout } = useAuth();
return (
<nav className="site-nav">
<Link className="nav-logo" to="/">Siti Plugin Repo</Link>
<div className="nav-links">
<NavLink to="/" end className={linkClass}>Plugins</NavLink>
<NavLink to="/licenses" className={linkClass}>Licenties</NavLink>
</div>
<div className="nav-user">
{user ? (
<>
<span className="nav-user-name">Hallo, {user.name}</span>
<button className="ghost ghost-small" type="button" onClick={logout}>
Uitloggen
</button>
</>
) : (
<span className="nav-user-guest">Niet ingelogd</span>
)}
</div>
</nav>
);
}

140
src/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,140 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react";
const AuthContext = createContext(null);
function getStoredToken() {
try {
return localStorage.getItem("authToken") || "";
} catch {
return "";
}
}
function persistToken(token) {
try {
if (token) {
localStorage.setItem("authToken", token);
} else {
localStorage.removeItem("authToken");
}
} catch {
// ignore storage issues
}
}
export function AuthProvider({ children }) {
const [token, setToken] = useState(getStoredToken);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(Boolean(getStoredToken()));
const [error, setError] = useState(null);
useEffect(() => {
if (!token) {
setUser(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${token}` }
})
.then(async (response) => {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Kon sessie niet verifiëren.");
}
if (!cancelled) {
setUser(data.user || null);
setError(null);
}
})
.catch(() => {
if (!cancelled) {
setUser(null);
setToken("");
persistToken("");
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [token]);
async function login(credentials) {
setError(null);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Login mislukt.");
}
setToken(data.token);
persistToken(data.token);
setUser(data.user);
return data.user;
}
async function register(credentials) {
setError(null);
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Registratie mislukt.");
}
setToken(data.token);
persistToken(data.token);
setUser(data.user);
return data.user;
}
function logout() {
setToken("");
persistToken("");
setUser(null);
}
const authFetch = useMemo(() => {
return (url, options = {}) => {
const headers = {
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
return fetch(url, { ...options, headers });
};
}, [token]);
const value = {
user,
token,
loading,
error,
login,
register,
logout,
authFetch
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth moet binnen een AuthProvider gebruikt worden");
}
return ctx;
}

View File

@@ -2,12 +2,15 @@ import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import { AuthProvider } from "./context/AuthContext.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

89
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
export default function Home() {
const [plugins, setPlugins] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastSync, setLastSync] = useState(null);
useEffect(() => {
async function loadPlugins() {
try {
const response = await fetch("/api/plugins");
if (!response.ok) {
throw new Error("Kon plugins niet laden");
}
const data = await response.json();
setPlugins(data.items || []);
setLastSync(data.updatedAt);
} catch (err) {
setError("Laden van GitHub data is mislukt.");
} finally {
setLoading(false);
}
}
loadPlugins();
}, []);
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">WordPress plugin overzicht</p>
<h1>Siti Plugin Repo</h1>
<p className="subtitle">Al je publieke WordPress plugins op één plek.</p>
</div>
<a className="cta" href="https://github.com/SitiWeb" target="_blank" rel="noreferrer">
GitHub SitiWeb
</a>
</header>
<section className="grid">
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!loading && !error && plugins.length === 0 && (
<div className="state">Geen repositories gevonden.</div>
)}
{plugins.map((plugin) => {
const displayName = plugin.manifest?.plugin_name || plugin.name;
const description = plugin.manifest?.description || plugin.description;
return (
<article className="card" key={plugin.fullName}>
<div className="card-header">
<h2>{displayName}</h2>
<span className="pill">{plugin.fullName}</span>
</div>
<p>{description}</p>
<div className="meta">
<span> {plugin.stars}</span>
<span>Forks {plugin.forks}</span>
<span>Issues {plugin.issues}</span>
</div>
{plugin.topics.length > 0 && (
<div className="topics">
{plugin.topics.slice(0, 4).map((topic) => (
<span className="topic" key={topic}>{topic}</span>
))}
</div>
)}
<div className="actions">
<Link className="link" to={`/plugin/${plugin.fullName}`}>
Bekijk details
</Link>
<a className="ghost" href={plugin.repoUrl} target="_blank" rel="noreferrer">
GitHub
</a>
</div>
</article>
);
})}
</section>
<footer className="footer">
<span>Laatste sync: {lastSync ? new Date(lastSync).toLocaleString("nl-NL") : "-"}</span>
</footer>
</div>
);
}

View File

@@ -0,0 +1,476 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import LicenseCard from "../components/LicenseCard.jsx";
import { formatDateTime } from "../utils/dates.js";
import { useAuth } from "../context/AuthContext.jsx";
export default function LicenseManager() {
const { user, token, authFetch, login, register: registerUser, loading: authLoading } = useAuth();
const [licenses, setLicenses] = useState([]);
const [plugins, setPlugins] = useState([]);
const [selectedPluginId, setSelectedPluginId] = useState("");
const [label, setLabel] = useState("");
const [note, setNote] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [creating, setCreating] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [lastSync, setLastSync] = useState(null);
const [formStatus, setFormStatus] = useState(null);
const [verifyStatus, setVerifyStatus] = useState(null);
const [verifying, setVerifying] = useState(false);
const [verifyKey, setVerifyKey] = useState("");
const [verifyHostname, setVerifyHostname] = useState("");
const isAuthenticated = Boolean(user && token);
useEffect(() => {
let cancelled = false;
async function loadPlugins() {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/plugins");
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Kon plugins niet laden.");
}
if (cancelled) return;
setPlugins(data.items || []);
const firstPlugin = data.items?.[0];
if (firstPlugin) {
const defaultId = firstPlugin.ownerRepo || firstPlugin.fullName;
setSelectedPluginId((prev) => prev || defaultId);
}
} catch (err) {
if (!cancelled) {
setError(err.message || "Kon plugins niet laden.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadPlugins();
return () => {
cancelled = true;
};
}, []);
const refreshLicenses = useCallback(
async (showStatus = true) => {
if (!token) {
setLicenses([]);
setLastSync(null);
if (showStatus) {
setFormStatus({ variant: "error", message: "Log in om licenties te beheren." });
}
return;
}
if (showStatus) {
setFormStatus(null);
}
setRefreshing(true);
try {
const response = await authFetch("/api/licenses");
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
throw new Error("Sessie verlopen, log opnieuw in.");
}
if (!response.ok) {
throw new Error(data.error || "Kon licenties niet laden.");
}
setLicenses(data.items || []);
setLastSync(data.updatedAt);
} catch (err) {
if (showStatus) {
setFormStatus({ variant: "error", message: err.message });
}
} finally {
setRefreshing(false);
}
},
[authFetch, token]
);
useEffect(() => {
refreshLicenses(false);
}, [refreshLicenses]);
useEffect(() => {
if (!selectedPluginId && plugins.length > 0) {
const fallback = plugins[0].ownerRepo || plugins[0].fullName;
setSelectedPluginId(fallback);
}
}, [plugins, selectedPluginId]);
const selectedPlugin = useMemo(
() => plugins.find((plugin) => (plugin.ownerRepo || plugin.fullName) === selectedPluginId) || null,
[plugins, selectedPluginId]
);
const sortedLicenses = useMemo(() => {
const getTime = (value) => (value ? new Date(value).getTime() : 0);
return [...licenses].sort((a, b) => getTime(b.createdAt) - getTime(a.createdAt));
}, [licenses]);
async function handleCreateLicense(event) {
event.preventDefault();
setFormStatus(null);
if (!isAuthenticated) {
setFormStatus({ variant: "error", message: "Log in om een licentie aan te maken." });
return;
}
if (!selectedPlugin) {
setFormStatus({ variant: "error", message: "Selecteer een plugin." });
return;
}
setCreating(true);
try {
const payload = {
label:
label.trim() ||
selectedPlugin.manifest?.plugin_name ||
selectedPlugin.name ||
selectedPlugin.fullName,
note: note.trim() || undefined,
repo: {
repo: selectedPlugin.ownerRepo || selectedPlugin.fullName,
provider: selectedPlugin.provider || "github",
baseUrl: selectedPlugin.baseUrl
}
};
const response = await authFetch("/api/licenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
throw new Error("Sessie verlopen, log opnieuw in.");
}
if (!response.ok) {
throw new Error(data.error || "Licentie aanmaken mislukt.");
}
setLicenses((prev) => [data, ...prev]);
setFormStatus({ variant: "success", message: "Licentie aangemaakt." });
setLabel("");
setNote("");
} catch (err) {
setFormStatus({ variant: "error", message: err.message });
} finally {
setCreating(false);
}
}
async function handleVerifyLicense(event) {
event.preventDefault();
setVerifyStatus(null);
if (!verifyKey.trim() || !verifyHostname.trim()) {
setVerifyStatus({ ok: false, message: "Vul zowel licentiecode als hostname in." });
return;
}
setVerifying(true);
try {
const response = await fetch("/api/licenses/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: verifyKey.trim(),
hostname: verifyHostname.trim()
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || "Controle mislukt.");
}
setVerifyStatus({ ok: true, data });
if (data.license) {
setLicenses((prev) => prev.map((license) => (license.key === data.license.key ? data.license : license)));
}
} catch (err) {
setVerifyStatus({ ok: false, message: err.message });
} finally {
setVerifying(false);
}
}
const handleLogin = useCallback(
async (credentials) => {
await login(credentials);
await refreshLicenses(false);
},
[login, refreshLicenses]
);
const handleRegister = useCallback(
async (payload) => {
await registerUser(payload);
await refreshLicenses(false);
},
[registerUser, refreshLicenses]
);
const isLoadingState = loading || (authLoading && Boolean(token));
return (
<div className="page">
<header className="hero">
<div>
<p className="eyebrow">Licentiebeheer</p>
<h1>Licenties</h1>
<p className="subtitle">
Maak licenties voor iedere plugin en beheer welke hostname de licentie daadwerkelijk gebruikt.
</p>
<p className="hint">
Een licentie is geldig voor één hostname. De eerste hostname die controleert wordt automatisch gekoppeld als
licentiehouder.
</p>
</div>
<button className="ghost" type="button" onClick={() => refreshLicenses()} disabled={refreshing || !isAuthenticated}>
{refreshing ? "Vernieuwen…" : "Vernieuw lijst"}
</button>
</header>
<div className="license-meta-bar">
<span>Actieve licenties: {licenses.length}</span>
<span>Laatste update: {formatDateTime(lastSync)}</span>
{user && <span>Ingelogd als: {user.email}</span>}
</div>
{isLoadingState && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!isLoadingState && !error && (
<>
<section className="license-forms">
{isAuthenticated ? (
<article className="card">
<h2>Nieuwe licentie</h2>
<p className="hint">Kies een plugin en genereer direct een licentiesleutel.</p>
<form className="form-grid" onSubmit={handleCreateLicense}>
<label className="field">
<span>Plugin</span>
<select
value={selectedPluginId}
onChange={(event) => setSelectedPluginId(event.target.value)}
disabled={plugins.length === 0}
>
{plugins.map((plugin) => {
const id = plugin.ownerRepo || plugin.fullName;
return (
<option key={id} value={id}>
{plugin.manifest?.plugin_name || plugin.name} ({plugin.fullName})
</option>
);
})}
</select>
</label>
<label className="field">
<span>Label (optioneel)</span>
<input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="Naam of klant"
/>
</label>
<label className="field">
<span>Notitie</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Bijv. contactpersoon of extra info"
rows={3}
/>
</label>
<button className="cta" type="submit" disabled={creating || !selectedPlugin}>
{creating ? "Aanmaken…" : "Licentie aanmaken"}
</button>
</form>
{formStatus && (
<div className={`state inline ${formStatus.variant === "error" ? "error" : "success"}`}>
{formStatus.message}
</div>
)}
</article>
) : (
<AuthForms onLogin={handleLogin} onRegister={handleRegister} />
)}
<article className="card">
<h2>Test of valideer</h2>
<p className="hint">
Gebruik dit formulier zoals de plugin dat zou doen om de huidige versie en hostname te controleren.
</p>
<form className="form-grid" onSubmit={handleVerifyLicense}>
<label className="field">
<span>Licentiecode</span>
<input
value={verifyKey}
onChange={(event) => setVerifyKey(event.target.value)}
placeholder="SITI-XXXX-XXXX"
/>
</label>
<label className="field">
<span>Hostname</span>
<input
value={verifyHostname}
onChange={(event) => setVerifyHostname(event.target.value)}
placeholder="voorbeeld.nl"
/>
</label>
<button className="ghost" type="submit" disabled={verifying}>
{verifying ? "Controleren…" : "Controleer licentie"}
</button>
</form>
{verifyStatus && verifyStatus.ok && verifyStatus.data?.license && (
<div className="state success inline">
<strong>Licentie geldig</strong>
<p>
{verifyStatus.data.license.pluginName || "Plugin"} versie {verifyStatus.data.license.pluginVersion || "-"}
</p>
<p>
Gekoppeld aan: <strong>{verifyStatus.data.license.primaryHostname || "Nog niet gekoppeld"}</strong>
</p>
</div>
)}
{verifyStatus && !verifyStatus.ok && (
<div className="state error inline">{verifyStatus.message}</div>
)}
</article>
</section>
{isAuthenticated ? (
<section className="license-grid">
{sortedLicenses.length === 0 ? (
<div className="state">Nog geen licenties aangemaakt.</div>
) : (
sortedLicenses.map((license) => <LicenseCard key={license.id || license.key} license={license} />)
)}
</section>
) : (
<div className="state">Log in of registreer om licenties te bekijken en te beheren.</div>
)}
</>
)}
</div>
);
}
function AuthForms({ onLogin, onRegister }) {
const [mode, setMode] = useState("login");
const [loginForm, setLoginForm] = useState({ identifier: "", password: "" });
const [registerForm, setRegisterForm] = useState({ username: "", name: "", email: "", password: "" });
const [status, setStatus] = useState(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
setStatus(null);
setSubmitting(true);
try {
if (mode === "login") {
await onLogin(loginForm);
setStatus({ variant: "success", message: "Succesvol ingelogd." });
} else {
await onRegister(registerForm);
setStatus({ variant: "success", message: "Account aangemaakt en ingelogd." });
}
} catch (error) {
setStatus({ variant: "error", message: error.message || "Actie mislukt." });
} finally {
setSubmitting(false);
}
}
return (
<article className="card auth-card">
<div className="auth-tabs">
<button
type="button"
className={mode === "login" ? "auth-tab active" : "auth-tab"}
onClick={() => setMode("login")}
disabled={submitting}
>
Inloggen
</button>
<button
type="button"
className={mode === "register" ? "auth-tab active" : "auth-tab"}
onClick={() => setMode("register")}
disabled={submitting}
>
Registreren
</button>
</div>
<form className="form-grid" onSubmit={handleSubmit}>
{mode === "login" ? (
<>
<label className="field">
<span>Gebruikersnaam of e-mail</span>
<input
value={loginForm.identifier}
onChange={(event) => setLoginForm((prev) => ({ ...prev, identifier: event.target.value }))}
placeholder="jouwnaam of mail"
/>
</label>
<label className="field">
<span>Wachtwoord</span>
<input
type="password"
value={loginForm.password}
onChange={(event) => setLoginForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="••••••••"
/>
</label>
</>
) : (
<>
<label className="field">
<span>Gebruikersnaam</span>
<input
value={registerForm.username}
onChange={(event) => setRegisterForm((prev) => ({ ...prev, username: event.target.value }))}
placeholder="gebruikersnaam"
/>
</label>
<label className="field">
<span>Naam</span>
<input
value={registerForm.name}
onChange={(event) => setRegisterForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Volledige naam"
/>
</label>
<label className="field">
<span>E-mailadres</span>
<input
type="email"
value={registerForm.email}
onChange={(event) => setRegisterForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="naam@bedrijf.nl"
/>
</label>
<label className="field">
<span>Wachtwoord</span>
<input
type="password"
value={registerForm.password}
onChange={(event) => setRegisterForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="Minimaal 8 karakters"
/>
</label>
</>
)}
<button className="cta" type="submit" disabled={submitting}>
{submitting ? "Verwerken…" : mode === "login" ? "Inloggen" : "Registreren"}
</button>
</form>
{status && (
<div className={`state inline ${status.variant === "error" ? "error" : "success"}`}>
{status.message}
</div>
)}
</article>
);
}

125
src/pages/PluginDetail.jsx Normal file
View File

@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
export default function PluginDetail() {
const { owner, repo } = useParams();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadDetail() {
try {
const response = await fetch(`/api/plugins/${owner}/${repo}`);
if (!response.ok) {
throw new Error("Kon details niet laden");
}
const detail = await response.json();
setData(detail);
} catch (err) {
setError("Laden van plugin details is mislukt.");
} finally {
setLoading(false);
}
}
loadDetail();
}, [owner, repo]);
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 releases = useMemo(() => data?.releases || [], [data]);
const commits = useMemo(() => data?.commits || [], [data]);
return (
<div className="page">
<header className="detail-hero">
<div>
<p className="eyebrow">Plugin details</p>
<h1>{displayName}</h1>
<p className="subtitle">{description}</p>
</div>
<div className="detail-actions">
<Link className="ghost" to="/"> Terug</Link>
{data?.repoUrl && (
<a className="cta" href={data.repoUrl} target="_blank" rel="noreferrer">
GitHub
</a>
)}
</div>
</header>
{loading && <div className="state">Bezig met laden</div>}
{error && <div className="state error">{error}</div>}
{!loading && !error && data && (
<section className="detail-grid">
<div className="card">
<h2>Manifest</h2>
<div className="detail-list">
<div>
<span>Naam</span>
<strong>{displayName}</strong>
</div>
<div>
<span>Versie</span>
<strong>{version}</strong>
</div>
<div>
<span>Auteur</span>
<strong>{author}</strong>
</div>
<div>
<span>Repository</span>
<strong>{data.fullName}</strong>
</div>
</div>
{manifest?.author_url && (
<a className="link" href={manifest.author_url} target="_blank" rel="noreferrer">
Auteur website
</a>
)}
</div>
<div className="card">
<h2>Releases</h2>
{releases.length === 0 && <p>Geen releases gevonden.</p>}
<ul className="list">
{releases.map((release) => (
<li key={release.tag}>
<a href={release.url} target="_blank" rel="noreferrer">
{release.name}
</a>
<span>
{release.publishedAt
? new Date(release.publishedAt).toLocaleDateString("nl-NL")
: "-"}
</span>
</li>
))}
</ul>
</div>
<div className="card">
<h2>Recente commits</h2>
{commits.length === 0 && <p>Geen commits gevonden.</p>}
<ul className="list">
{commits.map((commit) => (
<li key={commit.sha}>
<a href={commit.url} target="_blank" rel="noreferrer">
{commit.message?.split("\n")[0] || commit.sha.slice(0, 7)}
</a>
<span>{commit.author || "-"}</span>
</li>
))}
</ul>
</div>
</section>
)}
</div>
);
}

9
src/utils/dates.js Normal file
View File

@@ -0,0 +1,9 @@
export const DEFAULT_LOCALE = "nl-NL";
export function formatDateTime(value, locale = DEFAULT_LOCALE) {
return value ? new Date(value).toLocaleString(locale) : "-";
}
export function formatDate(value, locale = DEFAULT_LOCALE) {
return value ? new Date(value).toLocaleDateString(locale) : "-";
}