WIP: PayPal-Einstellungen ueber die Verwaltung (Test folgt auf dem Server)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/* =====================================================================
|
||||
webdesign-geheimnisse.js — PayPal-Zugangsdaten aus der Datenbank
|
||||
|
||||
WARUM ES DAS GIBT
|
||||
|
||||
Die Zugangsdaten standen bisher nur in der .env auf dem Server. Das
|
||||
ist ein guter Ort — aber er verlangt SSH, einen Texteditor und einen
|
||||
Dienstneustart. Filipe soll dafür nicht in die Konsole müssen.
|
||||
|
||||
Jetzt kann er sie in seiner Verwaltung eintragen. Sie werden
|
||||
verschlüsselt gespeichert und beim nächsten Zugriff gelesen. Das
|
||||
heisst auch: kein Neustart nötig, die Änderung wirkt sofort.
|
||||
|
||||
DREI ENTSCHEIDUNGEN
|
||||
|
||||
1. VERSCHLÜSSELT, nicht im Klartext. Verwendet wird derselbe
|
||||
AES-GCM-Weg wie für die Zugangscodes des Universe
|
||||
(lib/crypto.js). Wer die Datenbankdatei in die Hände bekommt --
|
||||
etwa über eine alte Sicherungskopie -- hat damit noch nichts.
|
||||
|
||||
2. DIE .env BLEIBT GÜLTIG und hat VORRANG. Wer die Werte lieber
|
||||
klassisch dort pflegt, kann das weiter tun; das Supporter-Abo
|
||||
benutzt dieselben zwei Werte und läuft womöglich längst darüber.
|
||||
Die Datenbank ist die Ergänzung für alles, was dort noch fehlt --
|
||||
nicht ein zweiter, konkurrierender Ort.
|
||||
|
||||
Vorrang für die .env und nicht umgekehrt, weil sonst ein Eintrag
|
||||
im Browser stillschweigend eine funktionierende Servereinstellung
|
||||
aushebeln könnte. Ein Fehlgriff in einem Formular darf keinen
|
||||
laufenden Zahlungsverkehr umleiten.
|
||||
|
||||
3. WERTE KOMMEN NIE ZURÜCK. Es gibt keinen Weg, ein gespeichertes
|
||||
Geheimnis wieder auszulesen -- weder über die Schnittstelle noch
|
||||
in einer Protokollzeile. Angezeigt wird nur, OB etwas hinterlegt
|
||||
ist, wie lang es ist und wann es zuletzt geändert wurde. Wer den
|
||||
Wert verliert, erzeugt bei PayPal einen neuen; das ist sicherer,
|
||||
als ihn dauerhaft abrufbar zu halten.
|
||||
===================================================================== */
|
||||
|
||||
import { db } from "../db.js";
|
||||
import { encryptCode, decryptCode } from "./crypto.js";
|
||||
|
||||
/* Nur diese Schlüssel dürfen über die Verwaltung gesetzt werden. Eine
|
||||
feste Liste statt "alles, was reinkommt": Sonst könnte über dasselbe
|
||||
Formular jede beliebige Einstellung überschrieben werden. */
|
||||
export const ERLAUBTE_SCHLUESSEL = {
|
||||
PAYPAL_ENV: { geheim: false, beschreibung: "Betriebsart (live oder sandbox)" },
|
||||
PAYPAL_CLIENT_ID: { geheim: false, beschreibung: "Client ID aus dem PayPal-Entwicklerbereich" },
|
||||
PAYPAL_CLIENT_SECRET: { geheim: true, beschreibung: "Secret aus dem PayPal-Entwicklerbereich" },
|
||||
PAYPAL_WD_WEBHOOK_ID: { geheim: false, beschreibung: "Webhook-Kennung (beginnt mit WH-)" },
|
||||
PAYPAL_WD_PLAN_BASIS: { geheim: false, beschreibung: "Plan für die monatliche Betreuung" },
|
||||
};
|
||||
|
||||
const PRAEFIX = "wd_geheim_";
|
||||
|
||||
/* Ein SYNCHRONER Zwischenspeicher -- und das ist der ganze Kniff.
|
||||
|
||||
Entschluesseln ist von Natur aus asynchron. Die Pruefungen des
|
||||
PayPal-Moduls (istLive, istEingerichtet, fehlendeEinstellungen) sind
|
||||
dagegen synchron und werden an rund einem Dutzend Stellen aufgerufen,
|
||||
auch mitten in Antwortaufbauten.
|
||||
|
||||
Sie alle auf async umzustellen waere ein Eingriff quer durch den
|
||||
Bezahlvorgang -- viel Flaeche fuer Fehler an genau der Stelle, an der
|
||||
Fehler Geld kosten. Stattdessen werden die Werte EINMAL beim Start
|
||||
entschluesselt und danach synchron gelesen. Nach jeder Aenderung wird
|
||||
neu geladen, die Wirkung ist also sofort da.
|
||||
|
||||
Der Speicher haelt Klartext im Arbeitsspeicher. Das ist unvermeidlich:
|
||||
Die Werte muessen zum Anmelden bei PayPal ohnehin im Klartext
|
||||
vorliegen -- so, wie sie es auch als Umgebungsvariable taeten. */
|
||||
let speicher = {};
|
||||
let geladen = false;
|
||||
|
||||
/* Laedt und entschluesselt alles. Beim Start und nach jeder Aenderung. */
|
||||
export async function geheimnisseLaden() {
|
||||
const raus = {};
|
||||
for (const schluessel of Object.keys(ERLAUBTE_SCHLUESSEL)) {
|
||||
const zeile = db
|
||||
.prepare(`SELECT value FROM app_settings WHERE key = ?`)
|
||||
.get(PRAEFIX + schluessel);
|
||||
if (!zeile || !zeile.value) continue;
|
||||
try {
|
||||
raus[schluessel] = await decryptCode(zeile.value);
|
||||
} catch (e) {
|
||||
/* Fast immer: der ENCRYPTION_KEY wurde gewechselt. Dann ist der
|
||||
Wert verloren -- aber das darf den Dienst nicht anhalten. Er
|
||||
verhaelt sich, als waere nichts hinterlegt, und die Verwaltung
|
||||
zeigt es an. Der Wert selbst erscheint NICHT in der Meldung. */
|
||||
console.error(`[webdesign] ${schluessel} nicht entschluesselbar - bitte neu eintragen.`);
|
||||
}
|
||||
}
|
||||
speicher = raus;
|
||||
geladen = true;
|
||||
return Object.keys(raus).length;
|
||||
}
|
||||
|
||||
export function istGeladen() { return geladen; }
|
||||
|
||||
/* Der eine Zugriffspunkt fuer den Rest des Codes. SYNCHRON.
|
||||
|
||||
Reihenfolge: .env zuerst, Datenbank danach. Begruendung im Kopf. */
|
||||
export function einstellung(schluessel) {
|
||||
const ausEnv = process.env[schluessel];
|
||||
if (ausEnv && String(ausEnv).trim()) return String(ausEnv).trim();
|
||||
return speicher[schluessel] || "";
|
||||
}
|
||||
|
||||
/* Setzt einen Wert. Leerer Wert = Eintrag entfernen. */
|
||||
export async function einstellungSetzen(schluessel, wert) {
|
||||
if (!(schluessel in ERLAUBTE_SCHLUESSEL)) {
|
||||
throw new Error("Unbekannte Einstellung.");
|
||||
}
|
||||
const sauber = String(wert == null ? "" : wert).trim();
|
||||
|
||||
if (!sauber) {
|
||||
db.prepare(`DELETE FROM app_settings WHERE key = ?`).run(PRAEFIX + schluessel);
|
||||
await geheimnisseLaden();
|
||||
return { entfernt: true };
|
||||
}
|
||||
|
||||
if (schluessel === "PAYPAL_ENV" && !["live", "sandbox"].includes(sauber)) {
|
||||
throw new Error("Betriebsart muss 'live' oder 'sandbox' sein.");
|
||||
}
|
||||
|
||||
const verschluesselt = await encryptCode(sauber);
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(PRAEFIX + schluessel, verschluesselt, new Date().toISOString());
|
||||
|
||||
await geheimnisseLaden();
|
||||
return { gesetzt: true, laenge: sauber.length };
|
||||
}
|
||||
|
||||
/* Der Stand für die Anzeige. Gibt NIE einen Wert zurück -- nur, ob
|
||||
etwas da ist, woher es kommt, wie lang es ist und wann es zuletzt
|
||||
geändert wurde. */
|
||||
export function stand() {
|
||||
const raus = {};
|
||||
|
||||
for (const [schluessel, meta] of Object.entries(ERLAUBTE_SCHLUESSEL)) {
|
||||
const ausEnv = process.env[schluessel] && String(process.env[schluessel]).trim();
|
||||
const ausDb = speicher[schluessel];
|
||||
const wert = ausEnv || ausDb || "";
|
||||
const zeile = db
|
||||
.prepare(`SELECT updated_at FROM app_settings WHERE key = ?`)
|
||||
.get(PRAEFIX + schluessel);
|
||||
|
||||
raus[schluessel] = {
|
||||
gesetzt: !!wert,
|
||||
/* Die Betriebsart ist kein Geheimnis und wird ausgeschrieben --
|
||||
sie ist die Angabe, bei der man sich am ehesten vertut, und
|
||||
"gesetzt (7 Zeichen)" hilft dabei niemandem. */
|
||||
wert: meta.geheim ? null : (schluessel === "PAYPAL_ENV" ? wert : null),
|
||||
laenge: wert ? wert.length : 0,
|
||||
quelle: ausEnv ? "server" : (ausDb ? "verwaltung" : null),
|
||||
geaendert: zeile ? zeile.updated_at : null,
|
||||
beschreibung: meta.beschreibung,
|
||||
geheim: meta.geheim,
|
||||
};
|
||||
}
|
||||
return raus;
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
===================================================================== */
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { einstellung } from "./webdesign-geheimnisse.js";
|
||||
|
||||
export class PayPalNichtEingerichtet extends Error {
|
||||
constructor(fehlend) {
|
||||
@@ -47,13 +48,13 @@ export class PayPalNichtEingerichtet extends Error {
|
||||
live) würde echtes Geld bewegt. Die ungefährliche Richtung gehört in die
|
||||
Voreinstellung. */
|
||||
function basis() {
|
||||
return process.env.PAYPAL_ENV === "live"
|
||||
return einstellung("PAYPAL_ENV") === "live"
|
||||
? "https://api-m.paypal.com"
|
||||
: "https://api-m.sandbox.paypal.com";
|
||||
}
|
||||
|
||||
export function istLive() {
|
||||
return process.env.PAYPAL_ENV === "live";
|
||||
return einstellung("PAYPAL_ENV") === "live";
|
||||
}
|
||||
|
||||
/* Prüft, ob alles Nötige da ist. `fuer` unterscheidet die beiden Welten:
|
||||
@@ -62,9 +63,9 @@ export function istLive() {
|
||||
Betreuungsplan angelegt wurde. */
|
||||
export function fehlendeEinstellungen(fuer = "einmal") {
|
||||
const fehlt = [];
|
||||
if (!process.env.PAYPAL_CLIENT_ID) fehlt.push("PAYPAL_CLIENT_ID");
|
||||
if (!process.env.PAYPAL_CLIENT_SECRET) fehlt.push("PAYPAL_CLIENT_SECRET");
|
||||
if (fuer === "abo" && !process.env.PAYPAL_WD_PLAN_BASIS) fehlt.push("PAYPAL_WD_PLAN_BASIS");
|
||||
if (!einstellung("PAYPAL_CLIENT_ID")) fehlt.push("PAYPAL_CLIENT_ID");
|
||||
if (!einstellung("PAYPAL_CLIENT_SECRET")) fehlt.push("PAYPAL_CLIENT_SECRET");
|
||||
if (fuer === "abo" && !einstellung("PAYPAL_WD_PLAN_BASIS")) fehlt.push("PAYPAL_WD_PLAN_BASIS");
|
||||
return fehlt;
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ async function zugangstoken() {
|
||||
if (token && token.laeuftAb > Date.now() + 60_000) return token.wert;
|
||||
|
||||
const anmeldung = Buffer.from(
|
||||
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
|
||||
`${einstellung("PAYPAL_CLIENT_ID")}:${einstellung("PAYPAL_CLIENT_SECRET")}`
|
||||
).toString("base64");
|
||||
|
||||
const antwort = await fetch(`${basis()}/v1/oauth2/token`, {
|
||||
@@ -111,6 +112,14 @@ async function zugangstoken() {
|
||||
|
||||
/* Für Tests: Zwischenspeicher leeren, damit ein Wechsel der Zugangsdaten
|
||||
sofort wirkt statt erst nach einer Stunde. */
|
||||
/* Meldet sich testweise an und wirft, wenn PayPal ablehnt. Bewegt kein
|
||||
Geld. Erst das beweist, dass die Zugangsdaten stimmen -- "gesetzt"
|
||||
heisst nur, dass etwas dasteht. */
|
||||
export async function zugangstokenPruefen() {
|
||||
await zugangstoken();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function tokenVergessen() {
|
||||
token = null;
|
||||
}
|
||||
@@ -158,6 +167,17 @@ export function paypalZuCent(wert) {
|
||||
return parseInt(ganz, 10) * 100 + parseInt((rest + "00").slice(0, 2), 10);
|
||||
}
|
||||
|
||||
/* WOHER DIE ZUGANGSDATEN KOMMEN
|
||||
|
||||
Nicht mehr direkt aus process.env, sondern ueber einstellung() aus
|
||||
lib/webdesign-geheimnisse.js. Die liest zuerst die .env (dort hat sie
|
||||
Vorrang) und faellt sonst auf die verschluesselten Werte zurueck, die
|
||||
Filipe in seiner Verwaltung eintragen kann.
|
||||
|
||||
Der Grund ist praktisch: Fuer die .env braucht es SSH, einen Editor
|
||||
und einen Dienstneustart. Ueber die Verwaltung wirkt eine Aenderung
|
||||
sofort -- und niemand muss in die Konsole. */
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
1) EINMALZAHLUNG — Bestellung anlegen
|
||||
Der Kunde wird danach zum "approve"-Link geschickt und bestätigt dort
|
||||
@@ -273,9 +293,9 @@ export async function bestellungAbfragen(orderId) {
|
||||
PayPal den Betrag am Plan festmacht — nicht am einzelnen Abo. */
|
||||
export function planFuerPaket(paket) {
|
||||
const karte = {
|
||||
basis: process.env.PAYPAL_WD_PLAN_BASIS,
|
||||
plus: process.env.PAYPAL_WD_PLAN_PLUS,
|
||||
premium: process.env.PAYPAL_WD_PLAN_PREMIUM,
|
||||
basis: einstellung("PAYPAL_WD_PLAN_BASIS"),
|
||||
plus: einstellung("PAYPAL_WD_PLAN_PLUS"),
|
||||
premium: einstellung("PAYPAL_WD_PLAN_PREMIUM"),
|
||||
};
|
||||
return karte[paket] || karte.basis || null;
|
||||
}
|
||||
@@ -339,7 +359,7 @@ export async function aboKuendigen(subscriptionId, grund) {
|
||||
--------------------------------------------------------------------- */
|
||||
export async function webhookEchtheitPruefen(kopfzeilen, rohkoerper) {
|
||||
pruefeEingerichtet("einmal");
|
||||
if (!process.env.PAYPAL_WD_WEBHOOK_ID) {
|
||||
if (!einstellung("PAYPAL_WD_WEBHOOK_ID")) {
|
||||
throw new PayPalNichtEingerichtet(["PAYPAL_WD_WEBHOOK_ID"]);
|
||||
}
|
||||
|
||||
@@ -351,7 +371,7 @@ export async function webhookEchtheitPruefen(kopfzeilen, rohkoerper) {
|
||||
transmission_id: kopfzeilen["paypal-transmission-id"],
|
||||
transmission_sig: kopfzeilen["paypal-transmission-sig"],
|
||||
transmission_time: kopfzeilen["paypal-transmission-time"],
|
||||
webhook_id: process.env.PAYPAL_WD_WEBHOOK_ID,
|
||||
webhook_id: einstellung("PAYPAL_WD_WEBHOOK_ID"),
|
||||
webhook_event: JSON.parse(rohkoerper),
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user