💰 Abo-Verwaltung — Administrations-Doku

Diese Doku beschreibt die Pflege und Wartung der Abo-Verwaltungs-App. Sie ergänzt die anleitung.html im Abo-Paket (Erst-Installation).

ℹ️ Erreichbarkeit: https://abos.rusti.ipv64.net
Hosting: VM 122-g50zeiten (192.168.178.122), parallel zu Stunden und Treckertreff
Datenbank: MariaDB, Datenbank abos_db, User abos_user
Stand: 22. Mai 2026

Architektur — wer macht was?

Die App nutzt dieselbe Infrastruktur wie Stundenabrechnung und Treckertreff. Eine Anfrage durchläuft folgende Stationen:

SchrittKomponenteWas passiert
1 Browser/Smartphone Aufruf von https://abos.rusti.ipv64.net
2 DNS (ipv64.net) Löst abos.rusti.ipv64.net in die Heim-IP auf
3 FritzBox Port 80/443 → 192.168.178.131 (NPM)
4 NPM (192.168.178.131) Reverse-Proxy + Let's-Encrypt-SSL → 192.168.178.122:80
5 Apache (.122) VirtualHost abos.conf liefert Dateien aus /var/www/abos/
6 PHP / MariaDB api.php spricht mit abos_db
⚠️ Wichtig — VirtualHost-Konflikt: Damit Apache die drei Apps (Stunden, Abos, Treckertreff) korrekt auseinanderhalten kann, MUSS in jeder Site-Config der ServerName gesetzt sein. Fehlt er, fällt Apache auf die alphabetisch erste Site zurück. Siehe Subdomain zeigt falsche App.

Datei-Layout auf .122

Alle App-Dateien liegen in /var/www/abos/ und gehören www-data:www-data:

DateiFunktion
index.htmlLogin-Seite (dunkles Design)
abo_manager.htmlHaupt-App: Desktop-Tabelle + Mobile-Cards
api.phpBackend: Login, CRUD, Backup-Import/Export
config.phpDB-Zugang, App-Passwort-Hash
setup.sqlTabellen-Struktur
.htaccessApache-Schutzregeln
favicon.svgBrowser-Tab-Icon (Karten-Stapel mit Euro)
favicon-32.png32×32 PNG-Fallback
apple-touch-icon.pngiOS-Homescreen-Icon (180×180)
icon-192.png / icon-512.pngAndroid-PWA-Icons
manifest.jsonPWA-Manifest
anleitung.htmlInstallations-Anleitung

Apache-Konfiguration

/etc/apache2/sites-available/abos.conf

Wichtige Punkte:

Datenbank-Struktur

Die App nutzt eine einzige Tabelle:

Tabelle abos

SpalteTypBeschreibung
idVARCHAR(40) PRIMARY KEYUUID-artige ID (clientseitig generiert)
nameVARCHAR(200)z.B. "Spotify", "Office 365"
ekPriceDECIMAL(10,2)Einkaufspreis (Brutto netto, je nach Setup)
vkPriceDECIMAL(10,2)Verkaufspreis
interval_typVARCHAR(30)weekly / monthly / quarterly / biannual / yearly / biennial / triennial
nextDateDATENächster Fälligkeitstermin
categoryVARCHAR(50)Kategorie (frei wählbar)
customerVARCHAR(200)Kunde / Eigentümer
notesTEXTFreitext-Notizen
eolDateDATE NULLEnd-of-Life Datum (optional)
contractEndDATE NULLVertragsende (optional)
cancelDaysINTKündigungsfrist in Tagen
erstellt_am / geaendert_amTIMESTAMPAutomatisch

Features der App

FeatureBeschreibung
📊 Statistik-KachelnAnzahl Abos, Monatskosten, Jahreskosten, Marge (gesperrt)
📅 Termin-ÜbersichtSortiert nach nächster Fälligkeit, farbige Status (überfällig / heute / bald / später)
🔒 Marge-SchutzEK-Preis + Marge sind versteckt — Anzeige nur nach Login-Eingabe
🔍 Suche & FilterVolltextsuche, EOL-Filter, Kategorie-Filter
📱 Mobile-CardsAuf Smartphone Karten statt Tabelle (< 768px Breite)
📆 ICS-ExportKalendereinträge pro Abo (für Erinnerung)
📄 PDF-ExportFirmen-Export (ohne Marge) und Händler-Export (mit Marge)
💾 JSON-BackupDownload + Wiederherstellen (Merge oder Replace)

