Add activity push notifications and registration patches
This commit is contained in:
@@ -14,3 +14,13 @@ GITEA_URL=http://192.168.178.5:3000
|
|||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
GITEA_OWNER=kai
|
GITEA_OWNER=kai
|
||||||
GITEA_REPO=pingu-concerts
|
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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MetalCircle
|
# 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.
|
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
|
```bash
|
||||||
cp .env.example .env
|
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`.
|
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.
|
Ä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).
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
secrets/
|
||||||
|
**/*service-account*.json
|
||||||
|
**/*service_account*.json
|
||||||
|
**/*firebase-adminsdk*.json
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
private_uploads/
|
||||||
|
static/uploads/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
+29
-1
@@ -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 = (
|
FEATURE_SCHEMA = (
|
||||||
'''CREATE TABLE IF NOT EXISTS push_devices (
|
'''CREATE TABLE IF NOT EXISTS push_devices (
|
||||||
@@ -27,4 +27,32 @@ FEATURE_SCHEMA = (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user
|
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user
|
||||||
ON bug_report_submissions(user_id, submitted_at);''',
|
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');''',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -460,6 +460,27 @@
|
|||||||
"Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.",
|
"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?",
|
"Diesen Nutzer wirklich blockieren?": "Really block this user?",
|
||||||
"Account wirklich dauerhaft löschen?": "Really delete your account permanently?",
|
"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": "🐛 Report a bug",
|
||||||
"Bug melden · MetalCircle": "Report a bug · MetalCircle",
|
"Bug melden · MetalCircle": "Report a bug · MetalCircle",
|
||||||
"Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.",
|
"Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.",
|
||||||
|
|||||||
+49
-16
@@ -35,6 +35,8 @@ from fastapi.exception_handlers import request_validation_exception_handler
|
|||||||
from feature_schema import FEATURE_SCHEMA
|
from feature_schema import FEATURE_SCHEMA
|
||||||
import push_devices
|
import push_devices
|
||||||
import bug_reporter
|
import bug_reporter
|
||||||
|
import notifications
|
||||||
|
import community_badges
|
||||||
from i18n import (
|
from i18n import (
|
||||||
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
|
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
|
||||||
language_url, safe_return_path, format_time, format_datetime,
|
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"),
|
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle aufgebaut", "special"),
|
||||||
("admin", "Admin", "🏴☠️", None, "Verantwortung für MetalCircle", "special"),
|
("admin", "Admin", "🏴☠️", None, "Verantwortung für MetalCircle", "special"),
|
||||||
("captns_mate", "Captns Mate", "☠️", None, "Die treue Gefährtin des Captains", "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"),
|
("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"),
|
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
|
||||||
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
|
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
|
||||||
("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
|
("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
|
badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
|
||||||
if category == "venue"
|
if category == "venue"
|
||||||
)
|
)
|
||||||
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
|
|
||||||
BADGE_BY_CODE = {
|
BADGE_BY_CODE = {
|
||||||
badge_code: (name, icon, threshold, description, category)
|
badge_code: (name, icon, threshold, description, category)
|
||||||
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
|
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
|
||||||
@@ -527,7 +530,14 @@ def ensure_schema():
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
ensure_schema()
|
ensure_schema()
|
||||||
|
with get_db_connection() as connection:
|
||||||
|
community_badges.reconcile(connection.cursor())
|
||||||
|
worker = notifications.PushWorker(get_db_connection)
|
||||||
|
worker.start()
|
||||||
|
try:
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
|
worker.stop()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MetalCircle", lifespan=lifespan)
|
app = FastAPI(title="MetalCircle", lifespan=lifespan)
|
||||||
@@ -638,9 +648,14 @@ async def localize_request(request: Request, call_next):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/language/{language}")
|
@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"}:
|
if language not in {"de", "en"}:
|
||||||
return HTMLResponse(_("Ungültige Sprache."), status_code=400)
|
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 = RedirectResponse(safe_return_path(next), status_code=303)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60,
|
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 get_db_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
if registered_at < BETA_REGISTRATION_DEADLINE:
|
community_badges.reconcile(cursor, user_id)
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO user_badges (user_id, badge_code)
|
|
||||||
VALUES (%s, 'beta_tester')
|
|
||||||
ON CONFLICT (user_id, badge_code) DO NOTHING
|
|
||||||
""",
|
|
||||||
(user_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
|
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
|
||||||
if category == "attendance" and threshold is not None and stats["total"] >= threshold:
|
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 get_db_connection() as connection:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT badge_code, path FROM badge_assets")
|
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):
|
def load_profile(username: str):
|
||||||
@@ -2399,6 +2409,8 @@ def register_user(
|
|||||||
user_id,
|
user_id,
|
||||||
invite_id
|
invite_id
|
||||||
))
|
))
|
||||||
|
community_badges.reconcile(cursor, user_id)
|
||||||
|
notifications.save_language(connection, user_id, current_language.get())
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
@@ -2532,6 +2544,8 @@ def login(
|
|||||||
with get_db_connection() as connection:
|
with get_db_connection() as connection:
|
||||||
connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),))
|
connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),))
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
with get_db_connection() as connection:
|
||||||
|
notifications.save_language(connection, row[0], current_language.get())
|
||||||
response = RedirectResponse(next_path, status_code=303)
|
response = RedirectResponse(next_path, status_code=303)
|
||||||
return attach_session(response, create_session(row[0]))
|
return attach_session(response, create_session(row[0]))
|
||||||
|
|
||||||
@@ -2735,6 +2749,8 @@ def render_profile(
|
|||||||
}
|
}
|
||||||
badges.sort(key=lambda badge: badge["sort_key"])
|
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")
|
template = templates.get_template("profile.html")
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
template.render(
|
template.render(
|
||||||
@@ -2750,6 +2766,7 @@ def render_profile(
|
|||||||
form_error=form_error,
|
form_error=form_error,
|
||||||
form_success=form_success,
|
form_success=form_success,
|
||||||
instagram_input=instagram_input,
|
instagram_input=instagram_input,
|
||||||
|
push_preferences=push_preferences,
|
||||||
),
|
),
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
)
|
)
|
||||||
@@ -2766,6 +2783,8 @@ def own_profile(request: Request, saved: str = ""):
|
|||||||
messages.append(_("Profilbild aktualisiert"))
|
messages.append(_("Profilbild aktualisiert"))
|
||||||
if "instagram" in saved_items:
|
if "instagram" in saved_items:
|
||||||
messages.append(_("Instagram verknüpft"))
|
messages.append(_("Instagram verknüpft"))
|
||||||
|
if "notifications" in saved_items:
|
||||||
|
messages.append(_("Benachrichtigungseinstellungen gespeichert"))
|
||||||
return render_profile(
|
return render_profile(
|
||||||
request,
|
request,
|
||||||
user["username"],
|
user["username"],
|
||||||
@@ -2901,11 +2920,14 @@ def export_profile_data(request: Request):
|
|||||||
)
|
)
|
||||||
badges = cursor.fetchall()
|
badges = cursor.fetchall()
|
||||||
|
|
||||||
|
push_preferences = notifications.preferences(connection, user_id)
|
||||||
|
|
||||||
def rows_to_dicts(rows, keys):
|
def rows_to_dicts(rows, keys):
|
||||||
return [dict(zip(keys, row)) for row in rows]
|
return [dict(zip(keys, row)) for row in rows]
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"export_version": 1,
|
"export_version": 1,
|
||||||
|
"notification_preferences": push_preferences,
|
||||||
"exported_at": datetime.now(),
|
"exported_at": datetime.now(),
|
||||||
"account": dict(zip(
|
"account": dict(zip(
|
||||||
("id", "username", "email", "display_name", "avatar_path",
|
("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)
|
INSERT INTO friendships (requester_id, addressee_id)
|
||||||
VALUES (%s, %s)
|
VALUES (%s, %s)
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING RETURNING id
|
||||||
""",
|
""",
|
||||||
(user["id"], profile["id"]),
|
(user["id"], profile["id"]),
|
||||||
)
|
)
|
||||||
|
created = cursor.fetchone()
|
||||||
|
if created:
|
||||||
|
notifications.enqueue(cursor, 'friend_request', user['id'], profile['id'], created[0])
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
|
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:
|
if not partner:
|
||||||
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
|
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
|
||||||
cursor.execute(
|
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),
|
(user["id"], partner["id"], body),
|
||||||
)
|
)
|
||||||
|
notifications.enqueue(cursor, 'direct_message', user['id'], partner['id'], cursor.fetchone()[0])
|
||||||
connection.commit()
|
connection.commit()
|
||||||
return RedirectResponse(f"/messages/{partner['username']}#latest", status_code=303)
|
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)
|
INSERT INTO event_invitations (concert_id, user_id, invited_by)
|
||||||
SELECT %s, id, %s FROM users
|
SELECT %s, id, %s FROM users
|
||||||
WHERE id = ANY(%s) AND id <> %s
|
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"]),
|
(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()
|
connection.commit()
|
||||||
|
|
||||||
@@ -4257,10 +4286,13 @@ async def edit_concert(
|
|||||||
"""
|
"""
|
||||||
INSERT INTO event_invitations (concert_id, user_id, invited_by)
|
INSERT INTO event_invitations (concert_id, user_id, invited_by)
|
||||||
SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s
|
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"]),
|
(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):
|
elif can_manage_event_access(user, concert):
|
||||||
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
|
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
|
||||||
if can_edit_title(user, concert):
|
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)
|
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)
|
bug_reporter.register_routes(app, templates, get_db_connection, get_current_user)
|
||||||
|
notifications.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE)
|
||||||
|
|||||||
@@ -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_at<CURRENT_TIMESTAMP-INTERVAL '7 days'")
|
||||||
|
row = db.execute('''SELECT id,device_id,session_id,recipient_id,actor_id,kind,object_id,token_hash,attempts,
|
||||||
|
expires_at>CURRENT_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)
|
||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
"""Device registration only. This module deliberately contains no push sender."""
|
"""Device registration. Background sending lives in notifications.py."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -6,6 +6,8 @@ from uuid import UUID
|
|||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from i18n import current_language
|
||||||
|
from notifications import save_language
|
||||||
|
|
||||||
|
|
||||||
class DeviceRegistration(BaseModel):
|
class DeviceRegistration(BaseModel):
|
||||||
@@ -59,6 +61,7 @@ def register_routes(app, get_db, get_user, cookie_name):
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
|
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
|
||||||
device_id = cursor.fetchone()[0]
|
device_id = cursor.fetchone()[0]
|
||||||
|
save_language(connection, user['id'], current_language.get())
|
||||||
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
|
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
|
||||||
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
|
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
|
||||||
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
|
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
|
||||||
|
|||||||
@@ -609,3 +609,9 @@ h1, .page-title h1 {
|
|||||||
.bug-success { color:#bbf7d0; }
|
.bug-success { color:#bbf7d0; }
|
||||||
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
|
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
|
||||||
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
|
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
|
||||||
|
.notification-preferences { display: grid; gap: 12px; margin: 16px 0; }
|
||||||
|
.notification-preferences label { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.notification-preferences input[type="checkbox"] { width: auto; }
|
||||||
|
.patch.earned.patch-alpha_tester { border: 2px solid #e7bc58; box-shadow: 0 0 12px #e7bc5855; background: #211b0f; }
|
||||||
|
.patch.earned.patch-beta_tester { border: 1px solid #a7b4c5; }
|
||||||
|
.patch.earned.patch-early_bird { border: 1px solid #b4835e; }
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 210" role="img" aria-label="Alpha Tester">
|
||||||
|
<path fill="#151313" stroke="#e8bf63" stroke-width="5" d="M90 6 170 36v99q-12 45-80 69-68-24-80-69V36Z"/>
|
||||||
|
<path fill="none" stroke="#a88236" stroke-width="1.5" stroke-dasharray="3 3" d="M90 15 161 43v89q-10 39-71 62-61-23-71-62V43Z"/>
|
||||||
|
<path fill="#e8bf63" d="m57 37 16 10 17-20 17 20 16-10-7 27H64Z"/>
|
||||||
|
<path fill="#e8bf63" d="m90 73 31 65h-17l-5-13H81l-5 13H59Zm0 26-5 14h10Z"/>
|
||||||
|
<text x="90" y="157" fill="#f5dfab" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="18" letter-spacing="2">ALPHA</text>
|
||||||
|
<text x="90" y="177" fill="#d4b97a" text-anchor="middle" font-family="sans-serif" font-size="11" letter-spacing="3">TESTER</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 775 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 210" role="img" aria-label="Early Bird">
|
||||||
|
<path fill="#171514" stroke="#b98b66" stroke-width="4" d="M90 9 167 38v95q-11 43-77 67-66-24-77-67V38Z"/>
|
||||||
|
<path fill="none" stroke="#947153" stroke-dasharray="3 3" d="M90 18 158 45v85q-10 38-68 60-58-22-68-60V45Z"/>
|
||||||
|
<path fill="#c8996f" d="m40 65 44 32 33-29 15 11 16 3-15 9-12 25-33 18-26-14 21-4-28-14 26 1Z"/>
|
||||||
|
<circle cx="125" cy="80" r="2.5" fill="#171514"/>
|
||||||
|
<text x="90" y="155" fill="#e2be9f" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="17" letter-spacing="1">EARLY BIRD</text>
|
||||||
|
<text x="90" y="177" fill="#b98b66" text-anchor="middle" font-family="sans-serif" font-size="9" letter-spacing="2">METALCIRCLE</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 766 B |
@@ -15,6 +15,17 @@
|
|||||||
let sendQueue = Promise.resolve();
|
let sendQueue = Promise.resolve();
|
||||||
let panel;
|
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) {
|
function notice(message, offerPermission = false) {
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
panel = document.createElement('section');
|
panel = document.createElement('section');
|
||||||
@@ -130,9 +141,29 @@
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
|
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
|
||||||
push.addListener('pushNotificationActionPerformed', () => {
|
push.addListener('pushNotificationActionPerformed', async event => {
|
||||||
// Do not navigate to arbitrary URLs supplied by a notification payload.
|
try {
|
||||||
location.assign('/');
|
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(() => {
|
]).then(() => {
|
||||||
synchronize();
|
synchronize();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<nav class="language-switch" aria-label="{{ _('Sprache') }}">
|
<nav class="language-switch" aria-label="{{ _('Sprache') }}">
|
||||||
<a href="{{ language_url('de') }}" lang="de" hreflang="de" aria-label="Deutsch"{% if language() == 'de' %} aria-current="true" class="active"{% endif %}>DE</a>
|
{% set target_language = 'en' if language() == 'de' else 'de' %}
|
||||||
<a href="{{ language_url('en') }}" lang="en" hreflang="en" aria-label="English"{% if language() == 'en' %} aria-current="true" class="active"{% endif %}>EN</a>
|
<a class="active" href="{{ language_url(target_language) }}" lang="{{ target_language }}" hreflang="{{ target_language }}" aria-label="{{ _('Zu Englisch wechseln') if target_language == 'en' else _('Zu Deutsch wechseln') }}" title="{{ _('Zu Englisch wechseln') if target_language == 'en' else _('Zu Deutsch wechseln') }}">{{ target_language | upper }}</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script id="native-push-config" type="application/json">{{ {
|
<script id="native-push-config" type="application/json">{{ {
|
||||||
|
'openNotification': _('Neue Benachrichtigung öffnen'),
|
||||||
'permission': _('Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?'),
|
'permission': _('Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?'),
|
||||||
'enable': _('Benachrichtigungen aktivieren'),
|
'enable': _('Benachrichtigungen aktivieren'),
|
||||||
'later': _('Später'),
|
'later': _('Später'),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<h2>{{ _('Weitergabe und externe Dienste') }}</h2>
|
<h2>{{ _('Weitergabe und externe Dienste') }}</h2>
|
||||||
<p>{{ _('Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.') }}</p>
|
<p>{{ _('Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.') }}</p>
|
||||||
<p>{{ _('Für Android-Benachrichtigungen speichern wir die Gerätekennung, den FCM-Registrierungstoken, die App-Version und die Zuordnung zur aktuellen Anmeldung. Beim Abmelden wird die Zuordnung gelöscht. Firebase verarbeitet die für die Push-Zustellung erforderlichen Gerätedaten.') }}</p>
|
<p>{{ _('Für Android-Benachrichtigungen speichern wir die Gerätekennung, den FCM-Registrierungstoken, die App-Version und die Zuordnung zur aktuellen Anmeldung. Beim Abmelden wird die Zuordnung gelöscht. Firebase verarbeitet die für die Push-Zustellung erforderlichen Gerätedaten.') }}</p>
|
||||||
|
<p>{{ _('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.') }}</p>
|
||||||
<p>{{ _('Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab.') }}</p>
|
<p>{{ _('Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab.') }}</p>
|
||||||
<h2>{{ _('Deine Rechte') }}</h2>
|
<h2>{{ _('Deine Rechte') }}</h2>
|
||||||
<p>{{ _('Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
<p>{{ _('Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
{% for badge in badges %}
|
{% for badge in badges %}
|
||||||
{% if badge.earned %}
|
{% if badge.earned %}
|
||||||
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="{{ _('Details zu') }} {{ badge.name | t }} {{ _('anzeigen') }}">
|
<button class="patch-button" type="button" onclick="document.getElementById('patch-{{ badge.code }}').showModal()" aria-label="{{ _('Details zu') }} {{ badge.name | t }} {{ _('anzeigen') }}">
|
||||||
<span class="patch earned {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name | t }} – {{ badge.description | t }}">
|
<span class="patch earned patch-{{ badge.code }} {% if badge.image_path %}patch-image-frame{% else %}patch-icon-frame{% endif %}" title="{{ badge.name | t }} – {{ badge.description | t }}">
|
||||||
{% if badge.image_path %}
|
{% if badge.image_path %}
|
||||||
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">
|
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -243,6 +243,16 @@
|
|||||||
<span class="avatar-upload-status" id="avatar-upload-status">{{ _('Große Bilder werden vor dem Upload automatisch optimiert.') }}</span>
|
<span class="avatar-upload-status" id="avatar-upload-status">{{ _('Große Bilder werden vor dem Upload automatisch optimiert.') }}</span>
|
||||||
<button class="button" type="submit">{{ _('Profil speichern') }}</button>
|
<button class="button" type="submit">{{ _('Profil speichern') }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<section id="notification-settings">
|
||||||
|
<h2>{{ _('Push-Benachrichtigungen') }}</h2>
|
||||||
|
<p>{{ _('Wähle, welche Hinweise du auf deinen angemeldeten Android-Geräten erhalten möchtest. Nachrichteninhalte werden nicht angezeigt.') }}</p>
|
||||||
|
<form method="post" action="/profile/notifications" class="notification-preferences">
|
||||||
|
{% for kind, label in [('friend_request', 'Freundschaftsanfragen'), ('direct_message', 'Direktnachrichten'), ('event_invitation', 'Veranstaltungseinladungen')] %}
|
||||||
|
<label><input type="checkbox" name="{{ kind }}" value="true"{% if push_preferences is not defined or push_preferences[kind] %} checked{% endif %}> {{ label | t }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
<button class="button" type="submit">{{ _('Benachrichtigungen speichern') }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
<div class="account-actions">
|
<div class="account-actions">
|
||||||
<details class="account-delete">
|
<details class="account-delete">
|
||||||
<summary>{{ _('Account löschen') }}</summary>
|
<summary>{{ _('Account löschen') }}</summary>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const {test} = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../static/js/native-push.js'), 'utf8');
|
||||||
|
const id = '12345678-1234-1234-1234-123456789abc';
|
||||||
|
const tag = 'a'.repeat(64);
|
||||||
|
|
||||||
|
async function setup(options={}) {
|
||||||
|
const listeners = {}, navigations = [], elements = [];
|
||||||
|
const session = {authenticated:true, session_tag:tag, ...options.session};
|
||||||
|
const config = {textContent:JSON.stringify({openNotification:'Open new notification'})};
|
||||||
|
const main = {prepend(node) { elements.push(node); }};
|
||||||
|
const document = {
|
||||||
|
getElementById(name) { return name === 'native-push-config' ? config : null; },
|
||||||
|
querySelector(name) { return name === 'main' ? main : null; },
|
||||||
|
createElement(type) { return {type,children:[],append(node){this.children.push(node);},setAttribute(){}}; },
|
||||||
|
addEventListener() {}, body:main,
|
||||||
|
};
|
||||||
|
const push = {
|
||||||
|
addListener(name, callback) { listeners[name]=callback; return Promise.resolve(); },
|
||||||
|
checkPermissions:async()=>({receive:'granted'}), register:async()=>{},
|
||||||
|
};
|
||||||
|
const device = {getInfo:async()=>({deviceId:id,appVersion:'1.1.0',binding:options.binding ?? tag}), prepareSession:async()=>{}};
|
||||||
|
vm.runInNewContext(source, {document, window:{Capacitor:{getPlatform:()=> 'android',Plugins:{PushNotifications:push,MetalCircleDevice:device}},addEventListener(){}},
|
||||||
|
location:{pathname:'/',assign(value){navigations.push(value);}},
|
||||||
|
fetch:async()=>({ok:true,json:async()=>session}), localStorage:{getItem(){return 'seen';}}});
|
||||||
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
|
return {listeners,navigations,elements};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tap opens only backend-resolved destination for the matching session',async()=>{
|
||||||
|
const app=await setup();
|
||||||
|
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag,url:'https://evil.invalid'}}});
|
||||||
|
assert.deepEqual(app.navigations,['/notifications/'+id]);
|
||||||
|
});
|
||||||
|
test('old-account push cannot navigate after user switch',async()=>{
|
||||||
|
const app=await setup({session:{session_tag:'b'.repeat(64)}});
|
||||||
|
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
|
||||||
|
assert.deepEqual(app.navigations,[]);
|
||||||
|
});
|
||||||
|
test('logout and native binding mismatch cannot open a notification',async()=>{
|
||||||
|
for(const options of [{session:{authenticated:false}}, {binding:''}]) {
|
||||||
|
const app=await setup(options);
|
||||||
|
await app.listeners.pushNotificationActionPerformed({notification:{data:{notification_id:id,session_tag:tag}}});
|
||||||
|
assert.deepEqual(app.navigations,[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('arbitrary URL and malformed identifier are ignored',async()=>{
|
||||||
|
const app=await setup();
|
||||||
|
for(const data of [{url:'https://evil.invalid'}, {notification_id:'../../profile',session_tag:tag}])
|
||||||
|
await app.listeners.pushNotificationActionPerformed({notification:{data}});
|
||||||
|
assert.deepEqual(app.navigations,[]);
|
||||||
|
});
|
||||||
|
test('foreground hint uses local text and never remote HTML',async()=>{
|
||||||
|
const app=await setup();
|
||||||
|
await app.listeners.pushNotificationReceived({body:'<script>private message</script>',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);
|
||||||
|
});
|
||||||
@@ -34,12 +34,16 @@ class FeatureApiTests(unittest.TestCase):
|
|||||||
with main.get_db_connection() as db:
|
with main.get_db_connection() as db:
|
||||||
db.execute('''
|
db.execute('''
|
||||||
CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE,
|
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,
|
CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT UNIQUE, expires_at TIMESTAMP);
|
token_hash TEXT UNIQUE, expires_at TIMESTAMP);
|
||||||
CREATE TABLE friendships(addressee_id INTEGER, status TEXT);
|
CREATE TABLE friendships(addressee_id INTEGER, status TEXT);
|
||||||
CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP);
|
CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP);
|
||||||
CREATE TABLE event_invitations(user_id INTEGER, viewed_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)
|
for statement in FEATURE_SCHEMA: db.execute(statement)
|
||||||
cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode()
|
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):
|
def test_migrations_repeat_and_match_startup_schema(self):
|
||||||
with main.get_db_connection() as db:
|
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()
|
source = Path('/test-migrations', name).read_text()
|
||||||
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
|
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
|
||||||
self.assertEqual(normalize(source), normalize(runtime))
|
self.assertEqual(normalize(source), normalize(runtime))
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class TranslationTests(unittest.TestCase):
|
|||||||
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
|
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
|
||||||
page = client.get(path)
|
page = client.get(path)
|
||||||
self.assertIn(title, page.text)
|
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')
|
response = client.get('/language/de?next=/login')
|
||||||
self.assertIn('Anmelden', response.text)
|
self.assertIn('Anmelden', response.text)
|
||||||
self.assertIn('<html lang="de">', response.text)
|
self.assertIn('<html lang="de">', response.text)
|
||||||
@@ -136,7 +136,8 @@ class TranslationTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
|
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
|
||||||
main.normalize_instagram_url('https://evil.example/test')
|
main.normalize_instagram_url('https://evil.example/test')
|
||||||
self.assertIn('Please enter', str(error.exception))
|
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.')
|
self.assertEqual(response.body, b'Invalid language.')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'<a\s', html)), 1)
|
||||||
|
self.assertIn('/language/' + target, html)
|
||||||
|
self.assertIn('>' + target.upper() + '</a>', 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()
|
||||||
@@ -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
|
||||||
@@ -34,6 +34,10 @@ services:
|
|||||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||||
GITEA_OWNER: ${GITEA_OWNER:-}
|
GITEA_OWNER: ${GITEA_OWNER:-}
|
||||||
GITEA_REPO: ${GITEA_REPO:-}
|
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:
|
volumes:
|
||||||
- concert_uploads:/app/static/uploads
|
- concert_uploads:/app/static/uploads
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
@@ -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.
|
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.
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ flowchart TD
|
|||||||
FastAPI --> Uploads[Docker Volumes: Uploads]
|
FastAPI --> Uploads[Docker Volumes: Uploads]
|
||||||
FastAPI --> External[Nominatim / externe Dienste]
|
FastAPI --> External[Nominatim / externe Dienste]
|
||||||
FastAPI --> Gitea[Gitea REST API]
|
FastAPI --> Gitea[Gitea REST API]
|
||||||
|
FastAPI --> Worker[Push-Worker / DB-Warteschlange]
|
||||||
|
Worker --> FCM
|
||||||
Android --> FCM[Firebase Cloud Messaging]
|
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.
|
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.
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ Attendance-Patches werden als Upgrade-System vergeben:
|
|||||||
| 50 | 50 Gigs |
|
| 50 | 50 Gigs |
|
||||||
| 100 | 100 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.
|
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.
|
||||||
|
|||||||
@@ -13,5 +13,11 @@
|
|||||||
| `GITEA_TOKEN` | Token des `metalcircle-bot` | erforderlich, geheim |
|
| `GITEA_TOKEN` | Token des `metalcircle-bot` | erforderlich, geheim |
|
||||||
| `GITEA_OWNER` | Repository-Owner, aktuell `kai` | erforderlich |
|
| `GITEA_OWNER` | Repository-Owner, aktuell `kai` | erforderlich |
|
||||||
| `GITEA_REPO` | Repository, aktuell `pingu-concerts` | 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.
|
`.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.
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ Wichtige Beziehungen:
|
|||||||
- `user_badges` enthält Badges und optionale auslösende Konzerte.
|
- `user_badges` enthält Badges und optionale auslösende Konzerte.
|
||||||
- `push_devices` bindet Android-FCM-Tokens an Benutzer und die aktuelle Session.
|
- `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.
|
- `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.
|
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.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Docker/Compose, Git sowie für Android Node.js/npm, JDK 17, Android SDK und ein
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up --build
|
COOKIE_SECURE=false docker compose up --build
|
||||||
docker compose logs -f web
|
docker compose logs -f web
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
@@ -19,10 +19,12 @@ Die Web-App läuft auf Port 8080, PostgreSQL im Compose-Netzwerk. Die Datenbank
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm --no-deps -e METALCIRCLE_TEST_DATABASE=1 \
|
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
|
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
|
## Android lokal
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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=<echte Projekt-ID>
|
||||||
|
FIREBASE_SERVICE_ACCOUNT_FILE=<absoluter Pfad zur Secret-Datei>
|
||||||
|
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.
|
||||||
|
```
|
||||||
+36
-2
@@ -1,5 +1,39 @@
|
|||||||
# Firebase
|
# 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=<tatsaechliche-test-projekt-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.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ MetalCircle ist eine private, invite-only Konzert-Community. Dieses Wiki ergänz
|
|||||||
- [Photos and Uploads](Photos-and-Uploads.md)
|
- [Photos and Uploads](Photos-and-Uploads.md)
|
||||||
- [Android App](Android-App.md)
|
- [Android App](Android-App.md)
|
||||||
- [Firebase](Firebase.md)
|
- [Firebase](Firebase.md)
|
||||||
|
- [Push Notifications](Push-Notifications.md)
|
||||||
|
- [Firebase Setup Prompt](Firebase-Setup-Prompt.md)
|
||||||
- [Deployment](Deployment.md)
|
- [Deployment](Deployment.md)
|
||||||
- [Gitea Workflow](Gitea-Workflow.md)
|
- [Gitea Workflow](Gitea-Workflow.md)
|
||||||
- [Issues and Bug Reporting](Issues-and-Bug-Reporting.md)
|
- [Issues and Bug Reporting](Issues-and-Bug-Reporting.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.
|
||||||
@@ -4,7 +4,7 @@ Die folgenden Punkte sind aus dem aktuellen Produktstand und den vorhandenen Fun
|
|||||||
|
|
||||||
- Community-, Profil-, Attendance- und Interest-Funktionen weiter ausbauen
|
- Community-, Profil-, Attendance- und Interest-Funktionen weiter ausbauen
|
||||||
- Kommentare, Diary und Fotoalben vervollständigen
|
- 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
|
- 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.
|
Eintragungen hier sind Planung. Sie gelten erst als umgesetzt, wenn Code, Migrationen und Tests vorhanden sind.
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
- **Venue-Suche leer oder langsam:** Nominatim ist extern und rate-limited; manuelle Venue-Daten können verwendet werden.
|
- **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.
|
- **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.
|
- **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.
|
- **Bugreport kann nicht gesendet werden:** Gitea-URL, Bot-Token und Repository-Konfiguration nur lokal prüfen; Secrets nie in Logs kopieren.
|
||||||
|
|||||||
Reference in New Issue
Block a user