Table of Contents
- Anonymous User Identification — Browser-ID System
- Übersicht
- Architektur-Entscheidung
- Betroffene Dateien
- Datenbankänderungen (Migration 005)
- JavaScript-Änderungen (app.js)
- Block 1: Browser-ID generieren
- Block 7: Browser-ID bei Read mitsenden
- Block 9: Edit/Delete nur für eigenen Browser oder Admin
- Block 10: Browser-ID bei Create und Vote mitsenden
- PHP-Änderungen (index.php)
- API-Änderungen (contributions.php)
- handle_create — Speichert Browser-ID
- handle_vote — Identifiziert über Browser-ID
- handle_read — Liefert eigene Votes mit
- Datenfluss-Diagramm
- Testszenarien
- Test 1: Erste Nutzung — Cookie wird generiert
- Test 2: Vote bleibt nach Popup schließen/öffnen
- Test 3: Vote bleibt nach Seite neu laden
- Test 4: Vote Toggle — Like entfernen
- Test 5: Vote wechseln — Like zu Dislike
- Test 6: Eigene Beiträge bearbeiten/löschen
- Test 7: Fremde Beiträge — kein Bearbeiten/Löschen
- Test 8: Admin sieht alle Bearbeiten/Löschen-Buttons
- Test 9: Verschiedene Browser = verschiedene Identitäten
- Test 10: Doppel-Vote wird verhindert
- Test 11: Cookie löschen = neuer Nutzer
- Sicherheitsbetrachtung
- Zukünftige Verbesserungen
Anonymous User Identification — Browser-ID System
Übersicht
Das Bürgerbeteiligungsportal identifiziert Nutzer anonym über eine Browser-ID (UUID), die als Cookie gespeichert wird. Dieses System ermöglicht niedrigschwellige Bürgerbeteiligung ohne Registrierung oder Passwort, während gleichzeitig Beiträge und Abstimmungen zuverlässig einem Browser zugeordnet werden können.
Architektur-Entscheidung
Problem
- Bürger sollen ohne Registrierung Beiträge erstellen und abstimmen können.
- Nur der eingegebene Name reicht nicht zur Identifikation — jeder könnte sich als jemand anderes ausgeben und fremde Beiträge bearbeiten oder löschen.
- Abstimmungen (Likes/Dislikes) müssen pro Nutzer eindeutig sein, auch wenn mehrere Personen denselben Namen eingeben.
- Abstimmungen sollen nach dem Neuladen der Seite sichtbar bleiben (farbliche Markierung der Buttons).
Lösung: Zwei-Schicht-Identifikation
| Schicht | Zweck | Speicherort |
|---|---|---|
| Browser-ID (UUID) | Technische Identifikation für Rechte und Abstimmungen | Cookie (webgis_browser_id, 1 Jahr gültig) |
| Benutzername | Anzeigename in Beiträgen und Abstimmungen | sessionStorage (webgis_user) |
Rollen
| Rolle | Zugang | Rechte |
|---|---|---|
| Bürger | Name eingeben (freiwillig) | Beiträge erstellen, abstimmen, eigene Beiträge bearbeiten/löschen |
| Moderator/Admin | Passwort-Login über Lock-Icon im Header | Alle Beiträge moderieren, bearbeiten, löschen, News verwalten |
Betroffene Dateien
| Datei | Änderung |
|---|---|
migrations/005_browser_id.sql |
Neue Spalten und Constraints in der Datenbank |
public/js/app.js |
Browser-ID generieren, bei API-Calls mitsenden, Rechte prüfen |
public/index.php |
IS_ADMIN-Variable für JavaScript bereitstellen |
api/contributions.php |
Browser-ID bei Create, Vote und Read verarbeiten |
Datenbankänderungen (Migration 005)
Neue Spalten
-- Speichert die Browser-ID des Erstellers eines Beitrags
ALTER TABLE contributions
ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL;
-- Speichert die Browser-ID des Abstimmenden
ALTER TABLE votes
ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL;
Neuer Index
-- Beschleunigt die Suche nach Abstimmungen eines bestimmten Browsers
CREATE INDEX idx_votes_browser ON votes(browser_id);
Constraint-Änderung
-- VORHER: Ein Vote pro voter_name + contribution_id
-- → Problem: Zwei verschiedene Browser mit gleichem Namen
-- konnten nicht unabhängig abstimmen.
-- Entfernt den alten Constraint (Name-basiert)
ALTER TABLE votes
DROP CONSTRAINT IF EXISTS votes_unique_per_voter;
-- NACHHER: Ein Vote pro browser_id + contribution_id
-- → Jeder Browser kann genau einmal pro Beitrag abstimmen,
-- unabhängig vom eingegebenen Namen.
ALTER TABLE votes
ADD CONSTRAINT votes_contribution_browser_unique
UNIQUE (contribution_id, browser_id);
Auswirkung auf bestehende Daten
- Bestehende Beiträge erhalten
browser_id = NULL. Diese Beiträge können von niemandem über das Portal bearbeitet werden (nur über das Moderationsportal). - Bestehende Votes erhalten
browser_id = NULL. Diese Votes werden beim Neuladen nicht als farbliche Markierung angezeigt, bleiben aber in der Datenbank und zählen weiterhin.
JavaScript-Änderungen (app.js)
Block 1: Browser-ID generieren
// Browser Identification Number for anonymous User Identification stored as Cookie
let browserId = getBrowserId();
function getBrowserId() {
// Versucht, bestehende Browser-ID aus Cookie zu lesen
let id = document.cookie.replace(
/(?:(?:^|.*;\s*)webgis_browser_id\s*=\s*([^;]*).*$)|^.*$/, '$1'
);
// Falls keine ID vorhanden, wird eine neue UUID v4 generiert
if (!id) {
id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
// Cookie wird für ein Jahr gespeichert
document.cookie = 'webgis_browser_id=' + id + ';path=/;max-age=31536000;SameSite=Lax';
}
return id;
}
Wie es funktioniert:
- Beim ersten Seitenaufruf existiert kein Cookie → neue UUID wird generiert (z.B.
b5f326e1-8c83-4780-99a6-c98c6586ec07). - Die UUID wird als Cookie
webgis_browser_idgespeichert (1 Jahr Laufzeit). - Bei jedem weiteren Aufruf wird die bestehende UUID aus dem Cookie gelesen.
- Die UUID ist pro Browser einzigartig — verschiedene Browser auf demselben Computer haben verschiedene IDs.
Block 7: Browser-ID bei Read mitsenden
function loadContributions() {
const readParams = { action: 'read', municipality_id: MUNICIPALITY.id };
// Sendet Browser-ID, damit die API die eigenen Votes zurückgeben kann
readParams.browser_id = browserId;
apiCall(readParams, function (data) {
// ...
contributionsData = data.features || [];
// Stellt die Vote-Markierungen aus der API-Antwort wieder her
if (data.user_votes) {
userVotes = {};
for (const key in data.user_votes) {
userVotes[key] = data.user_votes[key];
}
}
// ...
});
}
Wie es funktioniert:
- Beim Laden der Beiträge wird die
browser_idan die API gesendet. - Die API sucht alle Votes dieser Browser-ID und gibt sie als
user_votes-Objekt zurück. - Das
userVotes-Objekt wird befüllt (z.B.{ "2": "like", "5": "dislike" }). - Wenn ein Popup geöffnet wird, prüft
buildPopupHtml()dieses Objekt und setzt die CSS-Klassenliked/dislikedauf die Buttons.
Block 9: Edit/Delete nur für eigenen Browser oder Admin
// VORHER: Prüfte nur den Benutzernamen
(currentUser === props.author_name ? /* Buttons anzeigen */ : '')
// NACHHER: Prüft die Browser-ID oder Admin-Status
(props.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) ? /* Buttons anzeigen */ : '')
Wie es funktioniert:
props.browser_idist die Browser-ID, die beim Erstellen des Beitrags gespeichert wurde.browserIdist die aktuelle Browser-ID des Betrachters.- Stimmen sie überein → der Betrachter ist der Ersteller → Bearbeiten/Löschen-Buttons werden angezeigt.
IS_ADMINisttrue, wenn der Nutzer über das Moderationsportal als Admin eingeloggt ist → sieht die Buttons bei allen Beiträgen.
Block 10: Browser-ID bei Create und Vote mitsenden
// CREATE — Browser-ID wird mit dem Beitrag gespeichert
apiCall({
action: 'create',
// ...
author_name: currentUser,
browser_id: browserId // ← NEU
}, function (response) { /* ... */ });
// VOTE — Browser-ID identifiziert den Abstimmenden
apiCall({
action: 'vote',
contribution_id: contributionId,
voter_name: currentUser,
vote_type: voteType,
browser_id: browserId // ← NEU
}, function (response) { /* ... */ });
PHP-Änderungen (index.php)
Admin-Status als JavaScript-Variable
<!-- Benötigt auth.php für die is_admin()-Funktion -->
<?php
require_once __DIR__ . '/api/db.php';
require_once __DIR__ . '/api/auth.php';
?>
<script>
// Wird von app.js genutzt, um Edit/Delete-Buttons bei allen Beiträgen anzuzeigen
const IS_ADMIN = <?= (function_exists('is_admin') && is_admin()) ? 'true' : 'false' ?>;
</script>
Wie es funktioniert:
is_admin()prüft$_SESSION['is_admin'].- Die Session wird gesetzt, wenn sich jemand über
admin.phpmit dem korrekten Passwort anmeldet. - Da
index.phpundadmin.phpdieselbe Session teilen, erkennt das Portal einen eingeloggten Moderator.
API-Änderungen (contributions.php)
handle_create — Speichert Browser-ID
$stmt = $pdo->prepare("
INSERT INTO contributions
(municipality_id, geom, geom_type, category, title, description, author_name, browser_id)
VALUES
(:mid, ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326), :geom_type,
:category, :title, :description, :author_name, :browser_id)
");
$stmt->execute([
// ...
':browser_id' => $input['browser_id'] ?? null // ← NEU
]);
handle_vote — Identifiziert über Browser-ID
// Prüft ob Browser-ID vorhanden ist (Pflichtfeld für Voting)
$browser_id = $input['browser_id'] ?? '';
if (empty($browser_id)) {
error_response('Browser ID required for Voting.');
}
// Sucht bestehenden Vote über Browser-ID statt über Name
$stmt = $pdo->prepare("
SELECT vote_id, vote_type FROM votes
WHERE contribution_id = :cid AND browser_id = :bid
");
$stmt->execute([':cid' => $input['contribution_id'], ':bid' => $browser_id]);
// Bei INSERT wird die Browser-ID mitgespeichert
$stmt = $pdo->prepare("
INSERT INTO votes (contribution_id, voter_name, vote_type, browser_id)
VALUES (:cid, :voter, :vtype, :bid)
");
Ablauf eines Votes:
- Browser sendet
contribution_id,voter_name,vote_typeundbrowser_id. - API prüft ob bereits ein Vote mit dieser
browser_idfür diesen Beitrag existiert. - Falls ja und gleicher Typ → Vote wird entfernt (Toggle).
- Falls ja und anderer Typ → Vote wird gewechselt (Like → Dislike oder umgekehrt).
- Falls nein → neuer Vote wird eingefügt.
- Der DB-Trigger
update_vote_counts()aktualisiertlikes_count/dislikes_countautomatisch.
handle_read — Liefert eigene Votes mit
// Sucht alle Votes der aktuellen Browser-ID
$browser_id = $input['browser_id'] ?? '';
if ($browser_id !== '') {
$stmt = $pdo->prepare("
SELECT contribution_id, vote_type
FROM votes
WHERE browser_id = :bid
");
$stmt->execute([':bid' => $browser_id]);
$user_votes = [];
foreach ($stmt->fetchAll() as $v) {
$user_votes[$v['contribution_id']] = $v['vote_type'];
}
// Hängt die Votes an die GeoJSON-Antwort an
$featureCollection['user_votes'] = $user_votes;
}
Beispiel API-Antwort mit Votes:
{
"type": "FeatureCollection",
"features": [ /* ... Beiträge ... */ ],
"user_votes": {
"2": "like",
"5": "dislike",
"7": "like"
}
}
Datenfluss-Diagramm
ERSTER BESUCH:
┌──────────┐ Cookie nicht vorhanden ┌──────────────┐
│ Browser │ ─────────────────────────────→ │ getBrowserId()│
│ │ ← UUID generiert + Cookie ──── │ │
└──────────┘ gesetzt (1 Jahr) └──────────────┘
BEITRAG ERSTELLEN:
┌──────────┐ POST: create + browser_id ┌──────────────┐ INSERT mit ┌──────────┐
│ Browser │ ─────────────────────────────→│ PHP API │ ──────────────────→ │ DB │
│ │ │ │ browser_id │ │
└──────────┘ └──────────────┘ └──────────┘
ABSTIMMEN:
┌──────────┐ POST: vote + browser_id ┌──────────────┐ SELECT WHERE bid ┌──────────┐
│ Browser │ ─────────────────────────────→│ PHP API │ ──────────────────→ │ DB │
│ │ ← action: created/removed ─── │ Prüft Vote │ ← Ergebnis ─────── │ │
└──────────┘ │ Toggle-Logik │ INSERT/DELETE │ │
└──────────────┘ └──────────┘
SEITE LADEN (Votes persistent):
┌──────────┐ POST: read + browser_id ┌──────────────┐ SELECT votes ┌──────────┐
│ Browser │ ────────────────────────────→│ PHP API │ ──────────────────→ │ DB │
│ │ ← features + user_votes ──── │ │ ← Votes ─────────── │ │
│ │ └──────────────┘ └──────────┘
│ │
│ buildPopupHtml() liest userVotes
│ → Setzt CSS-Klasse "liked"/"disliked"
│ → Buttons sind farbig markiert
└──────────┘
Testszenarien
Test 1: Erste Nutzung — Cookie wird generiert
Vorbereitung: Browser-Cookies löschen oder Inkognito-Modus öffnen.
Schritte:
- Portal öffnen.
- Browser-Entwicklertools öffnen (F12) → Reiter "Application" → "Cookies".
Erwartung:
- Ein Cookie
webgis_browser_idexistiert mit einem UUID-Wert (z.B.a1b2c3d4-e5f6-4789-abcd-ef0123456789). - Der Cookie hat
max-age=31536000(1 Jahr). - Der Cookie hat
SameSite=Lax.
Test 2: Vote bleibt nach Popup schließen/öffnen
Schritte:
- Anmelden mit einem Namen.
- Einen Beitrag anklicken → Popup öffnet sich.
- Like-Button klicken → Button wird grün, Zähler erhöht sich.
- Popup schließen (Klick auf Karte).
- Denselben Beitrag erneut anklicken.
Erwartung:
- Like-Button ist weiterhin grün markiert.
- Zähler zeigt den korrekten Wert.
Test 3: Vote bleibt nach Seite neu laden
Schritte:
- Einen Beitrag liken (wie Test 2, Schritt 1-3).
- Seite komplett neu laden (F5 oder Strg+R).
- Denselben Beitrag anklicken.
Erwartung:
- Like-Button ist weiterhin grün markiert.
- Der Vote wurde aus der Datenbank geladen, nicht aus dem Browser-Speicher.
Verifikation in der Datenbank:
SELECT * FROM votes WHERE browser_id = 'deine-browser-id-hier';
Test 4: Vote Toggle — Like entfernen
Schritte:
- Einen Beitrag liken (Button wird grün).
- Denselben Like-Button erneut klicken.
Erwartung:
- Like-Button wird wieder normal (nicht mehr grün).
- Zähler verringert sich um 1.
- In der Datenbank existiert kein Vote mehr für diesen Beitrag und diese Browser-ID.
Test 5: Vote wechseln — Like zu Dislike
Schritte:
- Einen Beitrag liken (Like-Button wird grün).
- Dislike-Button klicken.
Erwartung:
- Like-Button wird normal, Dislike-Button wird rot.
- Like-Zähler verringert sich um 1, Dislike-Zähler erhöht sich um 1.
Test 6: Eigene Beiträge bearbeiten/löschen
Schritte:
- Anmelden und einen neuen Beitrag erstellen.
- Warten bis der Beitrag vom Moderator freigegeben wird (oder über Admin selbst freigeben).
- Den eigenen Beitrag auf der Karte anklicken.
Erwartung:
- Popup zeigt "Bearbeiten" und "Löschen" Buttons.
Test 7: Fremde Beiträge — kein Bearbeiten/Löschen
Schritte:
- Einen anderen Browser oder Inkognito-Modus öffnen.
- Einen Beitrag anklicken, der im ersten Browser erstellt wurde.
Erwartung:
- Popup zeigt KEINE "Bearbeiten"/"Löschen" Buttons.
- Like/Dislike-Buttons sind weiterhin verfügbar.
Test 8: Admin sieht alle Bearbeiten/Löschen-Buttons
Schritte:
- Über das Lock-Icon im Header das Moderationsportal öffnen.
- Mit dem Admin-Passwort anmelden.
- Zurück zur Karte navigieren (Link "Bürgerportal" im Mod-Header).
- Einen beliebigen Beitrag anklicken.
Erwartung:
- Popup zeigt "Bearbeiten" und "Löschen" Buttons bei ALLEN Beiträgen, nicht nur eigenen.
Test 9: Verschiedene Browser = verschiedene Identitäten
Schritte:
- In Chrome einen Beitrag erstellen und liken.
- Denselben Beitrag in Firefox öffnen.
Erwartung:
- In Firefox ist der Like-Button NICHT grün markiert (anderer Browser = andere Browser-ID).
- In Firefox sind keine Bearbeiten/Löschen-Buttons sichtbar.
- In Firefox kann derselbe Beitrag unabhängig geliked werden (eigener Vote).
Test 10: Doppel-Vote wird verhindert
Schritte:
- Einen Beitrag liken.
- In der Datenbank nachschauen:
SELECT * FROM votes
WHERE contribution_id = [ID] AND browser_id = '[BROWSER-ID]';
Erwartung:
- Genau ein Eintrag existiert.
- Ein erneuter Like vom selben Browser entfernt den Vote (Toggle).
- Die Datenbank-Constraint
votes_contribution_browser_uniqueverhindert technisch jeden Versuch, zwei Votes pro Browser pro Beitrag einzufügen.
Test 11: Cookie löschen = neuer Nutzer
Schritte:
- Einen Beitrag erstellen und liken.
- Browser-Cookies löschen (Entwicklertools → Application → Cookies → alle löschen).
- Seite neu laden.
Erwartung:
- Like-Markierung ist weg (neue Browser-ID).
- Bearbeiten/Löschen-Buttons beim eigenen Beitrag sind weg.
- Der alte Vote und Beitrag bleiben in der Datenbank (mit der alten Browser-ID).
- Der Nutzer kann den Beitrag erneut liken (mit neuer Browser-ID).
Hinweis: Das ist eine bekannte Einschränkung. Für ein Bürgerbeteiligungsportal ohne Registrierung ist das ein akzeptabler Kompromiss.
Sicherheitsbetrachtung
| Szenario | Geschützt? | Erklärung |
|---|---|---|
| Jemand gibt einen fremden Namen ein | ✅ Ja | Edit/Delete prüft Browser-ID, nicht den Namen |
| Jemand versucht doppelt abzustimmen | ✅ Ja | DB-Constraint votes_contribution_browser_unique verhindert das |
| Jemand löscht seine Cookies | ⚠️ Teilweise | Wird als neuer Nutzer behandelt, kann erneut abstimmen |
| Jemand kopiert die Browser-ID eines anderen | ⚠️ Theoretisch möglich | Erfordert Zugriff auf den anderen Browser — unrealistisch für Bürgerbeteiligung |
| Admin-Zugang ohne Passwort | ✅ Ja | IS_ADMIN wird serverseitig aus der PHP-Session gesetzt |
Zukünftige Verbesserungen
| Verbesserung | Phase | Beschreibung |
|---|---|---|
| Rate Limiting | Phase 5 | Maximal X Votes pro Stunde pro Browser-ID |
| Cookie-Warnung (DSGVO) | Phase 5 | Hinweis auf Cookie-Nutzung beim ersten Besuch |
| Fingerprinting als Fallback | Phase 5 | Falls Cookies deaktiviert sind, Browser-Fingerprint als Alternative |
| users-Tabelle für Moderatoren | Phase 5 | Mehrere Moderatoren mit individuellen Passwörtern und Audit-Log |