App-Passwort ändern

Das Passwort wird als bcrypt-Hash in config.php gespeichert.

1 Per SSH einloggen
ssh root@192.168.178.122
2 Neues Passwort wählen und Hash generieren
PASSWORT='NeuesPasswort2026'

HASH=$(curl -s "http://localhost/api.php?aktion=hash_erzeugen&pw=$PASSWORT" \
  -H "Host: abos.rusti.ipv64.net" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['hash'])")

echo "Hash-Länge: ${#HASH}"   # sollte 60 zeigen
3 Hash in config.php einsetzen
sed -i "s|APP_PASSWORT_HASH', '[^']*'|APP_PASSWORT_HASH', '$HASH'|" \
  /var/www/abos/config.php
4 Testen
curl -s -X POST http://localhost/api.php \
  -H "Host: abos.rusti.ipv64.net" \
  -d "aktion=login&passwort=$PASSWORT"

# Erwartung: {"erfolg":true}
5 Aufräumen
unset PASSWORT HASH
⚠️ Wichtig: Das gleiche Passwort wird auch zum Entsperren der Marge-Anzeige verwendet. Wenn du es änderst, müssen alle Benutzer beim nächsten Margen-Klick das neue Passwort eingeben.

Backup erstellen

Variante 1: Per Browser (einfach)

  1. App öffnen, einloggen
  2. Auf ↓ Backup klicken
  3. JSON-Datei landet im Downloads-Ordner

Variante 2: Direkt auf der VM

mariadb-dump abos_db > /root/abos_$(date +%Y%m%d).sql
ls -la /root/abos_*.sql

Variante 3: Über die API

curl -b /tmp/abos_cookies.txt -s \
  "http://localhost/api.php?aktion=backup_export" \
  -H "Host: abos.rusti.ipv64.net" \
  -o /root/abos_backup_$(date +%Y%m%d).json
ℹ️ Proxmox sichert die VM automatisch. Die Daten sind also bereits in den Proxmox-Backups enthalten. Die JSON-Backups sind nützlich für:
  • Einzelne Daten wiederherstellen (z.B. versehentlich gelöschter Eintrag)
  • Daten auf einen anderen Server kopieren
  • Vor risikoreichen Änderungen

JSON-Backup importieren

So bekommst du Daten aus einer JSON-Sicherung wieder in die App:

1 App öffnen und einloggen
2 "↑ Wiederherstellen" klicken
3 JSON-Datei auswählen
4 Modus wählen:
  • OK = REPLACE: Alle bestehenden Daten werden gelöscht, dann das Backup eingespielt. Geeignet für komplettes Reset / Migration.
  • Abbrechen = MERGE: Nur neue Einträge dazu, bestehende mit gleicher ID werden aktualisiert. Geeignet wenn du nur einzelne fehlende Einträge wieder reinholen willst.

Datenbank-Backup einspielen

Wenn du ein SQL-Dump (nicht JSON) hast:

# Aktuelle DB sichern (Notfall-Backup)
mariadb-dump abos_db > /root/abos_alt_$(date +%Y%m%d).sql

# Datenbank leeren und Backup einspielen
mariadb -e "DROP DATABASE IF EXISTS abos_db; \
            CREATE DATABASE abos_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; \
            GRANT ALL PRIVILEGES ON abos_db.* TO 'abos_user'@'localhost';"

mariadb abos_db < /root/abos_db_20260522.sql

# Verifizieren
mariadb abos_db -e "SHOW TABLES; SELECT COUNT(*) FROM abos;"

Code-Änderungen einspielen

Wenn ich (Claude) dir eine neue Version einer Datei schicke:

1 Datei herunterladen (aus Chat in Downloads)
2 Hochladen per scp

Vom Mac:

cd ~/Downloads
scp abo_manager.html root@192.168.178.122:/var/www/abos/
3 Berechtigungen setzen
ssh root@192.168.178.122 "chown www-data:www-data /var/www/abos/*"
4 Hard-Reload im Browser

