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

This commit is contained in:
qcigano
2026-08-05 15:06:17 +02:00
parent 53e7bb158a
commit b8b8ec7b7a
19 changed files with 1322 additions and 0 deletions
+76
View File
@@ -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." });
}
+180
View File
@@ -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." };
}
}
+74
View File
@@ -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: "/" });
}
+41
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
+119
View File
@@ -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`);
}
}
+120
View File
@@ -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));
}
+69
View File
@@ -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) };
}
}
+54
View File
@@ -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;
}