From 55174684afa8820eb00f66076ebf62e44df79819 Mon Sep 17 00:00:00 2001 From: MetalCircle Codex Bot Date: Tue, 15 Sep 2026 08:31:45 +0200 Subject: [PATCH] Add activity push notifications and registration patches --- .env.example | 10 + README.md | 6 +- app/.dockerignore | 12 + app/Dockerfile | 2 +- app/community_badges.py | 43 +++ app/feature_schema.py | 30 +- app/locales/en.json | 21 ++ app/main.py | 67 +++-- app/notifications.py | 239 ++++++++++++++++ app/push_devices.py | 5 +- app/static/css/style.css | 6 + app/static/images/patch-alpha-tester.svg | 8 + app/static/images/patch-early-bird.svg | 8 + app/static/js/native-push.js | 37 ++- app/templates/_language_switch.html | 4 +- app/templates/_native_push.html | 1 + app/templates/datenschutz.html | 1 + app/templates/profile.html | 12 +- app/tests/native_push.test.cjs | 64 +++++ app/tests/test_feature_api.py | 9 +- app/tests/test_i18n.py | 5 +- app/tests/test_notifications.py | 334 +++++++++++++++++++++++ compose.push.yml | 12 + compose.yml | 4 + db/migrations/21_push_notifications.sql | 26 ++ db/migrations/22_registration_badges.sql | 3 + docs/wiki/Android-App.md | 4 +- docs/wiki/Architecture.md | 4 +- docs/wiki/Badges-and-Patches.md | 16 +- docs/wiki/Configuration.md | 6 + docs/wiki/Database.md | 3 + docs/wiki/Development-Setup.md | 6 +- docs/wiki/Firebase-Setup-Prompt.md | 55 ++++ docs/wiki/Firebase.md | 38 ++- docs/wiki/Home.md | 2 + docs/wiki/Push-Notifications.md | 31 +++ docs/wiki/Roadmap.md | 2 +- docs/wiki/Troubleshooting.md | 2 +- 38 files changed, 1096 insertions(+), 42 deletions(-) create mode 100644 app/.dockerignore create mode 100644 app/community_badges.py create mode 100644 app/notifications.py create mode 100644 app/static/images/patch-alpha-tester.svg create mode 100644 app/static/images/patch-early-bird.svg create mode 100644 app/tests/native_push.test.cjs create mode 100644 app/tests/test_notifications.py create mode 100644 compose.push.yml create mode 100644 db/migrations/21_push_notifications.sql create mode 100644 db/migrations/22_registration_badges.sql create mode 100644 docs/wiki/Firebase-Setup-Prompt.md create mode 100644 docs/wiki/Push-Notifications.md diff --git a/.env.example b/.env.example index ac5bfba..34e6d7a 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,13 @@ GITEA_URL=http://192.168.178.5:3000 GITEA_TOKEN= GITEA_OWNER=kai GITEA_REPO=pingu-concerts + +# Server push: keep disabled until a dedicated TEST service account is mounted. +PUSH_ENABLED=false +FIREBASE_PROJECT_ID= +# Only needed with: docker compose -f compose.yml -f compose.push.yml ... +FIREBASE_SERVICE_ACCOUNT_FILE= + +# Inclusive registration dates (Europe/Berlin); Early Bird starts the following day. +ALPHA_TESTER_UNTIL=2026-10-31 +BETA_TESTER_UNTIL=2026-12-31 diff --git a/README.md b/README.md index 3de9346..753a959 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MetalCircle -MetalCircle ist eine private, invite-only Community-Plattform rund um Heavy-Metal-Konzerte. Mitglieder verwalten einen gemeinsamen Konzertkalender, legen Veranstaltungen an, sehen Konzertdetails, bekunden Teilnahme oder Interesse, kommentieren Konzerte und pflegen Profile. Freundes- und Community-Funktionen, Fotos, ein Patch-/Badge-System, Web-App und Android-App gehören zum aktuellen Produkt; Push-Benachrichtigungen sind für Android technisch vorbereitet. +MetalCircle ist eine private, invite-only Community-Plattform rund um Heavy-Metal-Konzerte. Mitglieder verwalten einen Konzertkalender, sehen Konzertdetails, bekunden Teilnahme oder Interesse, kommentieren Konzerte und pflegen Profile. Dazu gehören Fotos, Freundschaften, Direktnachrichten, Patches sowie Web- und Android-App. Automatische Android-Pushs für Anfragen, Nachrichten und Einladungen sind nach Firebase-Backend-Einrichtung aktivierbar. Die Oberfläche unterstützt DE/EN über einen Umschaltbutton. Der Repository-Name ist historisch noch `pingu-concerts`. Das ist beabsichtigt und wird hier nicht automatisch umbenannt. @@ -32,7 +32,7 @@ Voraussetzung sind Docker und Docker Compose. Eine lokale Konfiguration wird aus ```bash cp .env.example .env -docker compose up --build +COOKIE_SECURE=false docker compose up --build ``` Die Anwendung ist anschließend unter `http://localhost:8080` erreichbar. Stoppen geht mit `docker compose down`; Logs zeigt `docker compose logs -f web`. @@ -58,4 +58,4 @@ Die App bleibt unter der Package ID `de.pinguholic.concerts`. Für einen lokalen Änderungen werden in einem Arbeitsbranch geprüft und als nachvollziehbarer Commit nach Review in `main` übernommen. Gitea Issues dienen als führendes Bug-System; der integrierte Reporter legt Issues über den dedizierten Bot an. -Ausführliche Entwickler-, Architektur-, Deployment- und Betriebsdokumentation befindet sich im [Gitea Wiki](docs/wiki/Home.md). Die Wiki-Seiten liegen hier zusätzlich als versionierbare Vorlage, falls der direkte Wiki-Zugriff nicht verfügbar ist. +Ausführliche Entwickler-, Architektur-, Deployment- und Betriebsdokumentation befindet sich im [Gitea Wiki](../../wiki). Die [Wiki-Quellen](docs/wiki/Home.md) sind zusätzlich versioniert. Siehe [Firebase-Einrichtung](docs/wiki/Firebase.md), [Push-Ablauf](docs/wiki/Push-Notifications.md), den [ChatGPT-Prompt](docs/wiki/Firebase-Setup-Prompt.md) und die konfigurierbaren [Alpha-/Beta-/Early-Bird-Stufen](docs/wiki/Badges-and-Patches.md). diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..bfb9603 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,12 @@ +.env +.env.* +secrets/ +**/*service-account*.json +**/*service_account*.json +**/*firebase-adminsdk*.json +*.pem +*.key +private_uploads/ +static/uploads/ +__pycache__/ +**/__pycache__/ diff --git a/app/Dockerfile b/app/Dockerfile index 8e391ed..75501a7 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim WORKDIR /app -RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow +RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow "firebase-admin==7.1.0" COPY . . diff --git a/app/community_badges.py b/app/community_badges.py new file mode 100644 index 0000000..b1bd02b --- /dev/null +++ b/app/community_badges.py @@ -0,0 +1,43 @@ +"""Exclusive cohorts; convert legacy DB-local registration timestamps to Europe/Berlin.""" +from datetime import date, datetime, time, timedelta +import os +from zoneinfo import ZoneInfo + +COHORTS = ('alpha_tester', 'beta_tester', 'early_bird') + + +def boundaries(): + alpha = date.fromisoformat(os.environ.get('ALPHA_TESTER_UNTIL', '2026-10-31')) + beta = date.fromisoformat(os.environ.get('BETA_TESTER_UNTIL', '2026-12-31')) + if beta <= alpha: + raise ValueError('BETA_TESTER_UNTIL must be after ALPHA_TESTER_UNTIL') + return (datetime.combine(alpha + timedelta(days=1), time()), + datetime.combine(beta + timedelta(days=1), time())) + + +def cohort(registered_at): + if registered_at.tzinfo: + registered_at = registered_at.astimezone(ZoneInfo('Europe/Berlin')).replace(tzinfo=None) + alpha_end, beta_end = boundaries() + return 'alpha_tester' if registered_at < alpha_end else 'beta_tester' if registered_at < beta_end else 'early_bird' + + +def reconcile(cursor, user_id=None): + """Also upgrades existing beta members and reclassifies when configured dates change.""" + alpha_end, beta_end = boundaries() + cursor.execute(''' + DELETE FROM user_badges b USING users u WHERE b.user_id=u.id + AND (%s::integer IS NULL OR u.id=%s) + AND b.badge_code=ANY(%s) AND b.badge_code <> CASE + WHEN u.created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'alpha_tester' + WHEN u.created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'beta_tester' ELSE 'early_bird' END + ''', (user_id, user_id, list(COHORTS), alpha_end, beta_end)) + cursor.execute(''' + INSERT INTO user_badges(user_id,badge_code,awarded_at) + SELECT id, CASE + WHEN created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'alpha_tester' + WHEN created_at AT TIME ZONE current_setting('TimeZone') AT TIME ZONE 'Europe/Berlin' < %s THEN 'beta_tester' + ELSE 'early_bird' END, created_at + FROM users WHERE (%s::integer IS NULL OR id=%s) + ON CONFLICT(user_id,badge_code) DO NOTHING + ''', (alpha_end, beta_end, user_id, user_id)) diff --git a/app/feature_schema.py b/app/feature_schema.py index 1e8c6a2..6385057 100644 --- a/app/feature_schema.py +++ b/app/feature_schema.py @@ -1,4 +1,4 @@ -"""Startup equivalents of migrations 19 and 20 (kept in sync by tests).""" +"""Startup equivalents of migrations 19–22 (kept in sync by tests).""" FEATURE_SCHEMA = ( '''CREATE TABLE IF NOT EXISTS push_devices ( @@ -27,4 +27,32 @@ FEATURE_SCHEMA = ( ); CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user ON bug_report_submissions(user_id, submitted_at);''', + '''CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + language VARCHAR(2) NOT NULL DEFAULT 'de' CHECK (language IN ('de', 'en')), + friend_request BOOLEAN NOT NULL DEFAULT TRUE, + direct_message BOOLEAN NOT NULL DEFAULT TRUE, + event_invitation BOOLEAN NOT NULL DEFAULT TRUE + ); + CREATE TABLE IF NOT EXISTS push_notifications ( + id UUID PRIMARY KEY, + device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(32) NOT NULL CHECK (kind IN ('friend_request', 'direct_message', 'event_invitation')), + object_id BIGINT NOT NULL, + event_key TEXT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'sent', 'dropped', 'failed')), + attempts SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 hour', + available_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(device_id, event_key) + ); + CREATE INDEX IF NOT EXISTS idx_push_notifications_pending + ON push_notifications(available_at) WHERE state='pending';''', + '''CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_registration_cohort + ON user_badges(user_id) WHERE badge_code IN ('alpha_tester', 'beta_tester', 'early_bird');''', ) diff --git a/app/locales/en.json b/app/locales/en.json index c5be192..ea4e2e5 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -460,6 +460,27 @@ "Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.", "Diesen Nutzer wirklich blockieren?": "Really block this user?", "Account wirklich dauerhaft löschen?": "Really delete your account permanently?", + "Alpha Tester": "Alpha Tester", + "Early Bird": "Early Bird", + "Schon in der Alpha dabei – unsere frühesten Tester": "Part of the alpha – our earliest testers", + "Früh Teil der MetalCircle-Community geworden": "An early member of the MetalCircle community", + "Zu Englisch wechseln": "Switch to English", + "Zu Deutsch wechseln": "Switch to German", + "Push-Benachrichtigungen": "Push notifications", + "Wähle, welche Hinweise du auf deinen angemeldeten Android-Geräten erhalten möchtest. Nachrichteninhalte werden nicht angezeigt.": "Choose which alerts to receive on your signed-in Android devices. Message contents are never displayed.", + "Freundschaftsanfragen": "Friend requests", + "Direktnachrichten": "Direct messages", + "Veranstaltungseinladungen": "Event invitations", + "Benachrichtigungen speichern": "Save notifications", + "Benachrichtigungseinstellungen gespeichert": "Notification preferences saved", + "Neue Freundschaftsanfrage": "New friend request", + "Du hast eine neue Freundschaftsanfrage.": "You have a new friend request.", + "Neue Nachricht": "New message", + "Du hast eine neue Nachricht.": "You have a new message.", + "Neue Veranstaltungseinladung": "New event invitation", + "Du wurdest zu einer Veranstaltung eingeladen.": "You have been invited to an event.", + "Neue Benachrichtigung öffnen": "Open new notification", + "Für automatische Pushs speichern wir deine Sprache, gewählte Kategorien und Versandmetadaten. Push-Texte enthalten keine privaten Nachrichteninhalte. Der aktive Versanddienst löscht Versandmetadaten nach sieben Tagen; beim Abmelden werden die zur Sitzung gehörenden Aufträge entfernt.": "For automatic push notifications, we store your language, selected categories and delivery metadata. Push texts contain no private message content. The active delivery worker deletes delivery metadata after seven days; logging out removes jobs belonging to that session.", "🐛 Bug melden": "🐛 Report a bug", "Bug melden · MetalCircle": "Report a bug · MetalCircle", "Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.", diff --git a/app/main.py b/app/main.py index a7ea35e..0fd173e 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,8 @@ from fastapi.exception_handlers import request_validation_exception_handler from feature_schema import FEATURE_SCHEMA import push_devices import bug_reporter +import notifications +import community_badges from i18n import ( LANGUAGE_COOKIE, current_language, current_page, gettext as _, language_url, safe_return_path, format_time, format_datetime, @@ -100,7 +102,9 @@ BADGE_DEFINITIONS = ( ("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle aufgebaut", "special"), ("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"), ("captns_mate", "Captns Mate", "☠️", None, "Die treue Gefährtin des Captains", "special"), + ("alpha_tester", "Alpha Tester", "👑", None, "Schon in der Alpha dabei – unsere frühesten Tester", "beta"), ("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"), + ("early_bird", "Early Bird", "🐦", None, "Früh Teil der MetalCircle-Community geworden", "beta"), ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"), ("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"), ("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"), @@ -121,7 +125,6 @@ VENUE_BADGE_CODES = tuple( badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS if category == "venue" ) -BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16) BADGE_BY_CODE = { badge_code: (name, icon, threshold, description, category) for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS @@ -527,7 +530,14 @@ def ensure_schema(): @asynccontextmanager async def lifespan(_app: FastAPI): ensure_schema() - yield + with get_db_connection() as connection: + community_badges.reconcile(connection.cursor()) + worker = notifications.PushWorker(get_db_connection) + worker.start() + try: + yield + finally: + worker.stop() app = FastAPI(title="MetalCircle", lifespan=lifespan) @@ -638,9 +648,14 @@ async def localize_request(request: Request, call_next): @app.get("/language/{language}") -def change_language(language: str, next: str = "/"): +def change_language(request: Request, language: str, next: str = "/"): if language not in {"de", "en"}: return HTMLResponse(_("Ungültige Sprache."), status_code=400) + if request.cookies.get(SESSION_COOKIE): + user = get_current_user(request) + if user: + with get_db_connection() as connection: + notifications.save_language(connection, user['id'], language) response = RedirectResponse(safe_return_path(next), status_code=303) response.set_cookie( LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60, @@ -1494,15 +1509,7 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at): with get_db_connection() as connection: with connection.cursor() as cursor: - if registered_at < BETA_REGISTRATION_DEADLINE: - cursor.execute( - """ - INSERT INTO user_badges (user_id, badge_code) - VALUES (%s, 'beta_tester') - ON CONFLICT (user_id, badge_code) DO NOTHING - """, - (user_id,), - ) + community_badges.reconcile(cursor, user_id) for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: if category == "attendance" and threshold is not None and stats["total"] >= threshold: @@ -1545,7 +1552,10 @@ def load_badge_assets(): with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute("SELECT badge_code, path FROM badge_assets") - return {row[0]: row[1] for row in cursor.fetchall()} + assets = {'alpha_tester': '/static/images/patch-alpha-tester.svg', + 'early_bird': '/static/images/patch-early-bird.svg'} + assets.update({row[0]: row[1] for row in cursor.fetchall()}) + return assets def load_profile(username: str): @@ -2399,6 +2409,8 @@ def register_user( user_id, invite_id )) + community_badges.reconcile(cursor, user_id) + notifications.save_language(connection, user_id, current_language.get()) connection.commit() @@ -2532,6 +2544,8 @@ def login( with get_db_connection() as connection: connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),)) connection.commit() + with get_db_connection() as connection: + notifications.save_language(connection, row[0], current_language.get()) response = RedirectResponse(next_path, status_code=303) return attach_session(response, create_session(row[0])) @@ -2735,6 +2749,8 @@ def render_profile( } badges.sort(key=lambda badge: badge["sort_key"]) + with get_db_connection() as connection: + push_preferences = notifications.preferences(connection, viewer['id']) template = templates.get_template("profile.html") return HTMLResponse( template.render( @@ -2750,6 +2766,7 @@ def render_profile( form_error=form_error, form_success=form_success, instagram_input=instagram_input, + push_preferences=push_preferences, ), status_code=status_code, ) @@ -2766,6 +2783,8 @@ def own_profile(request: Request, saved: str = ""): messages.append(_("Profilbild aktualisiert")) if "instagram" in saved_items: messages.append(_("Instagram verknüpft")) + if "notifications" in saved_items: + messages.append(_("Benachrichtigungseinstellungen gespeichert")) return render_profile( request, user["username"], @@ -2901,11 +2920,14 @@ def export_profile_data(request: Request): ) badges = cursor.fetchall() + push_preferences = notifications.preferences(connection, user_id) + def rows_to_dicts(rows, keys): return [dict(zip(keys, row)) for row in rows] data = { "export_version": 1, + "notification_preferences": push_preferences, "exported_at": datetime.now(), "account": dict(zip( ("id", "username", "email", "display_name", "avatar_path", @@ -3062,10 +3084,13 @@ def send_friend_request(request: Request, username: str): """ INSERT INTO friendships (requester_id, addressee_id) VALUES (%s, %s) - ON CONFLICT DO NOTHING + ON CONFLICT DO NOTHING RETURNING id """, (user["id"], profile["id"]), ) + created = cursor.fetchone() + if created: + notifications.enqueue(cursor, 'friend_request', user['id'], profile['id'], created[0]) connection.commit() return RedirectResponse(f"/users/{profile['username']}", status_code=303) @@ -3319,9 +3344,10 @@ def send_message(request: Request, username: str, body: str = Form(...)): if not partner: return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403) cursor.execute( - "INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)", + "INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s) RETURNING id", (user["id"], partner["id"], body), ) + notifications.enqueue(cursor, 'direct_message', user['id'], partner['id'], cursor.fetchone()[0]) connection.commit() return RedirectResponse(f"/messages/{partner['username']}#latest", status_code=303) @@ -4026,10 +4052,13 @@ async def create_concert( INSERT INTO event_invitations (concert_id, user_id, invited_by) SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s - ON CONFLICT (concert_id, user_id) DO NOTHING + ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id """, (concert_id, user["id"], invited_user_ids, user["id"]), ) + for invited_id, in cursor.fetchall(): + notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id, + 'invitation:' + uuid.uuid4().hex) connection.commit() @@ -4257,10 +4286,13 @@ async def edit_concert( """ INSERT INTO event_invitations (concert_id, user_id, invited_by) SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s - ON CONFLICT (concert_id, user_id) DO NOTHING + ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id """, (concert_id, user["id"], invited_user_ids, user["id"]), ) + for invited_id, in cursor.fetchall(): + notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id, + 'invitation:' + uuid.uuid4().hex) elif can_manage_event_access(user, concert): cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,)) if can_edit_title(user, concert): @@ -5115,3 +5147,4 @@ def search_venues(q: str): push_devices.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE) bug_reporter.register_routes(app, templates, get_db_connection, get_current_user) +notifications.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE) diff --git a/app/notifications.py b/app/notifications.py new file mode 100644 index 0000000..313b24c --- /dev/null +++ b/app/notifications.py @@ -0,0 +1,239 @@ +"""Transactional push outbox, recipient preferences and bounded background delivery. + +No message bodies, display names, FCM tokens or credentials are stored in the outbox. +""" +from datetime import timedelta +import hashlib +import logging +import os +import threading +from urllib.parse import quote +from uuid import uuid4 + +from fastapi import Form, Request +from fastapi.responses import RedirectResponse +from i18n import ENGLISH, current_language + +logger = logging.getLogger(__name__) +KINDS = ('friend_request', 'direct_message', 'event_invitation') +TEXT = { + 'friend_request': ('Neue Freundschaftsanfrage', 'Du hast eine neue Freundschaftsanfrage.'), + 'direct_message': ('Neue Nachricht', 'Du hast eine neue Nachricht.'), + 'event_invitation': ('Neue Veranstaltungseinladung', 'Du wurdest zu einer Veranstaltung eingeladen.'), +} + + +def enabled(): + return os.environ.get('PUSH_ENABLED', 'false').lower() in ('1', 'true', 'yes') + + +def save_language(db, user_id, language): + db.execute('''INSERT INTO notification_preferences(user_id,language) VALUES (%s,%s) + ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language''', + (user_id, language if language in ('de', 'en') else 'de')) + + +def preferences(db, user_id): + row = db.execute('SELECT language,friend_request,direct_message,event_invitation ' + 'FROM notification_preferences WHERE user_id=%s', (user_id,)).fetchone() + return dict(zip(('language', *KINDS), row or ('de', True, True, True))) + + +def enqueue(cursor, kind, actor_id, recipient_id, object_id, event_key=None): + """Called inside the domain write transaction; disabled means no backlog accumulation.""" + if not enabled() or actor_id == recipient_id: + return + if kind not in KINDS: + raise ValueError('Unknown notification kind') + cursor.execute('SELECT friend_request,direct_message,event_invitation FROM notification_preferences WHERE user_id=%s', + (recipient_id,)) + pref = cursor.fetchone() + if pref and not pref[KINDS.index(kind)]: + return + if kind == 'event_invitation': + # A removed and subsequently re-added invitation replaces its previous pending delivery. + cursor.execute("UPDATE push_notifications SET state='dropped' WHERE kind='event_invitation' " + "AND object_id=%s AND recipient_id=%s AND state='pending'", (object_id, recipient_id)) + cursor.execute('''SELECT p.id,p.session_id,p.token FROM push_devices p + JOIN sessions s ON s.id=p.session_id + WHERE p.user_id=%s AND s.user_id=%s AND s.expires_at>CURRENT_TIMESTAMP + AND p.last_seen_at>CURRENT_TIMESTAMP-INTERVAL '90 days' ''', (recipient_id, recipient_id)) + for device_id, session_id, token in cursor.fetchall(): + cursor.execute('''INSERT INTO push_notifications + (id,device_id,session_id,recipient_id,actor_id,kind,object_id,event_key,token_hash) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(device_id,event_key) DO NOTHING''', + (uuid4(), device_id, session_id, recipient_id, actor_id, kind, object_id, + event_key or f'{kind}:{object_id}', hashlib.sha256(token.encode()).hexdigest())) + + +def destination(db, kind, actor_id, recipient_id, object_id): + """Recheck current authorization and event state immediately before sending/opening.""" + if db.execute('''SELECT 1 FROM user_blocks WHERE + (blocker_id=%s AND blocked_id=%s) OR (blocker_id=%s AND blocked_id=%s)''', + (actor_id, recipient_id, recipient_id, actor_id)).fetchone(): + return None + actor = db.execute('SELECT username,is_admin FROM users WHERE id=%s', (actor_id,)).fetchone() + recipient = db.execute('SELECT is_admin FROM users WHERE id=%s', (recipient_id,)).fetchone() + if not actor or not recipient: + return None + if kind == 'friend_request': + valid = db.execute("SELECT 1 FROM friendships WHERE id=%s AND requester_id=%s AND addressee_id=%s AND status='pending'", + (object_id, actor_id, recipient_id)).fetchone() + return '/users/' + quote(actor[0], safe='') if valid else None + if kind == 'direct_message': + valid = db.execute('SELECT 1 FROM direct_messages WHERE id=%s AND sender_id=%s AND recipient_id=%s AND read_at IS NULL', + (object_id, actor_id, recipient_id)).fetchone() + allowed = actor[1] or recipient[0] or db.execute("""SELECT 1 FROM friendships WHERE status='accepted' + AND ((requester_id=%s AND addressee_id=%s) OR (requester_id=%s AND addressee_id=%s))""", + (actor_id, recipient_id, recipient_id, actor_id)).fetchone() + return '/messages/' + quote(actor[0], safe='') + '#latest' if valid and allowed else None + if kind == 'event_invitation': + valid = db.execute('''SELECT 1 FROM event_invitations i JOIN concerts c ON c.id=i.concert_id + WHERE i.concert_id=%s AND i.user_id=%s AND i.invited_by=%s AND i.viewed_at IS NULL + AND (c.visibility IN ('public','private') OR c.created_by=%s OR %s OR EXISTS ( + SELECT 1 FROM friendships f WHERE f.status='accepted' AND + ((f.requester_id=c.created_by AND f.addressee_id=%s) OR + (f.addressee_id=c.created_by AND f.requester_id=%s))))''', + (object_id, recipient_id, actor_id, recipient_id, recipient[0], recipient_id, recipient_id)).fetchone() + return '/concerts/' + str(object_id) if valid else None + return None + + +class DeliveryError(Exception): + def __init__(self, code): + self.code = code + super().__init__(code) + + +class FirebaseSender: + def __init__(self): + self.app = None + + def send(self, token, title, body, data, tag): + # Lazy import/init: missing credentials never prevent the core app from starting. + try: + import firebase_admin + from firebase_admin import credentials, messaging + if self.app is None: + path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', '') + project = os.environ.get('FIREBASE_PROJECT_ID', '') + if not path or not project: + raise DeliveryError('configuration') + credential = credentials.Certificate(path) + if credential.project_id != project: + raise DeliveryError('configuration') + self.app = firebase_admin.initialize_app(credential, {'projectId': project, 'httpTimeout': 10}, + name='metalcircle-push-' + uuid4().hex) + messaging.send(messaging.Message(token=token, notification=messaging.Notification(title=title, body=body), + data=data, android=messaging.AndroidConfig(priority='high', ttl=timedelta(minutes=5), + notification=messaging.AndroidNotification(tag=tag, icon='ic_notification', + sound='default', visibility='private'))), app=self.app) + except DeliveryError: + raise + except Exception as error: + # Never log SDK errors: they may contain requests, credentials or FCM tokens. + code = getattr(error, 'code', '') + name = type(error).__name__ + if name == 'UnregisteredError': + raise DeliveryError('unregistered') from None + if code in ('UNAVAILABLE', 'INTERNAL', 'DEADLINE_EXCEEDED', 'RESOURCE_EXHAUSTED'): + raise DeliveryError('transient') from None + if isinstance(error, (OSError, ValueError, ImportError)) or code in ('UNAUTHENTICATED', 'PERMISSION_DENIED'): + raise DeliveryError('configuration') from None + raise DeliveryError('permanent') from None + + +class PushWorker: + def __init__(self, get_db, sender=None): + self.get_db = get_db + self.sender = sender or FirebaseSender() + self.stop_event = threading.Event() + self.thread = None + + def start(self): + if enabled(): + self.thread = threading.Thread(target=self.run, name='metalcircle-push', daemon=True) + self.thread.start() + + def stop(self): + self.stop_event.set() + if self.thread: + self.thread.join(timeout=15) + + def run(self): + while not self.stop_event.is_set(): + try: + busy = self.deliver_one() + except Exception: + logger.warning('Push worker: database_or_processing_failure') + busy = False + self.stop_event.wait(0.1 if busy else 3) + + def deliver_one(self): + with self.get_db() as db: + db.execute("DELETE FROM push_notifications WHERE created_atCURRENT_TIMESTAMP FROM push_notifications WHERE state='pending' + AND available_at<=CURRENT_TIMESTAMP ORDER BY available_at LIMIT 1 FOR UPDATE SKIP LOCKED''').fetchone() + if not row: + return False + job, device_id, session_id, recipient, actor, kind, obj, token_hash, attempts, fresh = row + # Match registration's lock order. NOWAIT avoids a deadlock with session deletion's cascade. + session = db.execute('SELECT token_hash FROM sessions WHERE id=%s AND user_id=%s ' + 'AND expires_at>CURRENT_TIMESTAMP FOR UPDATE NOWAIT', (session_id, recipient)).fetchone() + device = db.execute('SELECT token FROM push_devices WHERE id=%s AND session_id=%s AND user_id=%s ' + 'FOR UPDATE NOWAIT', (device_id, session_id, recipient)).fetchone() + prefs = preferences(db, recipient) + target = destination(db, kind, actor, recipient, obj) if fresh and prefs[kind] else None + if not session or not device or not target or hashlib.sha256(device[0].encode()).hexdigest() != token_hash: + db.execute("UPDATE push_notifications SET state='dropped' WHERE id=%s", (job,)) + return True + title, body = TEXT[kind] + if prefs['language'] == 'en': + title, body = ENGLISH.get(title, title), ENGLISH.get(body, body) + try: + self.sender.send(device[0], title, body, + {'notification_id': str(job), 'session_tag': session[0]}, str(job)) + except DeliveryError as error: + logger.warning('Push delivery failed: %s', error.code) + if error.code == 'unregistered': + db.execute('DELETE FROM push_devices WHERE id=%s AND token=%s', (device_id, device[0])) + elif error.code in ('transient', 'configuration') and attempts < 3: + db.execute("UPDATE push_notifications SET attempts=attempts+1, available_at=CURRENT_TIMESTAMP + %s * INTERVAL '1 second' WHERE id=%s", + (60 * 2 ** attempts, job)) + else: + db.execute("UPDATE push_notifications SET state='failed',attempts=attempts+1 WHERE id=%s", (job,)) + else: + db.execute("UPDATE push_notifications SET state='sent',attempts=attempts+1 WHERE id=%s", (job,)) + return True + + +def register_routes(app, get_db, get_user, cookie_name): + @app.post('/profile/notifications') + def update_preferences(request: Request, friend_request: bool = Form(False), + direct_message: bool = Form(False), event_invitation: bool = Form(False)): + user = get_user(request) + with get_db() as db: + save_language(db, user['id'], current_language.get()) + db.execute('''UPDATE notification_preferences SET friend_request=%s,direct_message=%s,event_invitation=%s + WHERE user_id=%s''', (friend_request, direct_message, event_invitation, user['id'])) + # Turning a category off cancels pending notifications, even if quickly enabled again. + disabled = [kind for kind, value in zip(KINDS, (friend_request, direct_message, event_invitation)) if not value] + db.execute("UPDATE push_notifications SET state='dropped' WHERE recipient_id=%s AND state='pending' AND kind=ANY(%s)", + (user['id'], disabled)) + return RedirectResponse('/profile?saved=notifications#notification-settings', status_code=303) + + @app.get('/notifications/{identifier}') + def open_notification(request: Request, identifier: str): + from uuid import UUID + try: + identifier = UUID(identifier) + except ValueError: + return RedirectResponse('/', status_code=303) + user = get_user(request) + with get_db() as db: + row = db.execute('''SELECT kind,actor_id,object_id FROM push_notifications n JOIN sessions s ON s.id=n.session_id + WHERE n.id=%s AND n.recipient_id=%s AND s.token_hash=%s AND s.expires_at>CURRENT_TIMESTAMP''', + (identifier, user['id'], hashlib.sha256(request.cookies.get(cookie_name, '').encode()).hexdigest())).fetchone() + target = destination(db, row[0], row[1], user['id'], row[2]) if row else None + return RedirectResponse(target or '/', status_code=303) diff --git a/app/push_devices.py b/app/push_devices.py index 4e791b8..182be90 100644 --- a/app/push_devices.py +++ b/app/push_devices.py @@ -1,4 +1,4 @@ -"""Device registration only. This module deliberately contains no push sender.""" +"""Device registration. Background sending lives in notifications.py.""" import hashlib from uuid import UUID @@ -6,6 +6,8 @@ from uuid import UUID from fastapi import Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field +from i18n import current_language +from notifications import save_language class DeviceRegistration(BaseModel): @@ -59,6 +61,7 @@ def register_routes(app, get_db, get_user, cookie_name): RETURNING id ''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version)) device_id = cursor.fetchone()[0] + save_language(connection, user['id'], current_language.get()) cursor.execute('DELETE FROM push_devices WHERE session_id IN ' '(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) ' 'OR last_seen_at + + + + + ALPHA + TESTER + diff --git a/app/static/images/patch-early-bird.svg b/app/static/images/patch-early-bird.svg new file mode 100644 index 0000000..5a36178 --- /dev/null +++ b/app/static/images/patch-early-bird.svg @@ -0,0 +1,8 @@ + + + + + + EARLY BIRD + METALCIRCLE + diff --git a/app/static/js/native-push.js b/app/static/js/native-push.js index d9500dd..040ea8c 100644 --- a/app/static/js/native-push.js +++ b/app/static/js/native-push.js @@ -15,6 +15,17 @@ let sendQueue = Promise.resolve(); let panel; + async function notificationTarget(notification) { + const data = notification?.data || {}; + if (!/^[a-f0-9-]{36}$/.test(data.notification_id || '') || !/^[a-f0-9]{64}$/.test(data.session_tag || '')) return null; + const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store'}); + if (!response.ok || stopped) return null; + const session = await response.json(); + const native = await device.getInfo(); + if (!session.authenticated || session.session_tag !== data.session_tag || native.binding !== data.session_tag) return null; + return '/notifications/' + data.notification_id; + } + function notice(message, offerPermission = false) { if (!panel) { panel = document.createElement('section'); @@ -130,9 +141,29 @@ }); }), push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }), - push.addListener('pushNotificationActionPerformed', () => { - // Do not navigate to arbitrary URLs supplied by a notification payload. - location.assign('/'); + push.addListener('pushNotificationActionPerformed', async event => { + try { + const target = await notificationTarget(event.notification); + if (target) location.assign(target); + } catch (_) { /* A tap never bypasses current-session authorization. */ } + }), + push.addListener('pushNotificationReceived', async notification => { + try { + const target = await notificationTarget(notification); + if (!target) return; + document.getElementById('push-in-app-notice')?.remove(); + const banner = document.createElement('section'); + banner.id = 'push-in-app-notice'; + banner.className = 'native-push-panel'; + banner.setAttribute('role', 'status'); + const link = document.createElement('a'); + link.className = 'button'; + link.href = target; + // Generic localized text; never insert remote HTML or private message content. + link.textContent = texts.openNotification; + banner.append(link); + (document.querySelector('main') || document.body).prepend(banner); + } catch (_) { /* Push reception cannot interrupt use of the app. */ } }) ]).then(() => { synchronize(); diff --git a/app/templates/_language_switch.html b/app/templates/_language_switch.html index 4c05ceb..f43297d 100644 --- a/app/templates/_language_switch.html +++ b/app/templates/_language_switch.html @@ -1,4 +1,4 @@ diff --git a/app/templates/_native_push.html b/app/templates/_native_push.html index d6f0eb2..e4118e7 100644 --- a/app/templates/_native_push.html +++ b/app/templates/_native_push.html @@ -1,4 +1,5 @@ ',data:{notification_id:id,session_tag:tag}}); + assert.equal(app.elements.length,1); + const link=app.elements[0].children[0]; + assert.equal(link.textContent,'Open new notification'); + assert.equal(link.href,'/notifications/'+id); + assert.equal(link.innerHTML,undefined); +}); diff --git a/app/tests/test_feature_api.py b/app/tests/test_feature_api.py index 33bbdce..1d3c269 100644 --- a/app/tests/test_feature_api.py +++ b/app/tests/test_feature_api.py @@ -34,12 +34,16 @@ class FeatureApiTests(unittest.TestCase): with main.get_db_connection() as db: db.execute(''' CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE, - display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE); + display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT UNIQUE, expires_at TIMESTAMP); CREATE TABLE friendships(addressee_id INTEGER, status TEXT); CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP); CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP); + CREATE TABLE user_badges(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(user_id,badge_code)); ''') for statement in FEATURE_SCHEMA: db.execute(statement) cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode() @@ -81,7 +85,8 @@ class FeatureApiTests(unittest.TestCase): def test_migrations_repeat_and_match_startup_schema(self): with main.get_db_connection() as db: - for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql'), FEATURE_SCHEMA): + for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql', + '21_push_notifications.sql', '22_registration_badges.sql'), FEATURE_SCHEMA): source = Path('/test-migrations', name).read_text() normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s)) self.assertEqual(normalize(source), normalize(runtime)) diff --git a/app/tests/test_i18n.py b/app/tests/test_i18n.py index ef08054..83f561a 100644 --- a/app/tests/test_i18n.py +++ b/app/tests/test_i18n.py @@ -69,7 +69,7 @@ class TranslationTests(unittest.TestCase): for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]: page = client.get(path) self.assertIn(title, page.text) - self.assertIn('aria-label="English" aria-current="true"', page.text) + self.assertIn('aria-label="Switch to German"', page.text) response = client.get('/language/de?next=/login') self.assertIn('Anmelden', response.text) self.assertIn('', response.text) @@ -136,7 +136,8 @@ class TranslationTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'Instagram') as error: main.normalize_instagram_url('https://evil.example/test') self.assertIn('Please enter', str(error.exception)) - response = main.change_language('invalid') + from starlette.requests import Request + response = main.change_language(Request({'type': 'http', 'headers': []}), 'invalid') self.assertEqual(response.body, b'Invalid language.') diff --git a/app/tests/test_notifications.py b/app/tests/test_notifications.py new file mode 100644 index 0000000..4038fdd --- /dev/null +++ b/app/tests/test_notifications.py @@ -0,0 +1,334 @@ +"""Local PostgreSQL and simulated Firebase tests. Never contacts Firebase.""" +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +import hashlib +import os +from pathlib import Path +import unittest +from unittest.mock import Mock, patch +from uuid import uuid4 +from zoneinfo import ZoneInfo + +import bcrypt +import psycopg +from psycopg import sql +from psycopg.conninfo import make_conninfo +from fastapi.testclient import TestClient + +import main +from community_badges import cohort, reconcile +from i18n import current_language +from notifications import DeliveryError, FirebaseSender, PushWorker, enqueue, save_language + + +class BadgeAndLanguageTests(unittest.TestCase): + def test_registration_boundaries_and_timezone(self): + with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}): + for value, expected in ( + (datetime(2026, 10, 31, 23, 59, 59), 'alpha_tester'), + (datetime(2026, 11, 1), 'beta_tester'), + (datetime(2026, 12, 31, 23, 59, 59), 'beta_tester'), + (datetime(2027, 1, 1), 'early_bird'), + (datetime(2026, 10, 31, 23, tzinfo=ZoneInfo('UTC')), 'beta_tester')): + self.assertEqual(cohort(value), expected) + + def test_configurable_dates_and_invalid_order(self): + with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30', 'BETA_TESTER_UNTIL':'2027-01-31'}): + self.assertEqual(cohort(datetime(2026, 11, 15)), 'alpha_tester') + self.assertEqual(cohort(datetime(2027, 1, 1)), 'beta_tester') + with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2027-02-01', 'BETA_TESTER_UNTIL':'2027-01-31'}): + with self.assertRaises(ValueError): cohort(datetime(2026, 1, 1)) + + def test_one_language_link_targets_opposite_language(self): + import re + for language, target in [('de', 'en'), ('en', 'de')]: + token = current_language.set(language) + try: + html = main.templates.get_template('_language_switch.html').render() + finally: + current_language.reset(token) + self.assertEqual(len(re.findall(r'' + target.upper() + '', html) + + def test_sender_missing_credentials_is_safe(self): + with patch.dict(os.environ, {'GOOGLE_APPLICATION_CREDENTIALS':'', 'FIREBASE_PROJECT_ID':''}): + with self.assertRaisesRegex(DeliveryError, '^configuration$'): + FirebaseSender().send('secret-token', 'title', 'body', {}, 'tag') + + def test_sdk_payload_errors_and_no_credentials_in_payload(self): + from firebase_admin import messaging, exceptions + sender = FirebaseSender() + sender.app = object() + with patch.object(messaging, 'send') as send: + sender.send('synthetic-token', 'New message', 'You have a new message.', + {'notification_id':str(uuid4()), 'session_tag':'a'*64}, 'tag') + payload = send.call_args.args[0] + self.assertEqual(payload.android.notification.visibility, 'private') + self.assertEqual(payload.android.ttl.total_seconds(), 300) + self.assertEqual(payload.notification.body, 'You have a new message.') + for failure, expected in ((messaging.UnregisteredError('sensitive'), 'unregistered'), + (exceptions.UnavailableError('sensitive'), 'transient'), + (exceptions.PermissionDeniedError('sensitive'), 'configuration')): + with patch.object(messaging, 'send', side_effect=failure): + with self.assertRaisesRegex(DeliveryError, '^' + expected + '$'): + sender.send('secret-token', 'title', 'body', {}, 'tag') + + +@unittest.skipUnless(os.environ.get('METALCIRCLE_TEST_DATABASE') == '1', 'explicit local DB opt-in required') +class NotificationDatabaseTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.original_dsn = main.DATABASE_URL + cls.schema = 'metalcircle_push_test_' + uuid4().hex + with psycopg.connect(cls.original_dsn) as db: + db.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(cls.schema))) + main.DATABASE_URL = make_conninfo(cls.original_dsn, options='-csearch_path='+cls.schema) + with main.get_db_connection() as db: + db.execute(Path('/test-init/01_initial.sql').read_text()) + with patch.object(main, 'INITIAL_ADMIN_USERNAME', None): main.ensure_schema() + cls.password_hash = bcrypt.hashpw(b'Push-local-test-123', bcrypt.gensalt()).decode() + + @classmethod + def tearDownClass(cls): + main.DATABASE_URL = cls.original_dsn + with psycopg.connect(cls.original_dsn) as db: + db.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(cls.schema))) + + def setUp(self): + self.flags = patch.dict(os.environ, {'PUSH_ENABLED':'true', 'ALPHA_TESTER_UNTIL':'2026-10-31', 'BETA_TESTER_UNTIL':'2026-12-31'}) + self.flags.start() + self.secure = patch.object(main, 'COOKIE_SECURE', False) + self.secure.start() + main.rate_limit_buckets.clear() + with main.get_db_connection() as db: + db.execute('TRUNCATE users,concerts RESTART IDENTITY CASCADE') + for name in ('sender', 'recipient', 'outsider'): + db.execute('INSERT INTO users(username,email,password_hash,created_at) VALUES (%s,%s,%s,%s)', + (name, name+'@example.invalid', self.password_hash, datetime(2026, 9, 1))) + self.clients = [] + for name in ('sender', 'recipient', 'outsider'): + client = TestClient(main.app) + client.headers['Origin'] = 'http://testserver' + self.assertEqual(client.post('/login', data={'username':name, 'password':'Push-local-test-123'}, follow_redirects=False).status_code, 303) + self.clients.append(client) + self.device = dict(device_id=str(uuid4()), token='synthetic-fcm-token-'+'x'*120, platform='android', + session_tag=self.clients[1].get('/api/push/session').json()['session_tag']) + self.assertEqual(self.clients[1].post('/api/push/devices', json=self.device).status_code, 200) + self.sender = Mock() + self.worker = PushWorker(main.get_db_connection, self.sender) + + def tearDown(self): + for client in self.clients: client.close() + self.secure.stop() + self.flags.stop() + + def scalar(self, query, args=()): + with main.get_db_connection() as db: return db.execute(query, args).fetchone()[0] + + def friendship(self): + result = self.clients[0].post('/users/recipient/friend-request', follow_redirects=False) + self.assertEqual(result.status_code, 303) + + def message(self): + with main.get_db_connection() as db: + db.execute("INSERT INTO friendships(requester_id,addressee_id,status) VALUES (1,2,'accepted') ON CONFLICT DO NOTHING") + result = self.clients[0].post('/messages/recipient', data={'body':'PRIVATE message content'}, follow_redirects=False) + self.assertEqual(result.status_code, 303) + + def invitation(self): + result = self.clients[0].post('/concerts', data={'artist':'Private test event', 'start_datetime':'2027-04-01T20:00', + 'event_type':'other','visibility':'private','invited_user_ids':'2'}, follow_redirects=False) + self.assertEqual(result.status_code, 303) + return int(result.headers['location'].rsplit('/', 1)[1]) + + def test_friend_event_deduplicated_and_english_recipient(self): + self.clients[1].get('/language/en', follow_redirects=False) + self.friendship() + self.friendship() + self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 1) + self.assertTrue(self.worker.deliver_one()) + args = self.sender.send.call_args.args + self.assertEqual(args[1], 'New friend request') + self.assertNotIn('PRIVATE', str(args)) + target = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) + self.assertEqual(target.headers['location'], '/users/sender') + denied = self.clients[2].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) + self.assertEqual(denied.headers['location'], '/') + + def test_message_route_and_private_content(self): + self.message() + self.worker.deliver_one() + args = self.sender.send.call_args.args + self.assertEqual(args[1:3], ('Neue Nachricht', 'Du hast eine neue Nachricht.')) + self.assertNotIn('PRIVATE message content', str(args)) + response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) + self.assertEqual(response.headers['location'], '/messages/sender#latest') + + def test_invitation_route_and_removed_invitation(self): + concert = self.invitation() + self.worker.deliver_one() + args = self.sender.send.call_args.args + response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) + self.assertEqual(response.headers['location'], '/concerts/'+str(concert)) + with main.get_db_connection() as db: db.execute('DELETE FROM event_invitations') + response = self.clients[1].get('/notifications/'+args[3]['notification_id'], follow_redirects=False) + self.assertEqual(response.headers['location'], '/') + + def test_disabled_sender_has_no_backlog(self): + with patch.dict(os.environ, {'PUSH_ENABLED':'false'}): self.friendship() + self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0) + + def test_invitation_edit_only_notifies_new_invitees(self): + concert = self.invitation() + form = {'artist':'Private test event','start_datetime':'2027-04-01T20:00', + 'event_type':'other','visibility':'private','invited_user_ids':'2'} + result = self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) + self.assertEqual(result.status_code, 303) + self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1) + form.pop('invited_user_ids') + self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) + form['invited_user_ids'] = '2' + self.clients[0].post(f'/concerts/{concert}/edit', data=form, follow_redirects=False) + self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='pending'"), 1) + + def test_profile_preferences_and_alpha_render_in_both_languages(self): + for language, label in [('de', 'Push-Benachrichtigungen'), ('en', 'Push notifications')]: + self.clients[1].get('/language/'+language, follow_redirects=False) + response = self.clients[1].get('/profile') + self.assertEqual(response.status_code, 200) + self.assertIn(label, response.text) + self.assertIn('patch-alpha-tester.svg', response.text) + self.assertNotIn('id="patch-beta_tester"', response.text) + exported = self.clients[1].get('/profile/export') + self.assertEqual(exported.status_code, 200) + self.assertEqual(exported.json()['notification_preferences']['language'], language) + + def test_enqueue_rolls_back_with_domain_transaction(self): + try: + with main.get_db_connection() as db: + request_id = db.execute('INSERT INTO friendships(requester_id,addressee_id) VALUES (1,2) RETURNING id').fetchone()[0] + enqueue(db.cursor(), 'friend_request', 1, 2, request_id) + raise RuntimeError('simulated rollback') + except RuntimeError: + pass + self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0) + self.assertEqual(self.scalar('SELECT count(*) FROM friendships'), 0) + + def test_preference_opt_out_and_csrf_auth(self): + unauth = TestClient(main.app) + self.assertEqual(unauth.post('/profile/notifications', follow_redirects=False).status_code, 303) + self.assertEqual(self.clients[1].post('/profile/notifications', headers={'Origin':'http://evil.invalid'}).status_code, 403) + self.friendship() + result = self.clients[1].post('/profile/notifications', data={'direct_message':'true'}, follow_redirects=False) + self.assertEqual(result.status_code, 303) + self.assertEqual(self.scalar("SELECT count(*) FROM push_notifications WHERE state='dropped'"), 1) + self.assertFalse(self.worker.deliver_one()) + self.sender.send.assert_not_called() + + def test_disabled_category_never_enqueues(self): + self.clients[1].post('/profile/notifications', data={}, follow_redirects=False) + self.friendship() + self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0) + + def test_logout_cancels_queue_and_switch_does_not_receive_old_push(self): + self.friendship() + self.clients[1].post('/logout', follow_redirects=False) + self.assertEqual(self.scalar('SELECT count(*) FROM push_notifications'), 0) + self.assertFalse(self.worker.deliver_one()) + self.sender.send.assert_not_called() + + def test_token_rotation_invalidates_old_delivery(self): + self.friendship() + self.device['token'] = 'synthetic-fcm-token-'+'y'*120 + self.clients[1].post('/api/push/devices', json=self.device) + self.worker.deliver_one() + self.sender.send.assert_not_called() + self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'dropped') + + def test_multiple_devices_each_receive_once(self): + another = dict(self.device, device_id=str(uuid4()), token='synthetic-fcm-token-'+'z'*120) + self.clients[1].post('/api/push/devices', json=another) + self.friendship() + self.worker.deliver_one() + self.worker.deliver_one() + self.assertEqual(self.sender.send.call_count, 2) + self.assertFalse(self.worker.deliver_one()) + + def test_read_message_block_and_removed_friendship_drop_pending(self): + for operation in ('read', 'block', 'unfriend'): + with self.subTest(operation=operation): + self.message() + with main.get_db_connection() as db: + if operation == 'read': db.execute('UPDATE direct_messages SET read_at=CURRENT_TIMESTAMP') + elif operation == 'block': db.execute('INSERT INTO user_blocks(blocker_id,blocked_id) VALUES (2,1)') + else: db.execute('DELETE FROM friendships') + self.worker.deliver_one() + self.sender.send.assert_not_called() + with main.get_db_connection() as db: db.execute('DELETE FROM user_blocks') + + def test_retry_limit_and_safe_logging(self): + self.friendship() + self.sender.send.side_effect = DeliveryError('transient') + with self.assertLogs('notifications', 'WARNING') as logs: + for attempt in range(4): + self.worker.deliver_one() + with main.get_db_connection() as db: + db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP') + self.assertNotIn(self.device['token'], str(logs.output)) + self.assertEqual(self.scalar('SELECT attempts FROM push_notifications'), 4) + self.assertEqual(self.scalar('SELECT state FROM push_notifications'), 'failed') + self.assertEqual(self.clients[1].get('/impressum').status_code, 200) + + def test_unregistered_removes_device_but_configuration_error_does_not(self): + self.friendship() + self.sender.send.side_effect = DeliveryError('configuration') + self.worker.deliver_one() + self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 1) + with main.get_db_connection() as db: db.execute('UPDATE push_notifications SET available_at=CURRENT_TIMESTAMP') + self.sender.send.side_effect = DeliveryError('unregistered') + self.worker.deliver_one() + self.assertEqual(self.scalar('SELECT count(*) FROM push_devices'), 0) + + def test_expired_job_and_expired_session_are_dropped(self): + self.friendship() + with main.get_db_connection() as db: db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second'") + self.worker.deliver_one() + self.sender.send.assert_not_called() + with main.get_db_connection() as db: + db.execute("UPDATE push_notifications SET expires_at=CURRENT_TIMESTAMP+INTERVAL '1 hour',state='pending'") + db.execute("UPDATE sessions SET expires_at=CURRENT_TIMESTAMP-INTERVAL '1 second' WHERE user_id=2") + self.worker.deliver_one() + self.sender.send.assert_not_called() + + def test_two_workers_do_not_send_same_job_twice(self): + self.friendship() + workers = [PushWorker(main.get_db_connection, self.sender) for _ in range(2)] + with ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(lambda worker: worker.deliver_one(), workers)) + self.assertEqual(self.sender.send.call_count, 1) + + def test_backfill_is_exclusive_idempotent_and_reconfigurable(self): + with main.get_db_connection() as db: + db.execute("INSERT INTO user_badges(user_id,badge_code) VALUES (1,'beta_tester')") + db.execute("UPDATE users SET created_at='2026-11-01' WHERE id=2") + db.execute("UPDATE users SET created_at='2027-01-01' WHERE id=3") + reconcile(db.cursor()) + reconcile(db.cursor()) + rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall() + self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')]) + with patch.dict(os.environ, {'ALPHA_TESTER_UNTIL':'2026-11-30'}): reconcile(db.cursor()) + self.assertEqual(db.execute('SELECT badge_code FROM user_badges WHERE user_id=2').fetchone()[0], 'alpha_tester') + + def test_db_utc_registration_respects_berlin_midnight(self): + with main.get_db_connection() as db: + db.execute("SET LOCAL TIME ZONE 'UTC'") + db.execute("UPDATE users SET created_at='2026-10-31 22:59:59' WHERE id=1") + db.execute("UPDATE users SET created_at='2026-10-31 23:00:00' WHERE id=2") + db.execute("UPDATE users SET created_at='2026-12-31 23:00:00' WHERE id=3") + reconcile(db.cursor()) + rows = db.execute('SELECT user_id,badge_code FROM user_badges ORDER BY user_id').fetchall() + self.assertEqual(rows, [(1,'alpha_tester'),(2,'beta_tester'),(3,'early_bird')]) + + +if __name__ == '__main__': unittest.main() diff --git a/compose.push.yml b/compose.push.yml new file mode 100644 index 0000000..aaeda4a --- /dev/null +++ b/compose.push.yml @@ -0,0 +1,12 @@ +# Optional server-only credentials mount. Combine with compose.yml after setup. +services: + web: + environment: + GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/firebase-service-account.json + volumes: + - type: bind + source: ${FIREBASE_SERVICE_ACCOUNT_FILE:?Set an absolute path to the test Firebase service account JSON} + target: /run/secrets/firebase-service-account.json + read_only: true + bind: + create_host_path: false diff --git a/compose.yml b/compose.yml index 4a0fd37..7ed559e 100644 --- a/compose.yml +++ b/compose.yml @@ -34,6 +34,10 @@ services: GITEA_TOKEN: ${GITEA_TOKEN:-} GITEA_OWNER: ${GITEA_OWNER:-} GITEA_REPO: ${GITEA_REPO:-} + PUSH_ENABLED: ${PUSH_ENABLED:-false} + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-} + ALPHA_TESTER_UNTIL: ${ALPHA_TESTER_UNTIL:-2026-10-31} + BETA_TESTER_UNTIL: ${BETA_TESTER_UNTIL:-2026-12-31} volumes: - concert_uploads:/app/static/uploads diff --git a/db/migrations/21_push_notifications.sql b/db/migrations/21_push_notifications.sql new file mode 100644 index 0000000..8703955 --- /dev/null +++ b/db/migrations/21_push_notifications.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS notification_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + language VARCHAR(2) NOT NULL DEFAULT 'de' CHECK (language IN ('de', 'en')), + friend_request BOOLEAN NOT NULL DEFAULT TRUE, + direct_message BOOLEAN NOT NULL DEFAULT TRUE, + event_invitation BOOLEAN NOT NULL DEFAULT TRUE +); +CREATE TABLE IF NOT EXISTS push_notifications ( + id UUID PRIMARY KEY, + device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(32) NOT NULL CHECK (kind IN ('friend_request', 'direct_message', 'event_invitation')), + object_id BIGINT NOT NULL, + event_key TEXT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + state VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'sent', 'dropped', 'failed')), + attempts SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 hour', + available_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(device_id, event_key) +); +CREATE INDEX IF NOT EXISTS idx_push_notifications_pending + ON push_notifications(available_at) WHERE state='pending'; diff --git a/db/migrations/22_registration_badges.sql b/db/migrations/22_registration_badges.sql new file mode 100644 index 0000000..d8aa643 --- /dev/null +++ b/db/migrations/22_registration_badges.sql @@ -0,0 +1,3 @@ +-- Assignment/backfill uses configurable dates in community_badges.reconcile at startup. +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_registration_cohort + ON user_badges(user_id) WHERE badge_code IN ('alpha_tester', 'beta_tester', 'early_bird'); diff --git a/docs/wiki/Android-App.md b/docs/wiki/Android-App.md index fcf9470..096ffa3 100644 --- a/docs/wiki/Android-App.md +++ b/docs/wiki/Android-App.md @@ -4,4 +4,6 @@ Die Android-App ist ein Capacitor-Wrapper der Web-App. Die Package ID bleibt dau Versionen im Repository: Capacitor 6.2.1, Push Notifications 6.0.5, Android Gradle Plugin 8.2.1, Gradle 8.2.1 und Firebase Messaging 23.3.1. Der übliche Ablauf ist `npm install`, `npm run sync` und `npm run build` im Verzeichnis `android`. Das Android-Projekt kann unter `android/android` in Android Studio geöffnet werden. -Die lokale `google-services.json` ist für den Firebase-Build erforderlich, wird aber ignoriert und nie eingecheckt. FCM-Registrierung, Berechtigungsdialog, Session-Bindung und Debug-Token-Anzeige sind implementiert. Ein serverseitiger Versand von Push-Nachrichten ist noch nicht implementiert. +Die lokale `google-services.json` ist für den Firebase-Build erforderlich, wird aber ignoriert und nie eingecheckt. FCM-Registrierung, Berechtigungsdialog, Session-Bindung und Debug-Token-Anzeige sind implementiert. Automatischer Backend-Versand ist nach [Firebase-Einrichtung](Firebase.md) aktivierbar. Die per WebView geladene JavaScript-Datei verarbeitet Antippen und Vordergrundhinweise; für diese Erweiterung ist keine neue native APK nötig. + +Ein Release-APK muss vor Installation signiert werden. `assembleRelease` erzeugt ohne Signing-Konfiguration eine **nicht installierbare** `app-release-unsigned.apk`. Für einen Test kann der lokale Debug-Keystore signieren; dies ist keine Produktionssignatur. Mit `apksigner verify` prüfen, nie Schlüssel ins Repository übernehmen. diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 286b086..0c75532 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -8,9 +8,11 @@ flowchart TD FastAPI --> Uploads[Docker Volumes: Uploads] FastAPI --> External[Nominatim / externe Dienste] FastAPI --> Gitea[Gitea REST API] + FastAPI --> Worker[Push-Worker / DB-Warteschlange] + Worker --> FCM Android --> FCM[Firebase Cloud Messaging] ``` Das Backend in `app/main.py` rendert Jinja2-Templates und liefert CSS und JavaScript aus `app/static`. Authentifizierung basiert auf serverseitigen Sessions; der Browser bzw. die Android-WebView spricht ausschließlich mit MetalCircle. PostgreSQL enthält Benutzer, Veranstaltungen, Venues, Community-Daten, Diary, Patches und Push-Geräte. -Dateien werden in den Compose-Volumes `concert_uploads` und `private_uploads` gehalten. Die Android-App ist ein Capacitor-Wrapper derselben Webanwendung. Firebase wird derzeit für Android-FCM-Registrierung genutzt; ein serverseitiger Push-Versand ist noch nicht aktiviert. Der Bugreporter ist die einzige Backend-Komponente mit Gitea-Zugriff. +Dateien werden in den Compose-Volumes `concert_uploads` und `private_uploads` gehalten. Die Android-App ist ein Capacitor-Wrapper derselben Webanwendung. Ein optionaler Worker im FastAPI-Prozess sendet FCM-Nachrichten aus einer PostgreSQL-Warteschlange. Bis zur Zugangseinrichtung ist er standardmäßig deaktiviert. Der Bugreporter ist die einzige Backend-Komponente mit Gitea-Zugriff. diff --git a/docs/wiki/Badges-and-Patches.md b/docs/wiki/Badges-and-Patches.md index bf52ede..21ba8f0 100644 --- a/docs/wiki/Badges-and-Patches.md +++ b/docs/wiki/Badges-and-Patches.md @@ -10,6 +10,20 @@ Attendance-Patches werden als Upgrade-System vergeben: | 50 | 50 Gigs | | 100 | 100 Gigs | -Im Profil wird nur der höchste erreichte Attendance-Patch sichtbar; der höhere ersetzt die niedrigeren Stufen. Weitere definierte Patches sind Gründer/Capt’n, Pit Wächter, Capt’n’s Mate, Border Breaker, Globe Banger und Alpha Tester. Zusätzlich existieren venue- und ereignisbezogene Auszeichnungen im Code. +Im Profil wird nur der höchste erreichte Attendance-Patch sichtbar; der höhere ersetzt die niedrigeren Stufen. Weitere definierte Patches sind Gründer, Admin, Captns Mate, Border Breaker und Globe Banger. Zusätzlich existieren venue- und ereignisbezogene Auszeichnungen im Code. + +## Exklusive Registrierungs-Patches + +| Rang | Registrierungsdatum (Europe/Berlin) | Patch | +|---:|---|---| +| 1 | bis einschließlich 31.10.2026 | Alpha Tester | +| 2 | 01.11.2026 bis einschließlich 31.12.2026 | Beta Tester | +| 3 | ab 01.01.2027 | Early Bird | + +Entscheidend ist das ursprüngliche Registrierungsdatum. Alpha-Mitglieder bekommen keinen Beta- oder Early-Bird-Patch hinzu. Bereits vergebene Beta-Patches früher Mitglieder werden beim Start rückwirkend zu Alpha korrigiert. Ein Datenbankindex verhindert gleichzeitige Registrierungsstufen. + +`ALPHA_TESTER_UNTIL` und `BETA_TESTER_UNTIL` konfigurieren die inklusive letzte Tagesgrenze. Early Bird beginnt am Folgetag der Beta-Grenze und hat aktuell kein Enddatum. Änderungen werden beim nächsten App-Start und bei der Profilberechnung abgeglichen. Historische zeitzonenlose `users.created_at`-Werte werden aus der PostgreSQL-Sitzungszeitzone (lokal UTC) nach Europe/Berlin umgerechnet. Eine spätere Änderung der DB-Zeitzone muss die Interpretation historischer Werte berücksichtigen. Implementierung: `app/community_badges.py`; Migration 22 ergänzt den exklusiven Index. + +Alpha erhält einen goldenen Schild mit Krone und den höchsten visuellen Rang. Beta verwendet den bestehenden Patch mit silbernem Rahmen; Early Bird erhält einen bronzenen Vogel-Schild. Standardgrafiken liegen als SVG unter `app/static/images`; Admin-Uploads haben Vorrang. Die Vergabe wird aus Konzertbesuchen und den jeweiligen Triggern berechnet. Patch-Bilder können Administratoren verwalten und liegen als Uploads. Neue Vergabelogik muss zuerst in den Definitionen und Tests nachvollziehbar ergänzt werden. diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index 7b51f46..6dd8c30 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -13,5 +13,11 @@ | `GITEA_TOKEN` | Token des `metalcircle-bot` | erforderlich, geheim | | `GITEA_OWNER` | Repository-Owner, aktuell `kai` | erforderlich | | `GITEA_REPO` | Repository, aktuell `pingu-concerts` | erforderlich | +| `PUSH_ENABLED` | Hintergrundversand und neue Versandaufträge aktivieren | optional, Standard `false` | +| `FIREBASE_PROJECT_ID` | tatsächliche Firebase-Projekt-ID | bei aktiviertem Versand erforderlich | +| `FIREBASE_SERVICE_ACCOUNT_FILE` | absoluter Host-Pfad zur privaten Service-Account-Datei | für `compose.push.yml` erforderlich | +| `GOOGLE_APPLICATION_CREDENTIALS` | Containerpfad zur Service-Account-Datei | durch `compose.push.yml` gesetzt | +| `ALPHA_TESTER_UNTIL` | inklusive Alpha-Registrierungsgrenze | Standard `2026-10-31` | +| `BETA_TESTER_UNTIL` | inklusive Beta-Registrierungsgrenze, nach Alpha | Standard `2026-12-31` | `.env.example` enthält nur Platzhalter. `.env` wird nie committed. `google-services.json` liegt ausschließlich lokal im Android-App-Modul und wird durch `.gitignore` ausgeschlossen. diff --git a/docs/wiki/Database.md b/docs/wiki/Database.md index 7bb92ab..c65379a 100644 --- a/docs/wiki/Database.md +++ b/docs/wiki/Database.md @@ -11,5 +11,8 @@ Wichtige Beziehungen: - `user_badges` enthält Badges und optionale auslösende Konzerte. - `push_devices` bindet Android-FCM-Tokens an Benutzer und die aktuelle Session. - `bug_report_submissions` enthält ausschließlich kurzlebige Status-/Nonce-Metadaten zur Duplicate-Vermeidung, keine vollständigen Issues. +- `notification_preferences` enthält DE/EN und individuelle Push-Kategorien (Migration 21). +- `push_notifications` enthält transaktionale Aufträge je Gerät/Session, ohne Klartext-Token oder Nachrichteninhalt (Migration 21). Logout löscht sie per Fremdschlüssel; Aufbewahrung siehe [Push Notifications](Push-Notifications.md). +- Migration 22 erlaubt per partiellem Unique-Index nur einen Alpha-/Beta-/Early-Bird-Patch je Nutzer. Die datumsabhängige Rückbefüllung erfolgt konfigurierbar beim App-Start. Primäre Indizes unterstützen Konzertdatum, Venue-Suche, Kommentare, Fotos, Attendance, Nachrichten und Einladungen. Backups und Wiederherstellung der PostgreSQL-Daten sind umgebungsabhängig und werden nicht durch diese Repository-Migrationen automatisiert. diff --git a/docs/wiki/Development-Setup.md b/docs/wiki/Development-Setup.md index e0f9921..bcba193 100644 --- a/docs/wiki/Development-Setup.md +++ b/docs/wiki/Development-Setup.md @@ -8,7 +8,7 @@ Docker/Compose, Git sowie für Android Node.js/npm, JDK 17, Android SDK und ein ```bash cp .env.example .env -docker compose up --build +COOKIE_SECURE=false docker compose up --build docker compose logs -f web docker compose down ``` @@ -19,10 +19,12 @@ Die Web-App läuft auf Port 8080, PostgreSQL im Compose-Netzwerk. Die Datenbank ```bash docker compose run --rm --no-deps -e METALCIRCLE_TEST_DATABASE=1 \ - -v "$PWD/app:/app" -v "$PWD/db/migrations:/test-migrations:ro" \ + -v "$PWD/app:/app" -v "$PWD/db/migrations:/test-migrations:ro" -v "$PWD/db/init:/test-init:ro" \ web python -m unittest discover -s tests -v ``` +Nur gegen die lokale Testdatenbank ausführen: Die Tests erstellen und entfernen isolierte Schemas. Firebase wird simuliert, Service-Account-Dateien sind dafür nicht nötig. Frontend-Prüfung: `node --test app/tests/native_push.test.cjs`. + ## Android lokal ```bash diff --git a/docs/wiki/Firebase-Setup-Prompt.md b/docs/wiki/Firebase-Setup-Prompt.md new file mode 100644 index 0000000..ebdf559 --- /dev/null +++ b/docs/wiki/Firebase-Setup-Prompt.md @@ -0,0 +1,55 @@ +# ChatGPT-Prompt: Firebase-Zugang für MetalCircle + +Den folgenden Prompt in einen neuen Chat kopieren. Keine Secret-Dateien mitgeben. + +```text +Hilf mir Schritt für Schritt, den serverseitigen Firebase-Zugang für MetalCircle +im Entwicklungs-/Testbetrieb einzurichten. Verwende aktuelle offizielle +Firebase-/Google-Cloud-Dokumentation und erkläre mir die Console-Bedienung. + +Ausgangslage: +- Privates Projekt MetalCircle, Repository historisch kai/pingu-concerts. +- Android: Capacitor 6, Package ID dauerhaft de.pinguholic.concerts. +- Firebase-Anzeigename MetalCircle; die tatsächliche Projekt-ID muss ich prüfen. +- Android-google-services.json ist vorhanden. Tokenregistrierung und manuelle + Firebase-Testnachrichten/Kampagnen funktionieren bereits. +- Backend: FastAPI, PostgreSQL, Docker Compose, firebase-admin Python 7.1.0. +- Pushs für Freundschaftsanfragen, Direktnachrichten und Einladungen sind implementiert. + Kategorien und DE/EN werden pro Empfänger beachtet; Nachrichteninhalt bleibt privat. +- Versand ist standardmäßig deaktiviert und wurde mit simuliertem Firebase getestet. + +Bitte begleite mich bei: +1. Auswahl des Projekts und Ermittlung der tatsächlichen Projekt-ID. +2. Prüfung/Aktivierung der Firebase Cloud Messaging API HTTP v1. +3. Einem eigenen Test-Service-Account, z. B. metalcircle-push-test, mit den für FCM + nötigen Rechten. Prüfe roles/firebasecloudmessaging.admin bzw. + cloudmessaging.messages.create. Kein persönliches Admin-Konto verwenden. +4. Erstellung und sicherer Ablage der Service-Account-JSON außerhalb von Repository + und Docker-Buildkontext. Nur der Betreiber soll die Datei lesen können. +5. Lokaler .env-Konfiguration: + PUSH_ENABLED=true + FIREBASE_PROJECT_ID= + FIREBASE_SERVICE_ACCOUNT_FILE= +6. Start mit compose.yml plus compose.push.yml. Dieses Override mountet die Datei + read-only unter /run/secrets/firebase-service-account.json und setzt im Backend + GOOGLE_APPLICATION_CREDENTIALS auf diesen Pfad. + Lokal HTTP: COOKIE_SECURE=false docker compose -f compose.yml -f compose.push.yml up -d --build web + Auf einem HTTPS-Testserver bleibt COOKIE_SECURE=true. +7. End-to-End-Test mit zwei Testkonten: Anfrage, Nachricht, Veranstaltungseinladung, + DE/EN, Kategorien, Antippen, Logout und Benutzerwechsel. + +Grenzen: +- Niemals Schlüssel, Tokens, vollständige .env oder JSON-Inhalte im Chat anfordern. +- google-services.json ist Client-Konfiguration, kein Backend-Service-Account. +- Keine Legacy-Server-Keys und keine Admin-Credentials in der Android-App. +- kai = persönlicher Gitea-Account; codex-bot = Git; metalcircle-bot = Issue-API. + Keinen dieser Zugänge für Firebase verwenden. +- PinguCore ist lokal. Cloud-Staging betreue ich separat. Kein automatischer + Cloud-/Produktionszugriff oder Produktionsdeployment. +- Test und Produktion bekommen getrennte Credentials. +- Verbietet eine Organisationsrichtlinie JSON-Schlüssel, umgehe sie nicht. + Erkläre die vorgesehenen Alternativen und welche Code-Anpassung nötig wäre. + +Frage zuerst nur nach der Ziel-Testumgebung und gehe dann schrittweise vor. +Prüfe Ergebnisse anhand ungefährlicher Statusangaben, niemals Secret-Inhalte. +``` diff --git a/docs/wiki/Firebase.md b/docs/wiki/Firebase.md index 9d0a5b8..afe6a7a 100644 --- a/docs/wiki/Firebase.md +++ b/docs/wiki/Firebase.md @@ -1,5 +1,39 @@ # Firebase -Das Firebase-Projekt heißt **MetalCircle**. Firebase wird aktuell für Android-Firebase Cloud Messaging verwendet: Die App fragt die Benachrichtigungsberechtigung an, erhält einen FCM-Token und registriert ihn beim MetalCircle-Backend. Das Backend speichert Geräte-/Session-Zuordnungen, versendet aber in dieser Phase keine Nachrichten. +Das Firebase-Projekt heißt **MetalCircle**. Android ist dauerhaft als `de.pinguholic.concerts` registriert. Manuelle Testnachrichten funktionieren bereits. Automatischer Versand für Freundschaftsanfragen, Direktnachrichten und Veranstaltungseinladungen ist implementiert und benötigt einen separaten serverseitigen Zugang sowie `PUSH_ENABLED=true`. -`android/android/app/google-services.json` ist eine lokale Konfigurationsdatei und durch `.gitignore` ausgeschlossen. Firebase-Client-Konfiguration ist kein Ersatz für Server-Secrets; Service-Accounts, Admin-Schlüssel und Tokens gehören weder ins Repository noch in Issues oder Logs. +## Unterschiedliche Konfigurationsdateien + +- `android/android/app/google-services.json`: lokale, Git-ignorierte Android-Client-Konfiguration. Projekt-/App-Kennungen und der Client-API-Key werden vom Build in die APK übernommen; sie sind kein Backend-Privatschlüssel. +- **Service-Account-JSON**: privater Schlüssel für das Backend. Niemals in Git, APK, Docker-Image, Webassets, Chat, Wiki oder Logs aufnehmen. Die Android-Datei ersetzt diesen Zugang nicht. + +## Entwicklung/Test einrichten + +1. In Firebase **MetalCircle** auswählen und die tatsächliche **Projekt-ID** notieren; sie kann vom Anzeigenamen abweichen. +2. In der zugehörigen Google Cloud Console die **Firebase Cloud Messaging API (HTTP v1)** prüfen/aktivieren. +3. Einen eigenen Test-Service-Account anlegen, z. B. `metalcircle-push-test`. Für Versand ist `cloudmessaging.messages.create` nötig, enthalten in **Firebase Cloud Messaging API Admin** (`roles/firebasecloudmessaging.admin`). Keine persönlichen oder Gitea-Zugänge verwenden. Siehe [Firebase IAM](https://firebase.google.com/docs/projects/iam/permissions) und [FCM-Rollen](https://docs.cloud.google.com/iam/docs/roles-permissions/firebasecloudmessaging). +4. Für diesen Account einen JSON-Schlüssel erstellen und geschützt **außerhalb des Repositories und Docker-Buildkontexts** speichern. Der Betreiber verwaltet den Schlüssel. [Firebase Admin Setup](https://firebase.google.com/docs/admin/setup) beschreibt Service-Account-Dateien. +5. Dateirechte einschränken, beispielsweise `chmod 600 /absoluter/pfad/firebase-service-account.json`. Keine Inhalte ausgeben. +6. In der lokalen `.env` die folgenden Werte selbst eintragen: + +```dotenv +PUSH_ENABLED=true +FIREBASE_PROJECT_ID= +FIREBASE_SERVICE_ACCOUNT_FILE=/absoluter/pfad/firebase-service-account.json +``` + +7. In der eigenen lokalen HTTP-Testumgebung starten: + +```bash +COOKIE_SECURE=false docker compose -f compose.yml -f compose.push.yml up -d --build web +``` + +`compose.push.yml` bindet die Datei schreibgeschützt unter `/run/secrets/firebase-service-account.json` ein und setzt dort `GOOGLE_APPLICATION_CREDENTIALS` für das Backend. Die Quelldatei muss existieren. Der Sender prüft, dass `FIREBASE_PROJECT_ID` zum Account passt. Die Android-App muss dasselbe Firebase-Projekt nutzen. + +Cloud-Staging richtet der Betreiber separat ein; dort hinter HTTPS `COOKIE_SECURE=true` lassen. Codex auf PinguCore greift nicht automatisch darauf zu. Produktion bekommt später eigene Credentials, keine kopierten Testschlüssel. + +## Prüfen + +Mit zwei Testkonten den Ablauf unter [Push Notifications](Push-Notifications.md) prüfen. Logs enthalten feste Kategorien wie `configuration`, `transient` oder `unregistered`. Bei `configuration` Mount, Projekt-ID, API-Aktivierung und Rechte prüfen. Keine Legacy-Server-Keys einsetzen. + +Der echte Backend-Integrationstest steht aus, solange kein Test-Service-Account hinterlegt ist. Automatisierte Tests simulieren Firebase und bestätigen nicht die Berechtigungen eines künftig erstellten Accounts. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index df74e7c..3a221f7 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -16,6 +16,8 @@ MetalCircle ist eine private, invite-only Konzert-Community. Dieses Wiki ergänz - [Photos and Uploads](Photos-and-Uploads.md) - [Android App](Android-App.md) - [Firebase](Firebase.md) +- [Push Notifications](Push-Notifications.md) +- [Firebase Setup Prompt](Firebase-Setup-Prompt.md) - [Deployment](Deployment.md) - [Gitea Workflow](Gitea-Workflow.md) - [Issues and Bug Reporting](Issues-and-Bug-Reporting.md) diff --git a/docs/wiki/Push-Notifications.md b/docs/wiki/Push-Notifications.md new file mode 100644 index 0000000..a6f38d4 --- /dev/null +++ b/docs/wiki/Push-Notifications.md @@ -0,0 +1,31 @@ +# Push Notifications + +## Umfang und Einstellungen + +Automatische Android-Pushs gibt es für neue Freundschaftsanfragen, Direktnachrichten und Veranstaltungseinladungen. Allgemeine Konzertänderungen und Kommentare lösen keine Pushs aus. Manuelle Firebase-Kampagnen sind getrennt und beachten die MetalCircle-Einstellungen nicht. + +`PUSH_ENABLED=false` ist der Standard. Erst nach [Firebase-Einrichtung](Firebase.md) wird der Versand aktiviert; während er deaktiviert ist, entstehen keine Versandaufträge für historische Aktivitäten. + +Im eigenen Profil lassen sich die drei Kategorien getrennt einstellen. Die Einstellungen gelten für alle angemeldeten Android-Geräte des Kontos; Android muss zusätzlich Benachrichtigungen erlauben. Ausschalten verwirft ausstehende Meldungen dieser Kategorie. Die zuletzt bei Anmeldung, Geräteanmeldung oder Sprachwechsel gewählte Sprache wird pro Konto in `notification_preferences.language` gespeichert. Der einzelne Sprachbutton bietet jeweils die andere Sprache DE/EN an. + +Pushs enthalten generische Texte, etwa „Neue Nachricht – Du hast eine neue Nachricht.“ Namen, Nachrichtentext und Veranstaltungstitel werden nicht auf den Sperrbildschirm übertragen. Android erhält eine zufällige Versandkennung und die Bindung an die aktuelle Sitzung. Im Vordergrund zeigt die WebView einen Hinweis zum Öffnen an; im Hintergrund übernimmt Android die Systembenachrichtigung. + +Beim Antippen prüft die App die Sitzung. `/notifications/{id}` prüft erneut Empfänger, Sitzung und Berechtigung und leitet zur Unterhaltung, Anfrage oder Veranstaltung weiter. Bereits gelesene Nachrichten, erledigte Anfragen, entfernte Einladungen oder blockierte Kontakte führen zur Übersicht. + +## Versand und Fehlerfälle + +Aktivität und Versandauftrag werden in derselben PostgreSQL-Transaktion gespeichert. Ein Hintergrund-Thread im FastAPI-Prozess verarbeitet `push_notifications`; ein zusätzlicher Broker oder Container ist nicht nötig. Mehrere Prozesse reservieren Aufträge über Zeilensperren und `SKIP LOCKED`. + +Aufträge entstehen nur für aktuell registrierte Geräte. Sie enthalten Geräte-/Session-Referenzen und einen Token-Fingerabdruck, keine Klartext-Tokens oder Nachrichteninhalte. Vor Versand werden Kategorie, Blockierungen, offener/ungelesener Zustand, Berechtigungen, Token und Sitzung geprüft. Geräte-/Session-Sperren koordinieren den Versand mit Logout und Tokenänderungen. Logout löscht Zuordnungen und Aufträge über Fremdschlüssel. + +Firebase Admin SDK 7.1.0 sendet über FCM. Vorübergehende Fehler und Konfigurationsfehler erhalten maximal drei Wiederholungen nach 60, 120 und 240 Sekunden. Nach vier Versuchen wird der Auftrag als fehlgeschlagen markiert. Nicht registrierte Tokens werden entfernt; Projekt-/Authentifizierungsfehler löschen keine Geräte. Logs enthalten nur feste Fehlerkategorien. + +Aufträge verfallen nach einer Stunde; FCM erhält fünf Minuten Gültigkeit. Der laufende Worker bereinigt Metadaten nach sieben Tagen (keine Bereinigung, solange er deaktiviert ist). Bei einem Prozessabbruch nach Firebase-Annahme und vor DB-Commit sind Doppelzustellungen nicht vollständig auszuschließen; die stabile Android-Kennung ersetzt Wiederholungen im Benachrichtigungsbereich. + +Bereits zugestellte Meldungen lassen sich serverseitig nicht zurückrufen. Die App leert eigene Benachrichtigungen beim Sitzungswechsel; generische Texte und Zielprüfung schützen zusätzlich. Ein kleiner Zeitraum zwischen letzter Berechtigungsprüfung und Netzwerkzustellung bleibt technisch bestehen. + +## Tests + +Automatisierte Tests nutzen isolierte lokale PostgreSQL-Schemas und simuliertes Firebase; sie senden keine echten Pushs. Nach der Zugangseinrichtung mit zwei Testkonten prüfen: Anfrage A → B, Freundschaft annehmen und Nachricht senden, private Veranstaltung mit Einladung für B; Vordergrund/Hintergrund, Antippen, EN/DE, Kategorien, Blockierung, entfernte Einladung sowie Logout/Benutzerwechsel. Keine Produktionsaktivitäten dafür verwenden. + +Ein [fertiger ChatGPT-Prompt](Firebase-Setup-Prompt.md) begleitet die Einrichtung. diff --git a/docs/wiki/Roadmap.md b/docs/wiki/Roadmap.md index 4996d6b..4b914cd 100644 --- a/docs/wiki/Roadmap.md +++ b/docs/wiki/Roadmap.md @@ -4,7 +4,7 @@ Die folgenden Punkte sind aus dem aktuellen Produktstand und den vorhandenen Fun - Community-, Profil-, Attendance- und Interest-Funktionen weiter ausbauen - Kommentare, Diary und Fotoalben vervollständigen -- Android-App und FCM-Benachrichtigungen weiter testen; serverseitigen Push-Versand separat planen +- Android-App und den implementierten serverseitigen Push-Versand nach der Firebase-Zugangseinrichtung live testen - weitere Patches und Gamification nach klarer Vergabelogik ergänzen Eintragungen hier sind Planung. Sie gelten erst als umgesetzt, wenn Code, Migrationen und Tests vorhanden sind. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index b063e14..3dea4cd 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -5,5 +5,5 @@ - **Venue-Suche leer oder langsam:** Nominatim ist extern und rate-limited; manuelle Venue-Daten können verwendet werden. - **Upload scheitert:** Dateityp, Größe, Schreibrechte und die Compose-Volumes prüfen. - **Android-Sync schlägt fehl:** Node/npm, JDK, Android SDK und die lokale ignorierte `google-services.json` prüfen. -- **FCM fehlt:** App-Berechtigung und Firebase-Konfiguration prüfen; Push-Versand ist aktuell nicht serverseitig aktiviert. +- **FCM fehlt:** Android-Berechtigung, Kategorie im Profil, `PUSH_ENABLED`, Projekt-ID und Service-Account-Mount prüfen. Client-Konfiguration allein reicht nicht für Backend-Versand. Siehe [Firebase](Firebase.md). - **Bugreport kann nicht gesendet werden:** Gitea-URL, Bot-Token und Repository-Konfiguration nur lokal prüfen; Secrets nie in Logs kopieren.