Cmd+Shift+R (Mac) oder Strg+F5 (Windows).

SSL-Zertifikat

Erneuerung läuft automatisch über NPM. Bei Bedarf manuell:

  1. NPM-UI: http://192.168.178.131:81
  2. Hosts → Proxy Hosts → abos.rusti.ipv64.net → Edit
  3. SSL-Tab → "Request a new SSL Certificate" → Save

Logs prüfen

BefehlZweck
tail -f /var/log/apache2/abos_access.logLive alle Zugriffe
tail -f /var/log/apache2/abos_error.logLive alle Fehler
tail -50 /var/log/apache2/abos_error.logLetzte 50 Fehler

PHP-Fehler sichtbar machen (Debug)

In api.php ganz oben temporär einfügen:

ini_set('display_errors', '1');
error_reporting(E_ALL);

⚠️ Nach dem Debuggen wieder entfernen.

API direkt testen

Login simulieren

curl -c /tmp/abos_cookies.txt -s -X POST http://localhost/api.php \
  -H "Host: abos.rusti.ipv64.net" \
  -d "aktion=login&passwort=DEINPASSWORT"
# Erwartung: {"erfolg":true}

Alle Abos laden

curl -b /tmp/abos_cookies.txt -s \
  "http://localhost/api.php?aktion=liste_laden" \
  -H "Host: abos.rusti.ipv64.net" | python3 -m json.tool | head -30

Backup-Export

curl -b /tmp/abos_cookies.txt -s \
  "http://localhost/api.php?aktion=backup_export" \
  -H "Host: abos.rusti.ipv64.net" \
  -o /tmp/test_backup.json

ls -la /tmp/test_backup.json
head -5 /tmp/test_backup.json

Datenbank-Tests

Inhalte prüfen

# Alle Abos
mariadb abos_db -e "SELECT id, name, vkPrice, interval_typ, nextDate FROM abos;"

# Anzahl + Summe
mariadb abos_db -e "SELECT COUNT(*) AS anzahl, SUM(vkPrice) AS summe_vk FROM abos;"

# Demnächst fällig (nächste 30 Tage)
mariadb abos_db -e "SELECT name, nextDate, DATEDIFF(nextDate, CURDATE()) AS in_tagen \
                    FROM abos WHERE nextDate <= DATE_ADD(CURDATE(), INTERVAL 30 DAY) \
                    ORDER BY nextDate;"

Subdomain zeigt falsche App

Klassischer Apache-VirtualHost-Bug: Du rufst abos.rusti.ipv64.net auf, siehst aber die Stundenabrechnung (oder umgekehrt).

Diagnose

apachectl -t -D DUMP_VHOSTS

Erwartung — jede Site hat ihren eigenen ServerName:

port 80 namevhost abos.rusti.ipv64.net (.../abos.conf:1)
port 80 namevhost stundng50.rusti.ipv64.net (.../stunden.conf:1)
port 80 namevhost tbrt.rusti.ipv64.net (.../treckertreff.conf:1)

Wenn dort statt stundng50.rusti.ipv64.net nur g50zeiten steht (Hostname der VM), fehlt der ServerName in der Config!

Fix

cat /etc/apache2/sites-available/stunden.conf | head -5

# Falls ServerName fehlt:
sed -i '/<VirtualHost \*:80>/a\    ServerName stundng50.rusti.ipv64.net' \
  /etc/apache2/sites-available/stunden.conf

apache2ctl configtest
systemctl reload apache2
ℹ️ Warum passiert das? Apache verteilt eingehende Anfragen anhand des Host:-Headers. Wenn keine Site einen passenden ServerName hat, nimmt Apache die alphabetisch erste — bei uns abos.conf. Deshalb landest du immer auf der Abo-App, egal welche Subdomain du eingibst.

Login funktioniert nicht

1. Hash-Format prüfen

python3 <<'EOF'
import re
with open('/var/www/abos/config.php', 'r') as f:
    content = f.read()
m = re.search(r"APP_PASSWORT_HASH'\s*,\s*'([^']+)'", content)
if m:
    h = m.group(1)
    print(f"Länge: {len(h)}")
    print(f"Beginnt mit: {h[:7]}")
    print(f"OK" if h.startswith('$2y$') and len(h) == 60 else "FALSCH")
