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: row.last_used_version || null, lastUsedVersion: row.last_used_version, 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 || row.last_used_version || null, lastUsedVersion: row.last_used_version, 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: row.last_used_version || null, lastUsedVersion: row.last_used_version, repo: repoEntry, hostnames }; } } export async function touchLicenseHostname(license, hostname, options = {}) { const normalizedHost = normalizeHostname(hostname); if (!normalizedHost) { return { ok: false, error: "Ongeldige hostname." }; } const trimmed = hostname.trim(); const rawVersion = typeof options?.pluginVersion === "string" && options.pluginVersion.trim().length > 0 ? options.pluginVersion.trim() : null; const pluginVersion = rawVersion ? rawVersion.slice(0, 64) : null; const updateFields = []; const params = []; if (!license.primary_hostname_normalized) { updateFields.push("primary_hostname = ?", "primary_hostname_normalized = ?"); params.push(trimmed, normalizedHost); } else if (license.primary_hostname_normalized !== normalizedHost) { return { ok: false, conflict: true, error: `Licentie hoort bij ${license.primary_hostname || "een andere site"}.` }; } else { // No hostname change, but still keep the hostname casing in sync. updateFields.push("primary_hostname = ?"); params.push(trimmed); } updateFields.push("last_version_check_at = NOW()", "updated_at = NOW()"); if (pluginVersion) { updateFields.push("last_used_version = ?"); params.push(pluginVersion); } await db.query(`UPDATE licenses SET ${updateFields.join(", ")} WHERE id = ?`, [...params, 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 }; }