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

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