diff --git a/.env.example b/.env.example index f308c3b..51f1a65 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,5 @@ INITIAL_ADMIN_USERNAME=Kai INITIAL_ADMIN_PASSWORD=CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD INITIAL_ADMIN_EMAIL=admin@example.invalid -# In Produktion hinter HTTPS auf true setzen. -COOKIE_SECURE=false +# Produktion ausschließlich hinter HTTPS betreiben. +COOKIE_SECURE=true diff --git a/android/android/build.gradle b/android/android/build.gradle index 85a5dda..9e26617 100644 --- a/android/android/build.gradle +++ b/android/android/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - + repositories { google() mavenCentral() diff --git a/app/main.py b/app/main.py index 86c69a4..87fd014 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/static/css/style.css b/app/static/css/style.css index 03b326e..76f0943 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -43,6 +43,7 @@ header { position: sticky; top: 0; z-index: 1000; + isolation: isolate; backdrop-filter: blur(10px); } @@ -79,12 +80,13 @@ header { color: var(--accent); } -.user-menu { position: relative; margin-left: auto; } +.user-menu { position: relative; z-index: 2000; margin-left: auto; } .header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; min-width: 0; } .user-menu summary { padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; cursor: pointer; list-style: none; font-weight: 700; } .user-menu summary::-webkit-details-marker { display: none; } +.user-menu[open] { z-index: 3000; } .user-menu[open] summary { border-color: var(--accent); background: var(--surface-hover); } -.user-menu-panel { position: absolute; top: calc(100% + 7px); right: 0; z-index: 1100; min-width: 170px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.6); } +.user-menu-panel { position: fixed; top: 72px; right: max(15px, calc((100vw - 1100px) / 2 + 20px)); z-index: 10000; min-width: 190px; padding: 7px; background: #090909; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 35px rgba(0,0,0,.75); } .user-menu-panel a, .user-menu-panel button { display: block; width: 100%; padding: 10px 11px; color: var(--text); background: transparent; border: 0; border-radius: 7px; text-align: left; font: inherit; cursor: pointer; } .user-menu-panel a:hover, .user-menu-panel button:hover { background: var(--surface-hover); color: #f87171; } .user-menu-panel form { max-width: none; margin: 0; } diff --git a/app/templates/index.html b/app/templates/index.html index 01bf6b8..e63717d 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -275,8 +275,8 @@ + {% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}Filter löschen{% endif %} - {% if filters.date_from or filters.date_to or filters.venue or filters.country or filters.category %}Filter löschen{% endif %} diff --git a/app/templates/profile.html b/app/templates/profile.html index a86ef8a..a16918f 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -52,6 +52,10 @@ .visibility-options label { display: flex; align-items: flex-start; gap: 9px; margin: 0; } .visibility-options input { width: auto; margin: 4px 0 0; } .connections { margin-top: 24px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; } + .connections-details > summary { color:#fca5a5; cursor:pointer; font-size:1.35rem; font-weight:700; } + .connections-content { margin-top:18px; } + .blocked-users { margin-top:18px; padding-top:12px; border-top:1px solid var(--border); } + .blocked-users > summary { width:fit-content; color:var(--muted); cursor:pointer; font-size:.78rem; text-decoration:underline; } .connection-list { display: grid; gap: 9px; } .connection-row { display: flex; align-items: center; gap: 11px; padding: 11px; background: #090909; border: 1px solid var(--border); border-radius: 10px; } .connection-row > a { display: flex; min-width: 0; flex: 1; align-items: center; gap: 10px; } @@ -90,21 +94,29 @@
Interaktionen mit diesem Nutzer sind nicht verfügbar.
{% endif %} {% if not can_view_details %}Weitere Profildetails sind nur für Freunde sichtbar.
{% endif %} {% endif %} @@ -134,7 +146,9 @@ {% if is_own_profile %}{{ connections.outgoing|length }} gesendete Anfrage(n) warten noch auf Antwort.
{% endif %} {% if not connections.incoming and not connections.friends and not connections.outgoing %}Noch keine Verbindungen. Finde andere User über die Suche auf der Startseite.
{% endif %} +Keine blockierten Nutzer.
+ {% endif %} +