Alle ausstehenden Aenderungen (Bilder, Texte) fuer den Server-Umzug uebernommen
This commit is contained in:
@@ -7,16 +7,19 @@
|
||||
- Team-Codes: SHA-256-Hash-Lookup (users.code_hash).
|
||||
- Nach 5 Fehlversuchen pro Client (IP-basiert) 15 Minuten Sperre —
|
||||
angelehnt an Cloudflares "Protect your login"-Standardregel.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout
|
||||
30 Minuten, absolute Höchstdauer 12 Stunden.
|
||||
- Sessions: Token wird nur gehasht gespeichert. Inaktivitäts-Timeout und
|
||||
absolute Höchstdauer beide 24 Stunden (Nutzer-Wunsch 04.08.2026: "den
|
||||
Zugangscode auf der Verwaltungsseite nur einmal am Tag eingeben
|
||||
müssen") — vorher 30 Min/12 Std, was mitten am Tag zum erneuten
|
||||
Login-Zwang führte.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
|
||||
import { logAction } from "./audit.js";
|
||||
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MINUTES = 15;
|
||||
const INACTIVITY_MS = 30 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000;
|
||||
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function clientKey(request) {
|
||||
return request.headers.get("CF-Connecting-IP") || "unknown";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* =====================================================================
|
||||
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
|
||||
anmelden") serverseitig, für den öffentlichen Supporter-Login.
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis — steht bewusst als
|
||||
normale [vars]-Variable in wrangler.toml, nicht als Secret). Ohne
|
||||
gesetzten Wert bleibt "Mit Google anmelden" ausgeblendet (siehe
|
||||
supporter.js im Frontend) statt kaputt zu sein.
|
||||
|
||||
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
|
||||
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
|
||||
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück. Das
|
||||
spart eine eigene JWKS-/RS256-Prüfung im Worker, ist aber genauso sicher
|
||||
(offiziell von Google für Server-Side-Verifikation empfohlen).
|
||||
===================================================================== */
|
||||
|
||||
export class GoogleAuthNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
|
||||
this.name = "GoogleAuthNotConfiguredError";
|
||||
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
|
||||
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
|
||||
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
|
||||
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
|
||||
*/
|
||||
export async function verifyGoogleIdToken(env, credential) {
|
||||
if (!env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
|
||||
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
|
||||
|
||||
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
||||
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
|
||||
const data = await res.json();
|
||||
|
||||
if (data.aud !== env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
|
||||
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
email: String(data.email).toLowerCase(),
|
||||
name: data.name || data.given_name || data.email.split("@")[0],
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
http.js — gemeinsame HTTP-Helfer (CORS, JSON-Responses)
|
||||
===================================================================== */
|
||||
|
||||
// TODO (bewusst noch NICHT auf eine feste Origin eingeschränkt): seit
|
||||
// 03.08.2026 ist die eigene Domain final (dogfather-universe.com, läuft
|
||||
// PARALLEL zur bisherigen workers.dev-Adresse weiter). Ein Origin-genaues
|
||||
// CORS bräuchte den `request` (für den Origin-Header) an praktisch jeder
|
||||
// `json(...)`-Aufrufstelle im gesamten Worker — ein größerer, eigener
|
||||
// Umbau, absichtlich nicht "schnell nebenbei" gemacht, um keine
|
||||
// Login-/Zahlungs-Endpunkte durch eine halbfertige Änderung zu brechen.
|
||||
// Erlaubte Origins für den künftigen Umbau: https://dogfather-universe.com,
|
||||
// https://www.dogfather-universe.com, https://dogfather-universe.dogfather1608.workers.dev
|
||||
export function corsHeaders() {
|
||||
// TODO: sobald die Domain final steht, statt "*" die echte Domain eintragen.
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =====================================================================
|
||||
lib/paypal.js — PayPal Subscriptions API Anbindung
|
||||
(Dogfather_VanVan_Supporter_Abo.odt, Abschnitt 7/8)
|
||||
|
||||
WICHTIG — offener Punkt, siehe README.md "Supporter-Abo Setup":
|
||||
Diese Datei ist vollständig fertig implementiert (OAuth2, Subscription
|
||||
erstellen, Subscription abfragen, Webhook-Signatur prüfen), kann aber
|
||||
erst wirklich Geld verarbeiten, sobald folgende Secrets gesetzt sind
|
||||
(per `wrangler secret put NAME`, NIEMALS im Code/Repo):
|
||||
PAYPAL_CLIENT_ID
|
||||
PAYPAL_CLIENT_SECRET
|
||||
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto, Abschnitt 7)
|
||||
PAYPAL_WEBHOOK_ID
|
||||
Und der Var (in wrangler.toml, unkritisch, kein Secret):
|
||||
PAYPAL_ENV = "sandbox" | "live"
|
||||
|
||||
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.
|
||||
===================================================================== */
|
||||
|
||||
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(env) {
|
||||
return env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
function assertConfigured(env) {
|
||||
if (!env.PAYPAL_CLIENT_ID || !env.PAYPAL_CLIENT_SECRET || !env.PAYPAL_PLAN_ID) {
|
||||
throw new PayPalNotConfiguredError();
|
||||
}
|
||||
}
|
||||
|
||||
let cachedToken = null; // { value, expiresAt } — pro Worker-Instanz, spart Requests
|
||||
|
||||
async function getAccessToken(env) {
|
||||
assertConfigured(env);
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
|
||||
|
||||
const res = await fetch(`${baseUrl(env)}/v1/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${env.PAYPAL_CLIENT_ID}:${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(env, path, options = {}) {
|
||||
const token = await getAccessToken(env);
|
||||
const res = await fetch(`${baseUrl(env)}${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 (Abschnitt 6, Schritt 6-7).
|
||||
* custom_id trägt unsere interne supporter_id, damit der Webhook später
|
||||
* weiß, wem die Zahlung gehört.
|
||||
*/
|
||||
export async function createSubscription(env, { supporterId, returnUrl, cancelUrl }) {
|
||||
const data = await paypalFetch(env, "/v1/billing/subscriptions", {
|
||||
method: "POST",
|
||||
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
|
||||
body: JSON.stringify({
|
||||
plan_id: 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(env, paypalSubscriptionId) {
|
||||
return paypalFetch(env, `/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
|
||||
}
|
||||
|
||||
/** Kündigung (Abschnitt 22: "verständlich und leicht kündbar"). Zugang bleibt bis Periodenende bestehen. */
|
||||
export async function cancelSubscription(env, paypalSubscriptionId, reason) {
|
||||
await paypalFetch(env, `/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 (Abschnitt 8, "sichere PayPal-Webhook-Prüfung"
|
||||
* aus den Sicherheitsanforderungen, Abschnitt 29).
|
||||
*/
|
||||
export async function verifyWebhookSignature(env, request, rawBody) {
|
||||
assertConfigured(env);
|
||||
if (!env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
|
||||
|
||||
const h = request.headers;
|
||||
const verification = await paypalFetch(env, "/v1/notifications/verify-webhook-signature", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auth_algo: h.get("paypal-auth-algo"),
|
||||
cert_url: h.get("paypal-cert-url"),
|
||||
transmission_id: h.get("paypal-transmission-id"),
|
||||
transmission_sig: h.get("paypal-transmission-sig"),
|
||||
transmission_time: h.get("paypal-transmission-time"),
|
||||
webhook_id: env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: JSON.parse(rawBody),
|
||||
}),
|
||||
});
|
||||
return verification.verification_status === "SUCCESS";
|
||||
}
|
||||
@@ -56,6 +56,11 @@ export const PERMISSIONS = {
|
||||
SETTINGS_VIEW_STATS: "Statistiken ansehen",
|
||||
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
|
||||
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
|
||||
|
||||
// Supporter-Abo (Dogfather; die Teddy-Kollektion entsteht in Kooperation mit VanVan)
|
||||
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
|
||||
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
|
||||
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
|
||||
};
|
||||
|
||||
export const PERMISSION_GROUPS = [
|
||||
@@ -65,6 +70,7 @@ export const PERMISSION_GROUPS = [
|
||||
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
|
||||
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
|
||||
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
|
||||
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
|
||||
];
|
||||
|
||||
// Nur zur Dokumentation/Klarheit — diese Aktionen sind NIE als Permission
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-auth.js — Registrierung, E-Mail-Bestätigung, Magic-Link-
|
||||
Login und Sessions für den ÖFFENTLICHEN Supporter-Bereich.
|
||||
|
||||
Bewusst komplett getrennt von auth.js (das ist für das interne
|
||||
Team-Zugangssystem mit Rollen/Rechten). Supporter sind Fans/Kund:innen
|
||||
ohne jegliche Berechtigungen im internen Bereich.
|
||||
|
||||
Login-Prinzip (Abschnitt 5/116 im Auftrag: "bevorzugt … sicherer
|
||||
E-Mail-Code oder Magic Link"): kein Passwort nötig. Ein 6-stelliger Code
|
||||
wird an die hinterlegte E-Mail-Adresse geschickt, 15 Minuten gültig,
|
||||
einmal verwendbar, nur der Hash liegt in der Datenbank.
|
||||
===================================================================== */
|
||||
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage — Supporter sollen nicht ständig neu einloggen müssen
|
||||
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000; // 14 Tage ohne Aktivität → Session verfällt
|
||||
|
||||
export function normalizeTikTokUsername(input) {
|
||||
if (!input) return null;
|
||||
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function normalizeEmail(input) {
|
||||
return String(input || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function generateNumericCode() {
|
||||
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
|
||||
return String(n).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Erzeugt+speichert einen neuen 6-stelligen Code für E-Mail-Bestätigung oder Login. */
|
||||
export async function issueAuthCode(env, supporterId, purpose) {
|
||||
const code = generateNumericCode();
|
||||
const codeHash = await sha256Hex(code);
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso())
|
||||
.run();
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Prüft einen eingegebenen Code; markiert ihn bei Erfolg als verbraucht (einmal gültig). */
|
||||
export async function verifyAuthCode(env, supporterId, purpose, code) {
|
||||
const codeHash = await sha256Hex(String(code || "").trim());
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT id, expires_at, used_at FROM supporter_auth_codes
|
||||
WHERE supporter_id = ? AND purpose = ? AND code_hash = ?
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
)
|
||||
.bind(supporterId, purpose, codeHash)
|
||||
.first();
|
||||
if (!row) return false;
|
||||
if (row.used_at) return false;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) return false;
|
||||
await env.DB.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).bind(nowIso(), row.id).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createSupporterSession(env, supporterId) {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const now = new Date();
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString())
|
||||
.run();
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveSupporterSession(env, token) {
|
||||
if (!token) return null;
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const session = await env.DB.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).first();
|
||||
if (!session) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(tokenHash).run();
|
||||
return null;
|
||||
}
|
||||
await env.DB.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).bind(nowIso(), tokenHash).run();
|
||||
|
||||
const supporter = await env.DB.prepare(
|
||||
`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`
|
||||
)
|
||||
.bind(session.supporter_id)
|
||||
.first();
|
||||
if (!supporter) return null;
|
||||
return supporter;
|
||||
}
|
||||
|
||||
export async function endSupporterSession(env, token) {
|
||||
if (!token) return;
|
||||
await env.DB.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).bind(await sha256Hex(token)).run();
|
||||
}
|
||||
|
||||
export async function requireSupporterSession(request, env) {
|
||||
const auth = request.headers.get("Authorization") || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
|
||||
return resolveSupporterSession(env, token);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-
|
||||
Prämienzyklus des Dogfather Supporter-Abos (die Teddy-Kollektion
|
||||
entsteht in Kooperation mit VanVan, das Abo selbst gehört nur Dogfather).
|
||||
|
||||
Verbindliche Regeln (siehe Dogfather_VanVan_Supporter_Abo.odt,
|
||||
Abschnitt 11-13, wortwörtlich umgesetzt, NICHT verändert):
|
||||
Monat 6 → kleiner Hasen-Teddy
|
||||
Monat 12 → mittelgroßer Hasen-Teddy
|
||||
Monat 18 → großer Hasen-Teddy
|
||||
Monat 24 → Tasse + Autogrammkarte
|
||||
Monat 30 → Hoodie (Zyklus damit abgeschlossen)
|
||||
ab 31 → neuer Zyklus, neue Kollektion, Fortschritt bei 0
|
||||
|
||||
Reine Funktionen ohne D1-/Netzwerkzugriff — dadurch leicht von Hand
|
||||
nachrechenbar und ohne Testrunner überprüfbar (siehe Beispiel im
|
||||
Auftrag: 37 bezahlte Monate → Zyklus 2, Fortschritt 7 von 30).
|
||||
===================================================================== */
|
||||
|
||||
export const CYCLE_LENGTH = 30;
|
||||
|
||||
// Reihenfolge ist bewusst fest — bestimmt auch premium_type-Zuordnung.
|
||||
export const MILESTONES = [
|
||||
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
|
||||
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
|
||||
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
|
||||
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
|
||||
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Aus der GESAMTEN Anzahl erfolgreich angerechneter Abo-Monate (bleibt für
|
||||
* immer erhalten, auch über Kündigung/Reaktivierung hinweg) errechnet sich
|
||||
* rein rechnerisch: wie viele 30er-Zyklen sind vollständig abgeschlossen,
|
||||
* in welchem Zyklus befindet man sich gerade (1-basiert) und wie weit ist
|
||||
* der aktuelle Zyklus fortgeschritten (1..30).
|
||||
*/
|
||||
export function computeProgress(totalPaidMonths) {
|
||||
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
|
||||
if (total === 0) {
|
||||
return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
|
||||
}
|
||||
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
|
||||
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH; // 1..30
|
||||
return {
|
||||
totalPaidMonths: total,
|
||||
completedCycles,
|
||||
cycleNumber: completedCycles + 1, // 1-basiert = welche Kollektion gerade läuft
|
||||
currentCycleMonth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Welche Meilensteine sind im AKTUELLEN, laufenden Zyklus schon erreicht? */
|
||||
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
|
||||
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
|
||||
}
|
||||
|
||||
/** Nächster noch offener Meilenstein im laufenden Zyklus (oder null, wenn Zyklus fertig). */
|
||||
export function nextMilestone(currentCycleMonth) {
|
||||
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert ALLE (Zyklus, Meilenstein)-Kombinationen, die bei der gegebenen
|
||||
* Gesamtmonatszahl freigeschaltet sein MÜSSEN — über alle bereits
|
||||
* abgeschlossenen Zyklen hinweg plus den laufenden Zyklus. Wird beim
|
||||
* Verarbeiten einer erfolgreichen Zahlung benutzt, um herauszufinden,
|
||||
* welche supporter_premiums-Zeilen neu angelegt werden müssen.
|
||||
*/
|
||||
export function allUnlockedMilestones(totalPaidMonths) {
|
||||
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
|
||||
const unlocked = [];
|
||||
// Vollständig abgeschlossene Zyklen: ALLE 5 Meilensteine gelten als erreicht.
|
||||
for (let c = 1; c <= completedCycles; c++) {
|
||||
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
|
||||
}
|
||||
// Laufender Zyklus: nur die bereits erreichten.
|
||||
const cycleNumber = completedCycles + 1;
|
||||
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
|
||||
unlocked.push({ cycleNumber, ...m });
|
||||
}
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
/** Für die Fortschritts-Zeitleiste im Supporter-Bereich (Abschnitt 10, Fenster 2). */
|
||||
export function timelineForCurrentCycle(totalPaidMonths) {
|
||||
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
|
||||
return {
|
||||
cycleNumber,
|
||||
currentCycleMonth,
|
||||
milestones: MILESTONES.map((m) => ({
|
||||
...m,
|
||||
reached: currentCycleMonth >= m.month,
|
||||
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
|
||||
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =====================================================================
|
||||
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
|
||||
(E-Mail-Bestätigung, Login-Codes, Zahlungs-/Prämien-Benachrichtigungen).
|
||||
|
||||
WICHTIG — offener Punkt, siehe auch README.md "Supporter-Abo Setup":
|
||||
Es existiert aktuell KEIN E-Mail-Versand-Anbieter (kein Resend/
|
||||
MailChannels o.ä. eingerichtet) und KEINE eigene Domain (die Seite läuft
|
||||
noch auf *.workers.dev) — beides wird für zuverlässigen E-Mail-Versand
|
||||
gebraucht (Absender-Domain muss per SPF/DKIM verifiziert sein, sonst
|
||||
landet alles im Spam oder wird abgelehnt).
|
||||
|
||||
Diese Datei ist deshalb bewusst als AUSTAUSCHBARER Adapter gebaut:
|
||||
Sobald RESEND_API_KEY (+ RESEND_FROM) als Secret gesetzt ist, wird über
|
||||
die Resend-API (https://resend.com, kostenloser Tier reicht für den
|
||||
Start) verschickt. Fehlt der Key, wird NICHTS verschickt und ein
|
||||
sprechender Fehler zurückgegeben — bewusst "fail closed" statt den Code
|
||||
irgendwo im Klartext zu loggen (Sicherheitsprinzip wie beim Rest des
|
||||
Zugangssystems, siehe audit.js).
|
||||
===================================================================== */
|
||||
|
||||
export class EmailNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
|
||||
this.name = "EmailNotConfiguredError";
|
||||
this.code = "EMAIL_NOT_CONFIGURED";
|
||||
}
|
||||
}
|
||||
|
||||
async function send(env, { to, subject, html }) {
|
||||
if (!env.RESEND_API_KEY || !env.RESEND_FROM) throw new EmailNotConfiguredError();
|
||||
const res = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ from: env.RESEND_FROM, to: [to], subject, html }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const WRAP = (title, bodyHtml) => `
|
||||
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
|
||||
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
|
||||
${bodyHtml}
|
||||
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
export async function sendVerifyEmailCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
|
||||
html: WRAP(
|
||||
`Willkommen, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLoginCode(env, { to, name, code }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
|
||||
html: WRAP(
|
||||
`Hallo ${name} 👋`,
|
||||
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
|
||||
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMilestoneUnlocked(env, { to, name, premiumLabel }) {
|
||||
return send(env, {
|
||||
to,
|
||||
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
|
||||
html: WRAP(
|
||||
`Glückwunsch, ${name}! 🩵`,
|
||||
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
|
||||
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
|
||||
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
|
||||
),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user