e5514dd5da
- payments.js: einheitliche createPayment/Webhook-Schnittstelle (Mollie Default, Stripe, Demo); Auto-Provider-Wahl; Mollie-REST + /api/payments/webhook (idempotent); Fake-Key => sauberer Demo-Fallback - mailer.js: sendMail via Listmonk-Tx / SMTP (nodemailer) / Log-Fallback (email_log); gebrandete Bestellbestaetigung bei paid - DACH: products.mwst + base_amount/base_unit/base_price_per (Grundpreis); Storefront/Warenkorb/Checkout/Erfolg/Admin mit MwSt-Ausweis + Versand-Transparenz; tax_cents/shipping_cents/country an Orders - shipping_zones-Tabelle + CRUD + shippingFor(); Admin 'Versand'; serverseitige Versandberechnung in /api/checkout + /api/shipping-quote (Laenderwahl live) - Feature-Flags (feature_*) + feature()-Helper; Admin Module-Toggles; Newsletter-Gating (Popup/Subscribe) - Admin-API/Manifest/ai-admin.txt um shipping_zones erweitert; MCP list/upsert/delete_shipping; README/.env.example ergaenzt; Version 2.2.0
112 lines
4.3 KiB
JavaScript
112 lines
4.3 KiB
JavaScript
// hd-commerce — Session-Auth (stateless signiertes Cookie), Rollen-Gate, Rate-Limit.
|
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
import { getUserById } from './store.js';
|
|
|
|
const SECRET = process.env.SESSION_SECRET || 'hd-commerce-dev-secret-change-me';
|
|
export const COOKIE_NAME = 'hdc_session';
|
|
|
|
// --- konfigurierbarer Admin-Pfad ---
|
|
function rawAdminPath() {
|
|
let p = (process.env.ADMIN_PATH || 'admin').trim().replace(/^\/+|\/+$/g, '');
|
|
if (!p) p = 'admin';
|
|
return p;
|
|
}
|
|
export const adminBase = () => '/' + rawAdminPath(); // z.B. "/login" oder "/admin"
|
|
export const adminPathSegment = () => rawAdminPath(); // "login"
|
|
export const isCustomAdminPath = () => rawAdminPath() !== 'admin';
|
|
// Hilfsfunktion für Links in Astro-Seiten:
|
|
export const ab = (suffix = '') => adminBase() + (suffix || '');
|
|
|
|
// --- Cookie-Signatur ---
|
|
function b64url(buf) { return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
function b64urlDecode(str) { return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
|
|
|
|
export function signSession(uid, maxAgeSeconds) {
|
|
const exp = Math.floor(Date.now() / 1000) + (maxAgeSeconds || 60 * 60 * 12);
|
|
const payload = b64url(JSON.stringify({ uid: Number(uid), exp }));
|
|
const sig = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
|
return payload + '.' + sig;
|
|
}
|
|
|
|
export function verifySession(token) {
|
|
if (!token || typeof token !== 'string' || !token.includes('.')) return null;
|
|
const [payload, sig] = token.split('.');
|
|
if (!payload || !sig) return null;
|
|
const expected = b64url(createHmac('sha256', SECRET).update(payload).digest());
|
|
try {
|
|
const a = Buffer.from(sig), b = Buffer.from(expected);
|
|
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
} catch { return null; }
|
|
let data;
|
|
try { data = JSON.parse(b64urlDecode(payload).toString('utf8')); } catch { return null; }
|
|
if (!data || !data.uid || !data.exp) return null;
|
|
if (data.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return data;
|
|
}
|
|
|
|
export function buildCookie(token, remember) {
|
|
const parts = [`${COOKIE_NAME}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
if (remember) parts.push('Max-Age=' + (60 * 60 * 24 * 30));
|
|
return parts.join('; ');
|
|
}
|
|
export function clearCookie() {
|
|
return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
}
|
|
|
|
export function parseCookies(request) {
|
|
const h = request.headers.get('cookie') || '';
|
|
const out = {};
|
|
h.split(';').forEach(p => {
|
|
const i = p.indexOf('=');
|
|
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
|
});
|
|
return out;
|
|
}
|
|
|
|
export function currentUser(request) {
|
|
const token = parseCookies(request)[COOKIE_NAME];
|
|
const sess = verifySession(token);
|
|
if (!sess) return null;
|
|
const u = getUserById(sess.uid);
|
|
if (!u || !u.active) return null;
|
|
return u;
|
|
}
|
|
|
|
// --- Rollen-Gate ---
|
|
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
|
|
const ROLE_SECTIONS = {
|
|
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'einstellungen', 'nutzer', 'audit'],
|
|
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen'],
|
|
versand: ['bestellungen'],
|
|
};
|
|
export function canAccess(role, section) {
|
|
const allowed = ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
|
return allowed.includes(section);
|
|
}
|
|
export function allowedSections(role) {
|
|
return ROLE_SECTIONS[role] || ROLE_SECTIONS.redaktion;
|
|
}
|
|
export function landingFor(role) {
|
|
if (role === 'versand') return adminBase() + '/bestellungen';
|
|
return adminBase();
|
|
}
|
|
|
|
// --- Login-Rate-Limit (In-Memory) ---
|
|
const attempts = new Map(); // ip -> { count, until }
|
|
export function rateLimited(ip) {
|
|
const r = attempts.get(ip);
|
|
if (r && r.until && Date.now() < r.until) return true;
|
|
return false;
|
|
}
|
|
export function registerFail(ip) {
|
|
const r = attempts.get(ip) || { count: 0, until: 0 };
|
|
r.count += 1;
|
|
if (r.count >= 5) { r.until = Date.now() + 60 * 1000; r.count = 0; }
|
|
attempts.set(ip, r);
|
|
}
|
|
export function clearFails(ip) { attempts.delete(ip); }
|
|
|
|
export function clientIp(request) {
|
|
return (request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'local').split(',')[0].trim();
|
|
}
|