diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..67c619c --- /dev/null +++ b/server/.env.example @@ -0,0 +1,34 @@ +# Vorlage — echte Werte kommen in eine eigene ".env"-Datei (wird nie eingecheckt/committed, +# siehe .gitignore), niemals hier direkt eintragen. + +# Wo die SQLite-Datenbankdatei liegt (außerhalb des Programmcodes, siehe db.js) +DB_PATH=/var/lib/vandiy-shop/vandiy-shop.db + +# Port, auf dem der Node-Server lokal lauscht (Caddy leitet von außen dorthin weiter) +PORT=4000 + +# Zugangscode-Schranke (ganze Seite) — selbst gewählte, zufällige Werte +SITE_ACCESS_SECRET= +SITE_ACCESS_CODE_QCIGA= +SITE_ACCESS_CODE_VANVAN= + +# Zweite Schranke für /verwaltung/ (eigenes Signier-Geheimnis, geprüfter Code ist derselbe wie oben) +VERWALTUNG_SESSION_SECRET= + +# Kundenkonto-Sitzungen (Google-/PayPal-Login) +CUSTOMER_SESSION_SECRET= + +# Sobald der Shop für echte Kund:innen live gehen soll: auf "true" setzen +SITE_PUBLIC=false + +# PayPal (Zahlung + "Log in with PayPal") — aus dem PayPal-Entwickler-Dashboard +PAYPAL_ENV=sandbox +PAYPAL_CLIENT_ID= +PAYPAL_CLIENT_SECRET= + +# Google-Login — aus der Google Cloud Console +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# E-Mail-Versand (Bestellbenachrichtigungen) — aus resend.com +RESEND_API_KEY= diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..8b6ba15 --- /dev/null +++ b/server/db.js @@ -0,0 +1,44 @@ +/* ===================================================================== + db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1. + + D1 ist selbst schon SQLite unter der Haube — deshalb funktioniert das bestehende Schema + (../d1/schema.sql) unverändert. WICHTIG: ../d1/schema.sql ist bereits der KONSOLIDIERTE + Endzustand (enthält gutschein_code, paypal_order_id, customers-Tabelle direkt) — die + migration-*.sql-Dateien daneben waren einmalige ALTER-TABLE-Schritte für die SCHON LAUFENDE + alte D1-Datenbank und dürfen auf einer frischen Datenbank NICHT nochmal ausgeführt werden + (migration-0002 würde z.B. mit "duplicate column name" fehlschlagen, weil schema.sql die + Spalte schon enthält). Deshalb hier bewusst NUR schema.sql. Echte, bestehende Bestelldaten aus + der alten D1-Datenbank werden separat importiert (siehe import-d1-daten.js), nicht über die + Migrationsdateien. + + Der Datenbank-Ordner liegt außerhalb des Programmcodes (siehe DB_PATH), damit ein Update des + Servers (z.B. "git pull") niemals die echten Bestelldaten anfassen kann. + ===================================================================== */ + +import Database from "better-sqlite3"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Überschreibbar über die Umgebungsvariable DB_PATH (siehe .env) — Standard: eigener, +// vom Programmcode getrennter Datenordner. +const DB_PATH = process.env.DB_PATH || "/var/lib/vandiy-shop/vandiy-shop.db"; +const D1_ORDNER = join(__dirname, "..", "d1"); + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db = new Database(DB_PATH); +db.pragma("journal_mode = WAL"); // bessere Nebenläufigkeit bei gleichzeitigen Lese-/Schreibzugriffen +db.pragma("foreign_keys = ON"); + +function fuehreSqlDateiAus(pfad) { + if (!existsSync(pfad)) return; + const sql = readFileSync(pfad, "utf-8"); + db.exec(sql); +} + +export function initDb() { + fuehreSqlDateiAus(join(D1_ORDNER, "schema.sql")); +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..d85f508 --- /dev/null +++ b/server/index.js @@ -0,0 +1,68 @@ +/* ===================================================================== + index.js — Haupteinstiegspunkt. Baut den kompletten Express-Server auf: liefert die fertig + gebaute Astro-Seite (../dist/) aus UND die API-Routen, genau wie Cloudflare Pages beides in + einem Deployment vereint hat. Die Zugangscode-Schranke (middleware/gate.js) läuft vor JEDER + Anfrage, exakt wie functions/_middleware.js das vorher bei Cloudflare tat. + + Umgebungsvariablen kommen aus einer .env-Datei — gestartet wird dieser Server mit + "node --env-file=.env index.js" (Node 22 kann .env-Dateien nativ laden, kein zusätzliches + Paket nötig). + ===================================================================== */ + +import express from "express"; +import cookieParser from "cookie-parser"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; + +import { initDb } from "./db.js"; +import { gateMiddleware } from "./middleware/gate.js"; +import { ordersRouter } from "./routes/orders.js"; +import { bestsellerRouter } from "./routes/bestseller.js"; +import { paypalRouter } from "./routes/paypal.js"; +import { authRouter } from "./routes/auth.js"; +import { accountRouter } from "./routes/account.js"; +import { verwaltungRouter } from "./routes/verwaltung.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIST_DIR = join(__dirname, "..", "dist"); +const PORT = Number(process.env.PORT || 4000); + +initDb(); + +const app = express(); + +// Läuft hinter Caddy (Reverse Proxy) — nötig, damit req.protocol/req.get("host") und +// "secure"-Cookies korrekt funktionieren, statt anzunehmen, die Verbindung sei unverschlüsselt. +app.set("trust proxy", 1); + +app.use(cookieParser()); +app.use(express.json({ limit: "2mb" })); + +// Zugangscode-Schranke vor allem anderen — exakt wie functions/_middleware.js bei Cloudflare. +app.use(gateMiddleware); + +app.use(ordersRouter); +app.use(bestsellerRouter); +app.use(paypalRouter); +app.use(authRouter); +app.use(accountRouter); +app.use(verwaltungRouter); + +// Statische Auslieferung der fertig gebauten Astro-Seite (nach "npm run build" in ../). +if (existsSync(DIST_DIR)) { + app.use(express.static(DIST_DIR, { extensions: ["html"] })); +} else { + console.warn(`⚠️ ${DIST_DIR} existiert nicht — "npm run build" im Astro-Projekt (../) noch nicht ausgeführt.`); +} + +// 404 für alles, was weder Datei noch bekannte Route ist. +app.use((req, res) => { + res.status(404).sendFile(join(DIST_DIR, "404.html"), (err) => { + if (err) res.status(404).send("Nicht gefunden."); + }); +}); + +app.listen(PORT, "127.0.0.1", () => { + console.log(`Van's DIY & Bastelbedarf – Server läuft auf http://127.0.0.1:${PORT}`); +}); diff --git a/server/lib/auth.js b/server/lib/auth.js new file mode 100644 index 0000000..ee78c4e --- /dev/null +++ b/server/lib/auth.js @@ -0,0 +1,76 @@ +/* ===================================================================== + lib/auth.js — gemeinsame Session-Logik für die Zugangscode-Schranke (qciga/VanVan). + + 1:1 portiert aus functions/_shared/auth.js — Node 22 hat crypto.subtle, btoa/atob global + verfügbar (Web-Crypto-Standard), deshalb konnte die Logik praktisch unverändert übernommen + werden. Einziger Unterschied: getCookie() liest jetzt aus Express' geparsten req.cookies statt + selbst den Cookie-Header zu zerlegen (cookie-parser übernimmt das). + ===================================================================== */ + +export const COOKIE_NAME = "vandiy_gate_session"; +export const ROLE_COOKIE_NAME = "vandiy_gate_role"; +export const SESSION_TAGE = 90; + +function b64urlEncode(bytes) { + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function b64urlDecodeToBytes(str) { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + const bin = atob(str); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +async function hmacKey(secret) { + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); +} + +export async function signSession(role, secret) { + const payload = JSON.stringify({ role, exp: Date.now() + SESSION_TAGE * 24 * 60 * 60 * 1000 }); + const payloadB64 = b64urlEncode(new TextEncoder().encode(payload)); + const key = await hmacKey(secret); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64)); + const sigB64 = b64urlEncode(new Uint8Array(sig)); + return `${payloadB64}.${sigB64}`; +} + +export async function verifySession(token, secret) { + if (!token || !token.includes(".")) return null; + const [payloadB64, sigB64] = token.split("."); + try { + const key = await hmacKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + b64urlDecodeToBytes(sigB64), + new TextEncoder().encode(payloadB64) + ); + if (!valid) return null; + const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64))); + if (!payload.exp || payload.exp < Date.now()) return null; + return payload; // { role, exp } + } catch { + return null; + } +} + +/** Liefert die Rolle ("qciga"/"vanvan") bei gültiger Sitzung, sonst null. */ +export async function getGateRole(req) { + if (!process.env.SITE_ACCESS_SECRET) return null; + const token = req.cookies?.[COOKIE_NAME]; + const payload = token ? await verifySession(token, process.env.SITE_ACCESS_SECRET) : null; + return payload?.role ?? null; +} + +/** Standard-401-Antwort für API-Routen ohne gültige Sitzung. */ +export function unauthorizedJson(res) { + return res.status(401).json({ ok: false, error: "Nicht angemeldet." }); +} diff --git a/server/lib/bestellung-erstellen.js b/server/lib/bestellung-erstellen.js new file mode 100644 index 0000000..5fe65c9 --- /dev/null +++ b/server/lib/bestellung-erstellen.js @@ -0,0 +1,180 @@ +/* ===================================================================== + lib/bestellung-erstellen.js — die eigentliche "Bestellung anlegen"-Logik. 1:1 portiert aus + functions/_shared/bestellung-erstellen.js, D1-Aufrufe (async, .bind().run()) auf better- + sqlite3 (synchron) umgestellt. Gleiche Validierung, gleiche "nur einmal pro Person"-Logik, + gleicher E-Mail-Versand. + ===================================================================== */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { db } from "../db.js"; +import { sendeEmail, escapeHtmlFuerEmail } from "./email.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gutscheineRaw = JSON.parse( + readFileSync(join(__dirname, "..", "..", "src", "content", "gutscheine.json"), "utf-8") +); + +const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"]; +const gutscheine = gutscheineRaw?.codes || []; +const BENACHRICHTIGUNGS_EMAIL = "vansdiyundbastelbedarf@gmail.com"; +const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" }; + +function formatPreis(n) { + return `${Number(n).toFixed(2).replace(".", ",")} €`; +} + +function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }) { + const artikelZeilen = artikel + .map((a) => `
  • ${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}
  • `) + .join(""); + const statusZeile = + status === "zahlungOffen" + ? `

    ⏳ Zahlung noch ausstehend — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.

    ` + : `

    ✅ Zahlung bestätigt (PayPal)

    `; + return ` +
    +

    🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}

    + ${statusZeile} +

    Von: ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})
    + Land: ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}

    +

    Artikel:

    + +

    Summe: ${formatPreis(summe)}

    +

    → Zur Bestellverwaltung

    +
    + `; +} + +function findeGutschein(code) { + const normalisiert = String(code || "").trim().toUpperCase(); + if (!normalisiert) return null; + return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null; +} + +/** + * Validiert die Bestelldaten, legt sie in SQLite an und verschickt die Benachrichtigungsmail. + * @param {*} body Roh-Payload vom Checkout (gleiches Format wie bisher) + * @param {{status: "zahlungOffen"|"bezahlt", paypalOrderId?: string|null}} optionen + */ +export async function erstelleBestellung(body, optionen) { + const { status, paypalOrderId = null } = optionen; + + const land = String(body.land || "").toLowerCase(); + if (!ERLAUBTE_LAENDER.includes(land)) { + return { ok: false, httpStatus: 400, error: "Ungültiges Land." }; + } + + const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {}; + const name = String(kunde.name || "").trim(); + const email = String(kunde.email || "").trim(); + if (!name || !email || !email.includes("@")) { + return { ok: false, httpStatus: 400, error: "Ungültige Kundendaten." }; + } + + const artikel = Array.isArray(body.artikel) ? body.artikel : []; + if (artikel.length === 0) { + return { ok: false, httpStatus: 400, error: "Die Bestellung enthält keine Artikel." }; + } + for (const a of artikel) { + if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) { + return { ok: false, httpStatus: 400, error: "Ungültige Artikeldaten." }; + } + if (!Number.isFinite(a.menge) || a.menge <= 0) { + return { ok: false, httpStatus: 400, error: "Ungültige Artikelmenge." }; + } + if (!Number.isFinite(a.preis) || a.preis < 0) { + return { ok: false, httpStatus: 400, error: "Ungültiger Artikelpreis." }; + } + } + + const summe = Number(body.summe); + if (!Number.isFinite(summe) || summe < 0) { + return { ok: false, httpStatus: 400, error: "Ungültige Bestellsumme." }; + } + const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0; + const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0; + const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0; + + const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : ""; + let gutscheinCodeNormalisiert = null; + if (gutscheinCodeRoh) { + const gutschein = findeGutschein(gutscheinCodeRoh); + gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase(); + if (gutschein && gutschein.einmaligProPerson) { + const { anzahl } = db + .prepare( + `SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'` + ) + .get(gutscheinCodeNormalisiert, email); + if (anzahl > 0) { + return { + ok: false, + httpStatus: 400, + error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.", + }; + } + } + } + + const now = new Date().toISOString(); + + try { + const insertOrder = db + .prepare( + `INSERT INTO orders + (created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort, + zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe, + treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code, paypal_order_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + ) + .run( + now, + now, + status, + name, + email, + land, + String(kunde.strasse || ""), + String(kunde.plz || ""), + String(kunde.ort || ""), + String(body.zahlungsart || ""), + zwischensumme, + versandkosten, + rabattGesamt, + summe, + body.treuebonusVerwendet ? 1 : 0, + body.aboRabattVerwendet ? 1 : 0, + gutscheinCodeNormalisiert, + paypalOrderId + ); + + const orderId = insertOrder.lastInsertRowid; + const jahr = new Date().getFullYear(); + const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`; + db.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).run(bestellnummer, orderId); + + const insertItem = db.prepare( + `INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis) + VALUES (?,?,?,?,?,?,?)` + ); + const insertAlleItems = db.transaction((items) => { + for (const a of items) { + insertItem.run(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0); + } + }); + insertAlleItems(artikel); + + await sendeEmail({ + to: BENACHRICHTIGUNGS_EMAIL, + subject: `${status === "zahlungOffen" ? "⏳" : "🩵"} Neue Bestellung ${bestellnummer}`, + html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe, status }), + text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Status: ${status === "zahlungOffen" ? "Zahlung ausstehend (Überweisung)" : "Bezahlt (PayPal)"}.`, + }); + + return { ok: true, id: orderId, bestellnummer }; + } catch (err) { + return { ok: false, httpStatus: 500, error: "Bestellung konnte nicht gespeichert werden." }; + } +} diff --git a/server/lib/customer-auth.js b/server/lib/customer-auth.js new file mode 100644 index 0000000..62d94dd --- /dev/null +++ b/server/lib/customer-auth.js @@ -0,0 +1,74 @@ +/* ===================================================================== + lib/customer-auth.js — Sitzungs-Logik für echte Kundenkonten (Google-/PayPal-Login). + Bewusst GETRENNT von lib/auth.js (Zugangscode-Schranke) — eigenes Geheimnis, eigenes Cookie, + 1:1 Prinzip wie im Original (functions/_shared/customer-auth.js). Einziger Unterschied: + getCustomer() fragt jetzt synchron better-sqlite3 statt async D1 ab. + ===================================================================== */ + +import { db } from "../db.js"; + +export const CUSTOMER_COOKIE_NAME = "vandiy_customer_session"; +export const CUSTOMER_SESSION_TAGE = 30; + +function b64urlEncode(bytes) { + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function b64urlDecodeToBytes(str) { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + const bin = atob(str); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} +async function hmacKey(secret) { + return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); +} + +export async function signCustomerSession(customerId, secret) { + const payload = JSON.stringify({ cid: customerId, exp: Date.now() + CUSTOMER_SESSION_TAGE * 24 * 60 * 60 * 1000 }); + const payloadB64 = b64urlEncode(new TextEncoder().encode(payload)); + const key = await hmacKey(secret); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64)); + return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`; +} + +export async function verifyCustomerSession(token, secret) { + if (!token || !token.includes(".")) return null; + const [payloadB64, sigB64] = token.split("."); + try { + const key = await hmacKey(secret); + const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64)); + if (!valid) return null; + const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64))); + if (!payload.exp || payload.exp < Date.now()) return null; + return payload; // { cid, exp } + } catch { + return null; + } +} + +/** Liefert den eingeloggten customers-Datensatz für die aktuelle Anfrage, oder null. Kunden-ID + * kommt NIE direkt vom Client, immer nur aus der signierten, serverseitig geprüften Sitzung. */ +export async function getCustomer(req) { + if (!process.env.CUSTOMER_SESSION_SECRET) return null; + const token = req.cookies?.[CUSTOMER_COOKIE_NAME]; + const payload = token ? await verifyCustomerSession(token, process.env.CUSTOMER_SESSION_SECRET) : null; + if (!payload?.cid) return null; + const row = db.prepare(`SELECT id, email, name, provider, created_at FROM customers WHERE id = ?`).get(payload.cid); + return row || null; +} + +export function setCustomerCookie(res, token, maxAgeSekunden) { + res.cookie(CUSTOMER_COOKIE_NAME, token, { + path: "/", + maxAge: maxAgeSekunden * 1000, + httpOnly: true, + secure: true, + sameSite: "lax", + }); +} + +export function clearCustomerCookie(res) { + res.clearCookie(CUSTOMER_COOKIE_NAME, { path: "/" }); +} diff --git a/server/lib/email.js b/server/lib/email.js new file mode 100644 index 0000000..eb7b680 --- /dev/null +++ b/server/lib/email.js @@ -0,0 +1,41 @@ +/* ===================================================================== + lib/email.js — E-Mail-Versand über Resend. 1:1 portiert aus functions/_shared/email.js. + sendeEmail() wirft NIE einen Fehler, gibt nur { ok, error? } zurück — eine fehlgeschlagene + E-Mail darf nie eine Bestellung zum Scheitern bringen (siehe bestellung-erstellen.js). + ===================================================================== */ + +const RESEND_API_URL = "https://api.resend.com/emails"; +const STANDARD_ABSENDER = "Van's DIY & Bastelbedarf "; + +export async function sendeEmail({ to, subject, html, text, from }) { + if (!process.env.RESEND_API_KEY) { + return { ok: false, error: "RESEND_API_KEY nicht gesetzt — E-Mail-Versand noch nicht eingerichtet." }; + } + try { + const res = await fetch(RESEND_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: from || process.env.RESEND_FROM || STANDARD_ABSENDER, + to: Array.isArray(to) ? to : [to], + subject, + html, + text, + }), + }); + if (!res.ok) { + const fehlertext = await res.text().catch(() => ""); + return { ok: false, error: `Resend antwortete mit Status ${res.status}: ${fehlertext}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export function escapeHtmlFuerEmail(str) { + return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} diff --git a/server/lib/oauth-handlers.js b/server/lib/oauth-handlers.js new file mode 100644 index 0000000..9f966b2 --- /dev/null +++ b/server/lib/oauth-handlers.js @@ -0,0 +1,119 @@ +/* ===================================================================== + lib/oauth-handlers.js — gemeinsame Start-/Callback-Logik für alle OAuth-Anbieter. 1:1 portiert + aus functions/_shared/oauth-handlers.js, auf Express (req/res) statt Cloudflare + Request/Response umgestellt. + ===================================================================== */ + +import { + erzeugeZufallswert, + codeChallengeFuer, + baueAutorisierungsUrl, + istProviderKonfiguriert, + tauscheCodeGegenToken, + holeNutzerprofil, +} from "./oauth.js"; +import { signCustomerSession, setCustomerCookie, CUSTOMER_SESSION_TAGE } from "./customer-auth.js"; +import { db } from "../db.js"; + +const STATE_COOKIE = "vandiy_oauth_state"; +const VERIFIER_COOKIE = "vandiy_oauth_verifier"; +const LANG_COOKIE = "vandiy_oauth_lang"; +const OAUTH_TEMP_MAXAGE = 600; // Sekunden +const ERLAUBTE_SPRACHEN = ["", "en", "ch", "fr"]; + +function langPrefix(lang) { + return ERLAUBTE_SPRACHEN.includes(lang) ? lang : ""; +} + +function originVon(req) { + return `${req.protocol}://${req.get("host")}`; +} + +/** GET /api/auth/:provider/start */ +export async function starteOAuth(req, res, providerName) { + const origin = originVon(req); + const lang = langPrefix(req.query.lang || ""); + + if (!istProviderKonfiguriert(providerName)) { + return res.redirect(302, `${origin}/${lang ? lang + "/" : ""}konto/?login_error=${providerName}_unconfigured`); + } + + const state = erzeugeZufallswert(24); + const codeVerifier = erzeugeZufallswert(48); + const codeChallenge = await codeChallengeFuer(codeVerifier); + const redirectUri = `${origin}/api/auth/${providerName}/callback`; + const authUrl = baueAutorisierungsUrl(providerName, { redirectUri, state, codeChallenge }); + + const cookiePath = `/api/auth/${providerName}`; + const cookieOpts = { path: cookiePath, maxAge: OAUTH_TEMP_MAXAGE * 1000, httpOnly: true, secure: true, sameSite: "lax" }; + res.cookie(STATE_COOKIE, state, cookieOpts); + res.cookie(VERIFIER_COOKIE, codeVerifier, cookieOpts); + res.cookie(LANG_COOKIE, lang, cookieOpts); + return res.redirect(302, authUrl); +} + +/** GET /api/auth/:provider/callback */ +export async function verarbeiteOAuthCallback(req, res, providerName) { + const origin = originVon(req); + const code = req.query.code; + const state = req.query.state; + const errorParam = req.query.error; + const lang = langPrefix(req.cookies?.[LANG_COOKIE] || ""); + const kontoPfad = `${origin}/${lang ? lang + "/" : ""}konto/`; + const cookiePath = `/api/auth/${providerName}`; + + function loescheTempCookies() { + res.clearCookie(STATE_COOKIE, { path: cookiePath }); + res.clearCookie(VERIFIER_COOKIE, { path: cookiePath }); + res.clearCookie(LANG_COOKIE, { path: cookiePath }); + } + function fehlerRedirect(grund) { + loescheTempCookies(); + return res.redirect(302, `${kontoPfad}?login_error=${grund}`); + } + + if (errorParam) return fehlerRedirect(`${providerName}_denied`); + if (!code || !state) return fehlerRedirect(`${providerName}_invalid`); + + const stateCookie = req.cookies?.[STATE_COOKIE]; + const verifierCookie = req.cookies?.[VERIFIER_COOKIE]; + if (!stateCookie || stateCookie !== state || !verifierCookie) { + return fehlerRedirect(`${providerName}_state_mismatch`); + } + + if (!process.env.CUSTOMER_SESSION_SECRET) { + return fehlerRedirect(`${providerName}_backend_unconfigured`); + } + + try { + const redirectUri = `${origin}/api/auth/${providerName}/callback`; + const accessToken = await tauscheCodeGegenToken(providerName, { code, redirectUri, codeVerifier: verifierCookie }); + const profil = await holeNutzerprofil(providerName, accessToken); + + const now = new Date().toISOString(); + const bestehend = db + .prepare(`SELECT id FROM customers WHERE provider = ? AND provider_user_id = ?`) + .get(providerName, profil.providerUserId); + + let customerId; + if (bestehend) { + customerId = bestehend.id; + db.prepare(`UPDATE customers SET email = ?, name = ?, updated_at = ?, last_login_at = ? WHERE id = ?`) + .run(profil.email, profil.name, now, now, customerId); + } else { + const insert = db + .prepare( + `INSERT INTO customers (created_at, updated_at, last_login_at, provider, provider_user_id, email, name) VALUES (?,?,?,?,?,?,?)` + ) + .run(now, now, now, providerName, profil.providerUserId, profil.email, profil.name); + customerId = insert.lastInsertRowid; + } + + const sessionToken = await signCustomerSession(customerId, process.env.CUSTOMER_SESSION_SECRET); + setCustomerCookie(res, sessionToken, CUSTOMER_SESSION_TAGE * 24 * 60 * 60); + loescheTempCookies(); + return res.redirect(302, `${origin}/${lang ? lang + "/" : ""}konto/angemeldet/`); + } catch (err) { + return fehlerRedirect(`${providerName}_failed`); + } +} diff --git a/server/lib/oauth.js b/server/lib/oauth.js new file mode 100644 index 0000000..22f51db --- /dev/null +++ b/server/lib/oauth.js @@ -0,0 +1,120 @@ +/* ===================================================================== + lib/oauth.js — echte OAuth-/OpenID-Connect-Anmeldung für Google und "Log in with PayPal". + 1:1 portiert aus functions/_shared/oauth.js, Zugangsdaten aus process.env statt env.*. + ===================================================================== */ + +function paypalBasis() { + return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; +} +function paypalSigninBasis() { + return process.env.PAYPAL_ENV === "live" ? "https://www.paypal.com" : "https://www.sandbox.paypal.com"; +} + +const PROVIDERS = { + google: { + authorizeUrl: () => "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: () => "https://oauth2.googleapis.com/token", + userinfoUrl: () => "https://www.googleapis.com/oauth2/v3/userinfo", + scope: "openid email profile", + clientIdEnvKey: "GOOGLE_CLIENT_ID", + clientSecretEnvKey: "GOOGLE_CLIENT_SECRET", + tokenAuthStyle: "body", + parseUserinfo: (data) => ({ providerUserId: data.sub, email: data.email, name: data.name || null }), + }, + paypal: { + authorizeUrl: () => `${paypalSigninBasis()}/signin/authorize`, + tokenUrl: () => `${paypalBasis()}/v1/oauth2/token`, + userinfoUrl: () => `${paypalBasis()}/v1/identity/openidconnect/userinfo/?schema=openid`, + scope: "openid email profile", + clientIdEnvKey: "PAYPAL_CLIENT_ID", + clientSecretEnvKey: "PAYPAL_CLIENT_SECRET", + tokenAuthStyle: "basic", + parseUserinfo: (data) => ({ providerUserId: data.user_id || data.payer_id, email: data.email, name: data.name || null }), + }, +}; + +export function provider(name) { + const p = PROVIDERS[name]; + if (!p) throw new Error(`Unbekannter OAuth-Anbieter: ${name}`); + return p; +} + +export function istProviderKonfiguriert(name) { + const p = provider(name); + return !!(process.env[p.clientIdEnvKey] && process.env[p.clientSecretEnvKey]); +} + +export function baueAutorisierungsUrl(name, { redirectUri, state, codeChallenge }) { + const p = provider(name); + const clientId = process.env[p.clientIdEnvKey]; + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + scope: p.scope, + redirect_uri: redirectUri, + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + if (name === "google") params.set("prompt", "select_account"); + return `${p.authorizeUrl()}?${params.toString()}`; +} + +export async function tauscheCodeGegenToken(name, { code, redirectUri, codeVerifier }) { + const p = provider(name); + const clientId = process.env[p.clientIdEnvKey]; + const clientSecret = process.env[p.clientSecretEnvKey]; + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + const headers = { "Content-Type": "application/x-www-form-urlencoded" }; + if (p.tokenAuthStyle === "basic") { + headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; + } else { + body.set("client_id", clientId); + body.set("client_secret", clientSecret); + } + const res = await fetch(p.tokenUrl(), { method: "POST", headers, body: body.toString() }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Token-Austausch bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`); + } + const data = await res.json(); + if (!data.access_token) throw new Error(`${name} hat kein Zugangstoken geliefert.`); + return data.access_token; +} + +export async function holeNutzerprofil(name, accessToken) { + const p = provider(name); + const res = await fetch(p.userinfoUrl(), { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Profilabruf bei ${name} fehlgeschlagen (Status ${res.status}): ${text}`); + } + const data = await res.json(); + const profil = p.parseUserinfo(data); + if (!profil.providerUserId || !profil.email) { + throw new Error(`${name} hat kein vollständiges Profil geliefert (E-Mail/ID fehlt).`); + } + return profil; +} + +function b64url(bytes) { + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function erzeugeZufallswert(laenge = 32) { + const bytes = new Uint8Array(laenge); + crypto.getRandomValues(bytes); + return b64url(bytes); +} + +export async function codeChallengeFuer(codeVerifier) { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)); + return b64url(new Uint8Array(hash)); +} diff --git a/server/lib/paypal.js b/server/lib/paypal.js new file mode 100644 index 0000000..bf24b99 --- /dev/null +++ b/server/lib/paypal.js @@ -0,0 +1,69 @@ +/* ===================================================================== + lib/paypal.js — echte, server-seitige PayPal-Anbindung (REST API v2). 1:1 portiert aus + functions/_shared/paypal.js (reines fetch()-basiertes Modul, keine Cloudflare-Spezifika, + deshalb unverändert übernehmbar). Zugangsdaten kommen aus process.env statt env.*. + ===================================================================== */ + +function paypalBasisUrl() { + return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; +} + +export function paypalKonfiguriert() { + return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET); +} + +async function holeZugangstoken() { + const res = await fetch(`${paypalBasisUrl()}/v1/oauth2/token`, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PayPal-Zugangstoken konnte nicht geholt werden (Status ${res.status}): ${text}`); + } + const data = await res.json(); + return data.access_token; +} + +export async function erstellePaypalBestellung(betragEuro) { + const token = await holeZugangstoken(); + const res = await fetch(`${paypalBasisUrl()}/v2/checkout/orders`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + intent: "CAPTURE", + purchase_units: [{ amount: { currency_code: "EUR", value: Math.max(0.01, betragEuro).toFixed(2) } }], + }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`PayPal-Bestellung konnte nicht angelegt werden (Status ${res.status}): ${text}`); + } + const data = await res.json(); + return data.id; +} + +export async function erfassePaypalZahlung(paypalOrderId) { + try { + const token = await holeZugangstoken(); + const res = await fetch(`${paypalBasisUrl()}/v2/checkout/orders/${encodeURIComponent(paypalOrderId)}/capture`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok || !data) { + return { ok: false, error: `PayPal-Zahlung konnte nicht erfasst werden (Status ${res.status}).` }; + } + const capture = data?.purchase_units?.[0]?.payments?.captures?.[0]; + if (data.status !== "COMPLETED" || !capture || capture.status !== "COMPLETED") { + return { ok: false, error: `PayPal-Zahlung ist nicht abgeschlossen (Status: ${data.status}).` }; + } + return { ok: true, captureId: capture.id, betragEuro: Number(capture.amount?.value ?? 0) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/server/lib/verwaltung-auth.js b/server/lib/verwaltung-auth.js new file mode 100644 index 0000000..67354dc --- /dev/null +++ b/server/lib/verwaltung-auth.js @@ -0,0 +1,54 @@ +/* ===================================================================== + lib/verwaltung-auth.js — ZWEITE, ZUSÄTZLICHE Zugangsschranke NUR für /verwaltung/ + + /api/verwaltung/*, unabhängig vom normalen 90-Tage-Seitenzugang (lib/auth.js). 24h-Sitzung, + geprüfter Code ist derselbe wie beim normalen Zugang (siehe middleware/gate.js). 1:1 portiert + aus functions/_shared/verwaltung-auth.js. + ===================================================================== */ + +export const VERWALTUNG_COOKIE_NAME = "vandiy_verwaltung_session"; +export const VERWALTUNG_SESSION_STUNDEN = 24; + +function b64urlEncode(bytes) { + let bin = ""; + bytes.forEach((b) => (bin += String.fromCharCode(b))); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function b64urlDecodeToBytes(str) { + str = str.replace(/-/g, "+").replace(/_/g, "/"); + while (str.length % 4) str += "="; + const bin = atob(str); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} +async function hmacKey(secret) { + return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); +} + +export async function signVerwaltungSession(secret) { + const payload = JSON.stringify({ exp: Date.now() + VERWALTUNG_SESSION_STUNDEN * 60 * 60 * 1000 }); + const payloadB64 = b64urlEncode(new TextEncoder().encode(payload)); + const key = await hmacKey(secret); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64)); + return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`; +} + +export async function verifyVerwaltungSession(token, secret) { + if (!token || !token.includes(".")) return null; + const [payloadB64, sigB64] = token.split("."); + try { + const key = await hmacKey(secret); + const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64)); + if (!valid) return null; + const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64))); + if (!payload.exp || payload.exp < Date.now()) return null; + return payload; + } catch { + return null; + } +} + +export async function hatGueltigeVerwaltungSitzung(req) { + if (!process.env.VERWALTUNG_SESSION_SECRET) return false; + const token = req.cookies?.[VERWALTUNG_COOKIE_NAME]; + const payload = token ? await verifyVerwaltungSession(token, process.env.VERWALTUNG_SESSION_SECRET) : null; + return !!payload; +} diff --git a/server/middleware/gate.js b/server/middleware/gate.js new file mode 100644 index 0000000..81c8fa9 --- /dev/null +++ b/server/middleware/gate.js @@ -0,0 +1,128 @@ +/* ===================================================================== + middleware/gate.js — Zugangs-Schranke vor der ganzen Website. 1:1 portiert aus + functions/_middleware.js (onRequest), auf Express-Middleware-Stil umgestellt (next() statt + context.next(), req/res statt Request/Response). Gleiche Regeln, gleiche Reihenfolge: + + - /admin/* nie anfassen (eigener, separater Worker mit GitHub-OAuth). + - /gate-logout immer erreichbar. + - Rechtstexte (Impressum, Datenschutz, AGB, Widerruf) immer ohne Code erreichbar. + - Fail-open, falls die Zugangs-Secrets fehlen (lieber offen als sich selbst aussperren). + - /verwaltung/* + /api/verwaltung/* bleiben IMMER geschützt, auch wenn SITE_PUBLIC=true. + - SITE_PUBLIC=true schaltet den Rest der Seite öffentlich (außer /verwaltung). + - API-Routen bekommen bei fehlender Sitzung 401-JSON statt HTML-Redirect. + ===================================================================== */ + +import { COOKIE_NAME, ROLE_COOKIE_NAME, SESSION_TAGE, signSession, verifySession } from "../lib/auth.js"; +import { + VERWALTUNG_COOKIE_NAME, + VERWALTUNG_SESSION_STUNDEN, + signVerwaltungSession, + verifyVerwaltungSession, +} from "../lib/verwaltung-auth.js"; + +const OEFFENTLICHE_SEITEN = new Set(["/impressum", "/datenschutz", "/agb", "/widerruf", "/muster-widerrufsformular"]); + +function istOeffentlichErreichbar(pfad) { + const ohneSlash = pfad.endsWith("/") && pfad.length > 1 ? pfad.slice(0, -1) : pfad; + if (OEFFENTLICHE_SEITEN.has(ohneSlash)) return true; + if (pfad.startsWith("/_astro/") && pfad.endsWith(".css")) return true; + if (pfad === "/img/gate-avatar-dogi.webp" || pfad === "/img/gate-avatar-vanvan.webp") return true; + if (/^\/(logo\.png|favicon\.ico|favicon-\d+x\d+\.png|apple-touch-icon\.png|icon-\d+(-maskable)?\.png|sw\.js|manifest\.webmanifest)$/.test(pfad)) return true; + return false; +} + +export async function gateAuthHandler(req, res) { + const code = (req.body?.code || "").trim(); + const next = typeof req.body?.next === "string" && req.body.next.startsWith("/") ? req.body.next : "/"; + + let role = null; + if (process.env.SITE_ACCESS_CODE_QCIGA && code === process.env.SITE_ACCESS_CODE_QCIGA) role = "qciga"; + else if (process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN) role = "vanvan"; + + if (!role) { + return res.status(401).json({ ok: false, error: "Falscher Zugangscode." }); + } + + const session = await signSession(role, process.env.SITE_ACCESS_SECRET); + const maxAge = SESSION_TAGE * 24 * 60 * 60 * 1000; + res.cookie(COOKIE_NAME, session, { path: "/", maxAge, httpOnly: true, secure: true, sameSite: "lax" }); + res.cookie(ROLE_COOKIE_NAME, role, { path: "/", maxAge, secure: true, sameSite: "lax" }); + return res.json({ ok: true, next }); +} + +export async function verwaltungAuthHandler(req, res) { + const code = (req.body?.code || "").trim(); + const next = typeof req.body?.next === "string" && req.body.next.startsWith("/") ? req.body.next : "/verwaltung/"; + + const codeGueltig = + (process.env.SITE_ACCESS_CODE_QCIGA && code === process.env.SITE_ACCESS_CODE_QCIGA) || + (process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN); + if (!codeGueltig) { + return res.status(401).json({ ok: false, error: "Falscher Code." }); + } + + const session = await signVerwaltungSession(process.env.VERWALTUNG_SESSION_SECRET); + const maxAge = VERWALTUNG_SESSION_STUNDEN * 60 * 60 * 1000; + res.cookie(VERWALTUNG_COOKIE_NAME, session, { path: "/", maxAge, httpOnly: true, secure: true, sameSite: "lax" }); + return res.json({ ok: true, next }); +} + +export function logoutHandler(req, res) { + res.clearCookie(COOKIE_NAME, { path: "/" }); + res.clearCookie(ROLE_COOKIE_NAME, { path: "/" }); + res.clearCookie(VERWALTUNG_COOKIE_NAME, { path: "/" }); + return res.json({ ok: true }); +} + +/** Die eigentliche Middleware, vor JEDER Anfrage montiert (siehe index.js). */ +export function gateMiddleware(req, res, next) { + const pfad = req.path; + + if (pfad.startsWith("/admin")) return next(); + if (pfad === "/gate-logout" && req.method === "POST") return logoutHandler(req, res); + if (istOeffentlichErreichbar(pfad)) return next(); + + const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_QCIGA || process.env.SITE_ACCESS_CODE_VANVAN)); + if (!codesKonfiguriert) return next(); + + const immerGeschuetzterPfad = pfad.startsWith("/verwaltung") || pfad.startsWith("/api/verwaltung"); + + const sitePublic = process.env.SITE_PUBLIC === "true"; + if (sitePublic && !immerGeschuetzterPfad) return next(); + + if (pfad === "/gate-auth" && req.method === "POST") return gateAuthHandler(req, res); + + const sessionToken = req.cookies?.[COOKIE_NAME]; + + (async () => { + const payload = sessionToken ? await verifySession(sessionToken, process.env.SITE_ACCESS_SECRET) : null; + + if (payload) { + if (!immerGeschuetzterPfad) return next(); + + if (pfad === "/verwaltung-code.html" || pfad === "/verwaltung-code") return next(); + if (pfad === "/verwaltung-auth" && req.method === "POST") return verwaltungAuthHandler(req, res); + + if (!process.env.VERWALTUNG_SESSION_SECRET) return next(); + + const vwToken = req.cookies?.[VERWALTUNG_COOKIE_NAME]; + const vwPayload = vwToken ? await verifyVerwaltungSession(vwToken, process.env.VERWALTUNG_SESSION_SECRET) : null; + if (vwPayload) return next(); + + if (pfad.startsWith("/api/")) { + return res.status(401).json({ ok: false, error: "Zusätzlicher Verwaltungs-Code erforderlich." }); + } + const vwNext = pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : ""); + return res.redirect(302, `/verwaltung-code.html?next=${encodeURIComponent(vwNext)}`); + } + + if (pfad === "/gate.html" || pfad === "/gate") return next(); + + if (pfad.startsWith("/api/")) { + return res.status(401).json({ ok: false, error: "Nicht angemeldet." }); + } + + const nextParam = pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : ""); + return res.redirect(302, `/gate?next=${encodeURIComponent(nextParam)}`); + })(); +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..f6e50b0 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "vandiy-shop-server", + "type": "module", + "version": "1.0.0", + "description": "Eigener Node.js/Express-Server für Van's DIY & Bastelbedarf – Ersatz für Cloudflare Pages Functions + D1, läuft auf dem eigenen Netcup-Server. Portiert 1:1 aus functions/ (Cloudflare Pages Functions), gleiche Sicherheitslogik, gleiche Datenbankstruktur (SQLite statt D1).", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "better-sqlite3": "^11.8.1", + "cookie-parser": "^1.4.7", + "express": "^4.21.2" + } +} diff --git a/server/routes/account.js b/server/routes/account.js new file mode 100644 index 0000000..27009ff --- /dev/null +++ b/server/routes/account.js @@ -0,0 +1,54 @@ +/* ===================================================================== + routes/account.js — Kundenkonto-Selbstbedienung: me, logout, delete (DSGVO Art. 17), + export (DSGVO Art. 15/20). 1:1 portiert aus functions/api/account/*.js. + ===================================================================== */ + +import { Router } from "express"; +import { getCustomer, clearCustomerCookie } from "../lib/customer-auth.js"; +import { db } from "../db.js"; + +export const accountRouter = Router(); + +accountRouter.get("/api/account/me", async (req, res) => { + const customer = await getCustomer(req); + if (!customer) return res.json({ ok: false }); + return res.json({ + ok: true, + customer: { email: customer.email, name: customer.name, provider: customer.provider, seit: customer.created_at }, + }); +}); + +accountRouter.post("/api/account/logout", (req, res) => { + clearCustomerCookie(res); + return res.json({ ok: true }); +}); + +accountRouter.post("/api/account/delete", async (req, res) => { + const customer = await getCustomer(req); + if (!customer) return res.status(401).json({ ok: false, error: "Nicht angemeldet." }); + + try { + db.prepare(`DELETE FROM customers WHERE id = ?`).run(customer.id); + } catch { + return res.status(500).json({ ok: false, error: "Konto konnte nicht gelöscht werden. Bitte versuche es erneut." }); + } + + clearCustomerCookie(res); + return res.json({ ok: true }); +}); + +accountRouter.get("/api/account/export", async (req, res) => { + const customer = await getCustomer(req); + if (!customer) return res.status(401).json({ ok: false, error: "Nicht angemeldet." }); + + const daten = { + konto: { email: customer.email, name: customer.name, anmeldeart: customer.provider, erstelltAm: customer.created_at }, + hinweis: + "Dies sind alle Daten, die Van's DIY & Bastelbedarf zu deinem Kundenkonto speichert. " + + "Bereits aufgegebene Bestellungen werden getrennt davon aus gesetzlichen Aufbewahrungsgründen " + + "weiter gespeichert (siehe Datenschutzerklärung, Abschnitt Bestellabwicklung).", + }; + + res.setHeader("Content-Disposition", 'attachment; filename="meine-daten.json"'); + return res.status(200).json(daten); +}); diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..b342c31 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,15 @@ +/* ===================================================================== + routes/auth.js — OAuth-Start/Callback für Google + PayPal-Login. 1:1 portiert aus + functions/api/auth/google/{start,callback}.js + functions/api/auth/paypal/{start,callback}.js + (beide Anbieter nutzen dieselbe gemeinsame Logik, siehe lib/oauth-handlers.js). + ===================================================================== */ + +import { Router } from "express"; +import { starteOAuth, verarbeiteOAuthCallback } from "../lib/oauth-handlers.js"; + +export const authRouter = Router(); + +authRouter.get("/api/auth/google/start", (req, res) => starteOAuth(req, res, "google")); +authRouter.get("/api/auth/google/callback", (req, res) => verarbeiteOAuthCallback(req, res, "google")); +authRouter.get("/api/auth/paypal/start", (req, res) => starteOAuth(req, res, "paypal")); +authRouter.get("/api/auth/paypal/callback", (req, res) => verarbeiteOAuthCallback(req, res, "paypal")); diff --git a/server/routes/bestseller.js b/server/routes/bestseller.js new file mode 100644 index 0000000..af821e7 --- /dev/null +++ b/server/routes/bestseller.js @@ -0,0 +1,28 @@ +/* ===================================================================== + routes/bestseller.js — GET /api/bestseller. 1:1 portiert aus functions/api/bestseller.js, + D1-Abfrage auf synchrones better-sqlite3 umgestellt. + ===================================================================== */ + +import { Router } from "express"; +import { db } from "../db.js"; + +export const bestsellerRouter = Router(); + +bestsellerRouter.get("/api/bestseller", (req, res) => { + try { + const results = db + .prepare( + `SELECT oi.produkt_slug AS slug, SUM(oi.menge) AS verkauft + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE o.status NOT IN ('storniert', 'zahlungOffen') AND oi.gratis = 0 + GROUP BY oi.produkt_slug + ORDER BY verkauft DESC, slug ASC + LIMIT 6` + ) + .all(); + return res.json({ ok: true, slugs: results.map((r) => r.slug) }); + } catch { + return res.json({ ok: true, slugs: [] }); + } +}); diff --git a/server/routes/orders.js b/server/routes/orders.js new file mode 100644 index 0000000..652b802 --- /dev/null +++ b/server/routes/orders.js @@ -0,0 +1,31 @@ +/* ===================================================================== + routes/orders.js — POST /api/orders, legt eine Überweisungs-Bestellung (Vorkasse) an. + 1:1 portiert aus functions/api/orders.js. Lehnt zahlungsart:"paypal" bewusst ab (Betrugsschutz + — PayPal-Bestellungen entstehen NUR über routes/paypal.js, nach echter Zahlungsprüfung). + ===================================================================== */ + +import { Router } from "express"; +import { erstelleBestellung } from "../lib/bestellung-erstellen.js"; + +export const ordersRouter = Router(); + +ordersRouter.post("/api/orders", async (req, res) => { + const body = req.body || {}; + const zahlungsart = String(body.zahlungsart || ""); + + if (zahlungsart === "paypal") { + return res.status(400).json({ + ok: false, + error: "PayPal-Bestellungen laufen über den PayPal-Button im Checkout, nicht über diese Route.", + }); + } + if (zahlungsart !== "ueberweisung") { + return res.status(400).json({ ok: false, error: "Ungültige oder nicht unterstützte Zahlungsart." }); + } + + const ergebnis = await erstelleBestellung(body, { status: "zahlungOffen" }); + if (!ergebnis.ok) { + return res.status(ergebnis.httpStatus || 500).json({ ok: false, error: ergebnis.error }); + } + return res.status(201).json({ ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer }); +}); diff --git a/server/routes/paypal.js b/server/routes/paypal.js new file mode 100644 index 0000000..f4f768b --- /dev/null +++ b/server/routes/paypal.js @@ -0,0 +1,75 @@ +/* ===================================================================== + routes/paypal.js — POST /api/paypal/create-order + /api/paypal/capture-order. 1:1 portiert + aus functions/api/paypal/create-order.js + capture-order.js. Der entscheidende Sicherheits- + Schritt bleibt unverändert: die Bestellung wird erst NACH server-seitig bei PayPal bestätigter + Zahlung angelegt, mit Betragsabgleich (Toleranz 0,02 €). + ===================================================================== */ + +import { Router } from "express"; +import { paypalKonfiguriert, erstellePaypalBestellung, erfassePaypalZahlung } from "../lib/paypal.js"; +import { erstelleBestellung } from "../lib/bestellung-erstellen.js"; + +export const paypalRouter = Router(); + +const BETRAG_TOLERANZ_EURO = 0.02; + +paypalRouter.post("/api/paypal/create-order", async (req, res) => { + if (!paypalKonfiguriert()) { + return res.status(503).json({ + ok: false, + error: "PayPal ist auf dieser Seite noch nicht eingerichtet (fehlende Zugangsdaten). Bitte eine andere Zahlungsart wählen.", + }); + } + + const body = req.body || {}; + const summe = Number(body.summe); + if (!Number.isFinite(summe) || summe <= 0) { + return res.status(400).json({ ok: false, error: "Ungültiger Betrag." }); + } + + try { + const paypalOrderId = await erstellePaypalBestellung(summe); + return res.json({ ok: true, id: paypalOrderId }); + } catch (err) { + return res.status(502).json({ ok: false, error: err instanceof Error ? err.message : "PayPal-Bestellung fehlgeschlagen." }); + } +}); + +paypalRouter.post("/api/paypal/capture-order", async (req, res) => { + if (!paypalKonfiguriert()) { + return res.status(503).json({ ok: false, error: "PayPal ist auf dieser Seite noch nicht eingerichtet." }); + } + + const body = req.body || {}; + const paypalOrderId = typeof body.paypalOrderId === "string" ? body.paypalOrderId.trim() : ""; + if (!paypalOrderId) { + return res.status(400).json({ ok: false, error: "Fehlende PayPal-Bestell-ID." }); + } + + const summe = Number(body.summe); + if (!Number.isFinite(summe) || summe <= 0) { + return res.status(400).json({ ok: false, error: "Ungültige Bestellsumme." }); + } + + const zahlung = await erfassePaypalZahlung(paypalOrderId); + if (!zahlung.ok) { + return res.status(402).json({ ok: false, error: `Zahlung konnte nicht bestätigt werden: ${zahlung.error}` }); + } + + if (Math.abs(zahlung.betragEuro - summe) > BETRAG_TOLERANZ_EURO) { + return res.status(402).json({ + ok: false, + error: `Der bei PayPal gezahlte Betrag (${zahlung.betragEuro.toFixed(2)} €) stimmt nicht mit der Bestellsumme (${summe.toFixed(2)} €) überein.`, + }); + } + + const ergebnis = await erstelleBestellung({ ...body, zahlungsart: "paypal" }, { status: "bezahlt", paypalOrderId }); + if (!ergebnis.ok) { + return res.status(500).json({ + ok: false, + error: `Deine Zahlung wurde erfolgreich bei PayPal abgebucht (Beleg-ID: ${zahlung.captureId}), aber die Bestellung konnte nicht gespeichert werden. Bitte kontaktiere uns mit dieser Beleg-ID, damit wir das von Hand nachtragen — bitte NICHT erneut bezahlen.`, + }); + } + + return res.status(201).json({ ok: true, id: ergebnis.id, bestellnummer: ergebnis.bestellnummer }); +}); diff --git a/server/routes/verwaltung.js b/server/routes/verwaltung.js new file mode 100644 index 0000000..6d74dea --- /dev/null +++ b/server/routes/verwaltung.js @@ -0,0 +1,95 @@ +/* ===================================================================== + routes/verwaltung.js — GET /api/verwaltung/orders (Liste) + PATCH /api/verwaltung/orders/:id + (Status/Tracking ändern). 1:1 portiert aus functions/api/verwaltung/orders.js + + functions/api/verwaltung/orders/[id].js. Prüft zusätzlich zur gate-Middleware (siehe + middleware/gate.js) hier nochmal eigenständig beide Schranken — Verteidigung in der Tiefe. + ===================================================================== */ + +import { Router } from "express"; +import { getGateRole, unauthorizedJson } from "../lib/auth.js"; +import { hatGueltigeVerwaltungSitzung } from "../lib/verwaltung-auth.js"; +import { db } from "../db.js"; + +export const verwaltungRouter = Router(); + +const ERLAUBTE_STATUS = ["zahlungOffen", "bezahlt", "bearbeitung", "versandVorbereitet", "versendet", "abgeschlossen", "storniert"]; + +async function pruefeSchranken(req, res) { + const role = await getGateRole(req); + if (!role) { + unauthorizedJson(res); + return false; + } + if (!(await hatGueltigeVerwaltungSitzung(req))) { + res.status(401).json({ ok: false, error: "Zusätzlicher Verwaltungs-Code erforderlich." }); + return false; + } + return true; +} + +verwaltungRouter.get("/api/verwaltung/orders", async (req, res) => { + if (!(await pruefeSchranken(req, res))) return; + + try { + const orders = db.prepare(`SELECT * FROM orders ORDER BY created_at DESC`).all(); + if (orders.length === 0) return res.json({ ok: true, orders: [] }); + + const ids = orders.map((o) => o.id); + const placeholders = ids.map(() => "?").join(","); + const items = db.prepare(`SELECT * FROM order_items WHERE order_id IN (${placeholders}) ORDER BY id ASC`).all(...ids); + + const itemsByOrder = new Map(); + items.forEach((item) => { + const liste = itemsByOrder.get(item.order_id) ?? []; + liste.push(item); + itemsByOrder.set(item.order_id, liste); + }); + + const result = orders.map((o) => ({ ...o, artikel: itemsByOrder.get(o.id) ?? [] })); + return res.json({ ok: true, orders: result }); + } catch (err) { + return res.status(500).json({ ok: false, error: "Bestellungen konnten nicht geladen werden." }); + } +}); + +verwaltungRouter.patch("/api/verwaltung/orders/:id", async (req, res) => { + if (!(await pruefeSchranken(req, res))) return; + + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ ok: false, error: "Ungültige Bestell-ID." }); + } + + const body = req.body || {}; + const updates = []; + const values = []; + + if (body.status !== undefined) { + if (!ERLAUBTE_STATUS.includes(body.status)) { + return res.status(400).json({ ok: false, error: "Ungültiger Status." }); + } + updates.push("status = ?"); + values.push(body.status); + } + if (body.tracking !== undefined) { + updates.push("tracking = ?"); + values.push(String(body.tracking || "")); + } + if (updates.length === 0) { + return res.status(400).json({ ok: false, error: "Nichts zu ändern übergeben." }); + } + + updates.push("updated_at = ?"); + values.push(new Date().toISOString()); + values.push(id); + + try { + const result = db.prepare(`UPDATE orders SET ${updates.join(", ")} WHERE id = ?`).run(...values); + if (result.changes === 0) { + return res.status(404).json({ ok: false, error: "Bestellung nicht gefunden." }); + } + return res.json({ ok: true }); + } catch (err) { + return res.status(500).json({ ok: false, error: "Bestellung konnte nicht aktualisiert werden." }); + } +});