Initial: Cloudflare Workers + D1 licensing server
This commit is contained in:
396
src/index.js
Normal file
396
src/index.js
Normal file
@@ -0,0 +1,396 @@
|
||||
// cowl-licensing - Cloudflare Workers + D1 licensing server
|
||||
// Replaces PHP 7 + MySQL backend for ComponentOwl products
|
||||
|
||||
const XML_HEADER = '<?xml version="1.0" encoding="utf-8"?>';
|
||||
|
||||
function xmlResponse(content) {
|
||||
return new Response(`${XML_HEADER}<response>${content}</response>`, {
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function xmlError(message) {
|
||||
return xmlResponse(`<error>${escXml(message)}</error>`);
|
||||
}
|
||||
|
||||
function escXml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function parseField(xml, field) {
|
||||
const re = new RegExp(`<${field}>([\\s\\S]*?)<\\/${field}>`);
|
||||
const m = xml.match(re);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
async function getBody(request) {
|
||||
try { return await request.text(); } catch { return ''; }
|
||||
}
|
||||
|
||||
// Generate a BMT Micro-style 16-char uppercase alphanumeric serial
|
||||
function generateSerial() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes, b => chars[b % chars.length]).join('');
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleActivate(request, env) {
|
||||
const body = await getBody(request);
|
||||
|
||||
const serial_number = parseField(body, 'serial_number');
|
||||
const product = parseField(body, 'product');
|
||||
const version = parseField(body, 'version');
|
||||
const reg_key = parseField(body, 'registration_key');
|
||||
const name = parseField(body, 'name') ?? '';
|
||||
const email = parseField(body, 'email') ?? '';
|
||||
|
||||
if (!serial_number || !product || !reg_key) {
|
||||
return xmlError('Missing required fields');
|
||||
}
|
||||
|
||||
// Validate product exists
|
||||
const prod = await env.DB.prepare(
|
||||
'SELECT * FROM products WHERE product = ? LIMIT 1'
|
||||
).bind(product).first();
|
||||
if (!prod) return xmlError('Product not found');
|
||||
|
||||
let edition, limit_version, limit_activation, limit_subscription, limit_support;
|
||||
|
||||
// Special serials
|
||||
if (serial_number === 'FREE') {
|
||||
edition = 1; limit_version = 0; limit_activation = 0;
|
||||
limit_subscription = 86400; limit_support = 86400;
|
||||
} else if (serial_number === 'TRIAL') {
|
||||
edition = 2; limit_version = 0; limit_activation = 0;
|
||||
limit_subscription = 86400; limit_support = 86400;
|
||||
} else {
|
||||
const sn = await env.DB.prepare(
|
||||
'SELECT * FROM serial_numbers WHERE serial_number = ? AND product = ?'
|
||||
).bind(serial_number, product).first();
|
||||
if (!sn) return xmlError('Serial number not found');
|
||||
|
||||
edition = sn.edition;
|
||||
limit_version = sn.limit_version;
|
||||
limit_activation = sn.limit_activation;
|
||||
limit_subscription = sn.limit_subscription;
|
||||
limit_support = sn.limit_support;
|
||||
|
||||
// Check activation limit (0 = unlimited)
|
||||
if (limit_activation > 0) {
|
||||
const { count } = await env.DB.prepare(
|
||||
'SELECT COUNT(*) as count FROM licenses WHERE serial_number = ? AND product = ?'
|
||||
).bind(serial_number, product).first();
|
||||
// Allow re-activation of same reg_key
|
||||
const existing = await env.DB.prepare(
|
||||
'SELECT 1 FROM licenses WHERE registration_key = ? AND serial_number = ? AND product = ?'
|
||||
).bind(reg_key, serial_number, product).first();
|
||||
if (!existing && count >= limit_activation) {
|
||||
return xmlError('Activation limit reached');
|
||||
}
|
||||
}
|
||||
|
||||
// Check version limit (0 = unlimited)
|
||||
if (limit_version > 0 && version) {
|
||||
const reqVer = parseInt(version.split('.')[0], 10) || 0;
|
||||
if (reqVer > limit_version) return xmlError('Version not licensed');
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp_sub = now + limit_subscription;
|
||||
const exp_supp = now + limit_support;
|
||||
const ver_int = version ? (parseInt(version.split('.')[0], 10) || 0) : 0;
|
||||
|
||||
// Upsert license
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO licenses
|
||||
(registration_key, serial_number, product, edition, version, timestamp,
|
||||
name, email, notify, expiration_subscription, expiration_support)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(registration_key, serial_number, product) DO UPDATE SET
|
||||
edition = excluded.edition,
|
||||
version = excluded.version,
|
||||
timestamp = excluded.timestamp,
|
||||
name = excluded.name,
|
||||
email = excluded.email,
|
||||
expiration_subscription = excluded.expiration_subscription,
|
||||
expiration_support = excluded.expiration_support
|
||||
`).bind(reg_key, serial_number, product, edition, ver_int, now,
|
||||
name, email, exp_sub, exp_supp).run();
|
||||
|
||||
return xmlResponse(
|
||||
`<serial_number>${escXml(serial_number)}</serial_number>` +
|
||||
`<product>${escXml(product)}</product>` +
|
||||
`<edition>${edition}</edition>` +
|
||||
`<version>${escXml(version ?? '')}</version>` +
|
||||
`<registration_key>${escXml(reg_key)}</registration_key>` +
|
||||
`<timestamp>${now}</timestamp>` +
|
||||
`<name>${escXml(name)}</name>` +
|
||||
`<email>${escXml(email)}</email>`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCheckUpdate(request, env) {
|
||||
let product = null;
|
||||
if (request.method === 'POST') {
|
||||
const body = await getBody(request);
|
||||
product = parseField(body, 'product');
|
||||
} else {
|
||||
product = new URL(request.url).searchParams.get('product');
|
||||
}
|
||||
if (!product) return xmlError('Missing product');
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT version, download_url FROM products WHERE product = ? LIMIT 1'
|
||||
).bind(product).first();
|
||||
if (!row) return xmlError('Product not found');
|
||||
|
||||
return xmlResponse(
|
||||
`<version>${escXml(row.version)}</version>` +
|
||||
`<download_url>${escXml(row.download_url)}</download_url>`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetLicenses(request, env) {
|
||||
const body = await getBody(request);
|
||||
const product = parseField(body, 'product');
|
||||
if (!product) return xmlError('Missing product');
|
||||
|
||||
const { results } = await env.DB.prepare(
|
||||
'SELECT * FROM licenses WHERE product = ? ORDER BY timestamp DESC'
|
||||
).bind(product).all();
|
||||
|
||||
const items = results.map(r =>
|
||||
`<license>` +
|
||||
`<registration_key>${escXml(r.registration_key)}</registration_key>` +
|
||||
`<serial_number>${escXml(r.serial_number)}</serial_number>` +
|
||||
`<product>${escXml(r.product)}</product>` +
|
||||
`<edition>${r.edition ?? ''}</edition>` +
|
||||
`<version>${r.version ?? ''}</version>` +
|
||||
`<timestamp>${r.timestamp}</timestamp>` +
|
||||
`<name>${escXml(r.name)}</name>` +
|
||||
`<email>${escXml(r.email)}</email>` +
|
||||
`<notify>${r.notify}</notify>` +
|
||||
`<expiration_subscription>${r.expiration_subscription}</expiration_subscription>` +
|
||||
`<expiration_support>${r.expiration_support}</expiration_support>` +
|
||||
`</license>`
|
||||
).join('');
|
||||
|
||||
return xmlResponse(items);
|
||||
}
|
||||
|
||||
async function handleGetProducts(request, env) {
|
||||
const { results: prods } = await env.DB.prepare(
|
||||
'SELECT * FROM products ORDER BY product, bmt_id'
|
||||
).all();
|
||||
const { results: eds } = await env.DB.prepare(
|
||||
'SELECT * FROM editions ORDER BY product, edition'
|
||||
).all();
|
||||
|
||||
// Group editions by product
|
||||
const edMap = {};
|
||||
for (const e of eds) {
|
||||
if (!edMap[e.product]) edMap[e.product] = [];
|
||||
edMap[e.product].push(e);
|
||||
}
|
||||
|
||||
const items = prods.map(p => {
|
||||
const productEds = (edMap[p.product] || []).map(e =>
|
||||
`<edition><id>${e.edition}</id><name>${escXml(e.name)}</name></edition>`
|
||||
).join('');
|
||||
return `<product>` +
|
||||
`<bmt_id>${p.bmt_id}</bmt_id>` +
|
||||
`<product>${escXml(p.product)}</product>` +
|
||||
`<product_name>${escXml(p.product_name)}</product_name>` +
|
||||
`<display_name>${escXml(p.display_name)}</display_name>` +
|
||||
`<short_name>${escXml(p.short_name)}</short_name>` +
|
||||
`<edition>${p.edition}</edition>` +
|
||||
`<version>${escXml(p.version)}</version>` +
|
||||
`<limit_version>${p.limit_version}</limit_version>` +
|
||||
`<limit_activation>${p.limit_activation}</limit_activation>` +
|
||||
`<limit_subscription>${p.limit_subscription}</limit_subscription>` +
|
||||
`<limit_support>${p.limit_support}</limit_support>` +
|
||||
`<download_url>${escXml(p.download_url)}</download_url>` +
|
||||
`<editions>${productEds}</editions>` +
|
||||
`</product>`;
|
||||
}).join('');
|
||||
|
||||
return xmlResponse(items);
|
||||
}
|
||||
|
||||
async function handleGetSerialNumbers(request, env) {
|
||||
const body = await getBody(request);
|
||||
const product = parseField(body, 'product');
|
||||
if (!product) return xmlError('Missing product');
|
||||
|
||||
const { results } = await env.DB.prepare(
|
||||
'SELECT * FROM serial_numbers WHERE product = ? ORDER BY serial_number'
|
||||
).bind(product).all();
|
||||
|
||||
const items = results.map(r =>
|
||||
`<serial_number>` +
|
||||
`<serial_number>${escXml(r.serial_number)}</serial_number>` +
|
||||
`<product>${escXml(r.product)}</product>` +
|
||||
`<edition>${r.edition}</edition>` +
|
||||
`<limit_version>${r.limit_version}</limit_version>` +
|
||||
`<limit_activation>${r.limit_activation}</limit_activation>` +
|
||||
`<limit_subscription>${r.limit_subscription}</limit_subscription>` +
|
||||
`<limit_support>${r.limit_support}</limit_support>` +
|
||||
`<note>${escXml(r.note ?? '')}</note>` +
|
||||
`</serial_number>`
|
||||
).join('');
|
||||
|
||||
return xmlResponse(items);
|
||||
}
|
||||
|
||||
async function handleKeygen(request, env) {
|
||||
const body = await getBody(request);
|
||||
|
||||
const keycount = parseInt(parseField(body, 'keycount') ?? '1', 10);
|
||||
const productid = parseField(body, 'productid');
|
||||
const note = parseField(body, 'note') ?? '';
|
||||
|
||||
// Serial config — can be overridden per-request or derived from product
|
||||
let edition = parseInt(parseField(body, 'edition') ?? '3', 10);
|
||||
let limit_version = parseInt(parseField(body, 'limit_version') ?? '0', 10);
|
||||
let limit_activation = parseInt(parseField(body, 'limit_activation') ?? '3', 10);
|
||||
let limit_subscription = parseInt(parseField(body, 'limit_subscription') ?? '86400', 10);
|
||||
let limit_support = parseInt(parseField(body, 'limit_support') ?? '86400', 10);
|
||||
|
||||
// If productid given, look up defaults from products table
|
||||
if (productid) {
|
||||
const prod = await env.DB.prepare(
|
||||
'SELECT * FROM products WHERE product = ? LIMIT 1'
|
||||
).bind(productid).first();
|
||||
if (prod) {
|
||||
edition = prod.edition;
|
||||
limit_version = prod.limit_version;
|
||||
limit_activation = prod.limit_activation;
|
||||
limit_subscription = prod.limit_subscription;
|
||||
limit_support = prod.limit_support;
|
||||
}
|
||||
}
|
||||
|
||||
const product = productid ?? parseField(body, 'product') ?? '';
|
||||
const count = Math.min(Math.max(keycount, 1), 100); // cap at 100
|
||||
|
||||
const generated = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let serial;
|
||||
// Retry on collision (extremely unlikely)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
serial = generateSerial();
|
||||
const existing = await env.DB.prepare(
|
||||
'SELECT 1 FROM serial_numbers WHERE serial_number = ?'
|
||||
).bind(serial).first();
|
||||
if (!existing) break;
|
||||
}
|
||||
if (product) {
|
||||
await env.DB.prepare(`
|
||||
INSERT OR IGNORE INTO serial_numbers
|
||||
(serial_number, product, edition, limit_version, limit_activation,
|
||||
limit_subscription, limit_support, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).bind(serial, product, edition, limit_version, limit_activation,
|
||||
limit_subscription, limit_support, note || null).run();
|
||||
}
|
||||
generated.push(serial);
|
||||
}
|
||||
|
||||
const items = generated.map(s =>
|
||||
`<keydata><serial_number>${escXml(s)}</serial_number></keydata>`
|
||||
).join('');
|
||||
|
||||
return xmlResponse(items);
|
||||
}
|
||||
|
||||
async function handleRenewLicense(request, env) {
|
||||
const body = await getBody(request);
|
||||
|
||||
const serial_number = parseField(body, 'serial_number');
|
||||
const expiration_subscription = parseField(body, 'expiration_subscription');
|
||||
const expiration_support = parseField(body, 'expiration_support');
|
||||
|
||||
if (!serial_number) return xmlError('Missing serial_number');
|
||||
|
||||
const exp_sub = expiration_subscription != null ? parseInt(expiration_subscription, 10) : null;
|
||||
const exp_supp = expiration_support != null ? parseInt(expiration_support, 10) : null;
|
||||
|
||||
if (exp_sub != null && exp_supp != null) {
|
||||
await env.DB.prepare(`
|
||||
UPDATE licenses SET
|
||||
expiration_subscription = ?,
|
||||
expiration_support = ?
|
||||
WHERE serial_number = ?
|
||||
`).bind(exp_sub, exp_supp, serial_number).run();
|
||||
} else if (exp_sub != null) {
|
||||
await env.DB.prepare(
|
||||
'UPDATE licenses SET expiration_subscription = ? WHERE serial_number = ?'
|
||||
).bind(exp_sub, serial_number).run();
|
||||
} else if (exp_supp != null) {
|
||||
await env.DB.prepare(
|
||||
'UPDATE licenses SET expiration_support = ? WHERE serial_number = ?'
|
||||
).bind(exp_supp, serial_number).run();
|
||||
}
|
||||
|
||||
return xmlResponse(
|
||||
`<serial_number>${escXml(serial_number)}</serial_number>` +
|
||||
`<expiration_subscription>${exp_sub ?? ''}</expiration_subscription>` +
|
||||
`<expiration_support>${exp_supp ?? ''}</expiration_support>`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUpdateProduct(request, env) {
|
||||
const body = await getBody(request);
|
||||
|
||||
const product = parseField(body, 'product');
|
||||
const version = parseField(body, 'version');
|
||||
|
||||
if (!product || !version) return xmlError('Missing product or version');
|
||||
|
||||
await env.DB.prepare(
|
||||
'UPDATE products SET version = ? WHERE product = ?'
|
||||
).bind(version, product).run();
|
||||
|
||||
return xmlResponse(
|
||||
`<product>${escXml(product)}</product>` +
|
||||
`<version>${escXml(version)}</version>`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ROUTES = {
|
||||
'/v1/activate.php': handleActivate,
|
||||
'/v1/check_update.php': handleCheckUpdate,
|
||||
'/v1/get_licenses.php': handleGetLicenses,
|
||||
'/v1/get_products.php': handleGetProducts,
|
||||
'/v1/get_serial_numbers.php': handleGetSerialNumbers,
|
||||
'/v1/keygen.php': handleKeygen,
|
||||
'/v1/renew_license.php': handleRenewLicense,
|
||||
'/v1/update_product.php': handleUpdateProduct,
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const path = new URL(request.url).pathname;
|
||||
const handler = ROUTES[path];
|
||||
if (!handler) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
try {
|
||||
return await handler(request, env);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return xmlError('Internal server error');
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user