feat: expand MetalCircle app and privacy controls
This commit is contained in:
+163
-14
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user