v2: Session-Login & Rollen, Premium-Admin, Visual-Block-Builder, KI-/MCP-API

- Auth-Umbau: Session-Login (signiertes HMAC-Cookie, scrypt-Hashing) statt Basic-Auth;
  users-/audit-Tabellen, Initial-Owner aus ENV, Rate-Limit, konfigurierbarer ADMIN_PATH
  (Middleware-Rewrite), Rollen-Gate (owner/redaktion/versand), Nutzerverwaltung, Audit-Log,
  Login/Logout/Konto-Seiten.
- Premium-Pass: Command-Palette (Cmd-K), Toasts, Account-Menue, aufgewertetes Dashboard
  (KPI-Trend+Sparkline, Aktivitaets-Feed, Schnellaktionen), schoene Empty-States.
- Block-Builder: pages.blocks, Vollbild-Editor (Liste/Live-Vorschau/Settings, Desktop/Mobil),
  10 Block-Typen, Storefront-BlockRenderer auf /seite/[slug], Save-Endpoint.
- KI-Editierbarkeit: token-gesicherte /api/admin/* (CRUD), Manifest /api/admin + /ai-admin.txt,
  MCP-Server unter mcp/ (14 Tools).
- Docs: README + .env.example + mcp/README aktualisiert.
This commit is contained in:
2026-06-17 12:46:31 +00:00
parent 3c48b69880
commit aec179db36
41 changed files with 9525 additions and 143 deletions
+121
View File
@@ -0,0 +1,121 @@
// hd-commerce — Token-gesicherte Admin-JSON-API (für KI/MCP).
// Bearer-Token aus ENV HDC_API_TOKEN (getrennt von der Session-Auth).
import * as store from './store.js';
import { BLOCK_TYPES } from './blocks.js';
export function json(obj, status = 200) {
return new Response(JSON.stringify(obj, null, 2), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}
export function authOk(request) {
const token = (process.env.HDC_API_TOKEN || '').trim();
if (!token) return false; // ohne konfiguriertes Token bleibt die API gesperrt
const hdr = request.headers.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
return !!m && m[1].trim() === token;
}
// ---- Ressourcen-Definitionen für das Manifest ----
export const RESOURCES = {
products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] },
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
popups: { rw: true, fields: ['title', 'type', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort'] },
settings: { rw: true, fields: ['key/value-Map (shop_name, brand_accent, currency, free_shipping_cents, …)'] },
orders: { rw: false, fields: ['number', 'email', 'customer_name', 'status', 'total_cents', 'items[]', 'address', 'created_at'] },
customers: { rw: false, fields: ['name', 'email', 'city', 'orders_count', 'total_spent_cents', 'created_at'] },
};
export function listResource(name) {
switch (name) {
case 'products': return store.listProducts();
case 'pages': return store.listPages();
case 'slides': return store.listSlides();
case 'popups': return store.listPopups();
case 'orders': return store.listOrders();
case 'customers': return store.listCustomers();
case 'settings': return store.getSettings();
default: return null;
}
}
export function getResource(name, id) {
switch (name) {
case 'products': return store.getProductById(id);
case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
case 'slides': return store.getSlideById(id);
case 'popups': return store.getPopupById(id);
case 'orders': return store.getOrderById(id);
case 'customers': return store.getCustomerById(id);
default: return null;
}
}
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
export function upsertResource(name, body) {
if (name === 'products') {
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
const id = store.createProduct(body); return store.getProductById(id);
}
if (name === 'pages') {
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
if (body.slug) { const ex = store.getPageBySlug(body.slug); if (ex) { store.updatePage(ex.id, { ...ex, ...body }); return store.getPageById(ex.id); } }
const id = store.createPage(body); return store.getPageById(id);
}
if (name === 'slides') {
if (body.id) { store.updateSlide(body.id, body); return store.getSlideById(body.id); }
const id = store.createSlide(body); return store.getSlideById(id);
}
if (name === 'popups') {
if (body.id) { store.updatePopup(body.id, body); return store.getPopupById(body.id); }
const id = store.createPopup(body); return store.getPopupById(id);
}
if (name === 'settings') {
const entries = body && typeof body === 'object' ? Object.entries(body) : [];
for (const [k, v] of entries) store.setSetting(k, v);
return store.getSettings();
}
throw new Error('Ressource nicht schreibbar: ' + name);
}
export function deleteResource(name, id) {
switch (name) {
case 'products': store.deleteProduct(id); return true;
case 'pages': store.deletePage(id); return true;
case 'slides': store.deleteSlide(id); return true;
case 'popups': store.deletePopup(id); return true;
default: throw new Error('Ressource nicht löschbar: ' + name);
}
}
export function updatePageBlocks(id, blocks) { store.updatePageBlocks(id, blocks); return store.getPageById(id); }
export function recordAudit(o) { store.recordAudit(o); }
export function blockTypes() { return BLOCK_TYPES.map(b => ({ key: b.key, label: b.label, fields: b.fields.map(f => ({ name: f.name, type: f.type })) })); }
export function manifest(origin) {
const ep = [];
ep.push({ method: 'GET', path: '/api/admin', desc: 'Dieses Manifest' });
for (const [name, def] of Object.entries(RESOURCES)) {
ep.push({ method: 'GET', path: `/api/admin/${name}`, desc: `Liste ${name}` });
ep.push({ method: 'GET', path: `/api/admin/${name}/{id}`, desc: `Einzelnes ${name}` });
if (def.rw) {
ep.push({ method: 'POST', path: `/api/admin/${name}`, desc: `Upsert ${name} (id oder slug => Update, sonst Create)` });
if (name !== 'settings') ep.push({ method: 'DELETE', path: `/api/admin/${name}/{id}`, desc: `Löschen ${name}` });
}
}
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
return {
name: 'hd-commerce Admin API',
version: '2.0.0',
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
base_url: origin || '',
resources: RESOURCES,
block_types: blockTypes(),
endpoints: ep,
notes: [
'Preise in Cent (priceCents/total_cents).',
'orders und customers sind nur lesbar.',
'settings ist eine Key/Value-Map; POST mit beliebigen Keys aktualisiert sie.',
'pages.blocks ist ein Array von Blöcken (siehe block_types) für den Visual-Builder.',
],
};
}
+111
View File
@@ -0,0 +1,111 @@
// 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', 'inhalte', 'einstellungen', 'nutzer', 'audit'],
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'analytics'],
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();
}
+94
View File
@@ -0,0 +1,94 @@
// hd-commerce — Block-Definitionen für den Visual-Builder.
// Jeder Block-Typ: key, label, icon (svg path), defaults, fields (für Settings-Panel).
export const BLOCK_TYPES = [
{
key: 'hero', label: 'Hero', icon: 'M3 5h18v14H3z M3 11h18',
defaults: { headline: 'Willkommen', subline: 'Ein starker Untertitel', image: '', cta_text: 'Jetzt entdecken', cta_url: '/shop', align: 'center' },
fields: [
{ name: 'headline', label: 'Headline', type: 'text' },
{ name: 'subline', label: 'Subline', type: 'textarea' },
{ name: 'image', label: 'Hintergrundbild', type: 'image' },
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
{ name: 'align', label: 'Ausrichtung', type: 'select', options: ['left', 'center'] },
],
},
{
key: 'richtext', label: 'Rich-Text', icon: 'M4 6h16M4 12h16M4 18h10',
defaults: { html: '<p>Dein Text hier. <strong>HTML</strong> ist erlaubt.</p>' },
fields: [{ name: 'html', label: 'Inhalt (HTML)', type: 'textarea' }],
},
{
key: 'image', label: 'Bild', icon: 'M3 5h18v14H3z M3 16l5-5 4 4 3-3 6 6',
defaults: { image: '', caption: '', width: 'wide' },
fields: [
{ name: 'image', label: 'Bild', type: 'image' },
{ name: 'caption', label: 'Bildunterschrift', type: 'text' },
{ name: 'width', label: 'Breite', type: 'select', options: ['narrow', 'wide', 'full'] },
],
},
{
key: 'gallery', label: 'Galerie', icon: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
defaults: { images: [], columns: 3 },
fields: [
{ name: 'images', label: 'Bilder (eine URL pro Zeile)', type: 'imagelist' },
{ name: 'columns', label: 'Spalten', type: 'select', options: ['2', '3', '4'] },
],
},
{
key: 'slider', label: 'Slider-Referenz', icon: 'M2 12h20 M7 7l-5 5 5 5 M17 7l5 5-5 5',
defaults: {},
fields: [],
},
{
key: 'features', label: 'Feature-Grid', icon: 'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z',
defaults: {
headline: 'Unsere Vorteile',
items: [
{ title: 'Schnell', text: 'Blitzschneller Versand.' },
{ title: 'Sicher', text: 'Geschützte Bezahlung.' },
{ title: 'Fair', text: 'Transparente Preise.' },
],
},
fields: [
{ name: 'headline', label: 'Überschrift', type: 'text' },
{ name: 'items', label: 'Features (3)', type: 'features' },
],
},
{
key: 'productgrid', label: 'Produkt-Grid', icon: 'M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z',
defaults: { headline: 'Beliebte Produkte', source: 'featured', category: '', limit: 4 },
fields: [
{ name: 'headline', label: 'Überschrift', type: 'text' },
{ name: 'source', label: 'Quelle', type: 'select', options: ['featured', 'category', 'all'] },
{ name: 'category', label: 'Kategorie (bei „category")', type: 'text' },
{ name: 'limit', label: 'Anzahl', type: 'number' },
],
},
{
key: 'cta', label: 'CTA-Banner', icon: 'M3 7h18v10H3z M8 12h8',
defaults: { headline: 'Bereit loszulegen?', text: 'Stöbere jetzt im Shop.', cta_text: 'Zum Shop', cta_url: '/shop' },
fields: [
{ name: 'headline', label: 'Headline', type: 'text' },
{ name: 'text', label: 'Text', type: 'textarea' },
{ name: 'cta_text', label: 'Button-Text', type: 'text' },
{ name: 'cta_url', label: 'Button-Link', type: 'text' },
],
},
{
key: 'spacer', label: 'Abstand', icon: 'M12 4v16 M6 8l6-4 6 4 M6 16l6 4 6-4',
defaults: { size: 'medium' },
fields: [{ name: 'size', label: 'Größe', type: 'select', options: ['small', 'medium', 'large'] }],
},
{
key: 'html', label: 'Roh-HTML', icon: 'M8 6l-5 6 5 6 M16 6l5 6-5 6',
defaults: { code: '<div style="padding:2rem;text-align:center">Eigenes HTML</div>' },
fields: [{ name: 'code', label: 'HTML-Code', type: 'textarea' }],
},
];
export const blockMeta = (type) => BLOCK_TYPES.find(b => b.key === type) || null;
export function blockDefaults(type) {
const m = blockMeta(type);
return m ? JSON.parse(JSON.stringify(m.defaults)) : {};
}
+135 -12
View File
@@ -10,6 +10,12 @@ const DB_PATH = process.env.DB_PATH || './data/hdc.db';
try { mkdirSync(dirname(DB_PATH), { recursive: true }); } catch {}
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
function ensureColumn(table, col, ddl) {
try {
const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
if (!cols.includes(col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
} catch {}
}
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
@@ -37,7 +43,16 @@ CREATE TABLE IF NOT EXISTS slides (
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT, body TEXT,
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99
type TEXT DEFAULT 'content', active INTEGER DEFAULT 1, sort INTEGER DEFAULT 99, blocks TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT UNIQUE NOT NULL,
pass_hash TEXT, pass_salt TEXT, role TEXT DEFAULT 'owner', active INTEGER DEFAULT 1,
created_at TEXT, last_login TEXT
);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, action TEXT, entity TEXT,
entity_id TEXT, created_at TEXT
);
CREATE TABLE IF NOT EXISTS popups (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, type TEXT DEFAULT 'newsletter',
@@ -58,7 +73,9 @@ CREATE TABLE IF NOT EXISTS media (
);
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);
`);
ensureColumn('pages', 'blocks', "blocks TEXT DEFAULT '[]'");
// ---------- mappers ----------
const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured });
@@ -170,10 +187,17 @@ export const listFeatured = () => db.prepare('SELECT * FROM products WHERE featu
export const getProductBySlug = (slug) => P(db.prepare('SELECT * FROM products WHERE slug=?').get(slug));
export const getProductById = (id) => P(db.prepare('SELECT * FROM products WHERE id=?').get(Number(id)));
export const listCategories = () => [...new Set(db.prepare("SELECT category FROM products WHERE category IS NOT NULL AND category<>'' ORDER BY sort").all().map(r => r.category))];
function slugify(str) {
return String(str || '').toLowerCase()
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
.replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
}
function normProduct(d) {
const cardImage = d.cardImage || (Array.isArray(d.images) && d.images[0]) || '';
const slug = (d.slug && String(d.slug).trim()) ? slugify(d.slug) : slugify(d.name || 'produkt');
return {
slug: d.slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
slug, name: d.name, shortName: d.shortName || d.name, priceCents: Math.round(Number(d.priceCents) || 0), category: d.category || '',
sizes: JSON.stringify(d.sizes && d.sizes.length ? d.sizes : ['One Size']), images: JSON.stringify(d.images || []), cardImage,
badge: d.badge || '', stock: (d.stock === '' || d.stock == null) ? null : Math.round(Number(d.stock)), material: d.material || '',
features: JSON.stringify(d.features || []), featured: d.featured ? 1 : 0, sort: Number(d.sort) || 99, desc: d.desc || '',
@@ -236,18 +260,35 @@ export function updateSlide(id, d) {
export const deleteSlide = (id) => db.prepare('DELETE FROM slides WHERE id=?').run(Number(id));
// ---------- pages ----------
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all();
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all();
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all();
export const getPageBySlug = (slug) => db.prepare('SELECT * FROM pages WHERE slug=?').get(slug);
export const getPageById = (id) => db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id));
function PG(r) {
if (!r) return r;
let blocks = [];
try { blocks = JSON.parse(r.blocks || '[]'); if (!Array.isArray(blocks)) blocks = []; } catch { blocks = []; }
return { ...r, blocks };
}
export const listPages = () => db.prepare('SELECT * FROM pages ORDER BY sort, id').all().map(PG);
export const listActivePages = () => db.prepare('SELECT * FROM pages WHERE active=1 ORDER BY sort, id').all().map(PG);
export const listLegalPages = () => db.prepare("SELECT * FROM pages WHERE active=1 AND type='legal' ORDER BY sort, id").all().map(PG);
export const getPageBySlug = (slug) => PG(db.prepare('SELECT * FROM pages WHERE slug=?').get(slug));
export const getPageById = (id) => PG(db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)));
function normBlocks(b) {
if (typeof b === 'string') { try { b = JSON.parse(b); } catch { b = []; } }
return JSON.stringify(Array.isArray(b) ? b : []);
}
export function createPage(d) {
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort) VALUES (?,?,?,?,?,?)')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99).lastInsertRowid;
return db.prepare('INSERT INTO pages (slug,title,body,type,active,sort,blocks) VALUES (?,?,?,?,?,?,?)')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, normBlocks(d.blocks)).lastInsertRowid;
}
export function updatePage(id, d) {
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=? WHERE id=?')
.run(d.slug, d.title || '', d.body || '', d.type || 'content', d.active ? 1 : 0, Number(d.sort) || 99, Number(id));
const cur = db.prepare('SELECT * FROM pages WHERE id=?').get(Number(id)) || {};
const blocks = (d.blocks !== undefined) ? normBlocks(d.blocks) : (cur.blocks || '[]');
db.prepare('UPDATE pages SET slug=?,title=?,body=?,type=?,active=?,sort=?,blocks=? WHERE id=?')
.run(d.slug ?? cur.slug, d.title ?? cur.title ?? '', d.body ?? cur.body ?? '', d.type ?? cur.type ?? 'content',
(d.active !== undefined ? (d.active ? 1 : 0) : cur.active), Number(d.sort ?? cur.sort) || 99, blocks, Number(id));
return id;
}
export function updatePageBlocks(id, blocks) {
db.prepare('UPDATE pages SET blocks=? WHERE id=?').run(normBlocks(blocks), Number(id));
return id;
}
export const deletePage = (id) => db.prepare('DELETE FROM pages WHERE id=?').run(Number(id));
@@ -358,5 +399,87 @@ export function dashboard() {
const recentOrders = db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC LIMIT 6').all().map(O);
const lowStock = db.prepare('SELECT * FROM products WHERE stock IS NOT NULL AND stock <= 35 ORDER BY stock ASC LIMIT 6').all().map(P);
const a = analyticsSummary(30);
return { revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock, funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases } };
// 14-Tage Umsatz-Spark + Trend (zweite Hälfte vs. erste Hälfte)
const spark = a.series.slice(-14).map(d => d.revenue);
const half = Math.floor(spark.length / 2) || 1;
const first = spark.slice(0, half).reduce((x, y) => x + y, 0) || 0;
const second = spark.slice(half).reduce((x, y) => x + y, 0) || 0;
const revTrend = first ? Math.round(((second - first) / first) * 100) : (second ? 100 : 0);
const feed = recentAudit(8);
return {
revenueCents: revenue, orderCount, productCount, customerCount, pending, recentOrders, lowStock,
funnelMini: { views: a.pageviews, cart: a.addToCart, buy: a.purchases },
spark, revTrend, visitors: a.visitors, conversion: a.conversion, feed,
};
}
// ---------- users / auth ----------
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
function hashPassword(password, salt) {
const s = salt || randomBytes(16).toString('hex');
const hash = scryptSync(String(password), s, 64).toString('hex');
return { pass_hash: hash, pass_salt: s };
}
export function verifyPassword(password, hash, salt) {
if (!hash || !salt) return false;
try {
const cand = scryptSync(String(password), salt, 64);
const ref = Buffer.from(hash, 'hex');
return cand.length === ref.length && timingSafeEqual(cand, ref);
} catch { return false; }
}
const U = (r) => r && ({ ...r, active: !!r.active });
export const listUsers = () => db.prepare('SELECT * FROM users ORDER BY id').all().map(U);
export const getUserById = (id) => U(db.prepare('SELECT * FROM users WHERE id=?').get(Number(id)));
export const getUserByEmail = (email) => U(db.prepare('SELECT * FROM users WHERE email=?').get(String(email || '').toLowerCase().trim()));
export const countUsers = () => db.prepare('SELECT COUNT(*) c FROM users').get().c;
const ROLES = ['owner', 'redaktion', 'versand'];
export function createUser({ name, email, password, role = 'owner', active = true }) {
const e = String(email || '').toLowerCase().trim();
if (!e) throw new Error('E-Mail erforderlich');
if (!ROLES.includes(role)) role = 'redaktion';
const { pass_hash, pass_salt } = hashPassword(password || randomBytes(8).toString('hex'));
const r = db.prepare('INSERT INTO users (name,email,pass_hash,pass_salt,role,active,created_at) VALUES (?,?,?,?,?,?,?)')
.run(name || e, e, pass_hash, pass_salt, role, active ? 1 : 0, new Date().toISOString());
return r.lastInsertRowid;
}
export function updateUserRole(id, role) {
if (!ROLES.includes(role)) return;
db.prepare('UPDATE users SET role=? WHERE id=?').run(role, Number(id));
}
export function setUserActive(id, active) {
db.prepare('UPDATE users SET active=? WHERE id=?').run(active ? 1 : 0, Number(id));
}
export function setUserPassword(id, password) {
const { pass_hash, pass_salt } = hashPassword(password);
db.prepare('UPDATE users SET pass_hash=?,pass_salt=? WHERE id=?').run(pass_hash, pass_salt, Number(id));
}
export function deleteUser(id) {
db.prepare('DELETE FROM users WHERE id=?').run(Number(id));
}
export function touchUserLogin(id) {
db.prepare('UPDATE users SET last_login=? WHERE id=?').run(new Date().toISOString(), Number(id));
}
// Seed the initial owner from ENV on first boot
export function seedAdminUser() {
if (countUsers() > 0) return;
const email = (process.env.ADMIN_EMAIL || 'admin@example.com').toLowerCase().trim();
const pass = process.env.ADMIN_PASS || 'admin';
try { createUser({ name: 'Administrator', email, password: pass, role: 'owner', active: true }); } catch {}
}
seedAdminUser();
// ---------- audit ----------
export function recordAudit({ user = '', action = '', entity = '', entity_id = '' }) {
try {
db.prepare('INSERT INTO audit (user,action,entity,entity_id,created_at) VALUES (?,?,?,?,?)')
.run(String(user || ''), String(action || ''), String(entity || ''), String(entity_id || ''), new Date().toISOString());
} catch {}
}
export const listAudit = (limit = 200) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 200);
export const recentAudit = (limit = 8) => db.prepare('SELECT * FROM audit ORDER BY id DESC LIMIT ?').all(Number(limit) || 8);