- Eltern-Ordner ist jetzt EIN Git-Repo (statt getrennter Repos). - root .gitignore haelt Secrets (.env), node_modules, DB und Build-Artefakte raus. - release.ps1: manueller Release (ZIP bauen + ans Backend laden). - root README mit Struktur und Release-Ablauf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
2.9 KiB
JavaScript
93 lines
2.9 KiB
JavaScript
import { randomInt, timingSafeEqual, createHmac } from 'node:crypto';
|
|
|
|
// Unambiguous alphabet (no 0/O/1/I/L) for human-readable, dictation-safe keys.
|
|
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
|
|
/** Generate a license key like ABCD-EFGH-JKMN-PQRS. */
|
|
export function generateKey(groups = 4, groupLen = 4) {
|
|
const parts = [];
|
|
for (let g = 0; g < groups; g++) {
|
|
let s = '';
|
|
for (let i = 0; i < groupLen; i++) {
|
|
s += ALPHABET[randomInt(0, ALPHABET.length)];
|
|
}
|
|
parts.push(s);
|
|
}
|
|
return parts.join('-');
|
|
}
|
|
|
|
/**
|
|
* Normalize a domain for stable comparison:
|
|
* strip scheme, path, port, leading www, lowercase.
|
|
*/
|
|
export function normalizeDomain(input) {
|
|
if (!input || typeof input !== 'string') return '';
|
|
let d = input.trim().toLowerCase();
|
|
d = d.replace(/^[a-z]+:\/\//, ''); // scheme
|
|
d = d.replace(/\/.*$/, ''); // path
|
|
d = d.replace(/:.*$/, ''); // port
|
|
d = d.replace(/^www\./, ''); // www
|
|
return d;
|
|
}
|
|
|
|
/** Timing-safe string compare for tokens. */
|
|
export function safeEqual(a, b) {
|
|
const ba = Buffer.from(String(a || ''));
|
|
const bb = Buffer.from(String(b || ''));
|
|
if (ba.length !== bb.length) return false;
|
|
return timingSafeEqual(ba, bb);
|
|
}
|
|
|
|
export function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
/** True if an ISO date string is in the past. */
|
|
export function isExpired(expiresAt) {
|
|
if (!expiresAt) return false;
|
|
const t = Date.parse(expiresAt);
|
|
if (Number.isNaN(t)) return false;
|
|
return t < Date.now();
|
|
}
|
|
|
|
/** Compare dotted version strings. Returns 1 if a>b, -1 if a<b, 0 if equal. */
|
|
export function compareVersions(a, b) {
|
|
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
const len = Math.max(pa.length, pb.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const x = pa[i] || 0;
|
|
const y = pb[i] || 0;
|
|
if (x > y) return 1;
|
|
if (x < y) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** Sign a small JSON payload (download tokens). Returns "body.sig" (base64url). */
|
|
export function signToken(payload, secret) {
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
|
return body + '.' + sig;
|
|
}
|
|
|
|
/** Verify a token from signToken. Returns the payload, or null if bad/expired. */
|
|
export function verifyToken(token, secret) {
|
|
if (typeof token !== 'string' || !token.includes('.')) return null;
|
|
const [body, sig] = token.split('.');
|
|
if (!body || !sig) return null;
|
|
|
|
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
|
const a = Buffer.from(sig);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
if (payload.exp && Date.now() > payload.exp) return null;
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|