diff --git a/README.md b/README.md index 49f7435..c4f6770 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,13 @@ npm test - `POST /v1/activate` — `{ "productKey": "...", "deviceId": "..." }` → `{ token, sub }`. - `GET /v1/status?sub=...` → `{ revoked: boolean }`. -- `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "key": "TTRPG-..." }` (один продуктовый ключ) или `{ "sub": "..." }` (все ключи с этим sub, legacy). +- `POST /v1/track/download` — `{ "platform": "windows"|"macos"|"linux" }` → учёт скачиваний в `data.json` → `downloadStats.byMonth`. +- `POST /v1/admin/revoke` — `Authorization: Bearer `, тело `{ "key": "TTRPG-..." }` или `{ "sub": "..." }`. +- `POST /v1/admin/product-keys/delete` — hard delete ключа `{ "key": "..." }`. - `POST /v1/admin/issue` — админская выдача (`sub`, `pid`, `iat`, `exp`, `did`). -- `GET /v1/admin/licenses` — список продуктовых ключей с активациями и флагом `revoked`. -- `POST /v1/admin/product-keys` — создание нового продуктового ключа (`pid`, `maxDevices`, опционально `key`, `sub`). - - **По умолчанию** — period-лицензия: `validDays: 365` (отсчёт с первой активации). - - **Fixed** (фиксированная дата): явно передайте `expiresAtSec` (unix-секунды). - - Нельзя указывать `expiresAtSec` и `validDays` одновременно. +- `GET /v1/admin/licenses` — список ключей (новые сверху), поля `createdAtSec`, `expiryMode`, `validDays`, … +- `GET /v1/admin/stats/downloads` — обзор `{ months, currentMonth }`; `?month=YYYY-MM` — статистика за месяц. +- `POST /v1/admin/product-keys` — создание ключа (`pid`, `maxDevices`, `validDays` или `expiresAtSec`, опционально `count` 1–100). - `GET /health` — `{ ok: true }`. ## Клиент diff --git a/data.example.json b/data.example.json index 3f0164b..2c3949b 100644 --- a/data.example.json +++ b/data.example.json @@ -5,17 +5,24 @@ "sub": "lic_demo_default", "pid": "dnd_player", "maxDevices": 3, - "expiresAtSec": 1893456000 + "expiresAtSec": 1893456000, + "createdAtSec": 1700000000 }, { "key": "TTRPG-DEMO-PERIOD-KEY", "sub": "lic_demo_period", "pid": "dnd_player", "maxDevices": 3, - "validDays": 365 + "validDays": 365, + "createdAtSec": 1800000000 } ], "revokedSubs": [], "revokedKeys": [], - "activations": {} + "activations": {}, + "downloadStats": { + "byMonth": { + "2026-07": { "windows": 0, "macos": 0, "linux": 0 } + } + } } diff --git a/lib/downloadStats.mjs b/lib/downloadStats.mjs new file mode 100644 index 0000000..9bbc635 --- /dev/null +++ b/lib/downloadStats.mjs @@ -0,0 +1,42 @@ +export const DOWNLOAD_PLATFORMS = ['windows', 'macos', 'linux']; + +export function monthKeyFromDate(date = new Date()) { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + return `${y}-${m}`; +} + +export function normalizeDownloadPlatform(platform) { + const p = String(platform ?? '').trim().toLowerCase(); + if (DOWNLOAD_PLATFORMS.includes(p)) return p; + return null; +} + +export function ensureDownloadStats(data) { + data.downloadStats ??= { byMonth: {} }; + data.downloadStats.byMonth ??= {}; + return data.downloadStats; +} + +export function recordDownload(data, platform, date = new Date()) { + const normalized = normalizeDownloadPlatform(platform); + if (!normalized) return { ok: false, error: 'invalid_platform' }; + + const stats = ensureDownloadStats(data); + const month = monthKeyFromDate(date); + stats.byMonth[month] ??= { windows: 0, macos: 0, linux: 0 }; + stats.byMonth[month][normalized] += 1; + return { ok: true, month, platform: normalized, count: stats.byMonth[month][normalized] }; +} + +export function getDownloadStatsForMonth(data, month) { + const stats = ensureDownloadStats(data); + const row = stats.byMonth[month] ?? { windows: 0, macos: 0, linux: 0 }; + const total = row.windows + row.macos + row.linux; + return { month, windows: row.windows, macos: row.macos, linux: row.linux, total }; +} + +export function listDownloadMonths(data) { + const stats = ensureDownloadStats(data); + return Object.keys(stats.byMonth).sort((a, b) => b.localeCompare(a)); +} diff --git a/lib/licenseLogic.mjs b/lib/licenseLogic.mjs index ee24793..76697fe 100644 --- a/lib/licenseLogic.mjs +++ b/lib/licenseLogic.mjs @@ -84,6 +84,7 @@ export function enrichLicenseEntry(pk, devices, revoked) { sub: pk.sub, pid: pk.pid, maxDevices: pk.maxDevices, + createdAtSec: pk.createdAtSec ?? null, expiryMode: getExpiryMode(pk), expiresAtSec: pk.expiresAtSec ?? null, validDays: pk.validDays ?? null, @@ -95,3 +96,54 @@ export function enrichLicenseEntry(pk, devices, revoked) { activatedCount: devices.length, }; } + +export function sortLicensesNewestFirst(licenses) { + return [...licenses].sort((a, b) => { + const aSec = a.createdAtSec ?? a.activatedAtSec ?? 0; + const bSec = b.createdAtSec ?? b.activatedAtSec ?? 0; + return bSec - aSec; + }); +} + +export function buildProductKeyEntry(body, { generateKey, generateSub, nowSec }) { + const expiry = parseProductKeyExpiry(body, nowSec); + if (!expiry.ok) return expiry; + + const entry = { + key: body.key ?? generateKey(), + sub: body.sub ?? generateSub(), + pid: body.pid ?? 'dnd_player', + maxDevices: body.maxDevices ?? 3, + createdAtSec: nowSec, + ...(expiry.expiresAtSec != null + ? { expiresAtSec: expiry.expiresAtSec } + : { validDays: expiry.validDays }), + }; + + if (!entry.key || !entry.sub) return { ok: false, error: 'invalid_entry' }; + return { ok: true, entry }; +} + +export function deleteProductKey(data, key) { + const idx = data.productKeys?.findIndex((x) => x.key === key) ?? -1; + if (idx < 0) return { ok: false, error: 'unknown_product_key' }; + + const pk = data.productKeys[idx]; + data.productKeys.splice(idx, 1); + + if (Array.isArray(data.revokedKeys)) { + data.revokedKeys = data.revokedKeys.filter((k) => k !== key); + } + + const subStillUsed = data.productKeys.some((x) => x.sub === pk.sub); + if (!subStillUsed) { + if (data.activations && pk.sub in data.activations) { + delete data.activations[pk.sub]; + } + if (Array.isArray(data.revokedSubs)) { + data.revokedSubs = data.revokedSubs.filter((s) => s !== pk.sub); + } + } + + return { ok: true, deleted: pk.key, sub: pk.sub }; +} diff --git a/package.json b/package.json index 743c0c1..77f41be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dndgameplayer-license-server", "private": true, - "version": "1.0.2", + "version": "1.1.0", "type": "module", "description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)", "scripts": { diff --git a/src/server.mjs b/src/server.mjs index 18b1699..30f6f22 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -5,11 +5,19 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { + buildProductKeyEntry, + deleteProductKey, enrichLicenseEntry, ensurePeriodActivated, - parseProductKeyExpiry, 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)); @@ -153,39 +161,91 @@ export function createServer(options = {}) { const revokedSubsList = revokedSubs(data); const revokedKeysList = revokedKeys(data); const activations = data.activations ?? {}; - const licenses = (data.productKeys ?? []).map((pk) => { - const devices = activations[pk.sub] ?? []; - const revoked = revokedKeysList.includes(pk.key) || revokedSubsList.includes(pk.sub); - return enrichLicenseEntry(pk, devices, revoked); - }); + 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/track/download') { + const raw = await readBody(req); + const body = JSON.parse(raw || '{}'); + const data = readData(); + const result = recordDownload(data, body.platform); + if (!result.ok) return json(res, 400, { error: result.error }); + writeData(data); + return json(res, 200, { ok: true, ...result }); + } + + 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 expiry = parseProductKeyExpiry(body); - if (!expiry.ok) return json(res, 400, { error: expiry.error }); + 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 entry = { - key: body.key ?? generateProductKey(), - sub: body.sub ?? generateSub(), - pid: body.pid ?? 'dnd_player', - maxDevices: body.maxDevices ?? 3, - ...(expiry.expiresAtSec != null - ? { expiresAtSec: expiry.expiresAtSec } - : { validDays: expiry.validDays }), - }; - if (!entry.key || !entry.sub) return json(res, 400, { error: 'invalid_entry' }); const data = readData(); data.productKeys ??= []; - if (data.productKeys.some((x) => x.key === entry.key || x.sub === entry.sub)) { - return json(res, 409, { error: 'duplicate_key_or_sub' }); + 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); } - data.productKeys.push(entry); + writeData(data); - return json(res, 201, entry); + 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') { diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs index 036da60..7283954 100644 --- a/test/adminApi.test.mjs +++ b/test/adminApi.test.mjs @@ -209,6 +209,117 @@ void test('POST /v1/admin/revoke by key revokes only that product key', async () } }); +void test('POST /v1/admin/product-keys/delete removes key from data', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.writeFileSync( + dataPath, + JSON.stringify({ + productKeys: [ + { + key: 'TTRPG-DELETE-ME', + sub: 'lic_delete', + pid: 'dnd_player', + maxDevices: 1, + validDays: 30, + createdAtSec: 100, + }, + ], + revokedSubs: [], + revokedKeys: ['TTRPG-DELETE-ME'], + activations: { lic_delete: ['dev1'] }, + }), + ); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const del = await request(port, 'POST', '/v1/admin/product-keys/delete', { + token: adminToken, + body: { key: 'TTRPG-DELETE-ME' }, + }); + assert.equal(del.status, 200); + assert.equal(del.body.ok, true); + + const data = JSON.parse(fs.readFileSync(dataPath, 'utf8')); + assert.equal(data.productKeys.length, 0); + assert.equal(data.activations.lic_delete, undefined); + assert.equal(data.revokedKeys.length, 0); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/admin/product-keys batch creates multiple keys', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.copyFileSync(path.join(root, 'data.example.json'), dataPath); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/admin/product-keys', { + token: adminToken, + body: { count: 3, maxDevices: 2, validDays: 30 }, + }); + assert.equal(res.status, 201); + assert.equal(res.body.count, 3); + assert.equal(res.body.keys.length, 3); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('GET /v1/admin/licenses sorts newest first by createdAtSec', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.writeFileSync( + dataPath, + JSON.stringify({ + productKeys: [ + { key: 'OLD', sub: 'lic_old', pid: 'dnd_player', maxDevices: 1, validDays: 1, createdAtSec: 100 }, + { key: 'NEW', sub: 'lic_new', pid: 'dnd_player', maxDevices: 1, validDays: 1, createdAtSec: 999 }, + ], + revokedSubs: [], + revokedKeys: [], + activations: {}, + }), + ); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const list = await request(port, 'GET', '/v1/admin/licenses', { token: adminToken }); + assert.equal(list.body.licenses[0].key, 'NEW'); + assert.equal(list.body.licenses[1].key, 'OLD'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/track/download and GET stats', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); + const dataPath = path.join(tmp, 'data.json'); + fs.copyFileSync(path.join(root, 'data.example.json'), dataPath); + + const { server, adminToken } = await makeServer(dataPath); + const port = await listen(server); + try { + const track = await request(port, 'POST', '/v1/track/download', { body: { platform: 'windows' } }); + assert.equal(track.status, 200); + + const stats = await request(port, 'GET', '/v1/admin/stats/downloads', { token: adminToken }); + assert.equal(stats.status, 200); + assert.ok(stats.body.currentMonth.windows >= 1); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + void test('admin endpoints reject missing token', async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lic-')); const dataPath = path.join(tmp, 'data.json'); diff --git a/test/downloadStats.test.mjs b/test/downloadStats.test.mjs new file mode 100644 index 0000000..f0a84b8 --- /dev/null +++ b/test/downloadStats.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + getDownloadStatsForMonth, + listDownloadMonths, + monthKeyFromDate, + recordDownload, +} from '../lib/downloadStats.mjs'; + +void test('recordDownload increments platform counts by month', () => { + const data = {}; + const r1 = recordDownload(data, 'windows', new Date('2026-07-15T12:00:00Z')); + assert.equal(r1.ok, true); + assert.equal(r1.month, '2026-07'); + assert.equal(r1.count, 1); + + recordDownload(data, 'macos', new Date('2026-07-20T12:00:00Z')); + recordDownload(data, 'windows', new Date('2026-07-21T12:00:00Z')); + + const stats = getDownloadStatsForMonth(data, '2026-07'); + assert.equal(stats.windows, 2); + assert.equal(stats.macos, 1); + assert.equal(stats.linux, 0); + assert.equal(stats.total, 3); +}); + +void test('recordDownload rejects invalid platform', () => { + const data = {}; + const r = recordDownload(data, 'android'); + assert.equal(r.ok, false); +}); + +void test('listDownloadMonths returns sorted keys', () => { + const data = { + downloadStats: { + byMonth: { + '2026-05': { windows: 1, macos: 0, linux: 0 }, + '2026-07': { windows: 2, macos: 0, linux: 0 }, + }, + }, + }; + assert.deepEqual(listDownloadMonths(data), ['2026-07', '2026-05']); + assert.equal(monthKeyFromDate(new Date('2026-03-01T00:00:00Z')), '2026-03'); +});