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:
358
server/index.js
358
server/index.js
@@ -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}`);
|
||||
});
|
||||
|
||||
21
server/lib/cache.js
Normal file
21
server/lib/cache.js
Normal 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
29
server/lib/config.js
Normal 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
11
server/lib/db.js
Normal 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;
|
||||
192
server/lib/licenseService.js
Normal file
192
server/lib/licenseService.js
Normal 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
204
server/lib/pluginService.js
Normal 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
57
server/lib/schema.js
Normal 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
19
server/lib/storage.js
Normal 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
69
server/lib/userService.js
Normal 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
23
server/middleware/auth.js
Normal 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." });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user