Postfach/Team-Verwaltung/Supporter-Abo als Node.js/Express-Server portiert
Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach) auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team- Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo (PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus) und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare- Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/ Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die echten Zugangsdaten eingetragen werden.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/* =====================================================================
|
||||
lib/paypal.js — PayPal Subscriptions API Anbindung (Supporter-Abo).
|
||||
1:1 portiert aus cloudflare-worker/src/lib/paypal.js, env.X → process.env.X.
|
||||
|
||||
WICHTIG — offener Punkt (wie im Original): vollständig fertig
|
||||
implementiert (OAuth2, Subscription erstellen, Subscription abfragen,
|
||||
Webhook-Signatur prüfen), kann aber erst wirklich Geld verarbeiten,
|
||||
sobald folgende echten Werte in der .env gesetzt sind:
|
||||
PAYPAL_CLIENT_ID
|
||||
PAYPAL_CLIENT_SECRET
|
||||
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto)
|
||||
PAYPAL_WEBHOOK_ID
|
||||
PAYPAL_ENV = "sandbox" | "live" (unkritisch, kein Secret)
|
||||
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
|
||||
Business-Konto + Developer-App sind an seine eigene Identität/Bank
|
||||
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
|
||||
Absturzes, solange sie fehlen. NIEMALS in .env.example oder Vault!
|
||||
===================================================================== */
|
||||
|
||||
export class PayPalNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
|
||||
this.name = "PayPalNotConfiguredError";
|
||||
this.code = "PAYPAL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl() {
|
||||
return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
function assertConfigured() {
|
||||
if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_PLAN_ID) {
|
||||
throw new PayPalNotConfiguredError();
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken = null; // { value, expiresAt } — pro Serverprozess, spart Requests
|
||||
|
||||
async function getAccessToken() {
|
||||
assertConfigured();
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
||||
|
||||
const res = await fetch(`${baseUrl()}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
|
||||
const data = await res.json();
|
||||
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
|
||||
return cachedToken.value;
|
||||
}
|
||||
|
||||
async function paypalFetch(path, options = {}) {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${baseUrl()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
|
||||
err.paypalStatus = res.status;
|
||||
err.paypalBody = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
|
||||
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
|
||||
* das Abo direkt bei PayPal zu bestätigen. custom_id trägt unsere interne
|
||||
* supporter_id, damit der Webhook später weiß, wem die Zahlung gehört.
|
||||
*/
|
||||
export async function createSubscription({ supporterId, returnUrl, cancelUrl }) {
|
||||
const data = await paypalFetch("/v1/billing/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
||||
body: JSON.stringify({
|
||||
plan_id: process.env.PAYPAL_PLAN_ID,
|
||||
custom_id: supporterId,
|
||||
application_context: {
|
||||
brand_name: "Dogfather",
|
||||
locale: "de-DE",
|
||||
shipping_preference: "NO_SHIPPING",
|
||||
user_action: "SUBSCRIBE_NOW",
|
||||
return_url: returnUrl,
|
||||
cancel_url: cancelUrl,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const approveLink = (data.links || []).find((l) => l.rel === "approve");
|
||||
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
|
||||
}
|
||||
|
||||
export async function getSubscription(paypalSubscriptionId) {
|
||||
return paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
||||
}
|
||||
|
||||
/** Kündigung — Zugang bleibt bis Periodenende bestehen. */
|
||||
export async function cancelSubscription(paypalSubscriptionId, reason) {
|
||||
await paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
|
||||
* Verify-Signature-Funktion. `req` ist ein Express-Request (Header über
|
||||
* req.headers Objekt, nicht .get()/.headers.get() wie bei Cloudflare).
|
||||
*/
|
||||
export async function verifyWebhookSignature(req, rawBody) {
|
||||
assertConfigured();
|
||||
if (!process.env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
||||
|
||||
const h = req.headers;
|
||||
const verification = await paypalFetch("/v1/notifications/verify-webhook-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auth_algo: h["paypal-auth-algo"],
|
||||
cert_url: h["paypal-cert-url"],
|
||||
transmission_id: h["paypal-transmission-id"],
|
||||
transmission_sig: h["paypal-transmission-sig"],
|
||||
transmission_time: h["paypal-transmission-time"],
|
||||
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return verification.verification_status === "SUCCESS";
|
||||
}
|
||||
Reference in New Issue
Block a user