From 847f20c9d7a60628b20aa1bc2bd9f0b96b6dcc8e Mon Sep 17 00:00:00 2001 From: maddin Date: Sun, 22 Mar 2026 15:36:47 +0000 Subject: [PATCH] chore: sync public repository --- VERSION | 2 +- app/main.py | 79 +++++++++++++++++++-- app/models.py | 1 + app/services/importers.py | 6 ++ app/services/legal_content.py | 30 +++++++- app/services/migrations.py | 2 + app/static/css/components.css | 35 ++++++++++ app/static/css/layout.css | 2 + app/static/css/tokens.css | 100 ++++++++++++++++++++++++++- app/static/js/app.js | 2 + app/static/js/components/theme.js | 42 +++++++++++ app/templates/base.html | 4 +- app/templates/pages/contact.html | 7 +- app/templates/pages/settings.html | 26 ++++--- tests/test_legal_and_support.py | 78 ++++++++++++++++++++- tests/test_vacations_and_settings.py | 9 +++ 16 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 app/static/js/components/theme.js diff --git a/VERSION b/VERSION index 41336a1..9c6d629 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.12 +1.6.1 diff --git a/app/main.py b/app/main.py index 1f4c659..0b1b4af 100644 --- a/app/main.py +++ b/app/main.py @@ -145,6 +145,12 @@ SUPPORT_TICKET_RATE_LIMIT_WINDOW = timedelta(minutes=30) SUPPORT_TICKET_RATE_LIMIT_MAX_PER_IP = 3 SUPPORT_TICKET_RATE_LIMIT_MAX_PER_EMAIL = 5 SUPPORT_TICKET_MIN_FORM_SECONDS = 3 +THEME_PREFERENCE_AUTO = "auto" +THEME_PREFERENCE_DARK = "dark" +THEME_PREFERENCE_LIGHT = "light" +THEME_PREFERENCES = {THEME_PREFERENCE_AUTO, THEME_PREFERENCE_DARK, THEME_PREFERENCE_LIGHT} +THEME_COLOR_DARK = "#2c2d2f" +THEME_COLOR_LIGHT = "#f3f4f6" DAY_STATUS_QUERY_LABELS = { DAY_STATUS_QUERY_VACATION: "Urlaub", SPECIAL_DAY_STATUS_HOLIDAY: "Feiertag", @@ -400,6 +406,16 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: "app_title": settings.resolved_app_title, "app_version": settings.app_version, "today_date": date.today(), + "theme_preference": ( + user.theme_preference + if user and user.theme_preference in THEME_PREFERENCES + else THEME_PREFERENCE_AUTO + ), + "theme_color": ( + THEME_COLOR_LIGHT + if user and user.theme_preference == THEME_PREFERENCE_LIGHT + else THEME_COLOR_DARK + ), } context.update(extra) return context @@ -463,6 +479,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: "automatic_break_rules_enabled": user.automatic_break_rules_enabled, "default_break_minutes": user.default_break_minutes, "preferred_home_view": user.preferred_home_view, + "theme_preference": user.theme_preference, "preferred_month_view_mode": user.preferred_month_view_mode, "entry_mode": user.entry_mode, "overtime_start_date": user.overtime_start_date.isoformat() if user.overtime_start_date else None, @@ -1897,6 +1914,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: "settings": { "weekly_target_minutes": user.weekly_target_minutes, "preferred_home_view": user.preferred_home_view, + "theme_preference": user.theme_preference, "preferred_month_view_mode": user.preferred_month_view_mode, "entry_mode": user.entry_mode, "working_days": sorted(get_user_working_days(user)), @@ -2027,7 +2045,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: def render_legal_page(request: Request, *, db: Session, key: str, title: str, subtitle: str | None = None) -> HTMLResponse: markdown_text = get_site_content_markdown(db, key) - html_content = render_safe_markdown(markdown_text) + html_content = render_safe_markdown(markdown_text, obfuscate_emails=True) user = get_current_user(request, db) return templates.TemplateResponse( "pages/legal_page.html", @@ -2041,6 +2059,14 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: ), ) + def require_verified_ticket_user(request: Request, db: Session) -> User | RedirectResponse: + user = get_current_user(request, db) + if not user or not user.is_active: + return RedirectResponse(url="/login?msg=contact_requires_login", status_code=status.HTTP_303_SEE_OTHER) + if not user.email_verified: + return RedirectResponse(url="/verify-email/resend", status_code=status.HTTP_303_SEE_OTHER) + return user + @app.get("/impressum", response_class=HTMLResponse) async def impressum_page(request: Request, db: Session = Depends(get_db)): return render_legal_page( @@ -2063,7 +2089,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: @app.get("/kontakt", response_class=HTMLResponse) async def contact_form(request: Request, db: Session = Depends(get_db)): - user = get_current_user(request, db) + required_user = require_verified_ticket_user(request, db) + if isinstance(required_user, RedirectResponse): + return required_user + user = required_user return templates.TemplateResponse( "pages/contact.html", build_context( @@ -2096,10 +2125,13 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: db: Session = Depends(get_db), ): verify_csrf(request, csrf_token) - user = get_current_user(request, db) + required_user = require_verified_ticket_user(request, db) + if isinstance(required_user, RedirectResponse): + return required_user + user = required_user normalized_name = name.strip() - normalized_email = email.strip().lower() + normalized_email = user.email.strip().lower() normalized_subject = subject.strip() normalized_message = message.strip() started_at_expected = request.session.get("contact_form_started_at") @@ -2495,6 +2527,10 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: success_message = "Bitte bestätige zuerst deine E-Mail-Adresse über den Link in der E-Mail." elif msg == "email_verified": success_message = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden." + elif msg == "contact_requires_login": + error_message = "Für Kontaktanfragen musst du zuerst eingeloggt sein." + elif msg == "contact_requires_verification": + error_message = "Bitte bestätige zuerst deine E-Mail-Adresse, bevor du das Ticketsystem nutzt." elif msg == "email_verification_send_failed": error_message = ( "Konto wurde erstellt, aber die Bestätigungs-E-Mail konnte nicht versendet werden. " @@ -4367,6 +4403,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: async def settings_update_preferences( request: Request, preferred_home_view: str = Form(...), + theme_preference: str = Form(default=""), preferred_month_view_mode: str = Form(...), entry_mode: str = Form(default=""), csrf_token: str = Form(...), @@ -4385,6 +4422,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: error="Ungueltige Standardansicht.", status_code=status.HTTP_400_BAD_REQUEST, ) + selected_theme_preference = (theme_preference or user.theme_preference or THEME_PREFERENCE_AUTO).strip() + if selected_theme_preference not in THEME_PREFERENCES: + return render_settings_form( + request, + db=db, + user=user, + error="Ungueltiges Darstellungs-Theme.", + status_code=status.HTTP_400_BAD_REQUEST, + ) if preferred_month_view_mode not in {"flat", "weeks"}: return render_settings_form( request, @@ -4403,6 +4449,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: ) user.preferred_home_view = preferred_home_view + user.theme_preference = selected_theme_preference user.preferred_month_view_mode = preferred_month_view_mode new_entry_mode = entry_mode or user.entry_mode switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY @@ -4529,6 +4576,7 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: async def settings_update_weekly_target( request: Request, weekly_target_hours: float = Form(...), + entry_mode: str = Form(default=""), automatic_break_rules_enabled: str | None = Form(default=None), default_break_minutes_value: str = Form(default="", alias="default_break_minutes"), csrf_token: str = Form(...), @@ -4549,6 +4597,15 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: status_code=status.HTTP_400_BAD_REQUEST, ) + if entry_mode and entry_mode not in {ENTRY_MODE_MANUAL, ENTRY_MODE_AUTO_UNTIL_TODAY}: + return render_settings_form( + request, + db=db, + user=user, + error="Ungueltiger Erfassungsmodus.", + status_code=status.HTTP_400_BAD_REQUEST, + ) + break_rules_enabled = automatic_break_rules_enabled == "on" default_break_minutes = user.default_break_minutes if break_rules_enabled else 0 if default_break_minutes_value.strip(): @@ -4580,9 +4637,23 @@ def create_app(settings_override: Settings | None = None) -> FastAPI: new_target_minutes=new_target_minutes, scope="all_weeks", ) + new_entry_mode = entry_mode or user.entry_mode + switched_to_auto_until_today = user.entry_mode != ENTRY_MODE_AUTO_UNTIL_TODAY and new_entry_mode == ENTRY_MODE_AUTO_UNTIL_TODAY user.weekly_target_minutes = new_target_minutes + user.entry_mode = new_entry_mode user.automatic_break_rules_enabled = break_rules_enabled user.default_break_minutes = default_break_minutes + + delete_future_auto_entries(db=db, user_id=user.id, after_date=date.today()) + if switched_to_auto_until_today: + ensure_user_has_default_target_rule(db, user) + autofill_entries_for_range( + db=db, + user=user, + range_start=user.created_at.date(), + range_end=date.today(), + ) + db.commit() return RedirectResponse(url="/settings?msg=weekly_target_updated", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/models.py b/app/models.py index d8448c5..d292f76 100644 --- a/app/models.py +++ b/app/models.py @@ -20,6 +20,7 @@ class User(Base): role: Mapped[str] = mapped_column(String(32), default="user", nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) preferred_home_view: Mapped[str] = mapped_column(String(16), default="week", nullable=False) + theme_preference: Mapped[str] = mapped_column(String(16), default="auto", nullable=False) preferred_month_view_mode: Mapped[str] = mapped_column(String(16), default="flat", nullable=False) entry_mode: Mapped[str] = mapped_column(String(16), default="manual", nullable=False) working_days_csv: Mapped[str] = mapped_column(String(32), default="0,1,2,3,4", nullable=False) diff --git a/app/services/importers.py b/app/services/importers.py index ff41880..a56f0e7 100644 --- a/app/services/importers.py +++ b/app/services/importers.py @@ -133,6 +133,10 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]: if preferred_home_view not in PREFERRED_HOME_VIEWS: preferred_home_view = "week" + theme_preference = settings_data.get("theme_preference", "auto") + if theme_preference not in {"auto", "dark", "light"}: + theme_preference = "auto" + preferred_month_view_mode = settings_data.get("preferred_month_view_mode", "flat") if preferred_month_view_mode not in PREFERRED_MONTH_VIEWS: preferred_month_view_mode = "flat" @@ -170,6 +174,7 @@ def _normalize_settings(payload: dict[str, Any]) -> dict[str, Any]: return { "weekly_target_minutes": _parse_int(settings_data.get("weekly_target_minutes", 1500), label="Wochenstunden", minimum=1), "preferred_home_view": preferred_home_view, + "theme_preference": theme_preference, "preferred_month_view_mode": preferred_month_view_mode, "entry_mode": entry_mode, "working_days": sorted(working_days), @@ -524,6 +529,7 @@ def parse_preview_payload(preview: ImportPreview) -> dict[str, Any]: def _apply_settings_from_backup(*, user: User, settings_data: dict[str, Any]) -> None: user.weekly_target_minutes = settings_data["weekly_target_minutes"] user.preferred_home_view = settings_data["preferred_home_view"] + user.theme_preference = settings_data["theme_preference"] user.preferred_month_view_mode = settings_data["preferred_month_view_mode"] user.entry_mode = settings_data["entry_mode"] user.working_days_csv = serialize_working_days(settings_data["working_days"]) diff --git a/app/services/legal_content.py b/app/services/legal_content.py index 178b2ab..eaf15a6 100644 --- a/app/services/legal_content.py +++ b/app/services/legal_content.py @@ -1,5 +1,8 @@ from __future__ import annotations +import html +import re + import markdown as markdown_lib import bleach @@ -194,13 +197,36 @@ _ALLOWED_ATTRIBUTES = { 'a': ['href', 'title', 'rel', 'target'], } _ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] +_EMAIL_RE = re.compile(r'(?P[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})', re.IGNORECASE) +_MAILTO_LINK_RE = re.compile( + r']*href="mailto:(?P[^"]+)"[^>]*>(?P @@ -68,6 +68,9 @@

Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.

+

+ Das Ticketsystem steht nur registrierten und bestätigten Nutzerkonten zur Verfügung. Die Rückmeldung wird mit der E-Mail-Adresse deines Kontos verknüpft. +

Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über Impressum und Datenschutz.

diff --git a/app/templates/pages/settings.html b/app/templates/pages/settings.html index ef36ef1..07555e4 100644 --- a/app/templates/pages/settings.html +++ b/app/templates/pages/settings.html @@ -99,6 +99,15 @@

Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).

+ -

- Im automatischen Modus werden fehlende Einträge für deine Arbeitstage bis einschließlich heute automatisch angelegt. Abweichungen kannst du danach einzeln anpassen. -

{% endcall %} diff --git a/tests/test_legal_and_support.py b/tests/test_legal_and_support.py index 9c4671b..32ee05d 100644 --- a/tests/test_legal_and_support.py +++ b/tests/test_legal_and_support.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.config import Settings from app.database import get_engine from app.main import create_app -from app.models import SupportTicket +from app.models import SupportTicket, User def _csrf_from_html(html: str) -> str: @@ -38,6 +38,18 @@ def _build_admin_app(db_url: str) -> object: ) +def _register_and_verify_user(client: TestClient, email: str, password: str = "verystrongPass123") -> None: + register = client.post( + "/auth/register", + json={"email": email, "password": password}, + ) + assert register.status_code == 200 + with Session(get_engine()) as db: + user = db.execute(select(User).where(User.email == email)).scalar_one() + user.email_verified = True + db.commit() + + def test_public_footer_and_legal_pages_render(app): with TestClient(app) as client: response = client.get("/login") @@ -54,6 +66,44 @@ def test_public_footer_and_legal_pages_render(app): assert privacy.status_code == 200 assert "Datenschutz" in privacy.text + contact = client.get("/kontakt", follow_redirects=False) + assert contact.status_code == 303 + assert contact.headers["location"] == "/login?msg=contact_requires_login" + + +def test_legal_pages_obfuscate_email_addresses(tmp_path): + db_path = tmp_path / "legal-obfuscation.db" + app = _build_admin_app(f"sqlite:///{db_path}") + + with TestClient(app) as client: + register = client.post( + "/auth/register", + json={"email": "admin@example.com", "password": "verystrongPass123"}, + ) + assert register.status_code == 200 + csrf = register.json()["csrf_token"] + + update_legal = client.post( + "/settings/admin/site-content", + data={ + "csrf_token": csrf, + "impressum_markdown": "# Impressum\n\nE-Mail: [kontakt@example.com](mailto:kontakt@example.com)", + "privacy_markdown": "# Datenschutz\n\nE-Mail: kontakt@example.com", + }, + follow_redirects=False, + ) + assert update_legal.status_code == 303 + + impressum = client.get("/impressum") + assert impressum.status_code == 200 + assert "kontakt [at] example [dot] com" in impressum.text + assert "mailto:" not in impressum.text + + privacy = client.get("/datenschutz") + assert privacy.status_code == 200 + assert "kontakt [at] example [dot] com" in privacy.text + assert "mailto:" not in privacy.text + def test_contact_form_creates_ticket(monkeypatch, app): import app.main as main_module @@ -62,6 +112,7 @@ def test_contact_form_creates_ticket(monkeypatch, app): monkeypatch.setattr(main_module, "utc_now", lambda: base_time) with TestClient(app) as client: + _register_and_verify_user(client, "max@example.com") form = client.get("/kontakt") assert form.status_code == 200 csrf = _csrf_from_html(form.text) @@ -76,7 +127,7 @@ def test_contact_form_creates_ticket(monkeypatch, app): "website": "", "category": "feature", "name": "Max Beispiel", - "email": "max@example.com", + "email": "ignored@example.com", "subject": "Bitte Monatsfilter erweitern", "message": "Ich wünsche mir eine bessere Filterung in der Monatsansicht.", }, @@ -88,6 +139,7 @@ def test_contact_form_creates_ticket(monkeypatch, app): with Session(get_engine()) as db: tickets = db.execute(select(SupportTicket)).scalars().all() assert len(tickets) == 1 + assert tickets[0].email == "max@example.com" assert tickets[0].category == "feature" assert tickets[0].status == "open" assert tickets[0].subject == "Bitte Monatsfilter erweitern" @@ -100,6 +152,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app): monkeypatch.setattr(main_module, "utc_now", lambda: base_time) with TestClient(app) as client: + _register_and_verify_user(client, "spam@example.com") form = client.get("/kontakt") csrf = _csrf_from_html(form.text) started_at = _started_at_from_html(form.text) @@ -113,7 +166,7 @@ def test_contact_form_honeypot_blocks_submission(monkeypatch, app): "website": "spam", "category": "problem", "name": "", - "email": "spam@example.com", + "email": "ignored@example.com", "subject": "Spamversuch", "message": "Das sollte blockiert werden.", }, @@ -212,3 +265,22 @@ def test_admin_can_manage_legal_content_and_tickets(tmp_path, monkeypatch): assert ticket.status == "closed" assert ticket.admin_notes == "Geschlossen im Test" assert ticket.closed_at is not None + + +def test_contact_requires_verified_user(tmp_path): + app = _build_admin_app(f"sqlite:///{tmp_path / 'contact-verified.db'}") + + with TestClient(app) as client: + register = client.post( + "/auth/register", + json={"email": "user@example.com", "password": "verystrongPass123"}, + ) + assert register.status_code == 200 + with Session(get_engine()) as db: + user = db.execute(select(User).where(User.email == "user@example.com")).scalar_one() + user.email_verified = False + db.commit() + + contact = client.get("/kontakt", follow_redirects=False) + assert contact.status_code == 303 + assert contact.headers["location"] == "/verify-email/resend" diff --git a/tests/test_vacations_and_settings.py b/tests/test_vacations_and_settings.py index e548160..397e5b5 100644 --- a/tests/test_vacations_and_settings.py +++ b/tests/test_vacations_and_settings.py @@ -1013,6 +1013,7 @@ def test_register_onboarding_applies_optional_settings(app): assert payload["vacation_days_total"] == 22 assert payload["vacation_show_in_header"] is True assert payload["preferred_home_view"] == "month" + assert payload["theme_preference"] == "auto" assert payload["entry_mode"] == "auto_until_today" assert payload["overtime_start_date"] == "2026-02-02" assert payload["overtime_expiry_days"] == 90 @@ -1070,6 +1071,7 @@ def test_settings_export_all_supports_backup_and_existing_formats(app): assert "application/json" in export_backup.headers["content-type"] payload = export_backup.json() assert payload["backup_version"] == 2 + assert payload["settings"]["theme_preference"] == "auto" assert "user" not in payload assert payload["settings"]["weekly_target_minutes"] == 1500 assert len(payload["time_entries"]) == 1 @@ -1169,6 +1171,7 @@ def test_register_can_import_backup_during_signup(app): "/settings/preferences", data={ "preferred_home_view": "month", + "theme_preference": "light", "preferred_month_view_mode": "weeks", "entry_mode": "auto_until_today", "csrf_token": source_csrf, @@ -1237,6 +1240,7 @@ def test_register_can_import_backup_during_signup(app): assert me.status_code == 200 payload = me.json() assert payload["preferred_home_view"] == "month" + assert payload["theme_preference"] == "light" assert payload["entry_mode"] == "auto_until_today" assert payload["working_days"] == [0, 1, 2, 3] assert payload["count_vacation_as_worktime"] is True @@ -1333,6 +1337,7 @@ def test_settings_default_view_redirect(app): "/settings/preferences", data={ "preferred_home_view": "month", + "theme_preference": "light", "preferred_month_view_mode": "weeks", "csrf_token": csrf, }, @@ -1348,6 +1353,10 @@ def test_settings_default_view_redirect(app): assert dashboard_redirect.status_code == 303 assert dashboard_redirect.headers["location"].startswith("/month?view=weeks") + month_page = client.get("/month") + assert month_page.status_code == 200 + assert 'data-theme="light"' in month_page.text + def test_main_navigation_uses_explicit_period_links(app): with TestClient(app) as client: