Node.js/Express-Server als Ersatz für Cloudflare Pages Functions + D1 (läuft auf eigenem Server statt Cloudflare)
Build & Deploy / deploy (push) Waiting to run
Build & Deploy / deploy (push) Waiting to run
This commit is contained in:
@@ -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=
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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." });
|
||||||
|
}
|
||||||
@@ -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 = "[email protected]";
|
||||||
|
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) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}</li>`)
|
||||||
|
.join("");
|
||||||
|
const statusZeile =
|
||||||
|
status === "zahlungOffen"
|
||||||
|
? `<p style="color:#b06a00;"><strong>⏳ Zahlung noch ausstehend</strong> — Überweisung, bitte Zahlungseingang abwarten, bevor die Ware verschickt wird.</p>`
|
||||||
|
: `<p style="color:#1f9254;"><strong>✅ Zahlung bestätigt</strong> (PayPal)</p>`;
|
||||||
|
return `
|
||||||
|
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
|
||||||
|
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
|
||||||
|
${statusZeile}
|
||||||
|
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
|
||||||
|
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
|
||||||
|
<p><strong>Artikel:</strong></p>
|
||||||
|
<ul>${artikelZeilen}</ul>
|
||||||
|
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
|
||||||
|
<p><a href="https://vans-diy-bastelbedarf.de/verwaltung/">→ Zur Bestellverwaltung</a></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "/" });
|
||||||
|
}
|
||||||
@@ -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 <[email protected]>";
|
||||||
|
|
||||||
|
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]));
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
@@ -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: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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." });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user