📋 Stundenabrechnung — Administrations-Doku

Diese Doku beschreibt die Pflege und Wartung der Stundenabrechnungs-App auf der VM 122-g50zeiten (nach der Migration von IONOS).

ℹ️ Erreichbarkeit: https://stundng50.rusti.ipv64.net
Hosting: VM 122-g50zeiten (192.168.178.122)
Datenbank: MariaDB, Datenbank stunden_db, User stunden_user
Stand: 21. Mai 2026

Architektur — wer macht was?

Die Stundenabrechnung läuft auf der gleichen VM wie die Tretbeckenreinigungs-App, aber unter einer eigenen Subdomain. Eine Anfrage durchläuft folgende Stationen:

SchrittKomponenteWas passiert
1 Browser/Smartphone Aufruf von https://stundng50.rusti.ipv64.net
2 DNS (ipv64.net) Löst Subdomain in deine 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 → weiter an 192.168.178.122:80
5 Apache (.122) VirtualHost stunden.conf liefert Dateien aus /var/www/stunden/
6 PHP / MariaDB api.php liest und schreibt in stunden_db

Datei-Layout auf .122

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

DateiFunktion
index.htmlDie Web-App (Oberfläche im Browser, mit JavaScript)
api.phpBackend: Login, Einträge, Exports, Rechnungskalk.
config.phpDB-Zugang, Passwort-Hash, Kundendaten, Stundensätze
xlsx_export.phpExcel-Generator (eigene Engine ohne externe Bibliotheken)
pdf_export.phpPDF-Generator (eigene Engine ohne externe Bibliotheken)
setup.sqlTabellen-Struktur (fĂĽr Erst-/Neuinstallation)
.htaccessApache-Schutzregeln (blockt config.php etc.)
favicon.svgBrowser-Tab-Icon (Geldsack)
apple-touch-icon.pngiOS-Homescreen-Icon
manifest.jsonPWA-Manifest
ANLEITUNG.md / anleitung.htmlOriginal-Installations-Anleitung

Apache-Konfiguration

Die VirtualHost-Datei liegt in:

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

Aktiviert mit a2ensite stunden.conf. Wichtige Punkte:

Datenbank-Struktur

Die App nutzt eine eigene MariaDB-Datenbank stunden_db mit einer Haupttabelle:

Tabelle stunden_eintraege

SpalteTypBeschreibung
idINT PRIMARY KEYAuto-Inkrement
datumDATEArbeits-Datum
taetigkeitTEXTBeschreibung der Tätigkeit (mehrzeilig)
vonTIMEArbeitsbeginn
bisTIMEArbeitsende
pauseTIMEPausenzeit (wird abgezogen)
archivTIMEDavon Archiv-Stunden
erstellt_am / geaendert_amTIMESTAMP

App-Passwort ändern

Das Login-Passwort der App 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: stundng50.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/stunden/config.php
4 Testen
curl -s -X POST http://localhost/api.php \
  -H "Host: stundng50.rusti.ipv64.net" \
  -d "aktion=login&passwort=$PASSWORT"

# Erwartung: {"erfolg":true}
5 Aufräumen
unset PASSWORT HASH
⚠️ Wichtig: Der hash_erzeugen-Endpunkt funktioniert ohne Login. Sobald die App produktiv läuft, kannst du ihn theoretisch nutzen, aber besser nicht in der URL-Leiste eintippen — das landet im Browser-Verlauf. Per curl auf der VM ist am sichersten.

Kundendaten und Stundensätze ändern

In config.php sind Kunde, Stundensätze und weitere Konstanten hinterlegt:

nano /var/www/stunden/config.php

Welche Werte gibt's?

KonstanteBedeutungBeispiel
KUNDE_NAMEErscheint im Excel/PDF-KopfGeno50
KUNDE_NRErscheint im Excel/PDF-Kopf69216
STD_VONStandard-Arbeitsbeginn07:00
STD_BISStandard-Arbeitsende13:00
STD_PAUSEStandard-Pausenzeit00:00
STD_TAETIGKEITVorlagentext für TätigkeitExt. Sicherung durchgeführt.

Stundensätze ändern

Falls die Stundensätze für die Rechnungs-Kalkulation angepasst werden müssen, suche in config.php nach den Preis-Konstanten und passe sie an:

