Die Website nutzt eine Hybrid-Architektur: Statische Seiten (GitHub Pages + Cloudflare CDN) + Pi-API Backend (Express.js + SQLite auf Raspberry Pi). Content-Daten werden im Browser gespeichert (localStorage) und als statische JavaScript-Datei exportiert. Nutzerdaten, Auth und Kontaktformular laufen über die Pi-API.
Kein Framework, kein Build-Tool
Läuft direkt im Browser
content-data.js (exportiert, deployed)
GitHub → Pi-Server (optional)
app.html als Standalone-App
Offline-fähig nach erstem Laden
JWT-basiertes Auth-System auf Pi-API (bcrypt + jsonwebtoken)
Session via localStorage + Refresh-Tokens
Seitenstruktur
| Datei | Zweck | Zugriff |
|---|---|---|
| index.html | Hauptseite (Landing Page) | Öffentlich |
| app.html | PWA-App: Geplante Rideouts | Öffentlich |
| blog.html | Blog-Übersicht | Öffentlich |
| blog-detail.html | Blog-Einzelseite (?post=id) | Öffentlich |
| events.html | Events-Übersicht (.edc-Karten) | Öffentlich |
| event-detail.html | Event-Einzelseite (?event=slug) | Öffentlich |
| loadout.html | Ausrüstungs-Showcase | Öffentlich |
| packlists.html | Packlisten-Ansicht | Öffentlich |
| stats.html | Club-Statistik (Strava km, Jahresvergleich) | Öffentlich |
| map.html | Interaktive Karte (Startpunkte & Locations) | Öffentlich |
| login.html | Login / Registrierung / Magic Link | Öffentlich |
| profil.html | Eigenes Profil bearbeiten | Eingeloggt (JWT) |
| mitglieder.html | Community-Übersicht | Eingeloggt (JWT) |
| impressum.html | Impressum / Datenschutz | Öffentlich |
| admin.html | Content Management System | Passwort-geschützt |
| admin-docs.html | Diese Dokumentation | Admin-intern |
So fließen Inhalte vom Admin-Formular bis zur öffentlichen Seite:
Publish-Flow (vollständig)
mb_content
generiert
git pull / 2min
Content-Lade-Reihenfolge (Seitenladen)
MagBikingStatic
mb_content
content.js
content-inject.js
window.MagBikingStatic (aus content-data.js) — ist das deployed, wird dieses als Basis genommen.Override:
localStorage.mb_content — Admin-Änderungen überschreiben die Basis.Fallback: Wenn beide leer, werden die DEFAULTS aus content.js genutzt.
Wichtig: Arrays werden komplett ersetzt (nicht gemergt). Leere Strings aus localStorage überschreiben keine vorhandenen Werte in der Basis.
Script-Ladereihenfolge (index.html)
// Alle Scripts haben defer — Ausführung in Dokumentreihenfolge:
1. content-data.js // window.MagBikingStatic = {...}
2. js/content.js // window.MagBiking.Content API
3. js/content-inject.js // DOM mit Inhalten befüllen
4. js/nav.js // Navigation, Burger-Menü
5. js/animations.js // Scroll-Reveal, Parallax
6. js/counter.js // Hero-Stats animieren (läuft nach inject!)
7. js/supabase-client.js // API-Client (Pi-API REST + JWT), muss vor club-stats!
8. js/club-stats.js // Community-Statistiken (Pi-API /api/stats)
9. js/contact.js // Formular-Handler (Self-Hosted Pi-API)
11. js/gpx-parser.js // GPX-Parsing & Canvas-Charts
12. js/rideouts.js // window.MagBiking.getRidesForDisplay()
13. js/index-rideouts.js // Rideout-Widget rendern
14. js/lightbox.js // Galerie-Lightbox
Diese Werte werden direkt aus dem aktuellen Datenstand gelesen.
Die Landing Page enthält alle 10 Sektionen als statisches HTML-Gerüst. Inhalte werden von content-inject.js überschrieben.
Sektionen (in Reihenfolge)
| ID / Klasse | Abschnitt | Wird befüllt von |
|---|---|---|
| #hero | Hero mit Hintergrundbild & Stats | content-inject.js |
| #about | Community-Beschreibung | content-inject.js |
| #events | Featured Event (großer Karte) | content-inject.js |
| #rideouts | Geplante Rides (Featured + Grid) | index-rideouts.js |
| #rides | Events-Grid (8 Karten) | content-inject.js |
| #equipment | Equipment-Bereich | content-inject.js |
| #blog | Blog-Vorschau (neuester Beitrag) | content-inject.js |
| #gallery | Masonry-Galerie | content-inject.js |
| #sponsors | Partner & Sponsoren | content-inject.js |
| #contact | Kontaktformular & Social-Links | content-inject.js + contact.js |
Das vollständige Content-Management-System. Login-Status in localStorage (bleibt über Reloads, Tab-Schließen und Browser-Neustarts gespeichert). Abmelden explizit über „← Abmelden". Achtung: Passwort ist im HTML-Quellcode sichtbar.
Admin-Sektionen (Sidebar)
Hero · About · Featured Event · Rideouts · Events-Grid · Equipment · Katalog · Loadout · Kontakt · Galerie · Sponsoren · Blog · Packlisten · Footer
Admin-Modals
Versionsverlauf: Letzte gespeicherte Stände wiederherstellen (in mb_backups)
Speicher-Übersicht: localStorage-Nutzung visualisieren
GitHub-Einstellungen: Repo, Branch, Token konfigurieren
Alle Unterseiten folgen dem gleichen Muster:
// Script-Ladereihenfolge auf jeder Unterseite:
1. content-data.js // MagBikingStatic laden
2. js/content.js // Content-API bereitstellen
3. js/gpx-parser.js // (wenn GPX benötigt)
4. js/[page].js // Seiten-spezifischer Renderer
| Seite | Renderer | Datenquelle |
|---|---|---|
| blog.html | js/blog.js | data.blog.posts[] |
| events.html | js/events.js | data.eventsGrid.cards[] |
| loadout.html | js/loadout.js | data.catalog.items[] + data.loadout.selectedIds[] |
| packlists.html | js/packlists.js | data.packlists.lists[] + data.catalog.items[] |
| app.html | js/app.js | MagBiking.getRidesForDisplay(8) |
| Datei | Modul | Beschreibung |
|---|---|---|
| js/content.js | Content-Store | Zentrale Daten-API. DEFAULTS definiert, deepMerge(), Content.get/set/getSection. MUSS als erstes geladen werden. |
| js/content-inject.js | DOM-Injector | Überschreibt alle statischen HTML-Elemente mit Inhalten aus dem Content-Store. Läuft einmalig beim Seitenladen. |
| js/rideouts.js | Rideout-Engine | Ride-Generierung, Template-Rotation, getRidesForDisplay(). Lädt Rides aus localStorage → MagBikingStatic → Content.getSection. |
| content-data.js | Statischer Export | window.MagBikingStatic = {...} — generiert vom Admin. Enthält alle deployt-ten Inhalte inkl. Thumbnails. |
| Datei | Seite | Beschreibung |
|---|---|---|
| js/index-rideouts.js | index.html | Rendert Featured-Ride-Karte + 3er-Grid. Zeigt GPX-Höhenprofil wenn vorhanden. |
| js/blog.js | blog.html | Rendert alle sichtbaren Blog-Beiträge. Markdown-Parsing (**fett**, _kursiv_). Sektionstypen: text, image-text, gallery, gpx. |
| js/events.js | events.html | Rendert Events-Detail-Karten mit Komoot/Strava-Links, GPX-Download, Streckenbeschreibung. |
| js/loadout.js | loadout.html | Liest catalog.items gefiltert durch loadout.selectedIds. Gruppiert nach Kategorie. Zeigt imageSrcThumb || imageSrc. |
| js/packlists.js | packlists.html | Liest packlists.lists mit item-IDs. Löst IDs gegen catalog.items auf. Zeigt Gesamtgewicht. |
| js/app.js | app.html | PWA-Listenansicht aller geplanten Rides. Zeigt GPX-Höhenprofil, Komoot/Strava-Links, Download. |
| Datei | Funktion | Beschreibung |
|---|---|---|
| js/admin.js | Haupt-Editor | ~3000 Zeilen. Alle Formular-Renderer, Image-Upload, Version-History, collectAll(), loadAll(). Größte Datei im Projekt. |
| js/admin-github.js | GitHub-Integration | Token-Management, Push content-data.js, Privacy-Scanner, Verbindungstest, Branch-Auswahl. |
| js/admin-storage.js | Speicher-Modal | Storage-Übersicht, GitHub-Dateibrowser, localStorage-Quota-Anzeige. |
| js/admin-backup.js | Backup/Restore | JSON-Export und -Import aller Inhalte. Strippt Base64-Bilder (imageSrc + src) vor Export. |
| Datei | Funktion | Beschreibung |
|---|---|---|
| js/gpx-parser.js | GPX-Parser | parseGPX(xmlStr) → {points, stats}. Haversine-Distanz. drawChart(canvas, points, stats). Downsampling auf 200 Punkte. |
| js/nav.js | Navigation | Scroll-Schatten, Burger-Menü Toggle, Active-Link via IntersectionObserver. |
| js/animations.js | Animationen | Scroll-Reveal (.reveal), Card-Stagger (80ms), Parallax Hero (0.25× Scroll). |
| js/counter.js | Stat-Counter | Animiert .stat-number[data-target]. Ease-out-Kurve, 2s Dauer. >1000 → "Xk". Trigger via IntersectionObserver. |
| js/contact.js | Kontaktformular | Self-Hosted Pi-API via /api/contact. Honeypot-Spam-Schutz, DSGVO-Checkbox, Fehlermeldung bei Fehler. |
| js/lightbox.js | Galerie-Lightbox | Bildvergrößerung bei Klick. Tastatur-Navigation. Touch-Support. |
| js/supabase-client.js | API-Client | API-Client (Pi-API REST + JWT Token-Handling). Verwaltet Access-/Refresh-Tokens, automatisches Token-Refresh, Auth-Header-Injection. Muss vor club-stats.js geladen werden. |
| js/club-stats.js | Community-Stats | Liest gefahrene km aus Pi-API (/api/stats). Zeigt Jahresvergleich der Strava-Club-Statistiken. |
Design-Tokens (css/base.css)
/* Farben */
--accent: #FF6B00; /* Orange – Primärfarbe */
--accent-light: #FF8C3A; /* Hover-Orange */
--accent-glow: rgba(255,107,0,0.08);
/* Hintergründe */
--bg: #FFFFFF; --bg-2: #F7F7F9; --bg-3: #EFEFF2;
/* Text */
--text: #1A1A2E; --text-muted: #6B6B80; --text-faint: #ABABBA;
/* Schriften */
--font-body: 'Inter', sans-serif; /* Fließtext */
--font-display: 'Bebas Neue'; /* Headlines, Zahlen */
/* Layout */
--nav-h: 72px; --radius: 14px; --radius-lg: 22px;
Responsive Breakpoints
CSS-Dateien
| Datei | Abschnitt | Geladen auf |
|---|---|---|
| css/base.css | Reset, Variablen, Buttons, Utility-Klassen | Alle |
| css/nav.css | Navigationsleiste | index.html |
| css/hero.css | Hero-Sektion | index.html |
| css/about.css | Community-Sektion | index.html |
| css/featured-event.css | Featured-Event-Karte | index.html |
| css/events-grid.css | Events-Karten-Grid | index.html |
| css/rideouts.css | Rideout-Widget auf Startseite | index.html |
| css/app.css | PWA-App-Stile | app.html |
| css/blog.css | Blog-Vorschau & Blog-Seite | index + blog.html |
| css/equipment.css | Equipment-Sektion | index.html |
| css/gallery.css | Masonry-Galerie | index.html |
| css/sponsors.css | Sponsoren-Bereich | index.html |
| css/contact.css | Kontaktformular | index.html |
| css/footer.css | Footer | index.html |
| css/responsive.css | Alle Media Queries | index.html |
| css/lightbox.css | Bild-Lightbox | index.html |
| css/loadout.css | Loadout-Seite | loadout.html |
| css/packlists.css | Packlisten-Seite | packlists.html |
| css/events.css | Events-Detail-Seite | events.html |
| css/admin.css + 4 weitere | Admin-Panel-Stile | admin.html |
Alle Seiteninhalte werden über window.MagBiking.Content gelesen. Diese API ist in js/content.js definiert.
API-Methoden
// Alle Inhalte (merged: MagBikingStatic + localStorage):
Content.get() → { hero, about, featuredEvent, ... }
// Einzelne Sektion:
Content.getSection('blog') → { posts: [...] }
// Speichern (gibt { ok, bytes } oder { ok: false, quota } zurück):
Content.set(data) → { ok: true, bytes: 12345 }
// Sektion auf Default zurücksetzen:
Content.resetSection('blog')
{
id: 'post_1234567890',
title: 'Beitragstitel',
date: '2026-03-15', // ISO-Format YYYY-MM-DD
category: 'Rideout',
teaser: 'Kurzbeschreibung…',
hidden: false, // true = nicht öffentlich
sections: [
{
type: 'text', // text | image-text | gallery | gpx
content: 'Fließtext…',
images: [{ src, srcThumb, caption, imagePosition }],
imageAlign: 'left', // nur bei image-text
gpxFile: { name, points, stats, downloadUrl }, // nur bei gpx
gpxTitle: 'Tag 1: Route…' // nur bei gpx
}
],
importedFrom: 'ride_2026-03-15' // optional: Quelle beim Import
}
{
date: '2026-04-06', // YYYY-MM-DD — Pflichtfeld!
title: 'Sunday Ride',
subtitle: 'Bodensee Loop',
time: '09:00',
distance: 80, // km
elevation: 650, // Höhenmeter
difficulty: 'Mittel', // Leicht | Mittel | Anspruchsvoll
meetupPlace: 'Marktplatz, Bad Waldsee',
tag: 'Rennrad',
tagColor: '#FF6B00',
komootLink: 'https://…',
stravaLink: 'https://…',
gpxFiles: [{ name, points, stats, downloadUrl }],
hidden: false
}
{
id: 'cat_1234567890', // eindeutige ID (genId())
name: 'Specialized Tarmac SL7',
category: 'Bikes & Rahmen',
weight: 6800, // Gramm (oder leer)
note: 'Di2, 50/34, 11-30',
imageSrc: 'data:image/jpeg;base64,…', // Full-Bild
imageSrcThumb: 'data:image/jpeg;base64,…', // 200px Thumb für Ladezeit
imagePosition: { x: 50, y: 50 } // object-position %
}
// loadout.selectedIds[] = [ 'cat_1234567890', … ] (geordnet)
// packlists.lists[].items[] = [ 'cat_1234567890', … ] (item-IDs)
{
category: 'Epic Ride',
title: 'Nizza – Barcelona',
description: 'Kurzbeschreibung',
imageSrc: '', // leer → defaultSrc
defaultSrc: 'Grafiken/Nizza-Barcelona.webp',
imageSrcThumb, imagePosition,
date: '2026-07-15',
distance: '820', elevation: '9200',
startLocation, route, detailText,
komootLink, stravaLink, stravaStats,
gpxData: { name, points, stats, downloadUrl },
hidden: false
}
Bilder werden als Base64 direkt in localStorage gespeichert. Dabei werden automatisch zwei Versionen angelegt:
Progressive Image Loading
progressiveImg(imgEl, thumb, full, position):
1. Setzt sofort img.src = thumb (schnell, sofort sichtbar)
2. Lädt full als neues Image-Objekt im Hintergrund
3. Wenn geladen: img.src = full (sanfter Übergang)
// Base64-Bilder werden direkt gesetzt (kein nachladen nötig)
imageSrcThumb ist in content-data.js gespeichert. Das Vollbild (imageSrc) ist leer → das Thumbnail wird als Vollbild verwendet.Im Admin: Vollbild ist aus localStorage verfügbar und wird angezeigt.
Für echte Vollbilder auf der öffentlichen Seite → GitHub-Upload aktivieren. Das Bild wird zu GitHub hochgeladen, eine URL wird gespeichert und exportiert.
GPX-Dateien werden im Browser geparst und als JavaScript-Objekte gespeichert. Kein Server-seitiges Parsing.
GPX-Parser API
// Parsen:
var result = MagBiking.GPX.parseGPX(xmlString);
// result:
{
name: 'route-name.gpx',
points: [[0.0, 450.2], [1.3, 480.1], …], // [distKm, elevM]
stats: {
distance: '82.4', elevGain: '1240',
elevLoss: '1180', maxElev: '1120', minElev: '390'
},
downloadUrl: null // wird nach GitHub-Upload befüllt
}
// Höhenprofil zeichnen:
MagBiking.GPX.drawChart(canvasEl, points, stats);
GPX-Nutzung je Bereich
| Bereich | Dateifeld | Darstellung |
|---|---|---|
| Rideouts | ride.gpxFiles[] | Höhenprofil in Featured-Karte (index.html) + app.html |
| Events-Grid | card.gpxData | Höhenprofil in events.html Detail-Ansicht |
| Blog | section.gpxFile | Höhenprofil + Stats als eigene Sektion im Beitrag |
Datenlage-Priorität
mb_content.rideouts.rides
rideouts.rides
kein Auto-Generate mehr
Vergangenheitsdaten werden automatisch herausgefiltert (
date < today).
Beitrag-Struktur
Jeder Blog-Beitrag besteht aus beliebig vielen Abschnitten (Sections):
Import-Funktion
Blog-Beiträge können aus Events-Grid-Einträgen oder Rideouts importiert werden. Der Import erstellt automatisch Text-, Bild- und GPX-Sektionen aus dem vorhandenen Inhalt.
Zentrale Datenbank
Auswahl für Loadout-Seite
Auswahl für Packlisten
2. Im Admin → Loadout: Gegenstand via Checkbox auswählen → erscheint auf loadout.html.
3. Im Admin → Packlisten: Gegenstand einer Liste zuweisen → erscheint auf packlists.html.
4. Speichern & Deployen.
„Speichern & Deployen" (Haupt-Button oben rechts) führt folgendes aus:
1. collectAll() — alle Formularfelder werden gelesen
2. Content.set(data) — in localStorage gespeichert
3. Snapshot für Versionsverlauf wird angelegt (mb_backups)
4. content-data.js wird generiert (window.MagBikingStatic = ...)
5. Optional: GitHub-Push wenn Token konfiguriert
6. Pi-Server: git pull alle 2 Minuten → Änderungen live
Klick auf Bild-Widget: Dateiauswahl öffnet sich.
Drag & Drop: Bild auf das Widget ziehen.
Position: Im Widget kann die Bildausrichtung (object-position) angepasst werden.
Entfernen: × Button im Bild-Widget.
Bilder werden als Base64 gespeichert. Ein kleines Thumbnail (~200px) wird automatisch erstellt und in content-data.js exportiert. Das Vollbild bleibt nur in localStorage.
Jedes Speichern legt automatisch einen Snapshot an. Zugriff über „Verlauf" Modal (Uhr-Icon oben).
Snapshots sind in localStorage.mb_backups gespeichert. Bei vollem localStorage werden älteste Snapshots nicht automatisch gelöscht — manuell bereinigen über das Speicher-Modal.
Einstellungen: ⚙️ Icon oben rechts → GitHub-Tab
Felder: Repository (user/repo), Branch (main), Pfad (content-data.js), Token (GitHub PAT)
Token braucht: repo Scope (Lesen + Schreiben)
Token wird in localStorage.mb_gh_settings gespeichert.
pi-setup.sh.Cron-Job (alle 2 Min):
*/2 * * * * cd /var/www/magbiking && git pull origin mainPi holt Änderungen automatisch aus dem GitHub-Repo und stellt sie sofort bereit.
OpenClaw.
| Komponente | Version / Patchstand | Prüfbefehl |
|---|---|---|
| Hardware | Raspberry Pi 5 Model B Rev 1.0 (aarch64) | cat /proc/device-tree/model |
| OS | Debian GNU/Linux 13 (trixie) | lsb_release -a |
| Kernel | 6.12.62+rpt-rpi-2712 | uname -a |
| Firmware / EEPROM | up to date (2025-12-08) | sudo rpi-eeprom-update |
| Webserver | nginx 1.26.3 | nginx -v |
| Git | 2.47.3 | git --version |
| Cron | 3.0pl1-197 | dpkg -l cron | tail -1 |
| Node.js (API) | 22.22.0 | node -v |
| npm | 10.9.4 | npm -v |
| Express (API) | noch ermitteln | npm ls express |
| SQLite | nicht installiert (CLI) | sqlite3 --version |
| cloudflared (Tunnel) | 2026.3.0 (built 2026-03-09) | cloudflared --version |
| API-Service | noch ermitteln | systemctl status magbiking-api |
| Pi Uptime | – | uptime -p |
| Letztes apt-Update | – | ls -l /var/log/apt/history.log |
pi-sysinfo.sh läuft täglich per Cron auf dem Pi und schreibt /var/lib/magbiking/sysinfo.json. Diese Seite holt die Daten live über api.mag-biking.com/api/sysinfo. Falls der Endpoint nicht erreichbar ist, werden die zuletzt manuell eingetragenen Werte angezeigt (Fallback).
2. Inhalte bearbeiten
3. „Speichern & Deployen" klicken
4. Browser: content-data.js wird heruntergeladen
5. Datei manuell committen ODER GitHub-Push ist aktiviert → automatisch
6. Pi: git pull (max. 2 Minuten) → Änderungen live
localStorage-Schlüssel
| Schlüssel | Inhalt | Größe |
|---|---|---|
| Wird geladen… | ||
Offen (2)
js/content.js Zeile 137. Leere Strings aus localStorage überschreiben keine nicht-leeren Default-Werte. Admin kann Felder nicht wirklich leeren wenn ein Default existiert.Warum offen: Die Logik ist ein absichtlicher Schutz gegen teils leere localStorage-Einträge aus alten Versionen. Eine Änderung erfordert einen expliziten "Feld leeren"-Mechanismus (z.B. Sentinel-Wert), ohne bestehende Inhalte zu brechen. Kein Datenverlust-Risiko – nur UX-Einschränkung.
#. Fehlende DSGVO-Seiten sind rechtlich problematisch.Impressum (2026-03-31 behoben):
impressum.html erstellt. Admin-Editor unter "⚖️ Impressum" zum Befüllen der Pflichtangaben (§5 TMG: Name, Adresse, E-Mail). Footer-Link in index.html zeigt jetzt auf impressum.html.Noch offen:
datenschutz.html (DSGVO Art. 13) fehlt noch – Link zeigt weiterhin auf #. Newsletter-Link zeigt auf # (Newsletter noch nicht implementiert).Nächster Schritt: Impressum im Admin befüllen + veröffentlichen. Dann
datenschutz.html anlegen (Empfehlung: Datenschutz-Generator von eRecht24.de).
✓ Behoben (9) – BUG-01, 03, 04, 06–11
js/content-inject.js. Prüfte window.MagBiking.M — diese Eigenschaft existiert nicht.Fix (2026-03-31): Guard-Bedingung auf
window.MagBiking.getRidesForDisplay geändert.content-inject.js las nur equipment.packlists (alte Struktur).Fix (2026-03-31): Prüft jetzt zuerst
d.packlists.lists (neue Struktur), Fallback auf alte Daten.Grafiken/Mallorca Trainingslager.png. Alle Referenzen aktualisiert.catch-Zweig zeigt jetzt Fehlermeldung.events.js / event-detail.jshydrateGpx()-Promise eigenes .catch(() => null). Zusätzlich .catch(console.warn) am äußeren Promise.all.removeEventListener vor jedem addEventListener. Betrifft app.js, events.js, event-detail.js, blog-detail.js.respondWithe.respondWith(fetch(e.request, { cache: 'no-store' })) für Admin-Pfade in sw.js.lightbox.jsevent-detail.jsif (miniEl && miniEl.parentElement) vor dem Zugriff.Bekannte Sicherheitsrisiken, priorisiert nach Schweregrad. Letzter Audit: 2026-04-21 (Full-Stack inkl. Pi-API). 15 von 20 Findings behoben.
| ID | Schweregrad | Titel | Status |
|---|---|---|---|
| SEC-01 | Hoch | GitHub Token in localStorage | ✓ Behoben |
| SEC-02 | Hoch | Admin-Passwort clientseitig | ✓ Behoben |
| SEC-03 | Hoch | Kein Brute-Force-Schutz | ✓ Behoben |
| SEC-04 | Mittel | content-data.js öffentlich | ✓ Behoben |
| SEC-05 | Mittel | Kein CSP-Header | ✓ Behoben |
| SEC-06 | Mittel | innerHTML XSS-Vektoren | ✓ Behoben |
| SEC-07 | Mittel | admin.html ohne Server-Schutz | ✓ Behoben CF Access |
| SEC-08 | Niedrig | btoa kein Sicherheitsmechanismus | ✓ Behoben |
| SEC-09 | Niedrig | Legacy Supabase Anon-Key (migriert zu Pi-API) | ✓ Migriert |
| SEC-10 | Hoch | XSS via onerror Fallback-URL | ✓ Behoben |
| SEC-11 | Hoch | Timing-Attack API-Key | ✓ Behoben |
| SEC-12 | Hoch | Kontaktformular kein Rate-Limit | ✓ Behoben |
| SEC-13 | Mittel | CORS-Header Leak | ✓ Behoben |
| SEC-14 | Mittel | Fehlender HSTS-Header | ✓ Behoben |
| SEC-15 | Mittel | Sysinfo Pfad-Leak | ✓ Behoben |
| SEC-16 | Niedrig | Kein Body-Size-Limit | ✓ Behoben |
| SEC-17 | Mittel | CSP unsafe-inline | ✓ Behoben |
| SEC-18 | Mittel | Fehlende SRI | ✓ Behoben |
| SEC-19 | Niedrig | PW-Hash im Client | Entschärft (CF Access) |
| SEC-20 | Niedrig | GH-Token im Frontend | Entschärft (CF Access) |
Architekturabhängig (3) – Langfristig
Status: Erledigt durch Migration zu Pi-API. Auth läuft jetzt über JWT-Tokens (Access + Refresh), keine externen Cloud-Dienste mehr.
_adminPWHash in content-data.js sichtbar. Durch Cloudflare Access entschärft.Architekturabhängig: Erfordert serverseitige Auth (Pi-API mit bcrypt/Argon2). Aktuell durch CF Access geschützt.
sessionStorage (nicht persistent), aber in DevTools sichtbar. Durch Cloudflare Access geschützt.Architekturabhängig: Erfordert Backend-Proxy (Pi-API als GitHub-Vermittler). Aktuell durch CF Access geschützt.
✓ Behoben (18) – SEC-01–08, 10–18
Hoch (2026-03-31)
sessionStorage. Auto-Migration entfernt alten Eintrag.esc(fallback).replace(/'/g,"\\'") in events.js und event-detail.js.Mittel (2026-03-31)
innerHTML-Stellen. 4 fehlende esc()-Aufrufe ergänzt.admin-github.js ergänzt.Pi-API Audit (2026-04-21)
crypto.timingSafeEqual() für konstante Vergleichszeit.Strict-Transport-Security: max-age=31536000; includeSubDomains; preload.express.json({ limit: '3mb' }) (erhöht für Avatar-Upload).Frontend-Hardening (2026-04-21)
content-data.js öffentlich lesbar auf GitHub'unsafe-inline' für Scripts.js-Dateien extrahiert (13 neue Module). 'unsafe-inline' aus script-src in allen 13 HTML-Dateien entfernt.fonts/, css/fonts.css). Externe Font-Abhängigkeit vollständig eliminiert.Geprüft: 2026-04-21 · Vollständige Prüfung aller JS-Module, Script-Ladeorder und Admin↔Öffentlich-Datenkonsistenz. Inkl. Pi-API Security Audit.
Ergebnis: Alle Module
| Modul | Geladen auf | Status |
|---|---|---|
content.js | Alle Seiten | ✓ OK |
content-data.js | Alle Seiten | ✓ OK |
content-inject.js | index.html | ✓ OK |
nav.js | index.html | ✓ OK |
animations.js | index.html | ✓ OK |
counter.js | index.html | ✓ OK |
supabase-client.js | index.html | ✓ Behoben fehlte – jetzt vor club-stats.js |
club-stats.js | index.html | ✓ OK Exit via DEMO_MODE-Guard |
contact.js (Self-Hosted API) | index.html | ✓ OK |
gpx-parser.js | index, events, event-detail, app | ✓ OK |
rideouts.js | index, app, event-detail | ✓ OK |
index-rideouts.js | index.html | ✓ OK |
lightbox.js | index, events, event-detail, blog | ✓ OK |
events.js | events.html | ✓ OK |
event-detail.js | event-detail.html | ✓ OK |
blog.js | blog.html | ✓ OK |
packlists.js | packlists.html | ✓ OK |
loadout.js | loadout.html | ✓ OK |
app.js | app.html | ✓ OK |
admin.js | admin.html | ✓ OK |
admin-github.js | admin.html | ✓ OK |
admin-backup.js | admin.html | ✓ OK |
admin-storage.js | admin.html | ✓ OK |
Admin ↔ Öffentlich: Datenkonsistenz
| Datenbereich | Admin verwaltet | Öffentlich angezeigt durch | Status |
|---|---|---|---|
| hero | collectHero() | content-inject.js | ✓ |
| about | collectAbout() | content-inject.js | ✓ |
| featuredEvent | collectFeaturedEvent() | content-inject.js (Slider-Fallback) | ✓ |
| eventsGrid | collectEventsGrid() | content-inject.js, events.js, Slider | ✓ |
| rideouts | collectRideouts() | index-rideouts.js, app.js | ✓ |
| equipment | collectEquipment() | content-inject.js | ✓ |
| contact | collectContact() | content-inject.js | ✓ |
| gallery | collectGallery() | content-inject.js | ✓ |
| catalog / loadout | collectCatalogData(), collectLoadout() | packlists.js, loadout.js | ✓ |
| sponsors | collectSponsors() | content-inject.js | ✓ |
| blog | collectBlog() | blog.js, Vorschau in content-inject.js | ✓ |
| footer | collectFooter() | content-inject.js (teilw.) | ✓ |
✓ Behobene Fehler (2026-03-31)
supabase-client.js vor club-stats.js in index.html eingefügt.navigator.serviceWorker.register('/sw.js') am Ende von app.html ergänzt.Hinweise (kein Handlungsbedarf)
supabase-client.js wurde zur Pi-API migriert und fungiert als REST-Client mit JWT-Token-Management. Kommuniziert mit api.mag-biking.com statt Supabase Cloud. Kein DEMO_MODE mehr nötig.
[Browser] → HTTPS → [Cloudflare Edge / DDoS-Schutz]
↓ Tunnel (kein offener Port)
[Raspberry Pi 5 / nginx 1.26.3]
↓ Reverse-Proxy
[Express.js / Node 22.22.0]
↓
Middleware-Chain: Rate-Limit → CORS → Security-Headers → API-Key
↓
Routes: /api/stats, /health (public) | /api/contact, /api/sysinfo (API-Key) | /api/auth/*, /api/members/*, /api/events/* (JWT)
↓
[SQLite] (Strava-Aktivitäten, zukünftig: Nutzerprofile, Tokens)
pi-api/security.js)Zentrale Middleware-Datei für alle Express-Routes. Einbindung: require('./security').init(app) vor allen Route-Definitionen.
| Middleware | Schutz | Konfiguration |
|---|---|---|
| Rate-Limiter | Max. Requests pro IP pro Zeitfenster (Default: 60/min). In-Memory, kein npm-Paket. | RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS |
| API-Key | Prüft X-API-Key Header, ?key= Query oder Bearer Token gegen API_KEY Env-Var. | API_KEY=<random-hex> |
| Security-Headers | X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Permissions-Policy, kein X-Powered-By | – |
| CORS | Nur erlaubte Origins, GET, POST, PUT, DELETE, OPTIONS, X-API-Key Header erlaubt | CORS_ORIGIN=https://mag-biking.com |
| Input-Sanitizer | Entfernt HTML-Tags aus POST-Body-Strings, Längenbegrenzung (10.000 Zeichen) | – |
| Request-Logger | Loggt Method, Path, Status, Dauer. IP-Adresse wird maskiert (letztes Oktett → .xxx) für DSGVO-Konformität. | – |
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"2. In
.env eintragen (im API-Verzeichnis auf dem Pi):API_KEY=<generierter-hex-key>CORS_ORIGIN=https://mag-biking.com3. API neustarten:
sudo systemctl restart magbiking-api4. In Admin-Doku testen: Oben in der Pi-Stack-Tabelle den Key eingeben und „Laden“ klicken.
pi-api/backup.sh)Setup:
chmod +x /var/www/html/pi-api/backup.shecho '30 3 * * * root /var/www/html/pi-api/backup.sh' | sudo tee /etc/cron.d/magbiking-backupFeatures:
sqlite3 .backupfür konsistenten Snapshot (Fallback:cp+ WAL)- Automatische gzip-Komprimierung
- Löscht Backups älter als 14 Tage
- Integritätsprüfung via
PRAGMA integrity_check - Log in
/var/log/magbiking-backup.log
DB_PATH und BACKUP_DIR im Script oder via Env-Vars MAGBIKING_DB_PATH / MAGBIKING_BACKUP_DIR.
Sobald das Ranking-/Punkte-System personenbezogene Daten verarbeitet (Strava OAuth, Aktivitäten mit GPS, Nutzerprofile), gelten strenge DSGVO-Anforderungen. Diese müssen vor dem Go-Live umgesetzt sein:
| Pflicht | Beschreibung | Status |
|---|---|---|
| Rechtsgrundlage | Art. 6 Abs. 1 lit. a DSGVO: Einwilligung. Nutzer verbindet Strava freiwillig. Einwilligung muss explizit, informiert und widerrufbar sein. | ☐ |
| Datenschutzerklärung | impressum.html erweitern: Welche Daten (Aktivitäten, Distanz, Hm, Zeitstempel, Strava-User-ID), Zweck (Ranking, Stempelbuch), Speicherdauer, Empfänger (nur mag.biking, kein Dritter), Rechte (Auskunft, Löschung, Export, Widerruf). |
☐ |
| Datenminimierung | Nur speichern was nötig: Distanz, Höhenmeter, Datum, Dauer. Keine GPS-Tracks (Standortdaten = besonders schützenswert). Keine Herzfrequenz, keine privaten Notizen. | ☐ |
| Token-Sicherheit | Strava OAuth Access- & Refresh-Tokens AES-256-GCM verschlüsselt in SQLite gespeichert. Key aus Env-Var TOKEN_ENCRYPTION_KEY. Modul: pi-api/crypto.js + pi-api/strava-tokens.js. |
✅ |
| Recht auf Löschung | API-Endpoint DELETE /api/user/data: Löscht alle Aktivitäten, Tokens, Badges, Profildaten eines Nutzers. Muss innerhalb 30 Tagen umsetzbar sein (Art. 17 DSGVO). |
☐ |
| Datenexport | API-Endpoint GET /api/user/export: Gibt alle gespeicherten Daten als JSON/CSV zurück (Art. 20 DSGVO, Datenübertragbarkeit). |
☐ |
| Speicherbegrenzung | Aktivitäten max. 2 Jahre aufbewahren (oder nach Saison-Ende aggregieren und Rohdaten löschen). Gelöschte Accounts: Daten sofort entfernen, nicht nur deaktivieren. | ☐ |
| Widerruf (Opt-out) | Nutzer kann jederzeit Strava-Verbindung trennen und alle Daten löschen lassen. Button im Profil-Bereich: „Strava trennen & Daten löschen“. | ☐ |
| Cloudflare DPA | Cloudflare-DPA (Data Processing Addendum) im Dashboard akzeptieren. In Datenschutzerklärung als Auftragsverarbeiter nennen – Daten laufen technisch über Cloudflare Edge. | ☐ |
Migriert zu Pi-API JWT Middleware. Zugriffskontrolle erfolgt über authRoute.jwt / authRoute.jwtOptional Middleware pro Route. Kein Anon-Key mehr im Frontend. |
✅ | |
| Verschlüsselung at Rest | SQLite-Datenbank auf dem Pi: Filesystem-Verschlüsselung (LUKS) oder SQLCipher. Backups ebenfalls verschlüsselt speichern (gpg --symmetric). |
☐ |
- Strava API Agreement: Vor OAuth-Nutzung akzeptieren – verbietet u.a. Daten-Weitergabe und dauerhafte GPS-Speicherung
- „Powered by Strava“: Logo-Badge Pflicht auf allen Seiten die Strava-Daten anzeigen
- Rate-Limits: 100 Requests / 15 Min, 1000 / Tag (pro App). Webhook bevorzugen statt Polling
- Deauthorization-Webhook: Strava kann
POST /api/strava/deauthsenden wenn Nutzer Zugriff widerruft → alle Daten löschen - Refresh-Token-Flow: Access-Token läuft nach 6h ab, Refresh-Token erneuern und sicher speichern
Pflichtdokumentation für alle Verarbeitungstätigkeiten mit personenbezogenen Daten. Dieses Verzeichnis muss aktuell gehalten und auf Anfrage der Aufsichtsbehörde vorgelegt werden können.
| Feld | Inhalt |
|---|---|
| Verantwortlicher | mag.biking Community – Lucas M. (Einzelperson, kein Unternehmen) Kontakt: siehe impressum.html |
| Verarbeitungstätigkeit 1 |
Strava Club Statistik (aktuell aktiv) Zweck: Gesamtkilometer und Fahrtenanzahl des Strava-Clubs anzeigen Kategorien betroffener Personen: Keine natürlichen Personen identifizierbar Datenkategorien: Tages-Aggregatwerte (km-Summe, Anzahl Fahrten) – keine Einzelaktivitäten, keine Nutzernamen, keine Strava-IDs Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) Empfänger: Keine Weitergabe an Dritte Speicherdauer: Unbegrenzt (nicht personenbezogen) Technische Maßnahmen: SQLite auf Raspberry Pi, Zugriff nur über Cloudflare Tunnel, kein offener Port |
| Verarbeitungstätigkeit 2 |
Kontaktformular (aktuell aktiv) Zweck: Kontaktanfragen von Website-Besuchern entgegennehmen und per E-Mail weiterleiten Kategorien betroffener Personen: Website-Besucher die das Formular ausfüllen Datenkategorien: Name, E-Mail-Adresse, Interessengebiet, Freitext-Nachricht, maskierte IP-Adresse Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung via Checkbox) Empfänger: E-Mail an Betreiber via Gmail SMTP. Cloudflare als technischer Durchleiter Speicherdauer: In SQLite auf Pi gespeichert. Löschung auf Anfrage Technische Maßnahmen: DSGVO-Checkbox, Honeypot-Spam-Schutz, Rate-Limiting, IP-Maskierung, CORS, Input-Validierung |
| Verarbeitungstätigkeit 4 |
Strava OAuth & Ranking (geplant, noch nicht aktiv) Zweck: Personalisiertes Ranking, Punkte-System, Stempelbuch für Community-Mitglieder Kategorien betroffener Personen: Strava-Nutzer die sich freiwillig verbinden Datenkategorien: Strava-User-ID, Anzeigename, Aktivitätsdaten (Distanz, Höhenmeter, Datum, Dauer), OAuth-Tokens Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung) Empfänger: Keine Weitergabe an Dritte. Cloudflare als technischer Auftragsverarbeiter (DPA abschließen!) Speicherdauer: Aktivitäten max. 2 Kalenderjahre, danach Aggregation. Bei Widerruf/Löschung: sofortige Löschung aller Daten Technische Maßnahmen: • OAuth-Tokens AES-256-GCM verschlüsselt ( crypto.js)• Encryption-Key in Env-Variable, nicht im Code • IP-Adressen in Logs maskiert ( security.js)• Rate-Limiting (60 req/min) • API-Key für sensible Endpoints • Tägliche Backups mit 14-Tage-Rotation • Keine GPS-Tracks gespeichert |
| Verarbeitungstätigkeit 5 |
Blog-Likes (aktuell aktiv) Zweck: Anonyme Like-Zählung für Blog-Beiträge Datenkategorien: Zählerstand pro Post, anonyme Browser-ID (localStorage). Im DEMO_MODE: rein lokal, kein Server Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) Speicherdauer: Unbegrenzt (anonym) |
| Auftragsverarbeiter |
Cloudflare Inc. – Tunnel/CDN (DPA abschließen vor Strava-OAuth-Launch) Strava Inc. – API-Datenquelle (API Agreement beachten) |
| Letzte Aktualisierung | 21. April 2026 |
pi-api/)| Datei | Zweck |
|---|---|
server.js | Express-App mit Security-Middleware, Stats-, Health-, Sysinfo-, Contact-Endpoints |
contact-route.js | POST /api/contact: Validierung, Honeypot, SQLite-Speicherung, Nodemailer SMTP-Versand |
security.js | Rate-Limit, API-Key, CORS, Security-Headers, Input-Sanitizer, Logger (zero dependencies) |
sysinfo-route.js | /api/sysinfo Route mit Stale-Check (>48h Warnung) |
crypto.js | AES-256-GCM Verschlüsselung für OAuth-Tokens (Node.js crypto, zero dependencies) |
strava-tokens.js | Token-Store: CRUD für verschlüsselte Strava-Tokens in SQLite, DSGVO-Löschung |
backup.sh | SQLite-Backup mit 14-Tage-Rotation, gzip, Integritätscheck. Cron aktiv: täglich 03:30 |
example-integration.js | Zeigt wie alle Module in die bestehende Express-App eingebunden werden |
Vollständige Liste aller Endpoints der Pi-API (api.mag-biking.com). Auth-Spalte: – = öffentlich, Rate = Rate-Limited, API-Key = X-API-Key Header, JWT = Bearer Token, JWT? = optional.
| Method | Path | Auth | Beschreibung |
|---|---|---|---|
| GET | /health | – | Server-Status + System-Metriken (CPU, RAM, Temperatur, Uptime) |
| GET | /api/stats | – | Strava Club Jahresstatistiken (Baseline + seen_rides) |
| GET | /api/sysinfo | API-Key | Pi-Systeminfo (detailliert) |
| POST | /api/contact | Rate | Kontaktformular (Validierung + Honeypot + SMTP-Versand) |
| GET | /api/contact/messages | API-Key | Alle Kontakt-Nachrichten auflisten |
| DELETE | /api/contact/messages/:id | API-Key | Einzelne Nachricht löschen |
| DELETE | /api/contact/cleanup | API-Key | Alte Nachrichten aufräumen |
| POST | /api/auth/register | Rate | Benutzer-Registrierung (E-Mail + Passwort + Name) |
| POST | /api/auth/login | Rate | Login (E-Mail + Passwort → JWT + Refresh-Token) |
| POST | /api/auth/logout | – | Logout (Refresh-Token invalidieren) |
| POST | /api/auth/magic-link | Rate | Magic Link per E-Mail senden |
| GET | /api/auth/magic-link/verify | – | Magic Link prüfen & einloggen |
| POST | /api/auth/refresh | – | JWT Access-Token erneuern (Refresh-Token) |
| GET | /api/auth/me | JWT | Eigene Benutzerdaten abrufen |
| GET | /api/auth/strava | – | Strava OAuth Redirect starten |
| GET | /api/auth/strava/callback | – | Strava OAuth Callback verarbeiten |
| GET | /api/members | JWT | Mitglieder-Liste (öffentliche Profile) |
| GET | /api/members/profile | JWT | Eigenes Profil abrufen |
| PUT | /api/members/profile | JWT | Profil aktualisieren |
| POST | /api/members/avatar | JWT | Avatar-Bild hochladen (Base64, max 3 MB) |
| GET | /api/members/my-events | JWT | Eigene Event-Anmeldungen auflisten |
| GET | /api/events/:slug/registrations | JWT? | Teilnehmerliste eines Events |
| POST | /api/events/:slug/register | JWT | Für Event anmelden |
| DELETE | /api/events/:slug/register | JWT | Event-Anmeldung zurückziehen |
Alle Tabellen werden beim Server-Start automatisch erstellt (CREATE TABLE IF NOT EXISTS). Datenbank-Datei: pi-api/strava-stats.db.
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | TEXT PK | UUID |
| TEXT UNIQUE | E-Mail-Adresse | |
| password_hash | TEXT | bcrypt-Hash |
| display_name | TEXT | Anzeigename |
| email_confirmed | INTEGER | 0/1 – E-Mail bestätigt |
| strava_id | TEXT | Verknüpfte Strava-ID (nullable) |
| created_at | INTEGER | Unix-Timestamp |
| updated_at | INTEGER | Unix-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| user_id | TEXT PK FK | Referenz auf users.id (CASCADE) |
| display_name | TEXT | Anzeigename |
| bike | TEXT | Fahrrad-Beschreibung |
| avatar_url | TEXT | Avatar-Bild-URL |
| favorite_route | TEXT | Lieblingsstrecke |
| strava_url | TEXT | Strava-Profil-Link |
| strava_id | TEXT | Strava-ID |
| bio | TEXT | Kurzbiografie |
| is_public | INTEGER | 1 = öffentlich sichtbar |
| created_at | INTEGER | Unix-Timestamp |
| updated_at | INTEGER | Unix-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | TEXT PK | UUID |
| event_slug | TEXT | Event-Slug (aus Content) |
| user_id | TEXT FK | Referenz auf users.id (CASCADE) |
| comment | TEXT | Optionaler Kommentar |
| created_at | INTEGER | Unix-Timestamp |
UNIQUE-Constraint auf (event_slug, user_id) – verhindert doppelte Anmeldung.
| Spalte | Typ | Beschreibung |
|---|---|---|
| token | TEXT PK | Zufälliger 32-Byte Hex-String |
| user_id | TEXT | Benutzer-ID |
| expires_at | INTEGER | Ablauf-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| token | TEXT PK | Zufälliger 32-Byte Hex-String |
| TEXT | Empfänger E-Mail | |
| expires_at | INTEGER | Ablauf-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| state | TEXT PK | Zufälliger State-Parameter |
| created_at | INTEGER | Erstellungs-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| code | TEXT PK | Einmal-Code |
| user_id | TEXT | Benutzer-ID |
| access_token | TEXT | Strava Access-Token |
| refresh_token | TEXT | Strava Refresh-Token |
| created_at | INTEGER | Erstellungs-Timestamp |
| expires_at | INTEGER | Ablauf-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| id | INTEGER PK AI | Auto-Increment ID |
| name | TEXT | Absender-Name |
| TEXT | Absender E-Mail | |
| interest | TEXT | Interesse (events, training, etc.) |
| message | TEXT | Nachricht |
| ip_masked | TEXT | IP-Adresse (letztes Oktett maskiert) |
| sent | INTEGER | 0/1 – SMTP-Versand erfolgreich |
| created_at | INTEGER | Unix-Timestamp |
| Spalte | Typ | Beschreibung |
|---|---|---|
| year | INTEGER PK | Kalenderjahr |
| total_km | REAL | Basis-Kilometer |
| ride_count | INTEGER | Basis-Fahrtenanzahl |
| Spalte | Typ | Beschreibung |
|---|---|---|
| hash | TEXT PK | Aktivitäts-Hash (Deduplizierung) |
| year | INTEGER | Kalenderjahr (Index) |
| km | REAL | Distanz in km |
| seen_at | INTEGER | Erfassungs-Timestamp |
Geplante Features, Architektur-Entscheidungen und offene Fragen. Jedes Vorhaben hat einen Status:
✓ Abgeschlossen – Details anzeigen
Das aktuelle Kontaktformular nutzt EmailJS (US-Drittanbieter). Ziel: Formular-Daten direkt an einen eigenen Raspberry Pi senden, der die E-Mail-Benachrichtigung übernimmt. Dadurch entfällt der Auftragsverarbeitungsvertrag mit EmailJS und die Daten bleiben auf eigener Infrastruktur.
[Website-Formular] → HTTPS → [Cloudflare Edge] → Tunnel → [Raspberry Pi API] → SMTP → [E-Mail]
- Kein offener Port am Heimnetz – Cloudflare Tunnel baut ausgehende Verbindung auf
- Kein DynDNS nötig – Cloudflare routet über den Tunnel
- HTTPS automatisch durch Cloudflare (Domain
mag-biking.comist bereits bei Cloudflare) - Subdomain: z.B.
api.mag-biking.com→ Pi via Tunnel
| Runtime | Node.js + Express (oder Python Flask) |
| E-Mail-Versand | nodemailer (SMTP via Gmail App-Passwort / GMX / eigene Domain-Mail) |
| Tunnel | cloudflared Daemon als systemd-Service |
| Spam-Schutz | Honeypot-Feld + Rate-Limiting (express-rate-limit) |
| CORS | Nur mag-biking.com erlauben |
- ✅ Einwilligungs-Checkbox am Formular (DSGVO-Pflicht) – umgesetzt 2026-04-21
- ☐ Datenschutztext erweitern: erhobene Daten, Zweck, Speicherdauer, Rechte
- ☐ Cloudflare DPA im Dashboard akzeptieren (Cloudflare als Durchleiter nennen)
- ✅ Kein AVV mit E-Mail-Dienst nötig – eigener SMTP via Gmail
- ✅ Formular-Daten nicht im Browser gespeichert – nur serverseitig in SQLite
- ✅
cloudflaredauf dem Pi installieren + Tunnel erstellen - ✅ DNS-Record:
api.mag-biking.com→ Tunnel-ID - ✅ Node.js API-Server:
/api/contactEndpoint (POST) –pi-api/contact-route.js - ☐ SMTP-Konfiguration: Gmail App-Passwort + Cloudflare Email Routing (
[email protected]) - ✅ Rate-Limiting + Honeypot + CORS eingerichtet (
security.js+ Formular-Honeypot) - ✅ Kontaktformular umgebaut:
js/contact.jsnutztfetch POSTstatt EmailJS - ✅ DSGVO-Checkbox + Link zur Datenschutzerklärung im Formular
- ✅ EmailJS-Script + CSP-Eintrag entfernt
- ✅ OS: Debian 13 (trixie) auf Raspberry Pi 5
- ✅ Node.js: v22.22.0 + Express 5.2.1
- ✅ E-Mail: Gmail SMTP + Cloudflare Email Routing (
[email protected]) - ✅ Andere Dienste: nginx 1.26.3 (Reverse Proxy), cloudflared Tunnel, Strava-Stats-API
- ✅ DB-Speicherung: Ja,
contact_messagesTabelle in SQLite (Backup bei SMTP-Ausfall)
- Verfügbarkeit: Pi offline = Formular geht ins Leere → Fallback-Meldung einbauen
- Sicherheit: Rate-Limiting + Input-Validierung + CORS sind Pflicht
- Cloudflare DSGVO: Daten laufen technisch über Cloudflare Edge → DPA akzeptieren + in Datenschutzerklärung nennen
✓ Abgeschlossen – Details anzeigen
Die Hero-Statistik "km gefahren" (Stat 3 auf der Startseite) wird automatisch aus den tatsächlich gefahrenen Kilometern des Strava-Clubs berechnet. Die Statistik-Unterseite (stats.html) zeigt Jahreswerte archiviert mit Balkendiagramm an.
Umgesetzt am 10.04.2026. Express-API auf Raspberry Pi (api.mag-biking.com) liefert live Daten aus SQLite. Stündlicher Cron sammelt neue Aktivitäten via Strava API. Hero-Stat wird automatisch aktualisiert, stats.html zeigt Jahresvergleich. Initialer Seed: 7.729 km (Summe aller 8 Mitglieder).
[Strava API] ← Cron stündlich ← [Raspberry Pi] → SQLite → [/api/stats + /health] → Cloudflare Tunnel → [Website]
- Pi als Collector: Cron-Job pollt Strava Club-Activities stündlich
- SQLite-Datenbank: Speichert nur aggregierte km pro Tag (keine personenbezogenen Daten)
- API-Endpoints:
GET /api/stats(Jahres-km),GET /health(System-Metriken: CPU, RAM, Temp, Uptime) - Cloudflare Tunnel:
api.mag-biking.com→localhost:3000 - Website:
club-stats.jsholt live km + Fahrten → Hero-Stat 2 + 3 (je nach Admin-Toggle) - Statistik-Seite:
stats.htmlzeigt Jahresvergleich mit Balkendiagramm - Admin-Pi-Tab: Speicherübersicht → Pi Server zeigt live CPU/RAM/Temperatur/Uptime
| Endpoint | GET /api/v3/clubs/{id}/activities |
| Auth | OAuth2 – Access Token + Refresh Token (automatische Erneuerung) |
| Voraussetzung | Authentifizierter Nutzer muss Mitglied des Clubs sein |
| Pagination | page + per_page (max. 200 pro Seite, Default 30) |
| Response-Felder | distance (Meter), firstname, type (Ride/Run/etc.) |
| Rate Limit | 100 Requests / 15 Min, 1.000 / Tag |
| Privacy | Enhanced Privacy Mode wird respektiert – diese Activities fehlen im Response |
| Einschränkung | Nur neueste Activities – kein historischer Abruf. Darum: regelmäßig pollen + lokal kumulieren |
-- Nur aggregierte Daten, keine Namen/User-IDs CREATE TABLE daily_km ( date TEXT PRIMARY KEY, -- '2026-04-09' total_km REAL DEFAULT 0, -- Summe aller Club-Rides an dem Tag ride_count INTEGER DEFAULT 0 -- Anzahl Fahrten ); -- Jahres-Cache für schnelle Abfrage CREATE TABLE yearly_stats ( year INTEGER PRIMARY KEY, total_km REAL DEFAULT 0, ride_count INTEGER DEFAULT 0 );
Wichtig: Es werden keine Personennamen, Strava-IDs oder Einzelfahrten gespeichert – nur die Tages-Summe der Kilometer. Das ist der Schlüssel zur DSGVO-Konformität.
| Strava App | Registrieren unter strava.com/settings/api → Client ID + Secret |
| OAuth2-Token | Einmalig manuell autorisieren → Refresh Token wird lokal gespeichert + automatisch erneuert |
| Collector-Script | Node.js Cron (stündlich): Strava API → Activities filtern (type: Ride + VirtualRide) → distance summieren → in SQLite schreiben |
| API-Server | Express: GET /api/stats (Jahres-km), GET /health (System-Metriken) |
| Datenbank | SQLite (eine Datei, kein separater DB-Server nötig) |
| Tunnel | Gleicher Cloudflare Tunnel wie Kontaktformular |
GET https://api.mag-biking.com/api/stats
{
"current_year": 2026,
"current_km": 7729,
"current_rides": 151,
"years": [
{ "year": 2026, "km": 7729, "rides": 151 }
],
"last_updated": "2026-04-10T10:00:04.029Z"
}
GET https://api.mag-biking.com/health
{
"status": "ok",
"ts": 1712764800,
"cpu_pct": 13,
"ram_used_mb": 843,
"ram_total_mb": 8063,
"temp_c": "51.3",
"uptime": "15d 2h 23m"
}
- Hero-Stat 2 + 3: Admin-Toggle (Checkbox) schaltet zwischen API und manuellem Wert.
club-stats.jsprüftdata-source="api"+data-api-stat="km|rides" - Fallback: Wenn API nicht erreichbar → manueller Admin-Wert bleibt stehen
- Statistik-Seite (
stats.html): Jahresvergleich-Balkendiagramm, Hero-Cards (km, Fahrten, Gesamt). Merge von API-Jahren + manuellen historischen Jahren aus Admin - Footer: "Club Statistik" Link (ein-/ausblendbar im Admin, Linktext konfigurierbar)
- Admin-Einstellungen: Footer → Club Statistik: Titel, Untertitel, Club-URL, API-URL, historische Jahreswerte
- Pi-Metriken: Speicherübersicht → Tab "Pi Server" zeigt live CPU/RAM/Temp via
/health - Impressum: DSGVO-Datenschutzhinweis für Strava-Daten (Art. 6 Abs. 1 lit. f)
- "Powered by Strava": Attribution auf stats.html (API Agreement)
| ✅ Keine Personendaten | Nur aggregierte km pro Tag gespeichert – kein Name, keine Strava-ID, keine Einzelfahrt |
| ✅ Lokale Speicherung | SQLite auf eigenem Pi – kein Cloud-Dienst als Datenspeicher |
| ✅ Kein AVV nötig | Da keine personenbezogenen Daten verarbeitet werden |
| ⚠ Strava API Agreement | Nutzung nur im Rahmen des Strava API Agreement – Logo-Attribution ("Powered by Strava") erforderlich |
| ✅ Datenschutztext | In impressum.html eingebaut: DSGVO Art. 6 Abs. 1 lit. f, keine personenbezogenen Daten, Link zur Strava Privacy Policy |
| ✅ Strava Attribution | "Powered by Strava" auf stats.html eingebaut |
| ⚠ Cloudflare Tunnel | Daten durchlaufen Cloudflare Edge → DPA im Dashboard akzeptieren |
- ✅ Strava API App registriert (Client ID: 223038)
- ✅ OAuth2-Flow durchlaufen → Refresh Token in
.envauf Pi - ✅ SQLite-Datenbank + Tabellen angelegt
- ✅ Collector-Script: Cron stündlich → Club Activities (Ride + VirtualRide) → daily_km
- ✅ API-Server:
GET /api/stats+GET /health(System-Metriken) - ✅ Systemd-Service (
mag-biking-api.service) für Auto-Start - ✅ Cloudflare Tunnel:
api.mag-biking.com→localhost:3000 - ✅
club-stats.js: API-Fetch mit Admin-Toggle (Stat 2: Fahrten, Stat 3: km) - ✅
stats.html: Jahresvergleich + Merge historischer Jahre aus Admin - ✅ Admin: Checkbox API/Manuell, Club-Statistik-Einstellungen, historische Jahreswerte
- ✅ Admin: Pi-Metriken Tab (Speicherübersicht → Pi Server)
- ✅ Footer-Link + "Powered by Strava" Attribution
- ✅ DSGVO-Datenschutzhinweis in
impressum.html - ✅ Initialer Seed: 7.729 km (Summe 8 Mitglieder) als Baseline in daily_km
| Strava Club | ID 2031165 · strava.com/clubs/2031165 |
| Aktivitätstypen | Nur Ride + VirtualRide |
| Cron-Intervall | Stündlich (0 * * * *) |
| Pi-Dateien | ~/mag-biking-api/server.js, collector.js, .env, strava-stats.db |
| Systemd | mag-biking-api.service (User: lucas, Restart: always) |
| Statistik-Seite | Öffentlich im Footer (ein-/ausblendbar im Admin) |
- API-Limit: 1.000 Requests/Tag – bei 24 Abrufen/Tag (stündlich, ~5 Seiten) kein Problem
- Kein historischer Abruf: Strava liefert nur neueste Activities → Jahre vor API-Start manuell im Admin eintragen
- Initialer Seed: 2026 wurde mit 4.012 km Differenz als Baseline am 01.01.2026 in daily_km eingefügt (echte Sammlung ab 10.04.2026)
- Token-Ablauf: Refresh Token wird automatisch erneuert. Bei Widerruf durch Strava:
collector.logprüfen - Enhanced Privacy: Mitglieder mit Privacy-Modus fehlen → km ist eine Untergrenze
- Pi offline: Fehlende Stunden werden nicht nachgeholt – Strava bietet keinen Zeitraum-Filter
Dieses Feature nutzt die gleiche Infrastruktur wie das Kontaktformular Self-Hosting: gleicher Pi, gleicher Express-Server, gleicher Cloudflare Tunnel. Der Aufwand für das zweite Feature ist daher deutlich geringer, wenn das erste bereits steht.
Erfolgreiche Communities (Rapha RCC, etc.) nutzen echte Zitate von Mitgliedern als Social Proof. Besucher vertrauen Erfahrungsberichten von echten Menschen mehr als Marketing-Texten. Aktuell fehlt dieser Vertrauensfaktor auf der mag.biking Startseite komplett.
1. Neue Section auf index.html zwischen Galerie und Kontakt
2. 2–4 Zitat-Karten mit Foto, Name, Rolle (z.B. „Mitglied seit 2025") und Zitat
3. Admin-Editor: Testimonials verwalten (Bild, Name, Text, sichtbar/unsichtbar)
4. Schema.org: Review Markup für jedes Testimonial
Klein – HTML-Section, CSS-Karten, Admin-Editor nach bestehendem Pattern (wie Sponsors). Kein Backend nötig.
Premium-Communities (Rapha, Canyon) nutzen cinematic Video-Backgrounds oder dynamische Bild-Slider im Hero. Der erste Eindruck entscheidet – aktuell ist der Hero statisch mit einem einzelnen Bild. Ein Countdown zum nächsten Event erzeugt Dringlichkeit.
A) Video-Background: Kurzes Loop-Video (10–15s) von Gruppenfahrten, Autoplay muted, Fallback auf Bild für Mobile/langsame Verbindung.
B) Bild-Slider: 3–5 Bilder mit Crossfade, verschiedene Event-Eindrücke.
C) Event-Countdown: Nächstes Event mit Titel, Datum und Countdown-Timer prominent im Hero.
Empfehlung: C zuerst (einfach, dynamisch aus Admin-Daten), dann B oder A.
Countdown: Klein – JS liest nächstes Event-Datum aus content-data.js, Anzeige im Hero.
Slider: Mittel – Bilder im Admin verwalten, CSS-Transition, Touch-Support.
Video: Mittel – Video-Hosting (GitHub LFS oder extern), Performance-Optimierung.
Der Blog existiert, aber die Startseite zeigt nur einen Beitrag als Vorschau. Erfolgreiche Communities leben von regelmäßigen Ride-Reports („Wir waren dort") und Mitglieder-Spotlights („Wer fährt bei uns mit?"). Das erzeugt Zugehörigkeit und motiviert Neue mitzumachen.
Umgesetzt: Blog-Detail-Seite mit Hero-Bild, Bild-Text-Abschnitten, GPX-Höhenprofilen pro Sektion, BlogPosting-Schema, Likes-System (js/likes.js + Pi-API). Reihenfolge im Admin bestimmt öffentliche Reihenfolge.
Noch offen: Blog-Preview auf Startseite erweitern (aktuell nur 1 Karte → 2–3), Kategorie-Filter auf blog.html, expliziter Kartentyp „Ride-Report“ / „Mitglieder-Spotlight“ als Admin-Dropdown. Blog-Detail-Karten-Funktion (mapShow-Flag pro GPX) ist skizziert aber noch nicht implementiert.
Eine Karte zeigt sofort wo die Community aktiv ist – Startpunkte der Rideouts, Event-Locations, Streckenverläufe. Besonders für Neue hilft das bei der Orientierung. Komoot und Strava zeigen Routen, aber keine eigene Community-Übersicht.
- Leaflet.js 1.9.4 vendored unter
vendor/leaflet/, CartoDB Voyager Tiles (passt zum Light-Theme) map.html– eigene Subseite, eingebunden per CTA hinter der Galerie aufindex.html- Rideout-Treffpunkt-Karte auf
app.html(PWA): Marker für Wutöschingen + Jestetten mit Hover-Popup „Nächster Ride“ - Event-Marker + GPX-Tracks auf
map.html– Start-Koordinaten werden viaMagBiking.Map.hydrateGpx()aus den GPX-Dateien extrahiert, Track-Polyline optional - CSP erweitert auf
https://*.basemaps.cartocdn.comin allen betroffenen Seiten - Zentrales Modul:
js/mini-map.js(MagBiking.Map.renderFull(),hydrateGpx()) – vonmap.html,app.html,events.html,event-detail.html,blog-detail.htmlwiederverwendet
Komoot/Strava-Embed für einzelne Routen (optional).
✅ Treffpunkt-Koordinaten (2026-04-15): Admin-UI existiert unter „Karte → Rideout-Treffpunkte“ (map.rideoutLocations). app.js liest Standorte jetzt dynamisch aus content-data.js statt hardcodiert.
Entscheidung: Self-hosted Lösung gewählt (Pi-API mit JWT Auth + SQLite statt Supabase Cloud).
Features:
• Login: E-Mail + Passwort, Magic Link, Strava OAuth
• Profil: Name, Bike, Foto, Lieblingsstrecke, Strava-Link, Bio
• Mitglieder-Übersicht: Steckbrief-Karten aller öffentlichen Profile
• Event-Anmeldung: Direkt auf Event-Detail-Seite mit Teilnehmerliste
• Nav-Integration: Login-Button / Avatar-Dropdown auf allen Seiten
login.html – Login/Registrier-Seite
profil.html – Eigenes Profil bearbeiten
mitglieder.html – Community-Übersicht (nur eingeloggt)
js/supabase-client.js – API-Client (Pi-API REST + JWT Token-Handling)
js/supabase-auth.js – Auth-State, Login/Logout, Nav-UI
js/login-page.js – Login-Seite UI-Logik
js/profile.js – Profil laden/speichern, Avatar-Upload
js/members.js – Mitglieder-Grid
js/event-registration.js – Anmeldung + Teilnehmerliste
css/auth.css – Alle Auth/Profil/Member-Styles
pi-api/auth-route.js – JWT Auth (Register, Login, Magic Link, Strava OAuth)
pi-api/members-route.js – Profil-CRUD, Mitglieder-Liste, Avatar-Upload, Event-Registrierung
1. Auf dem Pi: npm install bcryptjs jsonwebtoken im pi-api/-Ordner
2. In pi-api/.env folgende Variablen setzen:
JWT_SECRET=<zufälliger-string-32-zeichen>
SITE_URL=https://mag-biking.com
API_URL=https://api.mag-biking.com
3. Optional: Strava Developer App erstellen, Callback: https://api.mag-biking.com/api/auth/strava/callback
STRAVA_CLIENT_ID=<id>
STRAVA_CLIENT_SECRET=<secret>
4. Pi-API neustarten: Tabellen werden automatisch erstellt
5. SMTP-Variablen (bereits für Kontaktformular vorhanden) werden für Magic Links und Bestätigungs-Mails mitgenutzt
Komplett self-hosted: Alle Seiten, JS-Module, CSS, Datenschutz-Texte und Backend-API (Pi-API) sind implementiert.
Backend: JWT-Auth mit bcryptjs/jsonwebtoken, SQLite-Datenbank, Strava OAuth, Magic Link via SMTP.
Kein Cloud-Dienst: Supabase wurde durch lokale Pi-API ersetzt. Daten bleiben auf dem Raspberry Pi.
Gamification ist ein wirksamer Motivator – Strava macht es vor. Ein eigenes Ranking im Mitglieder-Bereich erzeugt Bindung zur mag.biking-Plattform und macht den Unterschied zwischen „einsam fahren“ und „Teil der Community sein“ sichtbar. Stempelbuch/Sticker-Mechanik aus der analogen Welt (Wandernadel, Brevet-Karten) überträgt sich 1:1 auf digitale Badges.
| Kategorie | Was zählt? |
| Solo-Kilometer | Alle privat gefahrenen Aktivitäten. Datenquelle: Strava OAuth (ideal) oder manuelle Eingabe. Zählt für persönliche Jahres-Bilanz, Höhenmeter-Meilensteine. |
| Community-Kilometer | Nur Fahrten in Verbindung mit einem Rideout oder Event („ich war dort“). Teilnahme wird im Admin bestätigt ODER automatisch über Strava-Segment/GPX-Match erkannt. Eigene Wertung, eigenes Stempelbuch. |
Die Trennung ist wichtig: Wer nur solo fährt, sieht seine Zahlen – wird aber im Community-Ranking nicht belohnt. Das schafft Anreiz, bei Rideouts dabei zu sein.
Damit Km und Hm in einer gemeinsamen Wertung vergleichbar werden, braucht es einen festen Umrechnungs-Schlüssel. Zusätzlich sollen offiziell (=im Community-Kontext) gefahrene Kilometer mehr wert sein als solo gefahrene. Vorschlag in drei Bausteinen:
1. Grundformel – Basispunkte
Basispunkte = km + (hm ÷ 10)
Warum hm ÷ 10? 100 Höhenmeter entsprechen im Schnitt dem Aufwand von ca. 10 zusätzlichen Flachland-Kilometern (etablierter Faktor aus Brevet- und Radmarathon-Szene: 1 hm ≈ 0,1 km Zuschlag, also 10 hm = 1 km). Das passt zu realer Fahrleistung: Ein 80 km-Ride mit 1500 hm ergäbe 80 + 150 = 230 Basispunkte – die Kletterei dominiert, wie es sich auch anfühlt.
2. Kategorie-Multiplikator – offiziell zählt mehr
Die Basispunkte werden mit einem Kategorie-Faktor multipliziert. Solo als 1,0× ist der neutrale Referenzwert, alles mit mag.biking-Bezug bekommt einen Aufschlag:
| Kategorie | Faktor | Kriterium |
|---|---|---|
| Solo | 1,0× |
Private Ausfahrt ohne Bezug zu einem Club-Termin. Zählt nur für Solo-Wertung. |
| Rideout | 1,5× |
Teilnahme an einem offiziellen wöchentlichen Rideout (Treffpunkt & Zeit bestätigt). |
| Event (Tagesveranstaltung) | 2,0× |
Radmarathon, gemeinsamer Tagesausflug, Schwarzwald-Cross etc. – einzelnes, aber besonderes Datum. |
| Mehrtägiges Event | 2,5× |
Trainingslager, Bikepacking-Tour, Alpencross, Germany-Austria III – höhere Hürde, seltener, wertvoller. |
| Club-Challenge | 3,0× |
Optional: Saison-weite Challenges („Everesting“, „1000 km-Monat“) als seltene Highlight-Wertung. |
Kategorie-Erkennung: Phase 1 manuell per Admin-Bestätigung (Teilnehmerliste zu einem Event/Rideout). Phase 4 automatisch via GPX-Start-Match (Treffpunkt-Koordinaten ±500 m, Startzeit ±60 min).
3. Gesamt-Formel
Punkte = (km + hm ÷ 10) × Kategorie-Faktor
4. Beispielrechnungen
| Fahrt | Km | Hm | Kat. | Rechnung | Punkte |
|---|---|---|---|---|---|
| Solo Feierabendrunde, flach | 40 | 200 | 1,0× | (40 + 20) × 1,0 |
60 |
| Rideout Sonntag, hügelig | 80 | 1000 | 1,5× | (80 + 100) × 1,5 |
270 |
| Schwarzwald-Cross (Event) | 150 | 2500 | 2,0× | (150 + 250) × 2,0 |
800 |
| Alpencross Tag 1 (mehrtägig) | 95 | 2200 | 2,5× | (95 + 220) × 2,5 |
787,5 |
| Solo Gravel, anspruchsvoll | 65 | 900 | 1,0× | (65 + 90) × 1,0 |
155 |
Beobachtung: Ein Rideout „schlägt“ die vierfache Distanz solo, ein Event die zehnfache. Das Punktesystem belohnt die Teilnahme spürbar, ohne Solo-Fahrten zu entwerten.
5. Tuning-Parameter (konfigurierbar im Admin)
Alle Stellschrauben sollten in einer ranking_config-Tabelle in der Pi-API SQLite-DB liegen, damit sie ohne Code-Deploy angepasst werden können:
hm_per_km(Default 10) – „Wie viele Hm entsprechen 1 km?“ Niedriger = Berge zählen mehr, höher = flach-freundlicher.factor_solo(Default 1,0),factor_rideout(1,5),factor_event(2,0),factor_multiday(2,5),factor_challenge(3,0)min_distance_km(z.B. 5) – Fahrten unter Schwelle werden ignoriert (keine Strava-„Commute“-Spam)max_points_per_day(optional, z.B. 1500) – Cap gegen Ausreißer-Langstreckenseason_start/season_end– Jahres-Ranking-Zeitraum (z.B. 01.03. – 31.10. für Roadbike-Saison)
6. Validierung & Fairness
- Doppelt-zähl-Schutz: Eine Fahrt zählt entweder als Solo oder Community, nie beides. Community-Markierung überschreibt Solo.
- Nachweis: Für Community-Faktor muss entweder Admin-Bestätigung oder GPX-Match vorliegen – sonst Fallback auf Solo (1,0×).
- Pro-Rata bei Ausstieg: Wer ein Rideout früh abbricht, bekommt nur den tatsächlich gefahrenen Anteil mit Community-Faktor. Rest solo.
- Einheitliche Datenquelle: Alle km/hm kommen aus derselben Quelle (Strava-Aktivität). Keine manuelle Veränderung nach Import.
A) Strava OAuth (empfohlen): Nutzer verbindet einmal sein Strava-Konto, api.mag-biking.com holt Aktivitäten per Webhook / Pull. Vorteil: Keine manuelle Pflege, Distanz/Hm/Zeit automatisch. Nachteil: Strava-Account Pflicht, API-Rate-Limits, Token-Refresh-Logik.
B) Manuelle Eingabe: Nutzer trägt nach jeder Fahrt Distanz/Hm selbst ein. Vorteil: Einfach, keine externen Abhängigkeiten. Nachteil: UX-Hürde, Vertrauensproblem.
C) Hybrid: Strava-OAuth als Default, manuelle Eingabe als Fallback für Nutzer ohne Strava.
Community-Attribution: Admin markiert beim Rideout/Event eine Teilnehmerliste, oder das System matcht Strava-Aktivität (Start innerhalb 500 m + ±1 h um Rideout-Zeit) automatisch.
Belohnungen sind nicht-kompetitiv (jeder kann sie erreichen), im Gegensatz zum Ranking (Top 10). Ideen:
- Meilenstein-Sticker: 100 / 500 / 1000 / 2500 / 5000 km pro Jahr (Solo & Community getrennt)
- Höhenmeter-Badges: 1000 / 5000 / 10 000 hm, „Alpencross-Äquivalent“ ab 15 000 hm
- Rideout-Stempel: Ein Stempel pro besuchtem Rideout. Bei 10/25/50 Rideouts: eigener Badge („Regular“, „Veteran“, „Legend“)
- Event-Stempel: Pro mehrtägigem Event (Bikepacking, Trainingslager) ein eigener Stempel mit Event-Logo – seltener, wertvoller
- Saison-Challenges: „Winter-Warrior“ (3 Rideouts im Januar), „Gravel-Sommer“ (5 Gravel-Touren zwischen Juni–August), editierbar im Admin
- Team-Badges: „Bergziege“ (hohes Hm/Km-Verhältnis), „Frühaufsteher“ (10 Starts vor 8:00 Uhr), „Kilometerfresser“ (>200 km an einem Tag)
Darstellung: Sticker-Sammelbogen im Profil (wie im realen Stempelheft), gesperrte Felder grau, freigeschaltete mit Datum & Titel.
- Jahres-Ranking: Top 10 Community-Km / Community-Hm des laufenden Jahres
- Monats-Ranking: Wer war im aktuellen Monat am aktivsten?
- Rideout-Frequenz: Wer war bei den meisten Rideouts dabei? (nicht Km-basiert)
- Team-Leistung: Aggregierte Community-Km aller Mitglieder – kollektives Ziel („Gemeinsam um die Welt: 40 075 km“)
- Eigene Historie: Persönlicher Verlauf mit Grafik, nicht-öffentlich
Opt-out: Wer nicht im öffentlichen Ranking erscheinen will, kann sich mit einem Flag im Profil ausblenden – Solo-Km und Stempelbuch bleiben privat sichtbar.
[Strava OAuth] → api.mag-biking.com/strava/callback
↓ Token speichern (Pi-API SQLite: strava_tokens)
[Webhook /strava/webhook] ← neue Aktivität
↓ Insert in activities (user_id, distance, elev, date, source)
↓ Match gegen rideout_dates → ggf. community_flag=true
↓ Badge-Trigger: Aggregate je user_id prüfen, neue Sticker vergeben
[Frontend Userbereich] → Pi-API REST Read → Ranking + Stempelbuch anzeigen
Neue Pi-API SQLite-Tabellen: profiles (aus Mitglieder-Bereich, bereits vorhanden), activities (eine Zeile pro Fahrt), rideout_attendance (User × Rideout), badges (Katalog aller Sticker + Regeln), user_badges (freigeschaltete Sticker pro User). Zugriffskontrolle über JWT Middleware.
- Mitglieder-Bereich (#roadmap-members) muss zuerst stehen – ohne Login kein Ranking
- Strava API Access: Existiert bereits für Club-Km (
api.mag-biking.com/api/stats), müsste um OAuth-Flow erweitert werden - DSGVO: Aktivitätsdaten sind personenbezogen, sensibel (Standort!). Einwilligung explizit, Datenschutzerklärung erweitern, Exportfunktion + Löschung auf Anfrage
- Offene Frage: Soll Community-Attribution automatisch (GPX-Match) oder manuell (Admin bestätigt Teilnehmerliste) laufen? Erste Phase wohl manuell – einfacher, fehlertoleranter
- Offene Frage: Sollen Solo-Km öffentlich sichtbar sein oder nur im eigenen Profil? Empfehlung: Nur Community-Km öffentlich (passt zum Community-Gedanken)
- Offene Frage: Physische Sticker (drucken, an aktivste Mitglieder verschicken) als Jahresabschluss-Aktion?
Phase 1 – Manuelles Tracking (klein): Nutzer tragen nach Login Km/Hm selbst ein, Community-Flag vom Admin bestätigt. Einfaches Ranking, noch ohne Sticker. Setzt nur Mitglieder-Bereich voraus.
Phase 2 – Stempelbuch (mittel): Badge-Katalog im Admin pflegen, Trigger-Regeln (Km-Summen, Anzahl Rideouts), Sticker-Ansicht im Profil.
Phase 3 – Strava OAuth (groß): OAuth-Flow auf api.mag-biking.com, Webhook für neue Aktivitäten, automatische Aggregation. Ersetzt manuelle Eingabe für Strava-Nutzer.
Phase 4 – Auto-Community-Match (optional): GPX-/Segment-Matching gegen Rideout-Startpunkte + Zeiten.
Während längerer Touren (Bikepacking, Alpencross, Trainingslager) ist es für die Community spannend mitzuverfolgen, wo ein Mitglied gerade unterwegs ist. Garmin „LiveTrack“ und ähnliche Dienste bieten das technisch an – aber als öffentliche Links, die beliebig weitergegeben werden können. Ziel: Dieses Tracking innerhalb des Mitglieder-Bereichs verfügbar machen, als Opt-in pro Tour, mit feingranularer Freigabe (alle Mitglieder / bestimmte User / privater Link).
- Opt-in pro Tour: Tracking wird nicht automatisch aktiviert, sondern pro Ausfahrt vom Mitglied freigegeben (Datenschutz).
- Sichtbarkeits-Stufen: (a) nur ich selbst, (b) ausgewählte Mitglieder (z.B. „meine Tour-Crew“), (c) alle eingeloggten Mitglieder, (d) privater Share-Link für Außenstehende (signierter Token mit Ablaufdatum).
- Interaktive Karte: Leaflet + CartoDB Voyager (konsistent mit map.html), Position-Marker, Tour-Track als Polyline, Auto-Refresh alle 30–60 s.
- Leistungsdaten nebenbei: Geschwindigkeit, Höhe, Distanz, ggf. Herzfrequenz/Power – je nachdem was die jeweilige API liefert.
| Anbieter | Schnittstelle | Leistungsdaten (potenziell) |
|---|---|---|
| Garmin | LiveTrack (öffentliche Session-URL, kein offizielles API-Programm für kleine Projekte). Garmin Connect IQ / Health API nur mit Partnervertrag. Alternative: Eigene Datenfeld-App auf dem Gerät, die per HTTP an unsere API postet. | Position (GPS), Speed, Herzfrequenz, Power (Wattmesser), Trittfrequenz, Höhe, Temperatur, Batteriestand. |
| Strava | Beacon (Premium-Feature, Sharing per Link nur an Kontakte). Keine offene API für Live-Position. | Nur Position + elapsed time (kein Power/HR via Beacon-Link). |
| Wahoo | Wahoo Live Track ähnlich zu Garmin LiveTrack, Session-URL. | Position, Speed, HR, Power, Cadence. |
| Smartphone | Eigene PWA-Funktion in app.html: Geolocation-API + Background-Sync, Position an Pi-API posten. |
Nur Position + Speed/Accuracy. Keine HR/Power ohne Geräte-Pairing. Akku-Verbrauch hoch. |
| Komoot | Kein offizielles Live-Tracking-API (Stand 2026). Touren werden erst nach Abschluss geteilt. | – |
- Pi-API: Neue Tabelle
live_sessions(id, user_id, started_at, ended_at, visibility, share_token, device_source). Tabellelive_points(session_id, ts, lat, lng, speed, altitude, hr, power, cadence). Endpoints:POST /api/live/session(start),POST /api/live/point(push),GET /api/live/session/:id(read, mit Auth-Prüfung gegen Sichtbarkeit). - Ingest-Adapter pro Quelle: Garmin LiveTrack-Link per Scrape/Proxy polling (falls offiziell erlaubt), Wahoo ähnlich, Smartphone direkt per JWT-auth.
- Mitglieder-Bereich: neue Seite
tracking.htmlmit Leaflet-Karte, Teilnehmer-Liste (aktuell live), Tour-Info-Box (km, ø Speed, HR/Power falls vorhanden). - Share-Link:
/live/<token>– signierter JWT mitsession_id + exp, keine Login-Pflicht. Nur Position + Speed sichtbar, HR/Power per Default aus.
- Garmin LiveTrack-Scraping: Ist das Polling des öffentlichen Session-Links AGB-konform? Gibt es ein offenes API-Programm? Connect IQ Data-Field-App als legitimer Workaround prüfen.
- Wahoo API: Developer-Portal checken, ob es ein Live-Tracking-Endpoint gibt.
- Leistungsdaten: Welche Werte werden tatsächlich mitübertragen? Herzfrequenz/Power als sensible Gesundheitsdaten (Art. 9 DSGVO) – dürfen nur mit expliziter Einwilligung pro Session verarbeitet werden.
- Akku-Impact: Continuous Position-Push vom Garmin-Gerät reduziert Laufzeit. Relevanz besonders bei Mehrtages-Touren.
- Retention: Live-Punkte nach Tour-Ende (z.B. 48 h) löschen oder als Track archivieren? DSGVO-konform: Opt-in pro Vorgang.
- Missbrauchs-Szenarien: Stalking-Risiko bei Share-Links – Token mit kurzer Gültigkeit, Revoke-Funktion im Profil, Rate-Limit beim Polling.
Phase 1 (Recherche): API-Landschaft Garmin/Wahoo prüfen, rechtliche Fragen (Gesundheitsdaten), 1–2 Tage.
Phase 2 (MVP „Smartphone“): PWA-Tracking aus app.html heraus + Karte im Mitglieder-Bereich. Nur Position, keine HR/Power. 3–5 Tage.
Phase 3 (Garmin-Integration): Je nach Ergebnis Phase 1 – LiveTrack-Proxy oder Connect-IQ-App. 5–10 Tage.
Phase 4 (Freigabe-Logik): Sichtbarkeitsstufen, Share-Tokens, Revoke-UI im Profil. 2–3 Tage.
Aktuell muss ein Beitrag (Ride-Report, Event-Ankündigung, Community-News) dreimal geschrieben werden: einmal im Admin für die Website, einmal als Post im Strava-Club, einmal als Instagram-Caption. Ziel: Ein einziger Schreibvorgang, automatisches Fan-out auf alle drei Kanäle.
- Website – voll automatisiert möglich (bestehender GitHub-Sync + Pi-API).
- Instagram – automatisierbar via Instagram Graph API (Meta for Developers). Voraussetzung: IG-Account als Business/Creator mit verknüpfter Facebook-Page + Meta-App.
- Strava Club-Posts – nicht automatisierbar. Der
club announcements-Endpoint wurde deprecated, nur nochDELETEverfügbar. Lösung: „Assist-Modus“ – Caption + Link in die Zwischenablage, Deep-Link zum Strava-Club, manueller Post-Klick.
Admin-first Authoring im vorhandenen Blog-Editor – dort sind alle Felder schon da (Titel, Teaser, Beschreibung, Bilder, Sections). Ein neuer Button „Cross-publish“ triggert den Pi-API-Job:
admin.html Blog-Editor -> [Cross-publish ▾]
[x] Website [x] Instagram [ ] Strava
Pi-API POST /api/publish/blog
+--> GitHub push content-data.js ==> Website live
+--> Graph API: create media container
Graph API: publish ==> Instagram-Feed
+--> Strava-Assist: Caption + Hero-URL
in Zwischenablage, Deep-Link ==> manuell
- Account: IG Business oder Creator, verknüpft mit einer Facebook-Page.
- Meta-App: In Meta for Developers anlegen, Scope
instagram_content_publishaktivieren. Entwicklungs-Modus reicht für eigenen Account – sonst App-Review nötig (2–4 Wochen). - Token: Long-lived Page Access Token (60 Tage). Muss per Cron alle ~50 Tage refresht werden.
- Bild-Regeln: JPEG, öffentliche HTTPS-URL (Meta pullt das Bild), Aspect 4:5 … 1.91:1, Caption ≤ 2200 Zeichen, ≤ 30 Hashtags.
- Rate-Limit: 100 API-Posts / 24 h – praxisfern als Engpass.
- Webhook „neuer Post“: existiert nicht. IG-first-Autoring-Richtung scheidet damit aus.
Der offizielle Strava-API-Changelog dokumentiert die Deprecation: Club-Announcements können nur noch gelöscht, aber nicht mehr erstellt werden. createActivity legt eine persönliche Aktivität an, keinen Club-Post – passt inhaltlich nicht zu einem Blog-Eintrag. Zapier, Make, Buffer und Publer bieten ebenfalls keine Strava-Club-Output-Aktion.
Assist-Modus statt Automatisierung: Beim Klick auf „Cross-publish“ generiert die Admin-UI eine vorbereitete Caption (Titel + Teaser + Link zum Website-Post) und kopiert sie in die Zwischenablage. Parallel öffnet sich die Strava-Club-Seite in einem neuen Tab – ein Klick zum Einfügen, fertig.
- Zapier / Make – IG-Publish ja, Strava-Output nein. Würde uns mit einem Drittanbieter-Abo koppeln.
- Buffer / Publer / Metricool / Hootsuite – alle unterstützen IG, keiner Strava Clubs.
- Mixpost / Postiz (self-hosted) – fähig auf ARM/Pi, lösen aber Strava auch nicht und sind für einen einzelnen Account Overkill. Eigene ~100-Zeilen-Pi-API-Integration ist schlanker.
| Schritt | Aufwand |
|---|---|
| Meta-App + FB-Page-Verknüpfung + IG Business-Switch + Token | 0.5 d (+ ggf. 2–4 Wo. Review) |
Pi-API-Endpoint /api/publish/instagram (Container + Publish) | 1–1.5 d |
| Token-Refresh-Cron + Secret-Storage | 0.5 d |
| Admin-UI „Cross-publish“-Button + Status-Toast | 1 d |
| Strava-Assist (Caption-Generator + Clipboard + Deep-Link) | 0.5 d |
| Bild-Pipeline: HTTPS-URL + Aspect-Validierung + JPEG-Reencode | 1 d |
| Tests + Error-Pfade + Logging | 0.5–1 d |
Summe: ~4–6 Entwicklertage + Meta-Review-Wartezeit.
Risiken: Token-Rotation (abgelaufener Token bricht Publish still ab), Bild-Aspect-Ratio (Blog-Bilder haben heute keine Einschränkung), Meta-App-Review falls IG-Account nicht im Dev-Mode läuft, Strava-Erwartungsmanagement (Assist-only statt voll-automatisch).
- IG-Account-Status: Ist
@mag.biking(oder der Community-Account) bereits Business/Creator mit verknüpfter FB-Page? Falls nein – Umstellung ist Voraussetzung. - Strava-Akzeptanz: Ist Assist-Modus (Caption kopieren + manuell einfügen) OK, oder ist Full-Auto ein Muss-Kriterium? Letzteres streicht Strava aus dem Scope.
- Carousel vs. Single Image: Blog-Posts haben oft mehrere Bilder. IG-Carousel (bis 10 Bilder) möglich, aber aufwändiger – starten mit Single-Hero-Image?
- Caption-Quelle: Automatisch aus Teaser generieren, oder eigenes Feld
instagramCaptionim Blog-Editor (Ton/Hashtags für IG anders als für Website)? - Fehler-Policy: Wenn IG-Publish fehlschlägt – trotzdem Website-Publish (Teil-Erfolg) oder atomar alles-oder-nichts?
- Stories-Publishing: Zusätzlich zu Feed-Posts auch IG-Stories? Gleiche API, separate Entscheidung.
- Backend committed (
3fe5ca3): 3-Rollen RBAC (admin/editor/member), Role-aware JWT mittoken_version-Revocation,requireRole()-Middleware, Admin-Endpoints (list/patch/delete/reset-password/export/audit-log), Last-Admin-Schutz, DSGVO-Anonymisierung, TOTP 2FA (RFC 6238, native HMAC-SHA1 ohne npm-Deps), Audit-Log (append-only). - Admin-UI committed (
dc43dba): Section „Benutzer“ in admin.html mit Suche, Rollen-Dropdown, Sperren/Entsperren, Passwort-Reset, DSGVO-Export (JSON-Download), Löschung mit „LÖSCHEN“-Bestätigung und Audit-Log-Ansicht. - 2FA-Frontend committed (
64c0a10): Profil-Seite mit Setup/Enable/Disable-Flow, Secret-Anzeige + Kopier-Button, 2-Step-Login (login.html erkennttotp_required→ TOTP-Feld). - Deployment noch ausstehend:
git pullauf Pi,pm2 restart mag-api; Migration läuft automatisch beim Start. DanachADMIN_EMAILinpi-api/.envsetzen und neu starten → erster Admin wird gebootstrapt.
Alle Admin-Endpoints erfordern JWT mit role=admin. Alle 2FA-Endpoints (außer verify) erfordern ein gültiges JWT (eingeloggt).
| Methode | Pfad | Zweck |
|---|---|---|
GET | /api/admin/users | Liste aller User. Query: search, include_deleted. |
GET | /api/admin/users/:id | Einzelner User inkl. Sessions und Event-Anmeldungen. |
PATCH | /api/admin/users/:id | Rolle oder Status ändern. Body: { role } oder { status }. Inkrementiert token_version → Sessions sofort ungültig. |
POST | /api/admin/users/:id/reset-password | Löst Magic-Link-Mail aus und beendet alle Sessions. |
GET | /api/admin/users/:id/export | DSGVO Art. 15 Auskunft: JSON-Download aller User-Daten. |
DELETE | /api/admin/users/:id | DSGVO Art. 17 Löschung. Event-Teilnahmen werden als „Mitglied“ anonymisiert. |
GET | /api/admin/audit-log | Letzte 200 Admin-Aktionen (Query limit). |
POST | /api/auth/2fa/setup | Generiert neues TOTP-Secret (Base32) + otpauth-URI. Noch NICHT aktiviert. |
POST | /api/auth/2fa/enable | Aktiviert 2FA nach erfolgreicher Code-Verifikation. Body: { code }. |
POST | /api/auth/2fa/disable | Deaktiviert 2FA. Body: { password_or_code }. |
POST | /api/auth/2fa/verify | Zweiter Schritt des Logins. Body: { totp_token, code }. Liefert access_token + refresh_token. |
Die Login-Antwort enthält { totp_required: true, totp_token } wenn für den User 2FA aktiv ist – dann erscheint im Frontend das Code-Eingabefeld.
Aktuell gibt es nur einen einzelnen Admin-Login; Mitglieder registrieren sich über den Mitglieder-Bereich. Es fehlt eine Übersicht aller registrierten User im Admin, sowie die Möglichkeit, Personen gezielt Berechtigungen zu geben (z.B. Blog-Editor ohne vollen Admin-Zugriff, Rideout-Leiter der Events pflegen darf, Moderator für spätere Community-Funktionen).
Ja, aber schlank. Full-RBAC mit feingranularen Permissions pro Ressource wäre Overkill. Eine 3-Rollen-Struktur reicht in der Praxis und ist später erweiterbar:
| Rolle | Darf | Typisches Profil |
|---|---|---|
| Admin | Alles: Content, Settings, User-Management, Deploy, GitHub-Sync, Impressum. | Betreiber (du). 1–2 Personen max. |
| Editor | Blog, Events, Rideouts, Galerie bearbeiten. Keine Settings, kein User-Management, kein GitHub-Push. | Ride-Leiter, Content-Schreiber, Foto-Sammler. |
| Member | Nur Mitglieder-Bereich: eigenes Profil, Event-Anmeldung, Mitglieder-Übersicht. | Reguläres Community-Mitglied. |
Warum jetzt entscheiden: Ein einzelnes role-Feld in der DB einziehen kostet jetzt ~30 Minuten. Nachträglich Permissions über eine binäre is_admin-Flag aufzuspalten ist ein Refactoring durch alle Auth-Middleware, Admin-UI-Guards und Tests – deutlich teurer.
- Admin-Section „Benutzer“: Tabelle mit E-Mail, Anzeigename, Rolle, Registriert-am, Letzter-Login, Strava-verknüpft (ja/nein), Status (aktiv/gesperrt).
- Suche & Filter: Freitext auf Name/Mail, Filter nach Rolle, Filter nach Anmeldequelle (Passwort vs. Magic-Link vs. Strava-OAuth).
- Aktionen pro User: Rolle ändern (Admin/Editor/Member), Sperren/Entsperren, Passwort-Reset auslösen, Löschen (DSGVO Art. 17).
- DSGVO-Export: User-Daten als JSON-Download für Auskunftsersuchen (Art. 15).
- Audit-Log: Wer hat wann welche Rolle änderung vorgenommen (simple Tabelle
admin_audit). - Anzahl-KPI im Admin-Dashboard: Gesamt-User, aktive letzte 30 Tage, Verteilung nach Rolle.
- DB-Migration: Spalte
users.roleTEXT NOT NULL DEFAULT 'member' CHECK IN ('admin','editor','member'). Migration: bestehendeis_admin=1→'admin', sonst'member'.is_adminkann deprecated bleiben, später entfernen. - JWT-Payload:
role-Claim aufnehmen – kein extra Roundtrip zur DB pro Request. - Backend-Middleware:
requireRole('admin'),requireRole('admin','editor')als Express-Middleware inpi-api/security.js. - Frontend-Guards:
window.MagBiking.Auth.hasRole(r)-Helper, damit Admin-Sidebar-Buttons je nach Rolle ausgeblendet werden. - Sperren-Mechanik:
users.status('active','suspended'). Login prüft Status, bestehende JWTs werden bei Status-Änderung nicht sofort ungültig – daher Token-Version-Spalte (token_version), JWT speichert Version, bei Mismatch Logout. - Admin-API-Endpoints:
GET /api/admin/users,PATCH /api/admin/users/:id,DELETE /api/admin/users/:id,POST /api/admin/users/:id/reset-password.
| Schritt | Aufwand |
|---|---|
| DB-Migration + JWT-Payload + Backend-Middleware | 0.5 d |
| Admin-UI: User-Liste + Tabelle + Suche/Filter | 1 d |
| Admin-Aktionen: Rolle ändern, Sperren, Löschen, Passwort-Reset | 1 d |
| DSGVO-Export (JSON-Download je User) | 0.5 d |
| Audit-Log (Tabelle + Append bei jeder Aktion) | 0.5 d |
| Frontend-Role-Guards (Sidebar je Rolle) | 0.5 d |
| Tests & Doku im Admin-Docs | 0.5 d |
Summe: ~4–5 Entwicklertage für den vollen Umfang. MVP (User-Liste lesen, Rolle ändern, Sperren, DSGVO-Löschen): ~2 Tage.
- Privilege-Escalation: Ein Editor darf niemals die eigene Rolle auf Admin setzen können. Backend muss explizit prüfen:
req.user.role==='admin'ANDtargetUserId !== req.user.idfür Admin-Aktionen. - Letzter Admin schutz: Beim Löschen/Downgrade des letzten verbleibenden Admins Fehler werfen, sonst Lock-out.
- JWT-Revocation: Ohne Token-Version-Feld bleibt ein gesperrter User bis zum JWT-Ablauf eingeloggt. Lösung:
token_version-Spalte, bei jedem Sperren/Rollen-Downgrade inkrementieren, JWT-Validation prüft Match. - Audit-Log unveränderlich:
admin_auditdarf nur Appends erhalten, keine UPDATEs/DELETEs – wichtig für spätere Nachvollziehbarkeit. - Passwort-Reset-Endpoint: Muss Rate-Limit bekommen (wie Login-Endpoint), sonst Missbrauchs-Vektor.
- Editor-Rolle: gebraucht oder Overkill? Wenn du der einzige bist, der Content pflegt, reicht Admin+Member. Editor-Rolle lohnt sich, sobald 2+ Leute Rideouts/Events pflegen wollen.
- Editor-Scope feinjustieren: Sollen Editor auch Admin-Docs oder Impressum bearbeiten dürfen? Vorschlag: nein – nur User-facing Content (Blog, Events, Rideouts, Galerie, Loadout, Packlisten).
- Editor-Vergabe: Nur Admin kann eine Rolle heben, oder gibt es einen Einladungs-Flow (Admin generiert One-Time-Link)?
- Sperren vs. Löschen: Auf Bitte eines Users reicht Sperren? Oder immer harte Löschung für DSGVO-Konformität? Vorschlag: beides anbieten, Default = Sperren, Löschen mit Bestätigung.
- Datenaufbewahrung nach Löschung: Event-Anmeldungen historisch behalten (anonymisiert, z.B. „Mitglied“) oder ebenfalls löschen? Beeinflusst Event-Teilnehmerstatistiken.
- 2FA für Admin/Editor: Separates Thema, aber denkbar als optionaler Schritt – TOTP-basierte 2FA für alle Rollen ≠ Member.
Die aktuelle Bildsprache nutzt hauptsächlich Event-Grafiken und Landschaftsbilder. Erfolgreiche Communities zeigen echte Gruppen-Fotos von Rides, Action-Shots und Rider-Portraits. Das vermittelt Gemeinschaft und Emotion – „Hier fahren echte Menschen zusammen".
1. Bei jedem Rideout und Event: 5–10 Fotos machen (Start, Gruppe, Landschaft, Pause, Ziel)
2. Konsistenter Bildstil: gleicher Filter/Farbton, dunkle Töne passend zum Dark-Theme
3. Galerie regelmäßig mit echten Ride-Fotos befüllen
4. Hero-Bild: Gruppenfahrt statt Solo-Landschaft
5. Blog-Beiträge: Rider-Portraits als Titelbild statt generische Grafiken
Kein Code-Aufwand – rein Content. Braucht einen „Foto-Beauftragten" oder regelmäßiges Sammeln von Bildern aus der Community (z.B. Strava-Fotos, Instagram-Reposts mit Erlaubnis).
| SEO | Search Engine Optimization – Klassische Suchmaschinen-Optimierung (Google, Bing). Ziel: Höheres Ranking in Suchergebnissen. |
| AEO | Answer Engine Optimization – Optimierung für KI-basierte Antwortsysteme (ChatGPT, Perplexity, Google AI Overview). Ziel: mag.biking wird als Antwort zitiert. |
| GEO | Generative Engine Optimization – Optimierung für generative KI-Suchen. Ziel: Strukturierte Daten die KI-Modelle direkt verwerten können. |
| Maßnahme | Dateien | Typ |
| FAQ-Section + FAQPage Schema | index.html (Section + JSON-LD) | AEO |
| SportsOrganization JSON-LD | Alle Seiten (<head>) | GEO |
| SportsEvent JSON-LD (dynamisch) | js/event-detail.js | GEO |
| Meta-Descriptions | Alle 10 öffentlichen Seiten | SEO |
| Open Graph Tags (og:*) | Alle Seiten + dynamisch in JS | SEO |
| Canonical Links | Alle Seiten (<link rel="canonical">) | SEO |
| BreadcrumbList Schema | events, blog, stats, event-detail | SEO |
| robots.txt für KI-Crawler | robots.txt (GPTBot, ClaudeBot, PerplexityBot etc.) | AEO |
| Sitemap | sitemap.xml (bei Google Search Console eingereicht) | SEO |
| About-Text als zitierbare Definition | index.html (About-Section) | AEO |
<noscript>-Fallback | index.html (Kerninhalte für JS-lose Crawler) | AEO |
| Dynamische OG-Images | event-detail.js, blog-detail.js | SEO |
Folgende JSON-LD Blöcke sind eingebettet:
- SportsOrganization – Auf allen Seiten. Beschreibt mag.biking als Organisation (Name, Region, Sport, Logo)
- FAQPage – Nur auf
index.html. 6 Fragen mit Antworten, Microdata + JSON-LD doppelt - SportsEvent – Dynamisch auf
event-detail.html. Wird per JS aus den Event-Daten generiert (Titel, Datum, Ort, Bild) - BreadcrumbList – Auf Unterseiten (events, blog, stats, event-detail). Zeigt Navigationspfad
Alle SEO-Texte verwenden konsistent diese Formulierung:
„Radsport-Community zwischen Bodensee und Schwarzwald an der Grenze zur Schweiz"
Nicht verwenden: „Bad Waldsee", „Oberschwaben" als Standort-Fokus. Diese Begriffe nur für konkrete Event-Namen (z.B. „Bad Waldsee Radmarathon").
Fokus-Themen: Wöchentliche Rideouts, Rennrad- & Gravel-Events, Bikepacking-Touren, Trainingslager Road & Gravel.
Im Admin-Editor gibt es den Button „SEO-Check" in der Topbar. Dieser prüft alle öffentlichen Seiten auf:
- Meta-Description vorhanden und richtige Länge (50–160 Zeichen)
- Open Graph Tags vollständig (og:title, og:description, og:image, og:url)
- Canonical Link vorhanden
- Schema.org JSON-LD vorhanden (SportsOrganization)
- FAQ-Section auf index.html vorhanden
robots.txterreichbarsitemap.xmlerreichbar<noscript>-Fallback auf index.html
Ergebnis wird als Modal mit Checkliste angezeigt (grün = OK, rot = fehlt).
- Google Search Console – Sitemap eingereicht (10.04.2026), Property verifiziert
- Google Business Profil – Empfohlen: kostenloses lokales Listing anlegen
- Strava Club Beschreibung – Keywords optimieren (Bodensee, Schwarzwald, Gravel, Bikepacking)
- Komoot Club/Profil – Routen veröffentlichen für zusätzliche Sichtbarkeit
- Lokalpresse – Artikel über Events platzieren (Backlinks + Autorität)
Vollständiger Audit durch Code-Review aller 11 öffentlichen HTML-Seiten. Fundament ist solide (OG, Canonicals, JSON-LD, robots.txt, Sitemap, Alt-Texte, einheitliche lang="de"). Folgende Punkte bieten den höchsten Hebel:
| Finding | Auswirkung & Fix |
|---|---|
SportsEvent-Schema nur auf Detail-Seiteevent-detail.html |
events.html (Übersicht) hat aktuell kein ItemList/Event-Schema. Rich-Result-Chance auf Event-Listen-Darstellung geht verloren. Fix: Dynamisches JSON-LD in js/events.js mit @type: ItemList + itemListElement: [{@type:'SportsEvent', ...}]. |
Canonical auf Detail-Seiten statischevent-detail.html, blog-detail.html |
Das <link rel="canonical"> zeigt initial auf /event-detail.html ohne Query-String. JS aktualisiert es zwar zu ?event=slug, Crawler können aber beide Varianten sehen ⇒ Duplicate-Content-Risiko. Fix: Canonical bereits beim ersten Paint über ein <script> in <head> setzen oder die statische Canonical entfernen und nur dynamisch injizieren. |
BlogPosting-Schema nur clientseitigblog-detail.js |
Googlebot rendert zwar JS, Crawl-Budget sinkt aber. Fix optional: Minimal-Schema schon statisch im <head> (Org + Website), vollständiges BlogPosting per JS anreichern. |
Sitemap unvollständigsitemap.xml |
map.html fehlt aktuell in der Sitemap. Detail-Seiten (event-detail.html?event=..., blog-detail.html?post=...) können nicht pro Item indexiert werden, solange Query-String-Routing genutzt wird. Fix Phase 1: map.html ergänzen. Phase 2: Sitemap bei Admin-Deploy per Script aus content-data.js generieren. |
| Twitter Cards fehlen komplett | Nur Open Graph gesetzt. Twitter/X fällt dadurch auf OG zurück, aber twitter:card = summary_large_image liefert konsistentere Previews. Fix: In den Head-Blöcken je Seite 4 Meta-Tags ergänzen (twitter:card/title/description/image). |
Impressum ohne Meta/OGimpressum.html |
Korrekt noindex, aber keine Meta-Description, kein Canonical, kein OG. Kleiner Fix: Minimale Description + Canonical ergänzen, OG optional. |
JS-abhängiger First Paintevents.html, blog.html, app.html |
Kerninhalte erscheinen erst nach content-data.js-Injection. Für Googlebot unproblematisch (renders JS), aber First-Contentful-Paint-Score leidet. Fix optional: Statisches Fallback-Listing der 3 nächsten Events im HTML-Quelltext. |
| Top-5 Priorität | 1) SportsEvent-Schema auf events.html · 2) Canonical-Fix Detail-Seiten · 3) Sitemap map.html + auto-Generation · 4) Twitter Cards · 5) Statisches Fallback-Listing |
catalog.items[]) eingeführt• loadout.selectedIds[] und packlists.lists[].items[] referenzieren Katalog-IDs
• Blog unterstützt GPX-Route als eigenen Abschnittstyp
• Rideout-App zeigt Höhenprofile (arc-gpx-canvas)
• Migration: Alte equipment.items / packlists.catalog werden automatisch migriert
• manifest.json für PWA
• GPX-Parser (Haversine, Höhenprofil-Canvas)
• Rideout-Featured-Karte mit GPX-Integration auf index.html
• content.js als zentraler Content-Store
• content-inject.js für DOM-Manipulation
• Admin mit GitHub-Push und Pi-Server-Sync
Changelog der letzten Admin-Speicherungen: Im Versionsverlauf-Modal einsehbar.