Files
DndGamePlayerLicenseServer/src/server.mjs
T
Ivan Fontosh f19b50b2ad feat(activate): allow retireDeviceId to free a device slot on migration
Supports client migration from per-user UUID to machine fingerprint without consuming an extra maxDevices slot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:39:46 +08:00

382 lines
13 KiB
JavaScript

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