# Beispiel - exakte Namen variieren je nach Code-Version:
define('PREIS_DIENSTLEISTUNG', 70.00);
define('PREIS_ARCHIV',         70.00);
define('PREIS_BEREITSTELLUNG', 80.00);
define('PAUSCHALE_STUNDEN',    20);
define('MAX_DIENSTLEISTUNG',   30);
define('MWST_PROZENT',         19);
ℹ️ Tipp: Mit grep -n "PREIS\|MWST\|PAUSCHALE" /var/www/stunden/config.php findest du schnell die richtigen Zeilen.

Standardzeiten ändern

Die Standardwerte für neue Einträge werden in config.php gesetzt:

nano /var/www/stunden/config.php

# Diese Zeilen anpassen:
define('STD_VON',   '07:00');  // Standard-Beginn
define('STD_BIS',   '13:00');  // Standard-Ende
define('STD_PAUSE', '00:00');  // Standard-Pause

Speichern, dann Browser-Cache leeren (Cmd+Shift+R) — beim nächsten Eintrag werden die neuen Werte vorgeschlagen.

Backup erstellen

Datenbank-Backup

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

Konfigurations-Backup

cp /var/www/stunden/config.php /root/stunden_config_$(date +%Y%m%d).php.bak
cp /etc/apache2/sites-available/stunden.conf /root/stunden.conf.bak

Vollständig: Tarball + DB-Dump

tar czf /root/stunden_full_$(date +%Y%m%d).tar.gz \
  /var/www/stunden/ \
  /etc/apache2/sites-available/stunden.conf

mariadb-dump stunden_db > /root/stunden_db_$(date +%Y%m%d).sql

ls -la /root/stunden_*
💡 Tipp: Vor jeder größeren Änderung (Schemaänderung, Update, etc.) ein Backup machen. Bei Stundeneinträgen ist Datenverlust besonders ärgerlich, weil die Daten nicht rekonstruierbar sind.

Backups auf den Mac herunterladen

scp root@192.168.178.122:/root/stunden_* ~/Backups/Stunden/

Backup einspielen

1 Datenbank zurĂĽcksetzen und einspielen
mariadb -e "DROP DATABASE IF EXISTS stunden_db; \
            CREATE DATABASE stunden_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

mariadb stunden_db < /root/stunden_db_20260521.sql

mariadb stunden_db -e "SHOW TABLES; SELECT COUNT(*) FROM stunden_eintraege;"
2 Dateien zurückspielen (falls nötig)
cd /
tar xzf /root/stunden_full_20260521.tar.gz
chown -R www-data:www-data /var/www/stunden/
systemctl reload apache2

Jahresarchiv anlegen (am Jahresende)

Zum Jahreswechsel willst du eventuell die Daten archivieren, damit die App schneller bleibt. Das geht ĂĽber zwei Wege:

Variante 1: Daten in der DB lassen (empfohlen)

MariaDB ist schnell genug für tausende Einträge. Du musst gar nichts tun — die Monatsansicht filtert automatisch.

Variante 2: Datenbank-Snapshot als Archiv

# Komplettes Jahres-Backup erstellen
mariadb-dump stunden_db > /root/archiv/stunden_2026_komplett.sql

# Jahres-Daten exportieren (nur zur Sicherheit)
mariadb stunden_db -e "
  SELECT * FROM stunden_eintraege
  WHERE YEAR(datum) = 2026
  ORDER BY datum;" > /root/archiv/stunden_2026.tsv

# Optional: alte Daten aus der aktiven DB löschen (nach Backup!)
# mariadb stunden_db -e "DELETE FROM stunden_eintraege WHERE YEAR(datum) = 2024;"
🚨 Achtung: Daten erst löschen, wenn das Backup verifiziert ist! Im Zweifel die Daten einfach drin lassen — Speicherplatz ist günstig, Datenverlust nicht.

Code-Änderungen einspielen

Wenn ich (Claude) dir eine neue Version von Dateien schicke:

1 Datei auf Mac/Windows herunterladen

Aus dem Chat in den Downloads-Ordner.

2 Mit scp hochladen

Vom Mac:

cd ~/Downloads
scp index.html root@192.168.178.122:/var/www/stunden/

Vom Windows-PC (PowerShell):

cd $env:USERPROFILE\Downloads
scp index.html root@192.168.178.122:/var/www/stunden/
3 Berechtigungen prĂĽfen
ssh root@192.168.178.122 "ls -la /var/www/stunden/index.html"

Wenn nicht www-data:www-data:

