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:
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user