diff --git a/.gitignore b/.gitignore
index 73ce422..357235e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ env/
.env
.env.*
!.env.example
+compose.dev.yml
# Logs
*.log
diff --git a/app/Dockerfile b/app/Dockerfile
index 7bb86f1..8e391ed 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -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
+RUN pip install --no-cache-dir fastapi uvicorn "psycopg[binary]" python-multipart jinja2 httpx bcrypt Pillow
COPY . .
diff --git a/app/main.py b/app/main.py
index bac8473..b25f54f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -3,6 +3,11 @@ import uuid
import secrets
import hashlib
import re
+import time
+import threading
+from collections import defaultdict, deque
+from io import BytesIO
+from urllib.parse import urlparse
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
@@ -10,6 +15,11 @@ import bcrypt
import httpx
import psycopg
+try:
+ from PIL import Image, ImageOps, UnidentifiedImageError
+except ImportError:
+ Image = ImageOps = UnidentifiedImageError = None
+
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -56,13 +66,18 @@ os.makedirs(PATCH_DIR, exist_ok=True)
SESSION_COOKIE = "pingu_session"
SESSION_DAYS = 30
+COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
MAX_IMAGE_BYTES = 10 * 1024 * 1024
+MAX_IMAGE_PIXELS = 25_000_000
+rate_limit_buckets = defaultdict(deque)
+rate_limit_lock = threading.Lock()
INITIAL_ADMIN_USERNAME = os.environ.get("INITIAL_ADMIN_USERNAME")
INITIAL_ADMIN_PASSWORD = os.environ.get("INITIAL_ADMIN_PASSWORD")
INITIAL_ADMIN_EMAIL = os.environ.get("INITIAL_ADMIN_EMAIL")
BADGE_DEFINITIONS = (
+ ("founder", "Gründer", "⚔️", None, "Von Anfang an dabei und Pingu Concerts mit aufgebaut", "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 besucht", "attendance"),
@@ -152,13 +167,30 @@ def ensure_schema():
concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (
- status IN ('attending', 'maybe', 'ticket_search')
+ status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer')
),
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (concert_id, user_id)
)
""",
"""
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'concert_attendance_status_check'
+ AND NOT (
+ pg_get_constraintdef(oid) LIKE '%ticket_search%'
+ AND pg_get_constraintdef(oid) LIKE '%ticket_offer%'
+ )
+ ) THEN
+ ALTER TABLE concert_attendance DROP CONSTRAINT concert_attendance_status_check;
+ ALTER TABLE concert_attendance ADD CONSTRAINT concert_attendance_status_check
+ CHECK (status IN ('attending', 'maybe', 'ticket_search', 'ticket_offer'));
+ END IF;
+ END $$
+ """,
+ """
CREATE TABLE IF NOT EXISTS user_badges (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_code VARCHAR(50) NOT NULL,
@@ -190,6 +222,118 @@ def ensure_schema():
CREATE INDEX IF NOT EXISTS idx_venue_aliases_alias
ON venue_aliases (LOWER(alias))
""",
+ """
+ ALTER TABLE concerts
+ ADD COLUMN IF NOT EXISTS event_type VARCHAR(20) NOT NULL DEFAULT 'concert'
+ CHECK (event_type IN ('concert', 'festival', 'other'))
+ """,
+ """
+ ALTER TABLE concerts
+ ADD COLUMN IF NOT EXISTS parent_event_id INTEGER
+ REFERENCES concerts(id) ON DELETE SET NULL
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_concerts_parent_event_id
+ ON concerts (parent_event_id)
+ """,
+ """
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS instagram_url VARCHAR(500)
+ """,
+ """
+ ALTER TABLE users DROP COLUMN IF EXISTS email_verified
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS account_tokens (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ purpose VARCHAR(30) NOT NULL CHECK (purpose = 'password_reset'),
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at TIMESTAMP NOT NULL,
+ used_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ """
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'account_tokens_purpose_check'
+ AND pg_get_constraintdef(oid) LIKE '%verify_email%'
+ ) THEN
+ DELETE FROM account_tokens WHERE purpose = 'verify_email';
+ ALTER TABLE account_tokens DROP CONSTRAINT account_tokens_purpose_check;
+ ALTER TABLE account_tokens ADD CONSTRAINT account_tokens_purpose_check
+ CHECK (purpose = 'password_reset');
+ END IF;
+ END $$
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_account_tokens_lookup
+ ON account_tokens (token_hash, purpose, expires_at)
+ """,
+ """
+ ALTER TABLE users
+ ADD COLUMN IF NOT EXISTS profile_visibility VARCHAR(20) NOT NULL DEFAULT 'friends'
+ CHECK (profile_visibility IN ('public', 'friends', 'nobody'))
+ """,
+ """
+ ALTER TABLE users ALTER COLUMN profile_visibility SET DEFAULT 'friends'
+ """,
+ """
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'users_profile_visibility_check'
+ AND pg_get_constraintdef(oid) LIKE '%nobody%'
+ ) THEN
+ ALTER TABLE users DROP CONSTRAINT IF EXISTS users_profile_visibility_check;
+ ALTER TABLE users ADD CONSTRAINT users_profile_visibility_check
+ CHECK (profile_visibility IN ('public', 'friends', 'nobody'));
+ END IF;
+ END $$
+ """,
+ """
+ ALTER TABLE concerts ADD COLUMN IF NOT EXISTS flyer_url TEXT
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS friendships (
+ id SERIAL PRIMARY KEY,
+ requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'accepted')),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CHECK (requester_id <> addressee_id),
+ UNIQUE (requester_id, addressee_id)
+ )
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_friendships_pair
+ ON friendships (LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id))
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS direct_messages (
+ id SERIAL PRIMARY KEY,
+ sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ body TEXT NOT NULL,
+ read_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CHECK (sender_id <> recipient_id)
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_direct_messages_conversation
+ ON direct_messages (sender_id, recipient_id, created_at)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_direct_messages_unread
+ ON direct_messages (recipient_id, read_at)
+ """,
]
with get_db_connection() as connection:
@@ -199,12 +343,11 @@ def ensure_schema():
if INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD:
cursor.execute(
- "SELECT id FROM users WHERE username = %s",
- (INITIAL_ADMIN_USERNAME,),
+ "SELECT EXISTS (SELECT 1 FROM users)",
)
- existing_user = cursor.fetchone()
+ users_exist = cursor.fetchone()[0]
- if not existing_user:
+ if not users_exist:
cursor.execute(
"""
INSERT INTO users (
@@ -239,12 +382,56 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="Pingu Concerts", lifespan=lifespan)
+@app.middleware("http")
+async def security_controls(request: Request, call_next):
+ 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):
+ return HTMLResponse("Anfrage aus fremder Quelle abgelehnt.", status_code=403)
+
+ path = request.url.path
+ if path == "/login":
+ bucket_name, limit, window = "login", 10, 15 * 60
+ elif path == "/register" or path.startswith("/password-reset"):
+ bucket_name, limit, window = "account", 10, 60 * 60
+ elif path.startswith("/messages/"):
+ bucket_name, limit, window = "messages", 30, 60
+ elif any(part in path for part in ("/photos", "/patches")) or path in {"/profile", "/concerts"}:
+ bucket_name, limit, window = "uploads", 20, 60 * 60
+ else:
+ bucket_name, limit, window = "writes", 120, 60
+ client_host = request.client.host if request.client else "unknown"
+ key = (client_host, bucket_name)
+ now = time.monotonic()
+ with rate_limit_lock:
+ bucket = rate_limit_buckets[key]
+ while bucket and bucket[0] <= now - window:
+ bucket.popleft()
+ if len(bucket) >= limit:
+ return HTMLResponse("Zu viele Anfragen. Bitte später erneut versuchen.", status_code=429,
+ headers={"Retry-After": str(window)})
+ bucket.append(now)
+
+ response = await call_next(request)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
+ if COOKIE_SECURE:
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
+ return response
+
+
@app.middleware("http")
async def require_login(request: Request, call_next):
if (
request.url.path == "/login"
or request.url.path == "/register"
or request.url.path.startswith("/register/")
+ or request.url.path.startswith("/password-reset")
or request.url.path.startswith("/static/")
):
return await call_next(request)
@@ -294,6 +481,25 @@ def generate_token() -> str:
return secrets.token_urlsafe(32)
+def create_account_token(user_id: int, purpose: str, hours: int = 24) -> str:
+ token = generate_token()
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "DELETE FROM account_tokens WHERE user_id = %s AND purpose = %s AND used_at IS NULL",
+ (user_id, purpose),
+ )
+ cursor.execute(
+ """
+ INSERT INTO account_tokens (user_id, purpose, token_hash, expires_at)
+ VALUES (%s, %s, %s, %s)
+ """,
+ (user_id, purpose, hash_token(token), datetime.now() + timedelta(hours=hours)),
+ )
+ connection.commit()
+ return token
+
+
def user_from_row(row):
if not row:
return None
@@ -303,6 +509,9 @@ def user_from_row(row):
"username": row[1],
"display_name": row[2] or row[1],
"is_admin": bool(row[3]),
+ "pending_friend_count": row[4],
+ "unread_message_count": row[5],
+ "notification_count": row[4] + row[5],
}
@@ -320,7 +529,11 @@ def get_current_user(request: Request):
users.id,
users.username,
users.display_name,
- users.is_admin
+ users.is_admin,
+ (SELECT COUNT(*) FROM friendships
+ WHERE addressee_id = users.id AND status = 'pending'),
+ (SELECT COUNT(*) FROM direct_messages
+ WHERE recipient_id = users.id AND read_at IS NULL)
FROM sessions
JOIN users
ON users.id = sessions.user_id
@@ -363,6 +576,8 @@ def attach_session(response: RedirectResponse, token: str) -> RedirectResponse:
max_age=SESSION_DAYS * 24 * 60 * 60,
httponly=True,
samesite="lax",
+ secure=COOKIE_SECURE,
+ path="/",
)
return response
@@ -379,6 +594,85 @@ def concert_is_past(start_datetime, end_datetime) -> bool:
return end < datetime.now()
+EVENT_TYPES = {
+ "concert": "Konzert",
+ "festival": "Festival",
+ "other": "Sonstiges",
+}
+
+
+def get_linkable_events(exclude_id: int | None = None):
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ exclude_clause = "AND id <> %s" if exclude_id is not None else ""
+ parameters = (exclude_id,) if exclude_id is not None else ()
+ cursor.execute(
+ f"""
+ SELECT id, artist, start_datetime, event_type
+ FROM concerts
+ WHERE event_type IN ('concert', 'festival')
+ AND DATE(
+ CASE
+ WHEN event_type = 'festival'
+ THEN COALESCE(end_datetime, start_datetime)
+ ELSE start_datetime
+ END
+ ) >= CURRENT_DATE
+ {exclude_clause}
+ ORDER BY start_datetime DESC
+ """,
+ parameters,
+ )
+ rows = cursor.fetchall()
+ return [
+ {
+ "id": row[0],
+ "title": row[1],
+ "date": row[2].strftime("%d.%m.%Y"),
+ "event_type": row[3],
+ "event_type_label": EVENT_TYPES[row[3]],
+ }
+ for row in rows
+ ]
+
+
+def resolve_event_relationship(cursor, event_type: str, parent_event_id: str, event_id=None):
+ if event_type not in EVENT_TYPES:
+ raise ValueError("Ungültige Veranstaltungskategorie.")
+ if event_type != "other" or not parent_event_id:
+ return None
+
+ try:
+ parent_id = int(parent_event_id)
+ except ValueError as error:
+ raise ValueError("Ungültige Hauptveranstaltung.") from error
+ if event_id is not None and parent_id == event_id:
+ raise ValueError("Eine Veranstaltung kann nicht mit sich selbst verknüpft werden.")
+
+ cursor.execute(
+ """
+ SELECT
+ event_type,
+ DATE(
+ CASE
+ WHEN event_type = 'festival'
+ THEN COALESCE(end_datetime, start_datetime)
+ ELSE start_datetime
+ END
+ ) >= CURRENT_DATE AS is_linkable
+ FROM concerts
+ WHERE id = %s
+ """,
+ (parent_id,),
+ )
+ parent = cursor.fetchone()
+ if not parent or parent[0] not in {"concert", "festival"}:
+ raise ValueError("Die Hauptveranstaltung muss ein Konzert oder Festival sein.")
+ if not parent[1]:
+ raise ValueError("Die Hauptveranstaltung ist bereits beendet und kann nicht mehr verknüpft werden.")
+ return parent_id
+
+
def load_concert(concert_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -401,10 +695,17 @@ def load_concert(concert_id: int):
venues.city,
venues.country,
venues.latitude,
- venues.longitude
+ venues.longitude,
+ concerts.event_type,
+ concerts.parent_event_id,
+ parent_event.artist,
+ parent_event.event_type,
+ concerts.flyer_url
FROM concerts
LEFT JOIN venues
ON concerts.venue_id = venues.id
+ LEFT JOIN concerts AS parent_event
+ ON concerts.parent_event_id = parent_event.id
WHERE concerts.id = %s
""",
(concert_id,),
@@ -427,6 +728,7 @@ def load_concert(concert_id: int):
"ticket_url": row[5],
"ticket_price": row[6],
"flyer_path": row[7],
+ "flyer_url": row[21],
"created_by": row[8],
"is_past": is_past,
"date": start_datetime.strftime("%d.%m.%Y"),
@@ -435,6 +737,14 @@ def load_concert(concert_id: int):
"end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"end_time": end_datetime.strftime("%H:%M") if end_datetime else None,
"end_local": end_datetime.strftime("%Y-%m-%dT%H:%M") if end_datetime else "",
+ "event_type": row[17],
+ "event_type_label": EVENT_TYPES[row[17]],
+ "parent_event": {
+ "id": row[18],
+ "title": row[19],
+ "event_type": row[20],
+ "event_type_label": EVENT_TYPES.get(row[20]),
+ } if row[18] else None,
"venue": {
"id": row[9],
"name": row[10] or "Veranstaltungsort unbekannt",
@@ -477,7 +787,7 @@ def can_delete_concert(user, concert) -> bool:
def serialize_concert_card(row):
- concert_id, artist, start_datetime, venue, city = row
+ concert_id, artist, start_datetime, end_datetime, venue, city, event_type, parent_event_id = row
venue_text = venue or "Veranstaltungsort unbekannt"
if city:
venue_text += f", {city}"
@@ -487,10 +797,118 @@ def serialize_concert_card(row):
"artist": artist,
"date": start_datetime.strftime("%d.%m.%Y"),
"time": start_datetime.strftime("%H:%M"),
+ "end_date": end_datetime.strftime("%d.%m.%Y") if end_datetime else None,
"venue": venue_text,
+ "event_type": event_type,
+ "event_type_label": EVENT_TYPES[event_type],
+ "parent_event_id": parent_event_id,
+ "children": [],
+ "_start_datetime": start_datetime,
+ "_end_datetime": end_datetime,
}
+def build_event_overview(rows):
+ cards = {row[0]: serialize_concert_card(row) for row in rows}
+ roots = []
+ for card in cards.values():
+ parent = cards.get(card["parent_event_id"])
+ if card["event_type"] == "other" and parent:
+ parent["children"].append(card)
+ else:
+ roots.append(card)
+
+ for card in roots:
+ card["children"].sort(key=lambda child: child["_start_datetime"])
+
+ upcoming = [card for card in roots if not concert_is_past(
+ card["_start_datetime"], card["_end_datetime"]
+ )]
+ past = [card for card in roots if card not in upcoming]
+ upcoming.sort(key=lambda card: card["_start_datetime"])
+ past.sort(key=lambda card: card["_start_datetime"], reverse=True)
+ return upcoming, past
+
+
+def load_event_overview(search_query: str = ""):
+ query = """
+ SELECT concerts.id, concerts.artist, concerts.start_datetime,
+ concerts.end_datetime, venues.name, venues.city,
+ concerts.event_type, concerts.parent_event_id
+ FROM concerts
+ LEFT JOIN venues ON concerts.venue_id = venues.id
+ ORDER BY concerts.start_datetime ASC
+ """
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(query)
+ upcoming, past = build_event_overview(cursor.fetchall())
+
+ term = search_query.strip().casefold()
+ if not term:
+ return upcoming, past
+
+ def matches(card):
+ text = f"{card['artist']} {card['venue']} {card['event_type_label']}".casefold()
+ return term in text or any(matches(child) for child in card["children"])
+
+ return (
+ [card for card in upcoming if matches(card)],
+ [card for card in past if matches(card)],
+ )
+
+
+def search_users(search_query: str):
+ term = search_query.strip()
+ if not term:
+ return []
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT username, COALESCE(display_name, username), avatar_path
+ FROM users
+ WHERE username ILIKE %s OR COALESCE(display_name, '') ILIKE %s
+ ORDER BY COALESCE(display_name, username), username
+ LIMIT 30
+ """,
+ (f"%{term}%", f"%{term}%"),
+ )
+ return [
+ {"username": row[0], "display_name": row[1], "avatar_path": row[2]}
+ for row in cursor.fetchall()
+ ]
+
+
+def normalize_instagram_url(value: str):
+ value = value.strip()
+ if not value:
+ return None
+ if value.startswith("@"):
+ value = value[1:]
+ if re.fullmatch(r"[A-Za-z0-9._]{1,30}", value):
+ return f"https://www.instagram.com/{value}/"
+
+ candidate = value if "://" in value else f"https://{value}"
+ parsed = urlparse(candidate)
+ if (parsed.hostname or "").lower() not in {"instagram.com", "www.instagram.com"}:
+ raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
+ username = parsed.path.strip("/").split("/", 1)[0]
+ if not re.fullmatch(r"[A-Za-z0-9._]{1,30}", username):
+ raise ValueError("Bitte einen gültigen Instagram-Profillink eingeben.")
+ return f"https://www.instagram.com/{username}/"
+
+
+def normalize_external_url(value: str, field_name: str):
+ value = value.strip()
+ if not value:
+ return None
+ parsed = urlparse(value)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
+ raise ValueError(f"Bitte für {field_name} eine vollständige HTTP- oder HTTPS-Adresse eingeben.")
+ return value
+
+
def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
if not upload or not upload.filename:
return None, None
@@ -503,8 +921,6 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
status_code=400,
)
- filename = str(uuid.uuid4()) + extension
- destination = os.path.join(destination_dir, filename)
contents = upload.file.read()
if len(contents) > MAX_IMAGE_BYTES:
@@ -513,12 +929,52 @@ def save_image(upload: UploadFile, destination_dir: str, url_prefix: str):
status_code=400,
)
+ if Image is None:
+ return None, HTMLResponse(
+ "Die sichere Bildprüfung ist noch nicht installiert. Bitte den Web-Container neu bauen.",
+ status_code=503,
+ )
+
+ try:
+ Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
+ with Image.open(BytesIO(contents)) as candidate:
+ candidate.verify()
+ with Image.open(BytesIO(contents)) as candidate:
+ image = ImageOps.exif_transpose(candidate)
+ if image.width * image.height > MAX_IMAGE_PIXELS:
+ raise ValueError("Bildauflösung zu groß")
+ image.thumbnail((2400, 2400))
+ if image.mode not in {"RGB", "RGBA"}:
+ image = image.convert("RGBA" if "transparency" in image.info else "RGB")
+ output = BytesIO()
+ image.save(output, format="WEBP", quality=88, method=6)
+ safe_contents = output.getvalue()
+ except (ValueError, OSError, UnidentifiedImageError, Image.DecompressionBombError):
+ return None, HTMLResponse(
+ "Die Datei ist kein gültiges oder unterstütztes Bild.", status_code=400
+ )
+
+ filename = str(uuid.uuid4()) + ".webp"
+ destination = os.path.join(destination_dir, filename)
with open(destination, "wb") as file:
- file.write(contents)
+ file.write(safe_contents)
return f"{url_prefix}{filename}", None
+def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str):
+ if not path or not path.startswith(url_prefix):
+ return False
+ relative_name = path.removeprefix(url_prefix)
+ if not relative_name or os.path.basename(relative_name) != relative_name:
+ return False
+ file_path = os.path.join(destination_dir, relative_name)
+ if not os.path.isfile(file_path):
+ return False
+ os.remove(file_path)
+ return True
+
+
def attended_concert_count(user_id: int) -> int:
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -586,7 +1042,8 @@ def load_profile(username: str):
with connection.cursor() as cursor:
cursor.execute(
"""
- SELECT id, username, display_name, avatar_path, created_at
+ SELECT id, username, display_name, avatar_path, created_at, instagram_url,
+ profile_visibility, is_admin
FROM users
WHERE LOWER(username) = LOWER(%s)
""",
@@ -610,6 +1067,10 @@ def load_profile(username: str):
"avatar_path": row[3],
"created_at": row[4].strftime("%d.%m.%Y"),
"registered_at": row[4],
+ "instagram_url": row[5],
+ "instagram_handle": row[5].rstrip("/").rsplit("/", 1)[-1] if row[5] else None,
+ "profile_visibility": row[6],
+ "is_admin": bool(row[7]),
"earned_codes": earned_codes,
}
@@ -800,7 +1261,13 @@ def admin_users_page(request: Request):
with connection.cursor() as cursor:
cursor.execute(
"""
- SELECT id, username, email, display_name, is_admin, created_at
+ SELECT users.id, users.username, users.email, users.display_name,
+ users.is_admin, users.created_at,
+ EXISTS (
+ SELECT 1 FROM user_badges
+ WHERE user_badges.user_id = users.id
+ AND user_badges.badge_code = 'founder'
+ )
FROM users
ORDER BY is_admin DESC, username ASC
"""
@@ -815,6 +1282,7 @@ def admin_users_page(request: Request):
"display_name": row[3] or row[1],
"is_admin": bool(row[4]),
"created_at": row[5].strftime("%d.%m.%Y"),
+ "has_founder_badge": bool(row[6]),
}
for row in rows
]
@@ -889,7 +1357,13 @@ def create_invite(request: Request):
with connection.cursor() as cursor:
cursor.execute(
"""
- SELECT id, username, email, display_name, is_admin, created_at
+ SELECT users.id, users.username, users.email, users.display_name,
+ users.is_admin, users.created_at,
+ EXISTS (
+ SELECT 1 FROM user_badges
+ WHERE user_badges.user_id = users.id
+ AND user_badges.badge_code = 'founder'
+ )
FROM users
ORDER BY is_admin DESC, username ASC
"""
@@ -904,6 +1378,7 @@ def create_invite(request: Request):
"display_name": row[3] or row[1],
"is_admin": bool(row[4]),
"created_at": row[5].strftime("%d.%m.%Y"),
+ "has_founder_badge": bool(row[6]),
}
for row in rows
]
@@ -1026,12 +1501,7 @@ def delete_venue(request: Request, venue_id: int):
def remove_patch_file(path: str | None):
- if not path:
- return
- filename = os.path.basename(path)
- file_path = os.path.join(PATCH_DIR, filename)
- if os.path.isfile(file_path):
- os.remove(file_path)
+ remove_uploaded_file(path, PATCH_DIR, "/static/uploads/patches/")
@app.post("/admin/patches/{badge_code}")
@@ -1097,6 +1567,7 @@ def update_user_role(
request: Request,
user_id: int,
role: str = Form(...),
+ founder_badge: str = Form(""),
):
user = require_admin(request)
@@ -1116,6 +1587,20 @@ def update_user_role(
"UPDATE users SET is_admin = %s WHERE id = %s",
(role == "admin", user_id),
)
+ if founder_badge == "on":
+ cursor.execute(
+ """
+ INSERT INTO user_badges (user_id, badge_code)
+ VALUES (%s, 'founder')
+ ON CONFLICT (user_id, badge_code) DO NOTHING
+ """,
+ (user_id,),
+ )
+ else:
+ cursor.execute(
+ "DELETE FROM user_badges WHERE user_id = %s AND badge_code = 'founder'",
+ (user_id,),
+ )
connection.commit()
return RedirectResponse("/admin/users", status_code=303)
@@ -1135,11 +1620,87 @@ def delete_user(request: Request, user_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
+ cursor.execute("SELECT avatar_path FROM users WHERE id = %s", (user_id,))
+ target_user = cursor.fetchone()
+ cursor.execute("SELECT path FROM concert_photos WHERE user_id = %s", (user_id,))
+ photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
connection.commit()
+ if target_user:
+ remove_uploaded_file(target_user[0], AVATAR_DIR, "/static/uploads/avatars/")
+ for photo_path in photo_paths:
+ remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/")
+
return RedirectResponse("/admin/users", status_code=303)
+
+@app.post("/admin/users/{user_id}/account-link", response_class=HTMLResponse)
+def create_user_account_link(request: Request, user_id: int, purpose: str = Form(...)):
+ user = require_admin(request)
+ if not user:
+ return HTMLResponse("
Nicht erlaubt
", status_code=403)
+ if purpose != "password_reset":
+ return HTMLResponse("
Ungültiger Linktyp
", status_code=400)
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute("SELECT username, email FROM users WHERE id = %s", (user_id,))
+ target = cursor.fetchone()
+ if not target:
+ return HTMLResponse("
Benutzer nicht gefunden
", status_code=404)
+ token = create_account_token(user_id, purpose, 2)
+ path = f"/password-reset/{token}"
+ template = templates.get_template("account_link.html")
+ return template.render(
+ user=user, target_username=target[0], target_email=target[1],
+ purpose=purpose, account_url=f"{str(request.base_url).rstrip('/')}{path}",
+ )
+
+
+@app.get("/password-reset/{token}", response_class=HTMLResponse)
+def password_reset_page(token: str):
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT 1 FROM account_tokens WHERE token_hash = %s
+ AND purpose = 'password_reset' AND used_at IS NULL
+ AND expires_at > CURRENT_TIMESTAMP
+ """,
+ (hash_token(token),),
+ )
+ valid = cursor.fetchone()
+ if not valid:
+ return HTMLResponse("
Reset-Link ungültig oder abgelaufen.
", status_code=410)
+ return templates.get_template("password_reset.html").render(token=token, error=None)
+
+
+@app.post("/password-reset/{token}", response_class=HTMLResponse)
+def password_reset(token: str, password: str = Form(...), password_repeat: str = Form(...)):
+ if len(password) < 10 or password != password_repeat:
+ return templates.get_template("password_reset.html").render(
+ token=token, error="Passwörter müssen übereinstimmen und mindestens 10 Zeichen lang sein."
+ )
+ with get_db_connection() as connection:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ SELECT id, user_id FROM account_tokens WHERE token_hash = %s
+ AND purpose = 'password_reset' AND used_at IS NULL
+ AND expires_at > CURRENT_TIMESTAMP FOR UPDATE
+ """,
+ (hash_token(token),),
+ )
+ account_token = cursor.fetchone()
+ if not account_token:
+ return HTMLResponse("
Reset-Link ungültig oder abgelaufen.
", status_code=410)
+ password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+ cursor.execute("UPDATE users SET password_hash = %s WHERE id = %s", (password_hash, account_token[1]))
+ cursor.execute("UPDATE account_tokens SET used_at = CURRENT_TIMESTAMP WHERE id = %s", (account_token[0],))
+ cursor.execute("DELETE FROM sessions WHERE user_id = %s", (account_token[1],))
+ connection.commit()
+ return RedirectResponse("/login?reset=1", status_code=303)
+
@app.post("/register")
def register_user(
token: str = Form(...),
@@ -1159,9 +1720,9 @@ def register_user(
status_code=400
)
- if len(password) < 8:
+ if len(password) < 10:
return HTMLResponse(
- "
Fehler
Das Passwort muss mindestens 8 Zeichen lang sein.
",
+ "
Fehler
Das Passwort muss mindestens 10 Zeichen lang sein.
",
status_code=404
)
@@ -1963,12 +2891,18 @@ def delete_concert(request: Request, concert_id: int):
with get_db_connection() as connection:
with connection.cursor() as cursor:
+ cursor.execute("SELECT path FROM concert_photos WHERE concert_id = %s", (concert_id,))
+ photo_paths = [row[0] for row in cursor.fetchall()]
cursor.execute(
"DELETE FROM concerts WHERE id = %s",
(concert_id,),
)
connection.commit()
+ remove_uploaded_file(concert["flyer_path"], UPLOAD_DIR, "/static/uploads/flyers/")
+ for photo_path in photo_paths:
+ remove_uploaded_file(photo_path, PHOTO_DIR, "/static/uploads/photos/")
+
return RedirectResponse("/", status_code=303)
@@ -1982,10 +2916,10 @@ def set_attendance(
if not user:
return login_redirect(f"/concerts/{concert_id}")
- if status not in {"attending", "maybe", "ticket_search"}:
+ if status not in {"attending", "maybe", "ticket_search", "ticket_offer"}:
return HTMLResponse("
Ungültige Auswahl
", status_code=400)
if not load_concert(concert_id):
- return HTMLResponse("
Konzert nicht gefunden
", status_code=404)
+ return HTMLResponse("
Veranstaltung nicht gefunden
", status_code=404)
with get_db_connection() as connection:
with connection.cursor() as cursor:
@@ -2020,7 +2954,7 @@ def add_comment(
if not concert:
return HTMLResponse(
- "
Konzert nicht gefunden
",
+ "
Veranstaltung nicht gefunden
",
status_code=404
)
@@ -2074,7 +3008,7 @@ async def add_photo(
if not concert:
return HTMLResponse(
- "
Änderungen werden direkt beim Konzert gespeichert.
+
✏️ Veranstaltung bearbeiten
+
Änderungen werden direkt bei der Veranstaltung gespeichert.
+ {% if can_edit_details %}
+
+
+
+
+
+
+ {% endif %}
-
@@ -53,9 +75,9 @@
-
+
Ende
-
+
@@ -73,14 +95,20 @@
+
+ Flyer extern verlinken (bevorzugt)
+
+
+ Bitte möglichst die offizielle Quelle verwenden. Ein externer Link wird gegenüber einem Upload bevorzugt und unter dem Bild genannt. Feld leeren, um wieder den Upload zu verwenden.
+
Neuen Flyer hochladen
Ohne neue Datei bleibt der bisherige Flyer erhalten.
- {% if concert.flyer_path %}
-
+ {% if concert.flyer_url or concert.flyer_path %}
+