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

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 };
try {
await ensureSchema();
} catch (error) {
console.error("Kon database schema niet initialiseren:", error);
process.exit(1);
}
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;
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." });
}
});
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;
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`;
if (password.length < 8) {
return res.status(400).json({ error: "Wachtwoord moet minimaal 8 karakters zijn." });
}
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;
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." });
}
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
}))
: [];
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 });
}
});
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}`);
});