6d4b74d410
Track landing downloads per platform/month, support admin delete and bulk key issue, and sort licenses by createdAtSec. Co-authored-by: Cursor <cursoragent@cursor.com>
150 lines
4.5 KiB
JavaScript
150 lines
4.5 KiB
JavaScript
export const DEFAULT_VALID_DAYS = 365;
|
|
export const SECONDS_PER_DAY = 86400;
|
|
|
|
export function isPeriodKey(pk) {
|
|
return pk.validDays != null && pk.validDays > 0;
|
|
}
|
|
|
|
export function isFixedKey(pk) {
|
|
return pk.expiresAtSec != null && Number.isFinite(pk.expiresAtSec) && !isPeriodKey(pk);
|
|
}
|
|
|
|
/** @returns {{ ok: true, expiresAtSec?: number, validDays?: number } | { ok: false, error: string }} */
|
|
export function parseProductKeyExpiry(body, nowSec = Math.floor(Date.now() / 1000)) {
|
|
const hasExpiresAtSec = body.expiresAtSec != null && body.expiresAtSec !== '';
|
|
const hasValidDays = body.validDays != null && body.validDays !== '';
|
|
|
|
if (hasExpiresAtSec && hasValidDays) {
|
|
return { ok: false, error: 'conflicting_expiry' };
|
|
}
|
|
|
|
if (hasExpiresAtSec) {
|
|
const expiresAtSec = Number(body.expiresAtSec);
|
|
if (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0) {
|
|
return { ok: false, error: 'invalid_expiresAtSec' };
|
|
}
|
|
return { ok: true, expiresAtSec };
|
|
}
|
|
|
|
if (hasValidDays) {
|
|
const validDays = Number(body.validDays);
|
|
if (!Number.isInteger(validDays) || validDays < 1) {
|
|
return { ok: false, error: 'invalid_validDays' };
|
|
}
|
|
return { ok: true, validDays };
|
|
}
|
|
|
|
return { ok: true, validDays: DEFAULT_VALID_DAYS };
|
|
}
|
|
|
|
export function getExpiryMode(pk) {
|
|
return isPeriodKey(pk) ? 'period' : 'fixed';
|
|
}
|
|
|
|
export function getEffectiveExpSec(pk) {
|
|
if (isFixedKey(pk)) return pk.expiresAtSec;
|
|
if (isPeriodKey(pk) && pk.licenseExpSec != null) return pk.licenseExpSec;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* On first activation of a period key, sets activatedAtSec and licenseExpSec on pk.
|
|
* @returns {{ changed: boolean, exp: number }}
|
|
*/
|
|
export function ensurePeriodActivated(pk, nowSec) {
|
|
if (isFixedKey(pk)) {
|
|
return { changed: false, exp: pk.expiresAtSec };
|
|
}
|
|
|
|
if (!isPeriodKey(pk)) {
|
|
throw new Error('invalid_product_key_expiry');
|
|
}
|
|
|
|
if (pk.licenseExpSec != null && pk.activatedAtSec != null) {
|
|
return { changed: false, exp: pk.licenseExpSec };
|
|
}
|
|
|
|
pk.activatedAtSec = nowSec;
|
|
pk.licenseExpSec = nowSec + pk.validDays * SECONDS_PER_DAY;
|
|
return { changed: true, exp: pk.licenseExpSec };
|
|
}
|
|
|
|
export function resolveTokenExp(pk, nowSec) {
|
|
if (isFixedKey(pk)) return pk.expiresAtSec;
|
|
if (isPeriodKey(pk)) {
|
|
if (pk.licenseExpSec != null) return pk.licenseExpSec;
|
|
return nowSec + pk.validDays * SECONDS_PER_DAY;
|
|
}
|
|
throw new Error('invalid_product_key_expiry');
|
|
}
|
|
|
|
export function enrichLicenseEntry(pk, devices, revoked) {
|
|
return {
|
|
key: pk.key,
|
|
sub: pk.sub,
|
|
pid: pk.pid,
|
|
maxDevices: pk.maxDevices,
|
|
createdAtSec: pk.createdAtSec ?? null,
|
|
expiryMode: getExpiryMode(pk),
|
|
expiresAtSec: pk.expiresAtSec ?? null,
|
|
validDays: pk.validDays ?? null,
|
|
activatedAtSec: pk.activatedAtSec ?? null,
|
|
licenseExpSec: pk.licenseExpSec ?? null,
|
|
effectiveExpSec: getEffectiveExpSec(pk),
|
|
revoked,
|
|
activatedDevices: devices,
|
|
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 };
|
|
}
|