ssh root@192.168.178.122 "chown www-data:www-data /var/www/stunden/index.html"
4 Im Browser Hard-Reload

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

SSL-Zertifikat

Das Zertifikat ist Let's-Encrypt und wird vom NPM auf 192.168.178.131 automatisch alle 60 Tage erneuert. Du musst normalerweise nichts tun.

Falls doch mal manuell:

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

Logs prĂĽfen

Apache-Logs

BefehlZweck
tail -f /var/log/apache2/stunden_access.logLive alle Zugriffe
tail -f /var/log/apache2/stunden_error.logLive alle Fehler
tail -50 /var/log/apache2/stunden_error.logLetzte 50 Fehler
grep -i "fatal\|error" /var/log/apache2/stunden_error.log | tail -20Nur Fehler

PHP-Fehler sichtbar machen

Beim Debuggen kannst du PHP-Fehler direkt im Browser sehen. In api.php ganz oben einfĂĽgen:

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

⚠️ Nach dem Debuggen wieder entfernen.

API direkt testen (ohne Browser)

Login simulieren

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

Einträge eines Monats laden

curl -b /tmp/stunden_cookies.txt -s \
  "http://localhost/api.php?aktion=eintraege_laden&jahr=2026&monat=5" \
  -H "Host: stundng50.rusti.ipv64.net" | python3 -m json.tool

Letzten Eintrag holen

curl -b /tmp/stunden_cookies.txt -s \
  "http://localhost/api.php?aktion=letzter_eintrag" \
  -H "Host: stundng50.rusti.ipv64.net"

Excel-Export simulieren

curl -b /tmp/stunden_cookies.txt -s \
  "http://localhost/api.php?aktion=excel_export&jahr=2026&monat=5" \
  -H "Host: stundng50.rusti.ipv64.net" \
  -o /tmp/test_export.xlsx

# PrĂĽfen ob's wirklich eine XLSX ist
file /tmp/test_export.xlsx
# Sollte zeigen: "Microsoft Excel 2007+"

Datenbank-Tests

Inhalt prĂĽfen

# Letzte 10 Einträge
mariadb stunden_db -e "SELECT id, datum, taetigkeit, von, bis, pause, archiv \
                       FROM stunden_eintraege \
                       ORDER BY datum DESC, id DESC LIMIT 10;"

# Anzahl pro Monat
mariadb stunden_db -e "SELECT YEAR(datum) AS jahr, MONTH(datum) AS monat, COUNT(*) AS anzahl \
                       FROM stunden_eintraege \
                       GROUP BY YEAR(datum), MONTH(datum) \
                       ORDER BY jahr, monat;"

Encoding prĂĽfen

mariadb stunden_db -e "SELECT taetigkeit, HEX(taetigkeit) FROM stunden_eintraege LIMIT 1;"

Bei korrektem UTF-8 ist ä = C3 A4 (2 Bytes). Falls 4 Bytes → siehe Umlaute kaputt.


Internal Server Error (500)

Browser zeigt "500 Internal Server Error" → PHP-Fehler. Diagnose:

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

Häufige Ursachen:

Login funktioniert nicht

1. Hash-Format prĂĽfen

