import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { buildProductKeyEntry, deleteProductKey, enrichLicenseEntry, ensurePeriodActivated, resolveTokenExp, sortLicensesNewestFirst, } from '../lib/licenseLogic.mjs'; import { getDownloadStatsForMonth, listDownloadMonths, monthKeyFromDate, recordDownload, } from '../lib/downloadStats.mjs'; import { signPayload } from '../lib/signPayload.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); function readData() { const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json'); const raw = fs.readFileSync(p, 'utf8'); return JSON.parse(raw); } function writeData(data) { const p = process.env.DND_LICENSE_DATA_PATH ?? path.join(root, 'data.json'); fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); } const DEFAULT_TRACK_DOWNLOAD_CORS_ORIGINS = [ 'https://ttrpgplayer.ru', 'https://www.ttrpgplayer.ru', 'http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:5174', 'http://127.0.0.1:5174', ]; function parseTrackDownloadBody(raw) { if (typeof raw !== 'string' || !raw.trim()) return {}; try { return JSON.parse(raw); } catch { return {}; } } function parseCorsOrigins(value) { if (typeof value !== 'string' || !value.trim()) return null; const list = value.split(',').map((s) => s.trim()).filter(Boolean); return list.length ? list : null; } function resolveAllowedCorsOrigin(req, allowedOrigins) { const origin = req.headers.origin; if (typeof origin !== 'string' || !origin) return null; return allowedOrigins.includes(origin) ? origin : null; } function trackDownloadCorsHeaders(origin) { if (!origin) return {}; return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', Vary: 'Origin', }; } function json(res, code, obj, extraHeaders = {}) { const body = JSON.stringify(obj); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), ...extraHeaders, }); res.end(body); } function empty(res, code, headers = {}) { res.writeHead(code, headers); res.end(); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function checkAdmin(req, adminToken) { const auth = req.headers.authorization ?? ''; const tok = auth.startsWith('Bearer ') ? auth.slice(7) : ''; return tok === adminToken; } function generateProductKey() { return `TTRPG-${randomUUID().toUpperCase()}`; } function generateSub() { return `lic_${randomUUID().replace(/-/g, '')}`; } function revokedKeys(data) { return Array.isArray(data.revokedKeys) ? data.revokedKeys : []; } function revokedSubs(data) { return Array.isArray(data.revokedSubs) ? data.revokedSubs : []; } function isProductKeyRevoked(data, productKey) { const pk = data.productKeys?.find((x) => x.key === productKey); if (!pk) return false; return revokedKeys(data).includes(pk.key) || revokedSubs(data).includes(pk.sub); } function isSubRevoked(data, sub) { return revokedSubs(data).includes(sub); } export function createServer(options = {}) { const privateKeyPem = options.privateKeyPem ?? process.env.LICENSE_PRIVATE_KEY_PEM; const adminToken = options.adminToken ?? process.env.LICENSE_ADMIN_TOKEN ?? 'change-me-admin'; const trackDownloadCorsOrigins = options.trackDownloadCorsOrigins ?? parseCorsOrigins(process.env.LICENSE_TRACK_DOWNLOAD_CORS_ORIGINS) ?? DEFAULT_TRACK_DOWNLOAD_CORS_ORIGINS; if (!privateKeyPem) throw new Error('LICENSE_PRIVATE_KEY_PEM required'); return http.createServer(async (req, res) => { try { const url = new URL(req.url ?? '/', `http://localhost`); if (url.pathname === '/v1/track/download') { const corsOrigin = resolveAllowedCorsOrigin(req, trackDownloadCorsOrigins); const corsHeaders = trackDownloadCorsHeaders(corsOrigin); if (req.method === 'OPTIONS') { if (!corsOrigin) return json(res, 404, { error: 'not_found' }); return empty(res, 204, corsHeaders); } if (req.method === 'POST') { const raw = await readBody(req); const body = parseTrackDownloadBody(raw); const data = readData(); const result = recordDownload(data, body.platform); if (!result.ok) return json(res, 400, { error: result.error }, corsHeaders); writeData(data); return json(res, 200, { ok: true, ...result }, corsHeaders); } } if (req.method === 'GET' && url.pathname === '/v1/status') { const sub = url.searchParams.get('sub'); if (!sub) return json(res, 400, { error: 'missing_sub' }); const data = readData(); const revoked = isSubRevoked(data, sub); return json(res, 200, { revoked }); } if (req.method === 'POST' && url.pathname === '/v1/activate') { const raw = await readBody(req); const body = JSON.parse(raw || '{}'); const productKey = body.productKey; const deviceId = body.deviceId; const retireDeviceId = typeof body.retireDeviceId === 'string' ? body.retireDeviceId.trim() : ''; if (!productKey || !deviceId) return json(res, 400, { error: 'productKey_and_deviceId_required' }); const data = readData(); const pk = data.productKeys?.find((x) => x.key === productKey); if (!pk) return json(res, 403, { error: 'unknown_product_key' }); if (isProductKeyRevoked(data, productKey)) return json(res, 403, { error: 'license_revoked' }); data.activations ??= {}; const list = data.activations[pk.sub] ?? []; // Миграция со старого per-user UUID: освобождаем слот retireDeviceId перед проверкой лимита. let next = [...list]; if (retireDeviceId && retireDeviceId !== deviceId) { next = next.filter((d) => d !== retireDeviceId); } const already = next.includes(deviceId); if (!already && next.length >= pk.maxDevices) { return json(res, 403, { error: 'too_many_devices' }); } const now = Math.floor(Date.now() / 1000); let dataChanged = false; try { const period = ensurePeriodActivated(pk, now); if (period.changed) dataChanged = true; } catch { return json(res, 403, { error: 'license_misconfigured' }); } if (!already) { next.push(deviceId); } if (next.length !== list.length || next.some((d, i) => d !== list[i])) { data.activations[pk.sub] = next; dataChanged = true; } if (dataChanged) writeData(data); let exp; try { exp = resolveTokenExp(pk, now); } catch { return json(res, 403, { error: 'license_misconfigured' }); } const payload = { v: 1, sub: pk.sub, pid: pk.pid, iat: now, exp, did: deviceId, }; const token = signPayload(payload, privateKeyPem); return json(res, 200, { token, sub: pk.sub }); } if (req.method === 'GET' && url.pathname === '/v1/admin/licenses') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const data = readData(); const revokedSubsList = revokedSubs(data); const revokedKeysList = revokedKeys(data); const activations = data.activations ?? {}; const licenses = sortLicensesNewestFirst( (data.productKeys ?? []).map((pk) => { const devices = activations[pk.sub] ?? []; const revoked = revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub); return enrichLicenseEntry(pk, devices, revoked); }), ); return json(res, 200, { licenses }); } if (req.method === 'GET' && url.pathname === '/v1/admin/stats/downloads') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const data = readData(); const monthParam = url.searchParams.get('month'); if (monthParam && monthParam !== 'all') { return json(res, 200, getDownloadStatsForMonth(data, monthParam)); } const months = listDownloadMonths(data); return json(res, 200, { months, currentMonth: getDownloadStatsForMonth(data, monthKeyFromDate()), }); } if (req.method === 'POST' && url.pathname === '/v1/admin/product-keys/delete') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const raw = await readBody(req); const body = JSON.parse(raw || '{}'); const key = body.key; if (!key) return json(res, 400, { error: 'missing_key' }); const data = readData(); const result = deleteProductKey(data, key); if (!result.ok) return json(res, 404, { error: result.error }); writeData(data); return json(res, 200, { ok: true, deleted: result.deleted }); } if (req.method === 'POST' && url.pathname === '/v1/admin/product-keys') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const raw = await readBody(req); const body = JSON.parse(raw || '{}'); const now = Math.floor(Date.now() / 1000); const countRaw = body.count ?? 1; const count = Number(countRaw); if (!Number.isInteger(count) || count < 1 || count > 100) { return json(res, 400, { error: 'invalid_count' }); } const data = readData(); data.productKeys ??= []; const created = []; for (let i = 0; i < count; i += 1) { const itemBody = { ...body, key: count === 1 ? body.key : undefined, sub: count === 1 ? body.sub : undefined, }; const built = buildProductKeyEntry(itemBody, { generateKey: generateProductKey, generateSub: generateSub, nowSec: now + i, }); if (!built.ok) return json(res, 400, { error: built.error }); if (data.productKeys.some((x) => x.key === built.entry.key || x.sub === built.entry.sub)) { return json(res, 409, { error: 'duplicate_key_or_sub' }); } data.productKeys.push(built.entry); created.push(built.entry); } writeData(data); if (count === 1) return json(res, 201, created[0]); return json(res, 201, { keys: created, count: created.length }); } if (req.method === 'POST' && url.pathname === '/v1/admin/revoke') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const raw = await readBody(req); const body = JSON.parse(raw || '{}'); const key = body.key; const sub = body.sub; if (!key && !sub) return json(res, 400, { error: 'missing_key_or_sub' }); const data = readData(); if (key) { const pk = data.productKeys?.find((x) => x.key === key); if (!pk) return json(res, 404, { error: 'unknown_product_key' }); data.revokedKeys ??= []; if (!data.revokedKeys.includes(key)) data.revokedKeys.push(key); } else { data.revokedSubs ??= []; if (!data.revokedSubs.includes(sub)) data.revokedSubs.push(sub); } writeData(data); return json(res, 200, { ok: true }); } if (req.method === 'POST' && url.pathname === '/v1/admin/issue') { if (!checkAdmin(req, adminToken)) return json(res, 401, { error: 'unauthorized' }); const raw = await readBody(req); const body = JSON.parse(raw || '{}'); const now = Math.floor(Date.now() / 1000); const payload = { v: 1, sub: body.sub ?? `lic_${randomUUID()}`, pid: body.pid ?? 'dnd_player', iat: body.iat ?? now, exp: body.exp ?? now + 86400 * 365, did: body.did === undefined ? null : body.did, }; const token = signPayload(payload, privateKeyPem); return json(res, 200, { token, payload }); } if (req.method === 'GET' && url.pathname === '/health') { return json(res, 200, { ok: true }); } return json(res, 404, { error: 'not_found' }); } catch (e) { json(res, 500, { error: e instanceof Error ? e.message : String(e) }); } }); } if (!process.env.SKIP_LICENSE_LISTEN) { const privateKeyPem = process.env.LICENSE_PRIVATE_KEY_PEM; if (!privateKeyPem) { console.error('Задайте LICENSE_PRIVATE_KEY_PEM (PKCS#8 PEM, Ed25519)'); process.exit(1); } const port = Number(process.env.PORT ?? 3847); const server = createServer(); server.listen(port, () => { console.log(`DndGamePlayerLicenseServer listening on http://localhost:${port}`); }); }