From 7538458677c39a186a7cb18fa64ad3e9f70cfad3 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Mon, 6 Jul 2026 15:08:21 +0800 Subject: [PATCH] Add CORS support for landing download tracking endpoint. Browsers on ttrpgplayer.ru need preflight and Access-Control-Allow-Origin on POST /v1/track/download. Co-authored-by: Cursor --- src/server.mjs | 70 +++++++++++++++++++++++++++++++++------- test/adminApi.test.mjs | 72 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src/server.mjs b/src/server.mjs index 30f6f22..078b8cf 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -34,15 +34,49 @@ function writeData(data) { fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); } -function json(res, code, obj) { +const DEFAULT_TRACK_DOWNLOAD_CORS_ORIGINS = [ + 'https://ttrpgplayer.ru', + 'http://localhost:5173', + 'http://127.0.0.1:5173', +]; + +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 = []; @@ -87,11 +121,35 @@ function isSubRevoked(data, 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 = JSON.parse(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' }); @@ -185,16 +243,6 @@ export function createServer(options = {}) { }); } - 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); diff --git a/test/adminApi.test.mjs b/test/adminApi.test.mjs index 7283954..2261ea1 100644 --- a/test/adminApi.test.mjs +++ b/test/adminApi.test.mjs @@ -19,10 +19,12 @@ function listen(server) { }); } -function request(port, method, pathname, { token, body } = {}) { +function request(port, method, pathname, { token, body, origin } = {}) { return new Promise((resolve, reject) => { - const headers = { 'Content-Type': 'application/json' }; + const headers = {}; + if (body) headers['Content-Type'] = 'application/json'; if (token) headers.Authorization = `Bearer ${token}`; + if (origin) headers.Origin = origin; const req = http.request( { hostname: '127.0.0.1', port, method, path: pathname, headers }, (res) => { @@ -30,7 +32,11 @@ function request(port, method, pathname, { token, body } = {}) { res.on('data', (c) => chunks.push(c)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); - resolve({ status: res.statusCode, body: raw ? JSON.parse(raw) : null }); + resolve({ + status: res.statusCode, + headers: res.headers, + body: raw ? JSON.parse(raw) : null, + }); }); }, ); @@ -320,6 +326,66 @@ void test('POST /v1/track/download and GET stats', async () => { } }); +void test('OPTIONS /v1/track/download returns CORS preflight for allowed origin', 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: 'https://ttrpgplayer.ru', + }); + assert.equal(res.status, 204); + assert.equal(res.headers['access-control-allow-origin'], 'https://ttrpgplayer.ru'); + assert.match(res.headers['access-control-allow-methods'], /POST/); + assert.equal(res.headers['access-control-allow-headers'], 'Content-Type'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('POST /v1/track/download includes CORS headers for allowed origin', 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', { + body: { platform: 'linux' }, + origin: 'https://ttrpgplayer.ru', + }); + assert.equal(res.status, 200); + assert.equal(res.headers['access-control-allow-origin'], 'https://ttrpgplayer.ru'); + } finally { + server.close(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +void test('OPTIONS /v1/track/download rejects unknown origin', 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: 'https://evil.example', + }); + assert.equal(res.status, 404); + assert.equal(res.body.error, 'not_found'); + } 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');