feat: expand MetalCircle app and privacy controls

This commit is contained in:
kai
2026-08-28 12:57:31 +02:00
parent bba611ca94
commit e72b93a74a
9 changed files with 239 additions and 33 deletions
+163 -14
View File
@@ -83,17 +83,21 @@ BADGE_DEFINITIONS = (
("admin", "Admin", "🏴‍☠️", None, "Verantwortung für MetalCircle", "special"),
("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"),
("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"),
("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"),
("tour_veteran", "Stammgast · Level 25", "", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"),
("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"),
("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"),
("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "venue"),
("ten_gigs", "10 Gigs", "🔥", 10, "10 besuchte Konzerte", "attendance"),
("tour_veteran", "25 Gigs", "", 25, "25 besuchte Konzerte", "attendance"),
("fifty_gigs", "50 Gigs", "💀", 50, "50 besuchte Konzerte", "attendance"),
("hundred_gigs", "100 Gigs", "👑", 100, "100 besuchte Konzerte", "attendance"),
)
ATTENDANCE_BADGE_CODES = tuple(
badge_code
for badge_code, _name, _icon, _threshold, _description, category in BADGE_DEFINITIONS
if category == "attendance"
)
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)
@@ -344,6 +348,19 @@ def ensure_schema():
ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id))
""",
"""
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
)
""",
"""
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked
ON user_blocks (blocked_id, blocker_id)
""",
"""
CREATE TABLE IF NOT EXISTS direct_messages (
id SERIAL PRIMARY KEY,
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -412,13 +429,22 @@ app = FastAPI(title="MetalCircle", lifespan=lifespan)
@app.middleware("http")
async def security_controls(request: Request, call_next):
if COOKIE_SECURE and request.url.path not in {"/impressum", "/datenschutz"}:
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme).split(",", 1)[0].strip()
if forwarded_proto != "https":
target = str(request.url).replace("http://", "https://", 1)
return RedirectResponse(target, status_code=308)
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
origin = request.headers.get("origin")
fetch_site = request.headers.get("sec-fetch-site")
origin_host = urlparse(origin).netloc if origin else None
expected_host = request.headers.get("host", request.url.netloc)
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host):
referer = request.headers.get("referer")
referer_host = urlparse(referer).netloc if referer else None
if fetch_site == "cross-site" or (origin_host and origin_host != expected_host) or (referer_host and referer_host != expected_host):
return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
if request.url.path not in {"/login", "/register"} and request.url.path.startswith("/password-reset") is False and not origin and not referer:
return HTMLResponse("CSRF-Prüfung fehlgeschlagen.", status_code=403)
path = request.url.path
if path == "/login":
@@ -1133,11 +1159,10 @@ def attended_concert_stats(user_id: int) -> tuple[int, int]:
def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at):
highest_attendance_badge = None
venue_badge = "regular" if max_same_venue >= 5 else None
for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS:
qualifies = category == "attendance" and attended_count >= threshold
if category == "attendance" and threshold >= 5:
qualifies = max_same_venue >= threshold
qualifies = category == "attendance" and threshold is not None and attended_count >= threshold
if qualifies:
highest_attendance_badge = badge_code
@@ -1157,7 +1182,7 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis
# erreichte Stufe anzeigen (ältere Stufen werden ersetzt).
cursor.execute(
"DELETE FROM user_badges WHERE user_id = %s AND badge_code = ANY(%s)",
(user_id, list(ATTENDANCE_BADGE_CODES)),
(user_id, list(ATTENDANCE_BADGE_CODES + VENUE_BADGE_CODES)),
)
if highest_attendance_badge:
cursor.execute(
@@ -1168,6 +1193,11 @@ def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, regis
""",
(user_id, highest_attendance_badge),
)
if venue_badge:
cursor.execute(
"INSERT INTO user_badges (user_id, badge_code) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(user_id, venue_badge),
)
connection.commit()
@@ -2221,6 +2251,7 @@ def render_profile(
is_own_profile = bool(viewer and viewer["id"] == profile["id"])
friendship = None
block_status = {"blocked_by_viewer": False, "blocked_viewer": False}
if viewer and not is_own_profile:
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2239,13 +2270,26 @@ def render_profile(
"id": row[0], "requester_id": row[1],
"addressee_id": row[2], "status": row[3],
}
cursor.execute(
"""
SELECT blocker_id, blocked_id FROM user_blocks
WHERE (blocker_id = %s AND blocked_id = %s)
OR (blocker_id = %s AND blocked_id = %s)
""",
(viewer["id"], profile["id"], profile["id"], viewer["id"]),
)
for blocker_id, _blocked_id in cursor.fetchall():
if blocker_id == viewer["id"]:
block_status["blocked_by_viewer"] = True
else:
block_status["blocked_viewer"] = True
can_view_details = (
profile["profile_visibility"] == "public"
or is_own_profile
or bool(viewer and viewer["is_admin"])
or bool(friendship and friendship["status"] == "accepted")
)
connections = {"incoming": [], "outgoing": [], "friends": []}
connections = {"incoming": [], "outgoing": [], "friends": [], "blocked": []}
if is_own_profile:
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2276,6 +2320,20 @@ def render_profile(
connections["incoming"].append(item)
else:
connections["outgoing"].append(item)
cursor.execute(
"""
SELECT u.username, COALESCE(u.display_name, u.username), u.avatar_path
FROM user_blocks b
JOIN users u ON u.id = b.blocked_id
WHERE b.blocker_id = %s
ORDER BY COALESCE(u.display_name, u.username), u.username
""",
(viewer["id"],),
)
connections["blocked"] = [
{"username": row[0], "display_name": row[1], "avatar_path": row[2]}
for row in cursor.fetchall()
]
attended_count, max_same_venue = attended_concert_stats(profile["id"])
grant_earned_badges(
profile["id"],
@@ -2311,6 +2369,7 @@ def render_profile(
is_own_profile=force_own or is_own_profile,
can_view_details=can_view_details,
friendship=friendship,
block_status=block_status,
connections=connections,
form_error=form_error,
form_success=form_success,
@@ -2389,6 +2448,16 @@ def export_profile_data(request: Request):
)
friendships = cursor.fetchall()
cursor.execute(
"""
SELECT blocker_id, blocked_id, created_at
FROM user_blocks WHERE blocker_id = %s OR blocked_id = %s
ORDER BY created_at
""",
(user_id, user_id),
)
blocks = cursor.fetchall()
cursor.execute(
"""
SELECT id, sender_id, recipient_id, body, read_at, created_at
@@ -2454,6 +2523,7 @@ def export_profile_data(request: Request):
),
"attendance": rows_to_dicts(attendance, ("concert_id", "status", "updated_at")),
"friendships": rows_to_dicts(friendships, ("id", "requester_id", "addressee_id", "status", "created_at", "updated_at")),
"blocks": rows_to_dicts(blocks, ("blocker_id", "blocked_id", "created_at")),
"messages": rows_to_dicts(messages, ("id", "sender_id", "recipient_id", "body", "read_at", "created_at")),
"event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")),
"comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")),
@@ -2476,9 +2546,18 @@ def delete_own_account(request: Request):
user_id = user["id"]
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT avatar_path FROM users WHERE id = %s", (user_id,))
avatar_path = cursor.fetchone()[0]
cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("SELECT flyer_path FROM concerts WHERE created_by = %s AND flyer_path IS NOT NULL", (user_id,))
flyer_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("UPDATE registration_invites SET used_by = NULL WHERE used_by = %s", (user_id,))
cursor.execute("UPDATE concerts SET flyer_path = NULL WHERE created_by = %s", (user_id,))
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
for path, directory, prefix in [(avatar_path, AVATAR_DIR, "/static/uploads/avatars/")] + [(p, PHOTO_DIR, "/static/uploads/photos/") for p in photo_paths] + [(p, UPLOAD_DIR, "/static/uploads/flyers/") for p in flyer_paths]:
remove_uploaded_file(path, directory, prefix)
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE, path="/", secure=COOKIE_SECURE, samesite="lax")
return response
@@ -2566,6 +2645,16 @@ def send_friend_request(request: Request, username: str):
return HTMLResponse("Du kannst dir nicht selbst eine Anfrage schicken.", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT 1 FROM user_blocks
WHERE (blocker_id = %s AND blocked_id = %s)
OR (blocker_id = %s AND blocked_id = %s)
""",
(user["id"], profile["id"], profile["id"], user["id"]),
)
if cursor.fetchone():
return HTMLResponse("Freundschaftsanfrage wegen einer Blockierung nicht möglich.", status_code=403)
cursor.execute(
"""
INSERT INTO friendships (requester_id, addressee_id)
@@ -2578,6 +2667,51 @@ def send_friend_request(request: Request, username: str):
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/users/{username}/block")
def block_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
if profile["id"] == user["id"]:
return HTMLResponse("Du kannst dich nicht selbst blockieren.", status_code=400)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES (%s, %s) ON CONFLICT DO NOTHING
""",
(user["id"], profile["id"]),
)
cursor.execute(
"""
DELETE FROM friendships
WHERE (requester_id = %s AND addressee_id = %s)
OR (requester_id = %s AND addressee_id = %s)
""",
(user["id"], profile["id"], profile["id"], user["id"]),
)
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/users/{username}/unblock")
def unblock_user(request: Request, username: str):
user = get_current_user(request)
profile = load_profile(username)
if not profile:
return HTMLResponse("Benutzer nicht gefunden.", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
cursor.execute(
"DELETE FROM user_blocks WHERE blocker_id = %s AND blocked_id = %s",
(user["id"], profile["id"]),
)
connection.commit()
return RedirectResponse(f"/users/{profile['username']}", status_code=303)
@app.post("/friendships/{friendship_id}/{action}")
def manage_friendship(request: Request, friendship_id: int, action: str, return_to: str = Form("")):
user = get_current_user(request)
@@ -2626,6 +2760,11 @@ def load_chat_partner(cursor, user, username: str):
FROM users u
WHERE LOWER(u.username) = LOWER(%s)
AND u.id <> %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = u.id)
OR (b.blocker_id = u.id AND b.blocked_id = %s)
)
AND (%s OR u.is_admin OR EXISTS (
SELECT 1 FROM friendships f
WHERE f.status = 'accepted'
@@ -2633,7 +2772,7 @@ def load_chat_partner(cursor, user, username: str):
OR (f.addressee_id = %s AND f.requester_id = u.id))
))
""",
(username, user["id"], user["is_admin"], user["id"], user["id"]),
(username, user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]),
)
row = cursor.fetchone()
if not row:
@@ -2686,6 +2825,11 @@ def message_inbox(request: Request):
SELECT u.id, u.username, COALESCE(u.display_name, u.username), u.avatar_path
FROM users u
WHERE u.id <> %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = u.id)
OR (b.blocker_id = u.id AND b.blocked_id = %s)
)
AND (%s OR u.is_admin OR EXISTS (
SELECT 1 FROM friendships f
WHERE f.status = 'accepted'
@@ -2694,7 +2838,7 @@ def message_inbox(request: Request):
))
ORDER BY COALESCE(u.display_name, u.username)
""",
(user["id"], user["is_admin"], user["id"], user["id"]),
(user["id"], user["id"], user["id"], user["is_admin"], user["id"], user["id"]),
)
for row in cursor.fetchall():
cursor.execute(
@@ -2906,9 +3050,14 @@ def concert_detail(request: Request, concert_id: int):
FROM concert_attendance
JOIN users ON users.id = concert_attendance.user_id
WHERE concert_attendance.concert_id = %s
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = %s AND b.blocked_id = concert_attendance.user_id)
OR (b.blocker_id = concert_attendance.user_id AND b.blocked_id = %s)
)
ORDER BY users.display_name NULLS LAST, users.username
""",
(concert_id,),
(concert_id, user["id"], user["id"]),
)
attendance_rows = cursor.fetchall()