WIP: PayPal-Einstellungen ueber die Verwaltung (Test folgt auf dem Server)

This commit is contained in:
2026-08-23 16:50:18 +02:00
parent 692d702ebf
commit 5043642951
6 changed files with 629 additions and 11 deletions
+17
View File
@@ -124,7 +124,13 @@ import {
import {
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
paypalWebhook, zahlungAnlegen, zahlungenListe,
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen,
} from "./routes/webdesign-zahlungen.js";
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
Sie muessen beim Start EINMAL entschluesselt werden, weil die
Pruefungen des PayPal-Moduls synchron sind. */
import { geheimnisseLaden } from "./lib/webdesign-geheimnisse.js";
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
initDb();
@@ -135,6 +141,14 @@ initDb();
Neustart weg -- und niemand kaeme auf die Idee, dass der Neustart
daran schuld war. */
vorlagenSicherstellen();
/* Ebenfalls nach initDb. Faellt das aus (z.B. weil der ENCRYPTION_KEY
fehlt), laeuft der Dienst trotzdem weiter -- er verhaelt sich dann so,
als waere in der Verwaltung nichts hinterlegt, und die .env gilt
weiter. Ein fehlender Zahlungsanbieter darf nicht die ganze Seite
anhalten. */
geheimnisseLaden()
.then((n) => { if (n) console.log(`[webdesign] ${n} Einstellungen aus der Datenbank geladen.`); })
.catch((e) => console.error("[webdesign] Einstellungen nicht ladbar:", e?.message));
const PORT = Number(process.env.PORT || 4200);
const app = express();
@@ -332,6 +346,9 @@ app.post("/webdesign/portal/zahlung/:id/start", wrap(zahlungStarten));
app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
app.get("/webdesign/admin/postfach", wrap(adminPostfachListe));
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf));
@@ -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;
}
+31 -11
View File
@@ -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),
}),
});
@@ -51,6 +51,7 @@ import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
import { brauchtKunde } from "./webdesign-portal.js";
import { zustimmungSpeichern } from "./webdesign-widerruf.js";
import * as paypal from "../lib/webdesign-paypal.js";
import * as geheim from "../lib/webdesign-geheimnisse.js";
import {
jetzt, neueId, naechsteNummer, text, verlauf, centZuText,
besucherIp, spracheOderDe,
@@ -454,3 +455,125 @@ export async function zahlungenListe(req, res) {
},
});
}
/* =====================================================================
EINSTELLUNGEN — PayPal-Zugangsdaten über die Verwaltung eintragen
Damit Filipe dafür nicht in die Konsole muss. Die Werte werden
verschlüsselt gespeichert (siehe lib/webdesign-geheimnisse.js) und
wirken sofort — ohne Dienstneustart.
===================================================================== */
/* GET /webdesign/admin/einstellungen
Gibt NIE einen hinterlegten Wert zurück, auch nicht teilweise. Nur:
ist etwas da, woher stammt es, wie lang ist es, wann zuletzt geändert.
Ein Geheimnis, das man wieder auslesen kann, ist über kurz oder lang
auch dort, wo es nicht hingehört. */
export async function einstellungenLesen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
return json(res, {
ok: true,
stand: geheim.stand(),
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
webhookUrl: `${(process.env.API_BASIS_URL || "https://postfach.dogfather-universe.com")}/webdesign/paypal-webhook`,
});
}
/* POST /webdesign/admin/einstellungen
Nimmt beliebig viele der erlaubten Schlüssel entgegen. Ein FEHLENDER
Schlüssel bleibt unverändert — nur ein ausdrücklich leerer Wert
löscht. Sonst würde ein Formular, das nur ein Feld ausfüllt, beim
Speichern alle anderen leeren. */
export async function einstellungenSchreiben(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const eingang = req.body?.werte;
if (!eingang || typeof eingang !== "object") {
return json(res, { ok: false, error: "Keine Werte übermittelt." }, 400);
}
const geaendert = [];
const abgelehnt = [];
for (const [schluessel, wert] of Object.entries(eingang)) {
if (!(schluessel in geheim.ERLAUBTE_SCHLUESSEL)) { abgelehnt.push(schluessel); continue; }
/* undefined = "nicht angefasst". Leerer String = "löschen". Das ist
ein echter Unterschied und darf nicht verwischt werden. */
if (wert === undefined || wert === null) continue;
try {
const r = await geheim.einstellungSetzen(schluessel, wert);
geaendert.push(schluessel + (r.entfernt ? " (entfernt)" : ""));
} catch (fehler) {
return json(res, { ok: false, error: fehler.message }, 400);
}
}
/* PayPal merkt sich ein Zugangstoken. Nach einem Wechsel der
Zugangsdaten wäre es das alte — und die nächste Zahlung liefe noch
über das vorherige Konto oder scheiterte mit "invalid client".
Deshalb hier ausdrücklich vergessen. */
paypal.tokenVergessen();
/* Im Protokoll steht, WAS geändert wurde, nie WOMIT. */
verlauf("einstellungen", "paypal", "geaendert",
"team:" + (p.sitzung?.actor || "?"), { felder: geaendert });
return json(res, {
ok: true,
geaendert,
abgelehnt,
stand: geheim.stand(),
einrichtung: {
einmal: paypal.istEingerichtet("einmal"),
abo: paypal.istEingerichtet("abo"),
fehlt: paypal.fehlendeEinstellungen("abo"),
live: paypal.istLive(),
},
});
}
/* POST /webdesign/admin/einstellungen/pruefen
Meldet sich testweise bei PayPal an. Erst das beweist, dass die Werte
stimmen — "gesetzt" heisst nur, dass etwas dasteht, nicht dass es
richtig ist. Es fliesst dabei kein Geld. */
export async function einstellungenPruefen(req, res) {
const p = await darf(req, "WD_ZAHLUNGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
if (!paypal.istEingerichtet("einmal")) {
return json(res, {
ok: false,
error: "Es fehlen noch Werte: " + paypal.fehlendeEinstellungen("einmal").join(", "),
}, 400);
}
try {
paypal.tokenVergessen();
await paypal.zugangstokenPruefen();
return json(res, {
ok: true,
live: paypal.istLive(),
meldung: paypal.istLive()
? "Anmeldung bei PayPal erfolgreich — Echtbetrieb."
: "Anmeldung erfolgreich — aber im TESTMODUS. Es fliesst kein echtes Geld.",
});
} catch (fehler) {
return json(res, {
ok: false,
error: "PayPal hat die Anmeldung abgelehnt. Meist stimmen Client ID und Secret "
+ "nicht zusammen, oder sie stammen aus verschiedenen Apps bzw. aus dem Testbereich.",
technisch: String(fehler.message || fehler).slice(0, 200),
}, 400);
}
}
+93
View File
@@ -0,0 +1,93 @@
/* Prueft die Ablage der PayPal-Zugangsdaten.
Der wichtigste Punkt ist NICHT, dass Speichern funktioniert, sondern
dass ein gespeichertes Geheimnis auf keinem Weg wieder herauskommt.
*/
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
const tmp = mkdtempSync(join(tmpdir(), "wd-geh-"));
process.env.DB_PATH = join(tmp, "test.db");
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
delete process.env.PAYPAL_CLIENT_ID;
delete process.env.PAYPAL_CLIENT_SECRET;
delete process.env.PAYPAL_ENV;
const LOG = "/tmp/wd-geh.log";
const L = (...a) => { try { appendFileSync(LOG, a.join(" ") + "\n"); } catch (e) {} console.log(...a); };
const { db, initDb } = await import("./db.js");
initDb();
const geheim = await import("./lib/webdesign-geheimnisse.js");
const paypal = await import("./lib/webdesign-paypal.js");
let ok = 0, fehler = 0;
const pruefe = (n, b, z) => { if (b) { ok++; L(" ok " + n); } else { fehler++; L(" FEHL " + n + (z ? " -> " + z : "")); } };
const SECRET = 'EMeineHeikleZeichen&/\$"~UndLang1234567890';
L("\n1. SPEICHERN UND LESEN");
await geheim.geheimnisseLaden();
pruefe("vor dem Eintragen gilt PayPal als nicht eingerichtet", paypal.istEingerichtet("einmal") === false);
await geheim.einstellungSetzen("PAYPAL_CLIENT_ID", "AbC-123");
await geheim.einstellungSetzen("PAYPAL_CLIENT_SECRET", SECRET);
pruefe("danach gilt es als eingerichtet", paypal.istEingerichtet("einmal") === true);
pruefe("der Wert kommt zeichengenau zurueck", geheim.einstellung("PAYPAL_CLIENT_SECRET") === SECRET);
pruefe("Sonderzeichen ueberstehen die Verschluesselung",
geheim.einstellung("PAYPAL_CLIENT_SECRET").includes('&/\$"~'));
L("\n2. IN DER DATENBANK STEHT NICHTS LESBARES");
const roh = db.prepare("SELECT value FROM app_settings WHERE key = ?").get("wd_geheim_PAYPAL_CLIENT_SECRET");
pruefe("der Eintrag existiert", !!roh);
pruefe("er enthaelt NICHT den Klartext", roh && !roh.value.includes(SECRET), roh ? roh.value.slice(0, 30) : "-");
pruefe("er enthaelt auch keinen Teil davon", roh && !roh.value.includes("MeineHeikle"));
L("\n3. DER STAND VERRAET DEN WERT NICHT");
const st = geheim.stand();
const alsText = JSON.stringify(st);
pruefe("kein Geheimnis in der Antwort", !alsText.includes(SECRET) && !alsText.includes("MeineHeikle"));
pruefe("aber es ist erkennbar, DASS etwas da ist", st.PAYPAL_CLIENT_SECRET.gesetzt === true);
pruefe("die Laenge wird gemeldet", st.PAYPAL_CLIENT_SECRET.laenge === SECRET.length,
String(st.PAYPAL_CLIENT_SECRET.laenge));
pruefe("das Secret ist als geheim markiert", st.PAYPAL_CLIENT_SECRET.wert === null);
pruefe("die Betriebsart wird dagegen ausgeschrieben", st.PAYPAL_ENV.wert !== undefined);
pruefe("die Herkunft wird benannt", st.PAYPAL_CLIENT_SECRET.quelle === "verwaltung");
L("\n4. DIE .env HAT VORRANG");
process.env.PAYPAL_CLIENT_ID = "AUS-DER-ENV";
pruefe("ein Wert auf dem Server sticht die Verwaltung",
geheim.einstellung("PAYPAL_CLIENT_ID") === "AUS-DER-ENV");
pruefe("und das wird auch so angezeigt", geheim.stand().PAYPAL_CLIENT_ID.quelle === "server");
delete process.env.PAYPAL_CLIENT_ID;
pruefe("faellt der Server-Wert weg, greift wieder die Verwaltung",
geheim.einstellung("PAYPAL_CLIENT_ID") === "AbC-123");
L("\n5. NUR ERLAUBTE SCHLUESSEL");
let abgelehnt = false;
try { await geheim.einstellungSetzen("ADMIN_PASSWORT", "x"); } catch (e) { abgelehnt = true; }
pruefe("ein fremder Schluessel wird abgewiesen", abgelehnt);
let falscheArt = false;
try { await geheim.einstellungSetzen("PAYPAL_ENV", "vielleicht"); } catch (e) { falscheArt = true; }
pruefe("die Betriebsart erlaubt nur live oder sandbox", falscheArt);
L("\n6. LEEREN");
await geheim.einstellungSetzen("PAYPAL_CLIENT_SECRET", "");
pruefe("ein leerer Wert entfernt den Eintrag", geheim.einstellung("PAYPAL_CLIENT_SECRET") === "");
pruefe("und die Zeile ist wirklich weg",
!db.prepare("SELECT 1 FROM app_settings WHERE key = ?").get("wd_geheim_PAYPAL_CLIENT_SECRET"));
pruefe("danach gilt PayPal wieder als nicht eingerichtet", paypal.istEingerichtet("einmal") === false);
L("\n7. FALSCHER SCHLUESSEL BRINGT DEN DIENST NICHT ZUM STEHEN");
await geheim.einstellungSetzen("PAYPAL_CLIENT_SECRET", SECRET);
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
let gestuerzt = false;
try { await geheim.geheimnisseLaden(); } catch (e) { gestuerzt = true; }
pruefe("das Laden mit falschem Schluessel wirft nicht", !gestuerzt);
pruefe("der Wert gilt dann als nicht vorhanden", geheim.einstellung("PAYPAL_CLIENT_SECRET") === "");
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
process.on("exit", () => rmSync(tmp, { recursive: true, force: true }));
process.exitCode = fehler ? 1 : 0;
+201
View File
@@ -638,6 +638,7 @@
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
<button type="button" class="vw-filter-knopf" data-tab="projekte">Projekte</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
<button type="button" class="vw-filter-knopf" data-tab="zahlungen">Zahlungen</button>
<button type="button" class="vw-filter-knopf" data-tab="postfach">Postfach<span
class="vw-abzeichen" id="vw-pf-zaehler" hidden>0</span></button>
</div>
@@ -725,6 +726,13 @@
<div id="vw-projekte-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
<!-- ============ ZAHLUNGEN ============ -->
<div id="vw-zahlungen-bereich" hidden>
<div id="vw-pp-stand"><div class="vw-laedt">Wird geladen…</div></div>
<h2 style="font-size:1.15rem;margin-top:1.6rem">Zahlungen</h2>
<div id="vw-zahlungen-liste"><div class="vw-laedt">Wird geladen…</div></div>
</div>
<!-- ============ POSTFACH ============ -->
<div id="vw-postfach-bereich" hidden>
<p class="wd-klein wd-leise" style="margin-bottom:1rem">
@@ -1423,6 +1431,7 @@
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
zahlungen:{ titel: "Zahlungen", kasten: "vw-zahlungen-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
@@ -1443,6 +1452,7 @@
if (aktiverTab === "kunden") ladeKunden();
if (aktiverTab === "projekte") ladeProjekte();
if (aktiverTab === "zahlungen") ladeZahlungen();
if (aktiverTab === "postfach") ladePostfach();
});
});
@@ -1956,6 +1966,197 @@
}
}
/* =================================================================
ZAHLUNGEN UND PAYPAL-EINRICHTUNG
Filipe soll die Zugangsdaten HIER eintragen koennen und nicht per
SSH in einer Datei. Die Werte gehen verschluesselt in die Datenbank
und wirken sofort -- ohne Dienstneustart.
Was hier NIE passiert: einen hinterlegten Wert wieder anzeigen. Der
Server gibt ihn gar nicht erst heraus. Sichtbar ist nur, ob etwas da
ist, woher es stammt und wie lang es ist.
================================================================= */
var PP_FELDER = [
{ k: "PAYPAL_ENV", titel: "Betriebsart", art: "wahl",
hilfe: "live = echtes Geld. sandbox = Testbetrieb, es fliesst nichts." },
{ k: "PAYPAL_CLIENT_ID", titel: "Client ID", art: "text",
hilfe: "developer.paypal.com → Apps & Credentials → deine App" },
{ k: "PAYPAL_CLIENT_SECRET", titel: "Secret", art: "geheim",
hilfe: "Dort daneben auf „Show“ klicken. Wird nach dem Speichern nie wieder angezeigt." },
{ k: "PAYPAL_WD_WEBHOOK_ID", titel: "Webhook-Kennung", art: "text",
hilfe: "Beginnt mit WH-. Entsteht, wenn du den Webhook anlegst." },
{ k: "PAYPAL_WD_PLAN_BASIS", titel: "Plan für die Betreuung", art: "text",
hilfe: "Beginnt mit P-. Nur nötig für monatliche Betreuungs-Abos." },
];
async function ladeZahlungen() {
var ziel = document.getElementById("vw-pp-stand");
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
try {
var d = await api("/webdesign/admin/einstellungen");
zeichnePayPal(d);
} catch (fehler) {
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
ziel.innerHTML = '<div class="vw-leer-hinweis">' + s(fehler.message) + "</div>";
}
ladeZahlungsliste();
}
function zeichnePayPal(d) {
var e = d.einrichtung || {};
var st = d.stand || {};
var kopf = e.einmal
? (e.live
? '<div class="wd-hinweis" style="margin-bottom:1rem;border-left-color:var(--wd-ok)">' +
'<p class="wd-hinweis-titel" style="color:var(--wd-ok)">PayPal ist eingerichtet — Echtbetrieb</p>' +
'<p class="wd-klein">Zahlungen gehen direkt auf dein Konto.</p></div>'
: '<div class="wd-hinweis" style="margin-bottom:1rem"><p class="wd-hinweis-titel">' +
'Eingerichtet, aber im TESTMODUS</p><p class="wd-klein">Es fließt kein echtes Geld. ' +
'Stell die Betriebsart auf <strong>Echtbetrieb</strong>, sobald du bereit bist.</p></div>')
: '<div class="wd-hinweis" style="margin-bottom:1rem"><p class="wd-hinweis-titel">' +
'Noch nicht eingerichtet</p><p class="wd-klein">Es fehlt: <strong>' +
s((e.fehlt || []).join(", ") || "—") + '</strong>. ' +
'Solange sagt das Kundenportal freundlich Bescheid und nennt den Weg über das Postfach.</p></div>';
var felder = PP_FELDER.map(function (f) {
var z = st[f.k] || {};
var lage = z.gesetzt
? '<span class="wd-tag wd-tag--ok">hinterlegt' +
(z.quelle === "server" ? " (auf dem Server)" : "") +
(z.laenge ? " · " + z.laenge + " Zeichen" : "") + "</span>"
: '<span class="wd-tag wd-tag--gold">fehlt</span>';
if (f.art === "wahl") {
var jetztWert = z.wert || "sandbox";
return '<div class="wd-feld" style="margin-bottom:1rem">' +
"<label>" + s(f.titel) + " " + lage + "</label>" +
'<div class="vw-status-wahl" style="margin:.3rem 0">' +
'<button type="button" class="vw-filter-knopf' + (jetztWert === "sandbox" ? " vw-an" : "") +
'" data-pp-env="sandbox">Testmodus</button>' +
'<button type="button" class="vw-filter-knopf' + (jetztWert === "live" ? " vw-an" : "") +
'" data-pp-env="live">Echtbetrieb</button>' +
"</div>" +
'<span class="wd-feld-hilfe">' + s(f.hilfe) + "</span></div>";
}
/* Der Platzhalter sagt ausdruecklich, dass Leerlassen NICHTS
aendert. Ohne diesen Hinweis loescht man beim Speichern eines
einzelnen Feldes versehentlich die anderen -- oder glaubt es
zumindest und traut sich deshalb nicht. */
return '<div class="wd-feld" style="margin-bottom:1rem">' +
'<label for="pp-' + f.k + '">' + s(f.titel) + " " + lage + "</label>" +
'<input type="' + (f.art === "geheim" ? "password" : "text") + '" id="pp-' + f.k +
'" autocomplete="off" spellcheck="false" placeholder="' +
(z.gesetzt ? "leer lassen → unverändert" : "hier einfügen") + '" />' +
'<span class="wd-feld-hilfe">' + s(f.hilfe) + "</span></div>";
}).join("");
document.getElementById("vw-pp-stand").innerHTML =
kopf +
'<div class="wd-karte">' +
'<h2 style="font-size:1.15rem;margin-top:0">PayPal einrichten</h2>' +
'<p class="wd-klein wd-leise">Die Werte werden verschlüsselt gespeichert und wirken sofort — ' +
"kein Neustart nötig. Ein gespeicherter Wert wird nie wieder angezeigt; " +
"wer ihn verliert, erzeugt bei PayPal einen neuen.</p>" +
'<div class="wd-hinweis" style="margin:.9rem 0">' +
'<p class="wd-hinweis-titel">Diese Adresse trägst du bei PayPal als Webhook ein</p>' +
'<code style="display:block;word-break:break-all;font-size:.86rem;margin-top:.3rem">' +
s(d.webhookUrl || "") + "</code></div>" +
felder +
'<div class="wd-btn-reihe">' +
'<button type="button" class="wd-btn wd-btn--haupt" id="pp-speichern">Speichern</button>' +
'<button type="button" class="wd-btn wd-btn--zweit" id="pp-pruefen">Verbindung testen</button>' +
"</div>" +
'<p class="wd-klein wd-leise" style="margin:.7rem 0 0">' +
"„Verbindung testen“ meldet sich probeweise bei PayPal an. " +
"Es fließt dabei kein Geld — aber erst das beweist, dass die Werte stimmen.</p>" +
"</div>";
var gewaehlteArt = null;
document.querySelectorAll("[data-pp-env]").forEach(function (b) {
b.addEventListener("click", function () {
gewaehlteArt = b.dataset.ppEnv;
document.querySelectorAll("[data-pp-env]").forEach(function (x) {
x.classList.toggle("vw-an", x === b);
});
});
});
document.getElementById("pp-speichern").addEventListener("click", async function () {
var knopf = this;
var werte = {};
PP_FELDER.forEach(function (f) {
if (f.art === "wahl") { if (gewaehlteArt) werte[f.k] = gewaehlteArt; return; }
var el = document.getElementById("pp-" + f.k);
/* Leeres Feld = NICHT anfassen. Nur so kann man ein einzelnes
Feld nachtragen, ohne die anderen zu verlieren. */
if (el && el.value.trim()) werte[f.k] = el.value.trim();
});
if (!Object.keys(werte).length) {
window.WD.meldung(meldung, "Nichts eingetragen — es wurde nichts geändert.", "fehler");
return;
}
window.WD.knopfSperren(knopf, true, "…");
try {
var r = await api("/webdesign/admin/einstellungen", { method: "POST", body: { werte: werte } });
/* Felder sofort leeren: Ein Secret soll nicht im Formular stehen
bleiben, wenn jemand anders auf den Bildschirm schaut. */
PP_FELDER.forEach(function (f) {
var el = document.getElementById("pp-" + f.k);
if (el) el.value = "";
});
window.WD.meldung(meldung, "Gespeichert: " + (r.geaendert || []).join(", "), "ok");
ladeZahlungen();
} catch (f2) {
window.WD.knopfSperren(knopf, false);
window.WD.meldung(meldung, f2.message, "fehler");
}
});
document.getElementById("pp-pruefen").addEventListener("click", async function () {
var knopf = this;
window.WD.knopfSperren(knopf, true, "Wird getestet…");
try {
var r = await api("/webdesign/admin/einstellungen/pruefen", { method: "POST", body: {} });
window.WD.meldung(meldung, r.meldung, r.live ? "ok" : "fehler");
} catch (f2) {
window.WD.meldung(meldung, f2.message, "fehler");
} finally { window.WD.knopfSperren(knopf, false); }
});
}
async function ladeZahlungsliste() {
var ziel = document.getElementById("vw-zahlungen-liste");
try {
var d = await api("/webdesign/admin/zahlungen");
if (!d.zahlungen.length) {
ziel.innerHTML = '<div class="vw-leer-hinweis" style="padding:2rem 1rem">' +
"Noch keine Zahlungen. Sie entstehen im Projekt.</div>";
return;
}
ziel.innerHTML = d.zahlungen.map(function (z) {
var marke = z.status === "bezahlt" ? "angenommen"
: z.status === "offen" ? "neu"
: z.status === "fehlge" ? "abgelehnt" : "pruefung";
return '<div class="vw-karte" style="cursor:default">' +
'<span class="vw-karte-nr">' + s(z.nummer) + "</span>" +
'<span class="vw-karte-mitte">' +
'<span class="vw-karte-name">' + s(z.betragText) + " · " + s(z.zweck_text || "") + "</span>" +
'<span class="vw-karte-info">' + s(z.kunde_name || "") +
(z.projekt_titel ? " · " + s(z.projekt_titel) : "") + "</span>" +
"</span>" +
'<span class="vw-karte-rechts">' +
'<span class="vw-status vw-status--' + marke + '">' + s(z.status) + "</span>" +
'<span class="vw-karte-zeit">' + window.WD.datum(z.erstellt_am) + "</span>" +
"</span></div>";
}).join("");
} catch (fehler) {
ziel.innerHTML = "";
}
}
/* =================================================================
POSTFACH