f19b50b2ad
Supports client migration from per-user UUID to machine fingerprint without consuming an extra maxDevices slot. Co-authored-by: Cursor <cursoragent@cursor.com>
382 lines
13 KiB
JavaScript
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}`);
|
|
});
|
|
}
|