python3 <<'EOF'
import re
with open('/var/www/stunden/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 IP fĂĽr 15 Min gesperrt. Workaround:

# PHP-Sessions löschen (für ALLE User der VM!)
rm /var/lib/php/sessions/sess_*
# Browser-Cookie löschen oder Inkognito-Tab nutzen

3. Session-Cookie zu alt (Auto-Logout)

Nach 120 Minuten Inaktivität wirst du automatisch ausgeloggt. Einfach neu einloggen.

Excel/PDF-Export Probleme

PDF zeigt Fehlermeldung als Text

Die App fängt PDF-Fehler ab und zeigt sie als Klartext (statt einer kaputten PDF). Wenn du sowas siehst:

PDF-Export Fehler:

[Fehlertext]

Datei: /var/www/stunden/pdf_export.php
Zeile: 123

→ Datei und Zeilennummer notieren und mir geben, dann fixe ich das schnell.

Excel öffnet sich nicht in Numbers (Mac)

Apple Numbers ist bei Excel-Format pingelig:

Excel-Tabelle leer / fehlerhaft

Test direkt per API:

curl -b /tmp/stunden_cookies.txt -s \
  "http://localhost/api.php?aktion=excel_export&jahr=2026&monat=5" \
  -H "Host: stundng50.rusti.ipv64.net" \
  -o /tmp/test.xlsx

file /tmp/test.xlsx
# Erwartung: Microsoft Excel 2007+

Wenn da was anderes steht (z.B. ASCII text), ist im Body ein Fehler. Mit head /tmp/test.xlsx ansehen.

Browser-Cache-Probleme

Symptom: Du hast was geändert, im Browser kommt aber noch die alte Version.

BrowserHard-Reload
Chrome / Edge (Windows)Strg+F5 oder Strg+Shift+R
Chrome / Edge / Firefox (Mac)Cmd+Shift+R
Safari (Mac)Cmd+Option+E (Cache leeren) dann Cmd+R
Mobile Safari (iOS)App schließen, Inkognito-Tab öffnen

Umlaute kaputt

Bei Migration von IONOS könnten Umlaute doppelt UTF-8 kodiert sein. Diagnose:

mariadb stunden_db -e "SELECT taetigkeit, HEX(taetigkeit) FROM stunden_eintraege \
                       WHERE taetigkeit LIKE '%ä%' OR taetigkeit LIKE '%ö%' OR taetigkeit LIKE '%ü%' \
                       LIMIT 5;"

Bei korrektem UTF-8 ist ä = C3 A4 (2 Bytes). Bei kaputtem doppelt-UTF-8 ist es 4 Bytes.

Reparatur (nur wenn nötig!):

# Backup vorher!
mariadb-dump stunden_db > /tmp/stunden_backup.sql

# Reparatur
mariadb stunden_db <<'EOF'
UPDATE stunden_eintraege
SET taetigkeit = CONVERT(CAST(CONVERT(taetigkeit USING latin1) AS BINARY) USING utf8mb4);
EOF
ℹ️ Tipp: Diese Reparatur funktioniert nur für ein zweimal-UTF-8-kodiertes Feld. Bei der Tretbeckenreinigungs-App hatten wir genau dieses Problem — die Methode ist erprobt.

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-Hash (Cost 10), niemals Klartext
Sessions HttpOnly + SameSite=Strict, Auto-Logout nach 120 Min
SQL-Injection Alle DB-Zugriffe nutzen Prepared Statements
config.php Per Apache <FilesMatch> blockiert
Login-Protokoll 7 Tage gespeichert
Doppel-Bestätigung beim Löschen "LÖSCHEN" in Großbuchstaben tippen

Apache-Sicherheits-Header hinzufĂĽgen (optional)

Falls noch nicht gesetzt, kannst du wie bei der Treckertreff-App die 3 Sicherheits-Header hinzufĂĽgen:

a2enmod headers

sed -i '/DocumentRoot/a\
\
    # === Sicherheits-Header ===\
    Header always set X-Frame-Options "SAMEORIGIN"\
    Header always set X-Content-Type-Options "nosniff"\
    Header always set Referrer-Policy "strict-origin-when-cross-origin"' \
  /etc/apache2/sites-available/stunden.conf

apache2ctl configtest
systemctl reload apache2

# Verifizieren
curl -sI https://stundng50.rusti.ipv64.net/ | grep -iE "x-frame|x-content|referrer"

Notfall-Wiederherstellung

App ist komplett down

systemctl status apache2 mariadb
tail -50 /var/log/apache2/stunden_error.log
tail -50 /var/log/mysql/error.log
apache2ctl configtest
systemctl restart apache2
systemctl restart mariadb

Datenbank kaputt — Backup einspielen

# Aktuelle DB als Notfall-Sicherung
mariadb-dump stunden_db > /root/stunden_kaputt.sql

# ZurĂĽcksetzen und letztes Backup einspielen
mariadb -e "DROP DATABASE stunden_db; \
            CREATE DATABASE stunden_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb stunden_db < /root/stunden_db_LETZTES.sql

Alles ist kaputt — VM-Rollback

Im Proxmox-Web-UI:

  1. VM 122-g50zeiten auswählen
  2. Links auf "Backup"
  3. Letztes funktionierendes Backup auswählen
  4. "ZurĂĽckspielen" klicken
🚨 Achtung: Damit gehen ALLE Änderungen seit dem Backup verloren — auch auf der Tretbeckenreinigungs-App, die ja auf der gleichen VM liegt!

Verwandte Dokus

Stundenabrechnung Admin-Doku Stand: 21.05.2026
https://stundng50.rusti.ipv64.net auf VM 122-g50zeiten