Add activity push notifications and registration patches

This commit is contained in:
2026-09-15 08:31:45 +02:00
parent 7c801dfddd
commit 55174684af
38 changed files with 1096 additions and 42 deletions
+12
View File
@@ -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
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow
RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow "firebase-admin==7.1.0"
COPY . .
+43
View File
@@ -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
View File
@@ -1,4 +1,4 @@
"""Startup equivalents of migrations 19 and 20 (kept in sync by tests)."""
"""Startup equivalents of migrations 1922 (kept in sync by tests)."""
FEATURE_SCHEMA = (
'''CREATE TABLE IF NOT EXISTS push_devices (
@@ -27,4 +27,32 @@ FEATURE_SCHEMA = (
);
CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user
ON bug_report_submissions(user_id, submitted_at);''',
'''CREATE TABLE IF NOT EXISTS notification_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
language VARCHAR(2) NOT NULL DEFAULT 'de' CHECK (language IN ('de', 'en')),
friend_request BOOLEAN NOT NULL DEFAULT TRUE,
direct_message BOOLEAN NOT NULL DEFAULT TRUE,
event_invitation BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS push_notifications (
id UUID PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind VARCHAR(32) NOT NULL CHECK (kind IN ('friend_request', 'direct_message', 'event_invitation')),
object_id BIGINT NOT NULL,
event_key TEXT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
state VARCHAR(16) NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'sent', 'dropped', 'failed')),
attempts SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 hour',
available_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(device_id, event_key)
);
CREATE INDEX IF NOT EXISTS idx_push_notifications_pending
ON push_notifications(available_at) WHERE state='pending';''',
'''CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_registration_cohort
ON user_badges(user_id) WHERE badge_code IN ('alpha_tester', 'beta_tester', 'early_bird');''',
)
+21
View File
@@ -460,6 +460,27 @@
"Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.",
"Diesen Nutzer wirklich blockieren?": "Really block this user?",
"Account wirklich dauerhaft löschen?": "Really delete your account permanently?",
"Alpha Tester": "Alpha Tester",
"Early Bird": "Early Bird",
"Schon in der Alpha dabei unsere frühesten Tester": "Part of the alpha our earliest testers",
"Früh Teil der MetalCircle-Community geworden": "An early member of the MetalCircle community",
"Zu Englisch wechseln": "Switch to English",
"Zu Deutsch wechseln": "Switch to German",
"Push-Benachrichtigungen": "Push notifications",
"Wähle, welche Hinweise du auf deinen angemeldeten Android-Geräten erhalten möchtest. Nachrichteninhalte werden nicht angezeigt.": "Choose which alerts to receive on your signed-in Android devices. Message contents are never displayed.",
"Freundschaftsanfragen": "Friend requests",
"Direktnachrichten": "Direct messages",
"Veranstaltungseinladungen": "Event invitations",
"Benachrichtigungen speichern": "Save notifications",
"Benachrichtigungseinstellungen gespeichert": "Notification preferences saved",
"Neue Freundschaftsanfrage": "New friend request",
"Du hast eine neue Freundschaftsanfrage.": "You have a new friend request.",
"Neue Nachricht": "New message",
"Du hast eine neue Nachricht.": "You have a new message.",
"Neue Veranstaltungseinladung": "New event invitation",
"Du wurdest zu einer Veranstaltung eingeladen.": "You have been invited to an event.",
"Neue Benachrichtigung öffnen": "Open new notification",
"Für automatische Pushs speichern wir deine Sprache, gewählte Kategorien und Versandmetadaten. Push-Texte enthalten keine privaten Nachrichteninhalte. Der aktive Versanddienst löscht Versandmetadaten nach sieben Tagen; beim Abmelden werden die zur Sitzung gehörenden Aufträge entfernt.": "For automatic push notifications, we store your language, selected categories and delivery metadata. Push texts contain no private message content. The active delivery worker deletes delivery metadata after seven days; logging out removes jobs belonging to that session.",
"🐛 Bug melden": "🐛 Report a bug",
"Bug melden · MetalCircle": "Report a bug · MetalCircle",
"Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.",
+50 -17
View File
@@ -35,6 +35,8 @@ from fastapi.exception_handlers import request_validation_exception_handler
from feature_schema import FEATURE_SCHEMA
import push_devices
import bug_reporter
import notifications
import community_badges
from i18n import (
LANGUAGE_COOKIE, current_language, current_page, gettext as _,
language_url, safe_return_path, format_time, format_datetime,
@@ -100,7 +102,9 @@ BADGE_DEFINITIONS = (
("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und MetalCircle aufgebaut", "special"),
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"),
("captns_mate", "Captns Mate", "☠️", None, "Die treue Gefährtin des Captains", "special"),
("alpha_tester", "Alpha Tester", "👑", None, "Schon in der Alpha dabei unsere frühesten Tester", "beta"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("early_bird", "Early Bird", "🐦", None, "Früh Teil der MetalCircle-Community geworden", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
@@ -121,7 +125,6 @@ VENUE_BADGE_CODES = tuple(
badge_code for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "venue"
)
BETA_REGISTRATION_DEADLINE = datetime(2026, 9, 16)
BADGE_BY_CODE = {
badge_code: (name, icon, threshold, description, category)
for badge_code, name, icon, threshold, description, category in BADGE_DEFINITIONS
@@ -527,7 +530,14 @@ def ensure_schema():
@asynccontextmanager
async def lifespan(_app: FastAPI):
ensure_schema()
yield
with get_db_connection() as connection:
community_badges.reconcile(connection.cursor())
worker = notifications.PushWorker(get_db_connection)
worker.start()
try:
yield
finally:
worker.stop()
app = FastAPI(title="MetalCircle", lifespan=lifespan)
@@ -638,9 +648,14 @@ async def localize_request(request: Request, call_next):
@app.get("/language/{language}")
def change_language(language: str, next: str = "/"):
def change_language(request: Request, language: str, next: str = "/"):
if language not in {"de", "en"}:
return HTMLResponse(_("Ungültige Sprache."), status_code=400)
if request.cookies.get(SESSION_COOKIE):
user = get_current_user(request)
if user:
with get_db_connection() as connection:
notifications.save_language(connection, user['id'], language)
response = RedirectResponse(safe_return_path(next), status_code=303)
response.set_cookie(
LANGUAGE_COOKIE, language, max_age=365 * 24 * 60 * 60,
@@ -1494,15 +1509,7 @@ def grant_earned_badges(user_id: int, stats: dict, registered_at):
with get_db_connection() as connection:
with connection.cursor() as cursor:
if registered_at < BETA_REGISTRATION_DEADLINE:
cursor.execute(
"""
INSERT INTO user_badges (user_id, badge_code)
VALUES (%s, 'beta_tester')
ON CONFLICT (user_id, badge_code) DO NOTHING
""",
(user_id,),
)
community_badges.reconcile(cursor, user_id)
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
if category == "attendance" and threshold is not None and stats["total"] >= threshold:
@@ -1545,7 +1552,10 @@ def load_badge_assets():
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT badge_code, path FROM badge_assets")
return {row[0]: row[1] for row in cursor.fetchall()}
assets = {'alpha_tester': '/static/images/patch-alpha-tester.svg',
'early_bird': '/static/images/patch-early-bird.svg'}
assets.update({row[0]: row[1] for row in cursor.fetchall()})
return assets
def load_profile(username: str):
@@ -2399,6 +2409,8 @@ def register_user(
user_id,
invite_id
))
community_badges.reconcile(cursor, user_id)
notifications.save_language(connection, user_id, current_language.get())
connection.commit()
@@ -2532,6 +2544,8 @@ def login(
with get_db_connection() as connection:
connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),))
connection.commit()
with get_db_connection() as connection:
notifications.save_language(connection, row[0], current_language.get())
response = RedirectResponse(next_path, status_code=303)
return attach_session(response, create_session(row[0]))
@@ -2735,6 +2749,8 @@ def render_profile(
}
badges.sort(key=lambda badge: badge["sort_key"])
with get_db_connection() as connection:
push_preferences = notifications.preferences(connection, viewer['id'])
template = templates.get_template("profile.html")
return HTMLResponse(
template.render(
@@ -2750,6 +2766,7 @@ def render_profile(
form_error=form_error,
form_success=form_success,
instagram_input=instagram_input,
push_preferences=push_preferences,
),
status_code=status_code,
)
@@ -2766,6 +2783,8 @@ def own_profile(request: Request, saved: str = ""):
messages.append(_("Profilbild aktualisiert"))
if "instagram" in saved_items:
messages.append(_("Instagram verknüpft"))
if "notifications" in saved_items:
messages.append(_("Benachrichtigungseinstellungen gespeichert"))
return render_profile(
request,
user["username"],
@@ -2901,11 +2920,14 @@ def export_profile_data(request: Request):
)
badges = cursor.fetchall()
push_preferences = notifications.preferences(connection, user_id)
def rows_to_dicts(rows, keys):
return [dict(zip(keys, row)) for row in rows]
data = {
"export_version": 1,
"notification_preferences": push_preferences,
"exported_at": datetime.now(),
"account": dict(zip(
("id", "username", "email", "display_name", "avatar_path",
@@ -3062,10 +3084,13 @@ def send_friend_request(request: Request, username: str):
"""
INSERT INTO friendships (requester_id, addressee_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
ON CONFLICT DO NOTHING RETURNING id
""",
(user["id"], profile["id"]),
)
created = cursor.fetchone()
if created:
notifications.enqueue(cursor, 'friend_request', user['id'], profile['id'], created[0])
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@@ -3319,9 +3344,10 @@ def send_message(request: Request, username: str, body: str = Form(...)):
if not partner:
return HTMLResponse(_("Chat nicht erlaubt. Er ist für Freunde und Unterhaltungen mit Admins verfügbar."), status_code=403)
cursor.execute(
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s)",
"INSERT INTO direct_messages (sender_id, recipient_id, body) VALUES (%s, %s, %s) RETURNING id",
(user["id"], partner["id"], body),
)
notifications.enqueue(cursor, 'direct_message', user['id'], partner['id'], cursor.fetchone()[0])
connection.commit()
return RedirectResponse(f"/messages/{partner['username']}#latest", status_code=303)
@@ -4026,10 +4052,13 @@ async def create_concert(
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users
WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
for invited_id, in cursor.fetchall():
notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id,
'invitation:' + uuid.uuid4().hex)
connection.commit()
@@ -4257,10 +4286,13 @@ async def edit_concert(
"""
INSERT INTO event_invitations (concert_id, user_id, invited_by)
SELECT %s, id, %s FROM users WHERE id = ANY(%s) AND id <> %s
ON CONFLICT (concert_id, user_id) DO NOTHING
ON CONFLICT (concert_id, user_id) DO NOTHING RETURNING user_id
""",
(concert_id, user["id"], invited_user_ids, user["id"]),
)
for invited_id, in cursor.fetchall():
notifications.enqueue(cursor, 'event_invitation', user['id'], invited_id, concert_id,
'invitation:' + uuid.uuid4().hex)
elif can_manage_event_access(user, concert):
cursor.execute("DELETE FROM event_invitations WHERE concert_id = %s", (concert_id,))
if can_edit_title(user, concert):
@@ -5115,3 +5147,4 @@ def search_venues(q: str):
push_devices.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE)
bug_reporter.register_routes(app, templates, get_db_connection, get_current_user)
notifications.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE)
+239
View File
@@ -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
View File
@@ -1,4 +1,4 @@
"""Device registration only. This module deliberately contains no push sender."""
"""Device registration. Background sending lives in notifications.py."""
import hashlib
from uuid import UUID
@@ -6,6 +6,8 @@ from uuid import UUID
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from i18n import current_language
from notifications import save_language
class DeviceRegistration(BaseModel):
@@ -59,6 +61,7 @@ def register_routes(app, get_db, get_user, cookie_name):
RETURNING id
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
device_id = cursor.fetchone()[0]
save_language(connection, user['id'], current_language.get())
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
+6
View File
@@ -609,3 +609,9 @@ h1, .page-title h1 {
.bug-success { color:#bbf7d0; }
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
@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; }
+8
View File
@@ -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

+8
View File
@@ -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

+34 -3
View File
@@ -15,6 +15,17 @@
let sendQueue = Promise.resolve();
let panel;
async function notificationTarget(notification) {
const data = notification?.data || {};
if (!/^[a-f0-9-]{36}$/.test(data.notification_id || '') || !/^[a-f0-9]{64}$/.test(data.session_tag || '')) return null;
const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store'});
if (!response.ok || stopped) return null;
const session = await response.json();
const native = await device.getInfo();
if (!session.authenticated || session.session_tag !== data.session_tag || native.binding !== data.session_tag) return null;
return '/notifications/' + data.notification_id;
}
function notice(message, offerPermission = false) {
if (!panel) {
panel = document.createElement('section');
@@ -130,9 +141,29 @@
});
}),
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
push.addListener('pushNotificationActionPerformed', () => {
// Do not navigate to arbitrary URLs supplied by a notification payload.
location.assign('/');
push.addListener('pushNotificationActionPerformed', async event => {
try {
const target = await notificationTarget(event.notification);
if (target) location.assign(target);
} catch (_) { /* A tap never bypasses current-session authorization. */ }
}),
push.addListener('pushNotificationReceived', async notification => {
try {
const target = await notificationTarget(notification);
if (!target) return;
document.getElementById('push-in-app-notice')?.remove();
const banner = document.createElement('section');
banner.id = 'push-in-app-notice';
banner.className = 'native-push-panel';
banner.setAttribute('role', 'status');
const link = document.createElement('a');
link.className = 'button';
link.href = target;
// Generic localized text; never insert remote HTML or private message content.
link.textContent = texts.openNotification;
banner.append(link);
(document.querySelector('main') || document.body).prepend(banner);
} catch (_) { /* Push reception cannot interrupt use of the app. */ }
})
]).then(() => {
synchronize();
+2 -2
View File
@@ -1,4 +1,4 @@
<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>
<a href="{{ language_url('en') }}" lang="en" hreflang="en" aria-label="English"{% if language() == 'en' %} aria-current="true" class="active"{% endif %}>EN</a>
{% set target_language = 'en' if language() == 'de' else 'de' %}
<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>
+1
View File
@@ -1,4 +1,5 @@
<script id="native-push-config" type="application/json">{{ {
'openNotification': _('Neue Benachrichtigung öffnen'),
'permission': _('Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?'),
'enable': _('Benachrichtigungen aktivieren'),
'later': _('Später'),
+1
View File
@@ -19,6 +19,7 @@
<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>{{ _('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>
<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>
+11 -1
View File
@@ -135,7 +135,7 @@
{% for badge in badges %}
{% if badge.earned %}
<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 %}
<img class="patch-image" src="{{ badge.image_path }}" alt="Patch {{ badge.name | t }}">
{% else %}
@@ -243,6 +243,16 @@
<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>
</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">
<details class="account-delete">
<summary>{{ _('Account löschen') }}</summary>
+64
View File
@@ -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);
});
+7 -2
View File
@@ -34,12 +34,16 @@ class FeatureApiTests(unittest.TestCase):
with main.get_db_connection() as db:
db.execute('''
CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE,
display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE);
display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE, expires_at TIMESTAMP);
CREATE TABLE friendships(addressee_id INTEGER, status TEXT);
CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP);
CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP);
CREATE TABLE user_badges(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
badge_code TEXT, awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id,badge_code));
''')
for statement in FEATURE_SCHEMA: db.execute(statement)
cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode()
@@ -81,7 +85,8 @@ class FeatureApiTests(unittest.TestCase):
def test_migrations_repeat_and_match_startup_schema(self):
with main.get_db_connection() as db:
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql'), FEATURE_SCHEMA):
for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql',
'21_push_notifications.sql', '22_registration_badges.sql'), FEATURE_SCHEMA):
source = Path('/test-migrations', name).read_text()
normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s))
self.assertEqual(normalize(source), normalize(runtime))
+3 -2
View File
@@ -69,7 +69,7 @@ class TranslationTests(unittest.TestCase):
for path, title in [('/datenschutz', 'Privacy policy'), ('/impressum', 'Legal notice')]:
page = client.get(path)
self.assertIn(title, page.text)
self.assertIn('aria-label="English" aria-current="true"', page.text)
self.assertIn('aria-label="Switch to German"', page.text)
response = client.get('/language/de?next=/login')
self.assertIn('Anmelden', response.text)
self.assertIn('<html lang="de">', response.text)
@@ -136,7 +136,8 @@ class TranslationTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'Instagram') as error:
main.normalize_instagram_url('https://evil.example/test')
self.assertIn('Please enter', str(error.exception))
response = main.change_language('invalid')
from starlette.requests import Request
response = main.change_language(Request({'type': 'http', 'headers': []}), 'invalid')
self.assertEqual(response.body, b'Invalid language.')
+334
View File
@@ -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()