From ab86790febb44ec33c7efa73de0e594ac1b5f37a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Tue, 7 Jul 2026 20:01:01 +0800 Subject: [PATCH] fix(analytics): accept beacon download tracking payloads (v1.1.1) Co-authored-by: Cursor --- package.json | 2 +- src/server.mjs | 14 +++++++++++- test/adminApi.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 77f41be..6fc8ff2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dndgameplayer-license-server", "private": true, - "version": "1.1.0", + "version": "1.1.1", "type": "module", "description": "Сервис выдачи и отзыва лицензий DNDGamePlayer (Ed25519)", "scripts": { diff --git a/src/server.mjs b/src/server.mjs index 078b8cf..8e3cf29 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -36,10 +36,22 @@ function writeData(data) { 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); @@ -142,7 +154,7 @@ export function createServer(options = {}) { if (req.method === 'POST') { const raw = await readBody(req); - const body = JSON.parse(raw || '{}'); + const body = parseTrackDownloadBody(raw); const data = readData(); const result = recordDownload(data, body.platform); if (!result.ok) return json(res, 400, { error: result.error }, corsHeaders); diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs index 2261ea1..44cb34d 100644 --- a/test/adminApi.test.mjs +++ b/test/adminApi.test.mjs @@ -19,10 +19,10 @@ function listen(server) { }); } -function request(port, method, pathname, { token, body, origin } = {}) { +function request(port, method, pathname, { token, body, origin, headers: extraHeaders, rawBody } = {}) { return new Promise((resolve, reject) => { - const headers = {}; - if (body) headers['Content-Type'] = 'application/json'; + const headers = { ...extraHeaders }; + if (body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; if (token) headers.Authorization = `Bearer ${token}`; if (origin) headers.Origin = origin; const req = http.request( @@ -41,7 +41,8 @@ function request(port, method, pathname, { token, body, origin } = {}) { }, ); req.on('error', reject); - if (body) req.write(JSON.stringify(body)); + if (rawBody !== undefined) req.write(rawBody); + else if (body) req.write(JSON.stringify(body)); req.end(); }); } @@ -386,6 +387,46 @@ void test('OPTIONS /v1/track/download rejects unknown origin', async () => { } }); +void test('POST /v1/track/download accepts text/plain beacon body', 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 } = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'POST', '/v1/track/download', { + rawBody: JSON.stringify({ platform: 'linux' }), + origin: 'https://ttrpgplayer.ru', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + }); + assert.equal(res.status, 200); + assert.equal(res.body.platform, 'linux'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('OPTIONS /v1/track/download allows localhost:5174', 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 } = await makeServer(dataPath); + const port = await listen(server); + try { + const res = await request(port, 'OPTIONS', '/v1/track/download', { + origin: 'http://localhost:5174', + }); + assert.equal(res.status, 204); + assert.equal(res.headers['access-control-allow-origin'], 'http://localhost:5174'); + } 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');