EOF

Erwartung: Länge 60, beginnt mit $2y$10$ oder $2y$12$.

2. Brute-Force-Sperre aktiv?

Nach 5 Fehlversuchen wird die Session für 15 Min gesperrt. Workaround:

rm /var/lib/php/sessions/sess_*
# Browser-Cookie löschen oder Inkognito-Tab

3. Test direkt mit password_verify

HASH='HIER_DEINEN_HASH_EINSETZEN'
PASSWORT='HIER_DEIN_PASSWORT'
php -r "echo password_verify('$PASSWORT', '$HASH') ? 'PASST' : 'PASST NICHT';"

Marge-Anzeige Probleme

Symptom: Du klickst auf das Schloss-Symbol, gibst Passwort ein, aber Margen werden nicht angezeigt.

Was passiert intern?

Beim Marge-Entsperren wird das eingegebene Passwort an api.php?aktion=login geschickt. Wenn die Antwort {"erfolg":true} ist, wird marginUnlocked = true gesetzt und die Tabelle/Karten werden neu gerendert.

Diagnose im Browser

  1. F12 öffnet Developer Tools
  2. Tab Network öffnen
  3. Schloss-Symbol klicken → Passwort eingeben
  4. Den api.php-Request anschauen — Status sollte 200 sein, Antwort {"erfolg":true}

Falls Fehler: Passwort prüfen (das gleiche wie für Login).

Internal Server Error (500)

tail -30 /var/log/apache2/abos_error.log

Häufige Ursachen:

Browser-Cache-Probleme

BrowserHard-Reload
Chrome / Edge / Firefox (Mac)Cmd+Shift+R
Chrome / Edge (Windows)Strg+F5 oder Strg+Shift+R
Safari (Mac)Cmd+Option+E dann Cmd+R
iPhone / iPadSafari schließen, Inkognito-Tab
📱 Mobile: Wenn die App als PWA auf dem Homescreen liegt, muss man sie ggf. komplett löschen und neu hinzufügen, damit Icon und CSS aktualisiert werden.

Was schützt die App?

SchutzWie
Verschlüsselung HTTPS via Let's Encrypt (NPM), Force-SSL aktiv
Brute-Force 5 Versuche → 15 Min Sperre
Passwort-Speicherung bcrypt (Cost 10/12), niemals Klartext
Sessions Cookie-Name ABOS_SESS, HttpOnly + SameSite=Lax, 7 Tage Lifetime
SQL-Injection Alle DB-Zugriffe über Prepared Statements
config.php Per Apache <FilesMatch> blockiert
Clickjacking HTTP-Header X-Frame-Options: SAMEORIGIN
MIME-Sniffing HTTP-Header X-Content-Type-Options: nosniff
Referrer-Leak HTTP-Header Referrer-Policy: strict-origin-when-cross-origin
Marge-Schutz EK-Preis nur nach erneuter Login-Eingabe sichtbar

Notfall-Wiederherstellung

App ist komplett down

systemctl status apache2 mariadb
tail -50 /var/log/apache2/abos_error.log
apache2ctl configtest
systemctl restart apache2
systemctl restart mariadb

Datenbank kaputt — Backup einspielen

mariadb-dump abos_db > /root/abos_kaputt.sql
mariadb -e "DROP DATABASE abos_db; \
            CREATE DATABASE abos_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb abos_db < /root/abos_db_LETZTES.sql

Versehentlich Einträge gelöscht — aus JSON wiederherstellen

JSON-Backup (Browser-Download) in die App importieren mit "↑ Wiederherstellen" und Modus MERGE (Abbrechen) wählen. Damit kommen nur die fehlenden Einträge wieder rein, ohne neuere zu überschreiben.

VM-Rollback (letzte Option)

Im Proxmox-Web-UI:

  1. VM 122-g50zeiten auswählen
  2. Backup → letztes Backup → Zurückspielen
🚨 Achtung: VM-Rollback betrifft AUCH die Stundenabrechnung UND Treckertreff, weil alle 3 Apps auf der gleichen VM laufen!

Verwandte Dokus

Abo-Verwaltung Admin-Doku Stand: 22.05.2026
https://abos.rusti.ipv64.net auf VM 122-g50zeiten