diff --git a/.env.example b/.env.example index c4de427..c20d010 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,6 @@ PUBLIC_BASE_URL=http://einkauf.example.de:46600 # "*" ist in Ordnung, solange api keinen oeffentlichen Port hat. FORWARDED_ALLOW_IPS=* -SESSION_DAYS=30 - # Cookie nur über HTTPS ausliefern. # false bei direktem HTTP-Zugriff auf Port 46600 # true sobald ein Reverse Proxy mit TLS davorsteht (dann muss @@ -114,6 +112,19 @@ CLEANUP_OPS_DAYS=7 # Abstand zwischen zwei Aufraeumdurchlaeufen. CLEANUP_INTERVAL_HOURS=24 +# ---- Startwerte fuer Fristen ---- +# Diese Werte gelten nur, solange in der Verwaltung nichts anderes +# eingestellt wurde. Nach der ersten Aenderung dort ist der gespeicherte +# Wert massgeblich - dasselbe Verhalten wie bei der Selbstregistrierung. +# Einzusehen und zu aendern unter: +# Einstellungen -> Benutzerverwaltung -> Fristen +SESSION_DAYS=30 +INVITE_DAYS=14 +WELCOME_DAYS=14 +VERIFY_HOURS=24 +RESET_HOURS=1 +EMAIL_CHANGE_HOURS=48 + # ========================================================================== # Push-Benachrichtigungen # ========================================================================== diff --git a/README.md b/README.md index a95f2f2..f184c3f 100644 --- a/README.md +++ b/README.md @@ -1128,16 +1128,69 @@ anderen Administratoren und vor sich selbst. Sonst könnte sich die Verwaltung aussperren. Wer einen Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht bewusst nur direkt in der Datenbank. -### Automatische Bereinigung +### Fristen -Vier Fristen, in der Verwaltung einstellbar: +**Alle** Fristen der Anwendung stehen unter *Einstellungen → +Benutzerverwaltung → Fristen* und lassen sich dort einsehen und ändern. +Zentral hinterlegt in `backend/app/runtime_settings.py`, Rangfolge: -| Einstellung | Voreinstellung | Wirkung | +``` +Einstellungstabelle → Umgebungsvariable → Vorgabewert im Quelltext +``` + +Die Umgebungsvariable ist also der Startwert; sobald in der Verwaltung etwas +geändert wird, gilt der gespeicherte Wert. + +| Gruppe | Frist | Vorgabe | |---|---|---| -| Konto deaktivieren nach … Monaten ohne Anmeldung | 12 | Konto wird gesperrt, Person bekommt eine Nachricht | -| Konto löschen nach … Monaten Deaktivierung | 12 | Konto wird endgültig entfernt | -| Liste archivieren nach … Monaten ohne Öffnen | 12 | **Je Person** – die Liste verschwindet nur aus deren Übersicht | -| Liste löschen nach … Monaten im Archiv | 12 | Nur nach dem Archivdatum des **Eigentümers** | +| Konten | deaktivieren nach … Monaten ohne Anmeldung | 12 | +| | löschen nach … Monaten Deaktivierung | 12 | +| Listen | archivieren nach … Monaten ohne Öffnen | 12 | +| | löschen nach … Monaten im Archiv | 12 | +| Aufbewahrung | Gelöschtes endgültig entfernen nach … Tagen | 30 | +| | Quittungen der Warteschlange … Tage | 7 | +| | Produktabfragen gültig … Tage | 180 | +| Sitzungen und Links | angemeldet bleiben … Tage | 30 | +| | Einladung gültig … Tage | 14 | +| | Willkommenslink gültig … Tage | 14 | +| | Bestätigungslink gültig … Stunden | 24 | +| | Passwortlink gültig … Stunden | 1 | +| | Adresswechsel bestätigen binnen … Stunden | 48 | + +Nicht dort einstellbar und deshalb in der Oberfläche gesondert aufgeführt: +die 24 Stunden der Missbrauchsabwehr, der Abstand der Aufräumläufe +(`CLEANUP_INTERVAL_HOURS`) und die Aufbewahrung der Webserver-Protokolle – +Letztere ist Sache der Protokollrotation auf dem Server. + +```bash +python3 tools/check-retention.py +``` + +Prüft dreierlei: dass jede einstellbare Frist auch tatsächlich irgendwo +angewandt wird (eine Einstellung ohne Wirkung ist schlimmer als keine – sie +behauptet eine), dass jede Tabelle mit Ablaufzeitpunkt vom Aufräumlauf erfasst +wird, und dass dort keine festen Zahlen mehr stehen. + +### Was der Aufräumlauf tut + +Einmal täglich, im `api`-Container: + +| Was | Wann | +|---|---| +| Abgelaufene Sitzungen | sofort nach Ablauf | +| Verbrauchte Mail-Token | 7 Tage nach Ablauf | +| Zähler der Missbrauchsabwehr | nach 24 Stunden | +| Quittungen der Warteschlange | nach `cleanup_ops_days` | +| Abgelaufene Einladungen und öffentliche Links | `cleanup_deleted_days` **nach Ablauf** | +| Abgeschlossene Adresswechsel | `cleanup_deleted_days` nach Ablauf | +| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | `cleanup_deleted_days` nach dem Löschen | +| Produktzwischenspeicher | doppelte Gültigkeit, mindestens 1 Jahr | +| Konten deaktivieren und löschen | `auto_deactivate_months`, `auto_delete_months` | +| Listen archivieren, vorwarnen und löschen | `auto_archive_months`, `archive_delete_months` | + +**Achtung bei den Einladungen:** Die Frist läuft *nach Ablauf*, nicht ab +Versand. Eine Einladung mit 14 Tagen Gültigkeit verschwindet also erst nach +44 Tagen aus der Datenbank – das gehört so in die Datenschutzerklärung. **0 bedeutet abgeschaltet, nicht „sofort".** Ein Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle Konten verschwinden. @@ -1283,6 +1336,55 @@ Der Eintrag in `alembic_version` am Ende ist wichtig: Ohne ihn würde Alembic beim nächsten Start alle Migrationen erneut anwenden und an den vorhandenen Tabellen scheitern. +## Rechtliche Seiten + +`web/html/impressum.html` und `web/html/datenschutz.html` – **Entwürfe**, die +vor der Veröffentlichung ausgefüllt und geprüft werden müssen. Alle offenen +Stellen sind mit `[…]` markiert; die Kästen mit der Klasse `draft` und die +Erläuterungen mit der Klasse `hint` gehören danach entfernt. + +Verlinkt sind beide in einer Fußzeile, die **außerhalb von `#app`** in +`index.html` steht. Dadurch erscheint sie in jeder Ansicht – auch auf der +Anmeldeseite und in der öffentlichen Listenansicht – ohne dass jede Ansicht +sie selbst einbauen müsste. Im Ausdruck und über dem Kamerasucher ist sie +ausgeblendet. + +Die Datenschutzerklärung beschreibt, was diese Anwendung tatsächlich tut, nicht +was üblicherweise in solchen Texten steht. Enthalten sind unter anderem: die +beiden technisch notwendigen Cookies, die lokale Speicherung in IndexedDB, die +Verarbeitung fremder E-Mail-Adressen bei Einladungen, die Push-Dienste der +Browserhersteller als Empfänger in Drittländern, Open Food Facts als +serverseitig aufgerufener Dienst, die vollständige Aufstellung der +Speicherfristen und der Hinweis, dass Listeninhalte in den Bereich des +Art. 9 DSGVO geraten können, wenn dort Medikamente stehen. + +Auszufüllen sind: Verantwortlicher, Hosting-Anbieter, SMTP-Anbieter, +Aufsichtsbehörde des Bundeslandes und die Fristen, falls du sie in der +Verwaltung anders eingestellt hast. + +## Datensparsamkeit im Betrieb + +Drei Stellen, an denen bewusst weniger gespeichert wird, als technisch möglich +wäre: + +**Keine externen Schriften.** Verwendet wird die Systemschrift des Geräts +(`system-ui`). Es wird nichts von fremden Servern nachgeladen – die CSP führt +`font-src 'self'` ausdrücklich auf, obwohl `default-src` es schon abdeckt, +damit die Sperre beim Lesen der Datei erkennbar ist. + +**Zugriffsprotokolle mit gekürzter IP-Adresse.** Der letzte Block wird auf Null +gesetzt (`map` in `nginx.conf`). Der `Referer` wird gar nicht protokolliert – +bei einem öffentlichen Listenlink stünde dort der Zugangsschlüssel, sobald +jemand von dort aus weiterklickt. + +**Missbrauchsbremse ohne Klartextadressen.** Die Zähler brauchen einen +gleichbleibenden Schlüssel je Absender, keinen lesbaren. Verwendet wird ein +Streuwert aus IP- beziehungsweise E-Mail-Adresse und einem zufälligen, +installationsweiten Zusatz. Das ist Pseudonymisierung, keine Anonymisierung – +wer Datenbank samt Zusatz hat, könnte eine vermutete Adresse nachrechnen. Es +verhindert aber, dass beim Blick in die Tabelle Adressen mitgelesen werden, und +die Werte verschwinden ohnehin nach 24 Stunden. + ## Sicherheit `docs/sicherheit.md` – Ergebnisse der Prüfung auf SQL-Injection, XSS, diff --git a/backend/app/config.py b/backend/app/config.py index 5d49bcb..e2a85a0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -72,6 +72,13 @@ class Settings(BaseSettings): cleanup_ops_days: int = 7 # Abstand zwischen zwei Durchlaeufen. cleanup_interval_hours: int = 24 + # Startwerte fuer Fristen, die zur Laufzeit in der Verwaltung + # geaendert werden koennen (siehe app/runtime_settings.py). + invite_days: int = 14 + welcome_days: int = 14 + verify_hours: int = 24 + reset_hours: int = 1 + email_change_hours: int = 48 # ---------------- Push-Benachrichtigungen ---------------- # Erzeugen mit: python3 tools/vapid-keys.py diff --git a/backend/app/deps.py b/backend/app/deps.py index f53ebe0..9208a63 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -60,18 +60,24 @@ def self_registration_enabled(db: Session) -> bool: # -------------------------------------------------------------------------- def create_session(db: Session, user: User, response: Response) -> UserSession: + # Spät importiert: runtime_settings greift auf get_setting in dieser + # Datei zu, ein Import auf Modulebene wäre ein Kreis. + from app.runtime_settings import get_duration + + days = get_duration(db, "session_days") + raw = new_token() csrf = new_token() sess = UserSession( token_hash=hash_token(raw), user_id=user.id, csrf_token=csrf, - expires_at=utcnow() + timedelta(days=settings.session_days), + expires_at=utcnow() + timedelta(days=days), ) db.add(sess) db.flush() - max_age = settings.session_days * 24 * 3600 + max_age = days * 24 * 3600 # HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token # nicht abgreifen kann. response.set_cookie( diff --git a/backend/app/maintenance.py b/backend/app/maintenance.py index 2a06add..1283744 100644 --- a/backend/app/maintenance.py +++ b/backend/app/maintenance.py @@ -38,6 +38,7 @@ from app.models import ( User, UserSession, ) +from app.runtime_settings import get_duration from app.security import utcnow from app.users import deactivate, delete_user, months_setting @@ -45,10 +46,19 @@ log = logging.getLogger(__name__) def run_cleanup(db: Session) -> dict[str, int]: - """Räumt auf und gibt zurück, was entfernt wurde.""" + """Räumt auf und gibt zurück, was entfernt wurde. + + Alle Fristen kommen aus der Laufzeitkonfiguration, damit das, was + hier geschieht, dem entspricht, was in der Verwaltung steht - und + dem, was die Datenschutzerklärung behauptet. + """ now = utcnow() counts: dict[str, int] = {} + deleted_days = get_duration(db, "cleanup_deleted_days") + ops_days = get_duration(db, "cleanup_ops_days") + product_days = get_duration(db, "product_cache_days") + def purge(model, condition, label: str) -> None: result = db.execute(delete(model).where(condition)) if result.rowcount: @@ -79,34 +89,33 @@ def run_cleanup(db: Session) -> dict[str, int]: # längst über die Wiederholungsgrenze hinaus versucht. purge( AppliedOp, - AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days), + AppliedOp.created_at < now - timedelta(days=ops_days), "Outbox-Quittungen", ) # --- Abgelaufene Einladungen und öffentliche Links --- purge( ListInvite, - ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days), + ListInvite.expires_at < now - timedelta(days=deleted_days), "alte Einladungen", ) purge( PublicShare, - PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days), + PublicShare.expires_at < now - timedelta(days=deleted_days), "abgelaufene öffentliche Links", ) # --- Produktzwischenspeicher --- purge( ProductCache, - ProductCache.fetched_at - < now - timedelta(days=max(settings.product_cache_days * 2, 365)), + ProductCache.fetched_at < now - timedelta(days=max(product_days * 2, 365)), "Produktzwischenspeicher", ) # --- Endgültiges Löschen weicher Löschungen --- # Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten # offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt. - cutoff = now - timedelta(days=settings.cleanup_deleted_days) + cutoff = now - timedelta(days=deleted_days) purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge") @@ -135,7 +144,7 @@ def run_cleanup(db: Session) -> dict[str, int]: # --- Abgeschlossene Adressänderungen --- purge( EmailChange, - EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days), + EmailChange.expires_at < now - timedelta(days=deleted_days), "alte Adressänderungen", ) diff --git a/backend/app/product_lookup.py b/backend/app/product_lookup.py index 32c40ee..32793e3 100644 --- a/backend/app/product_lookup.py +++ b/backend/app/product_lookup.py @@ -163,9 +163,11 @@ def lookup(db: Session, barcode: str) -> ProductCache | None: cached = db.get(ProductCache, barcode) if cached is not None: + from app.runtime_settings import get_duration + age = utcnow() - cached.fetched_at max_age = timedelta( - days=settings.product_cache_days if cached.found + days=get_duration(db, "product_cache_days") if cached.found else settings.product_miss_days ) if age < max_age: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2468b12..821017f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -26,16 +26,18 @@ from app.mail import ( from app.maintenance import describe, run_cleanup from app.models import EmailChange, User from app.schemas import MailCheckOut, MailTestIn, MessageOut +from app.runtime_settings import BY_KEY, describe_durations, set_duration from app.schemas_admin import ( AdminSettingsIn, AdminSettingsOut, AdminStatsOut, AdminUserOut, DeleteUserIn, + DurationOut, EmailChangeIn, UserCreateIn, ) -from app.security import check_rate_limit, normalize_email, utcnow +from app.security import bucket_key, check_rate_limit, normalize_email, utcnow from app.users import ( EMAIL_CHANGE_HOURS, WELCOME_DAYS, @@ -66,18 +68,17 @@ def _settings_out(db) -> AdminSettingsOut: return AdminSettingsOut( allow_self_registration=self_registration_enabled(db), locked_by_env=registration_locked_by_env(), - auto_deactivate_months=months_setting( - db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS - ), - auto_delete_months=months_setting( - db, "auto_delete_months", DEFAULT_DELETE_MONTHS - ), - auto_archive_months=months_setting( - db, "auto_archive_months", DEFAULT_ARCHIVE_MONTHS - ), - archive_delete_months=months_setting( - db, "archive_delete_months", DEFAULT_ARCHIVE_DELETE_MONTHS - ), + durations=[DurationOut(**entry) for entry in describe_durations(db)], + fixed={ + "Zähler der Missbrauchsabwehr": "24 Stunden", + "Fehlgeschlagene Produktabfragen": + f"{settings.product_miss_days} Tage (PRODUCT_MISS_DAYS)", + "Aufräumlauf": + f"alle {settings.cleanup_interval_hours} Stunden " + "(CLEANUP_INTERVAL_HOURS)", + "Zugriffsprotokolle des Webservers": + "Sache der Protokollrotation auf dem Server, nicht der Anwendung", + }, ) @@ -102,14 +103,12 @@ def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser): "true" if payload.allow_self_registration else "false", ) - if payload.auto_deactivate_months is not None: - set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months)) - if payload.auto_delete_months is not None: - set_setting(db, "auto_delete_months", str(payload.auto_delete_months)) - if payload.auto_archive_months is not None: - set_setting(db, "auto_archive_months", str(payload.auto_archive_months)) - if payload.archive_delete_months is not None: - set_setting(db, "archive_delete_months", str(payload.archive_delete_months)) + for key, value in (payload.durations or {}).items(): + if key not in BY_KEY: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, f"Unbekannte Frist: {key}" + ) + set_duration(db, key, value) db.commit() return _settings_out(db) @@ -222,7 +221,8 @@ def add_user( background: BackgroundTasks, ): if not check_rate_limit( - db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60 + db, bucket_key(db, "admin-create", client_ip(request)), + limit=30, window_minutes=60, ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.") @@ -241,7 +241,8 @@ def add_user( background.add_task( send_welcome, email, token, - admin.display_name or admin.email.split("@")[0], WELCOME_DAYS, + admin.display_name or admin.email.split("@")[0], + get_duration(db, "welcome_days"), ) return _user_out(user, {}, {}, {}) @@ -268,16 +269,19 @@ def resend_welcome( "nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.", ) - if not check_rate_limit(db, f"admin-welcome:{user.id}", limit=5, window_minutes=60): + if not check_rate_limit( + db, bucket_key(db, "admin-welcome", user.id), limit=5, window_minutes=60 + ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") - token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24) + days = get_duration(db, "welcome_days") + token = issue_token(db, user, "welcome", hours=days * 24) db.commit() background.add_task( send_welcome, user.email, token, - admin.display_name or admin.email.split("@")[0], WELCOME_DAYS, + admin.display_name or admin.email.split("@")[0], days, ) return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.") @@ -310,12 +314,13 @@ def change_email( change, raw_new, raw_old = start_email_change(db, user, target, admin.id) db.commit() + change_hours = get_duration(db, "email_change_hours") background.add_task( - send_email_change_verify, target, raw_new, old_email, EMAIL_CHANGE_HOURS + send_email_change_verify, target, raw_new, old_email, change_hours ) if change.requires_old and raw_old: background.add_task( - send_email_change_verify_old, old_email, raw_old, target, EMAIL_CHANGE_HOURS + send_email_change_verify_old, old_email, raw_old, target, change_hours ) detail = ( f"Bestätigungslinks an {target} und {old_email} versendet. " @@ -324,7 +329,7 @@ def change_email( ) else: background.add_task( - send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS + send_email_change_notice, old_email, target, change_hours ) detail = ( f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. " diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 9faecd9..8002bc5 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -28,6 +28,7 @@ from app.schemas import ( RegisterIn, UserOut, ) +from app.runtime_settings import get_duration from app.users import apply_if_complete from app.security import ( check_rate_limit, @@ -96,7 +97,8 @@ def register( ) if not check_rate_limit( - db, f"register:{client_ip(request)}", limit=5, window_minutes=60 + db, bucket_key(db, "register", client_ip(request)), + limit=5, window_minutes=60, ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") @@ -113,12 +115,14 @@ def register( ) db.add(user) db.flush() - raw = _issue_token(db, user, "verify", hours=24) + raw = _issue_token(db, user, "verify", hours=get_duration(db, "verify_hours")) background.add_task(send_verification, email, raw) elif existing.verified_at is None: # Unbestaetigtes Konto: neuen Link schicken, statt zu verraten, # dass die Adresse schon vergeben ist. - raw = _issue_token(db, existing, "verify", hours=24) + raw = _issue_token( + db, existing, "verify", hours=get_duration(db, "verify_hours") + ) background.add_task(send_verification, email, raw) # Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben. @@ -148,8 +152,13 @@ def verify(token: str, db: DbSession): def login(payload: LoginIn, request: Request, response: Response, db: DbSession): email = normalize_email(payload.email) - ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15) - acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15) + ip_ok = check_rate_limit( + db, bucket_key(db, "login-ip", client_ip(request)), + limit=20, window_minutes=15, + ) + acct_ok = check_rate_limit( + db, bucket_key(db, "login-acct", email), limit=8, window_minutes=15 + ) if not (ip_ok and acct_ok): db.commit() raise HTTPException( @@ -266,7 +275,8 @@ def reset_request( background: BackgroundTasks, ): if not check_rate_limit( - db, f"reset:{client_ip(request)}", limit=5, window_minutes=60 + db, bucket_key(db, "reset", client_ip(request)), + limit=5, window_minutes=60, ): db.commit() raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") @@ -274,7 +284,7 @@ def reset_request( email = normalize_email(payload.email) user = db.scalar(select(User).where(User.email == email)) if user is not None and user.is_active and user.verified_at is not None: - raw = _issue_token(db, user, "reset", hours=1) + raw = _issue_token(db, user, "reset", hours=get_duration(db, "reset_hours")) background.add_task(send_password_reset, email, raw) db.commit() diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py index 50a12c4..0bc76d8 100644 --- a/backend/app/routers/public.py +++ b/backend/app/routers/public.py @@ -31,7 +31,7 @@ from app.schemas_shopping import ( PublicToggleIn, PublicViewOut, ) -from app.security import check_rate_limit, hash_token, new_token, utcnow +from app.security import bucket_key, check_rate_limit, hash_token, new_token, utcnow router = APIRouter(tags=["public"]) @@ -189,7 +189,8 @@ def _load_share(db: DbSession, token: str, request: Request) -> PublicShare: # bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist # Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben. if not check_rate_limit( - db, f"public:{client_ip(request)}", limit=120, window_minutes=15 + db, bucket_key(db, "public", client_ip(request)), + limit=120, window_minutes=15, ): db.commit() raise HTTPException( diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py index 5283f03..153fb77 100644 --- a/backend/app/routers/sharing.py +++ b/backend/app/routers/sharing.py @@ -40,10 +40,17 @@ from app.security import ( router = APIRouter(tags=["sharing"]) +# Vorgabewert; maßgeblich ist die Einstellung in der Verwaltung. INVITE_DAYS = 14 MAX_OPEN_INVITES = 50 +def _invite_days(db) -> int: + from app.runtime_settings import get_duration + + return get_duration(db, "invite_days") + + def _status(invite: ListInvite) -> str: if invite.revoked_at: return "revoked" @@ -78,8 +85,13 @@ def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None: unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich eine Nachricht an eine frei wählbare Adresse. """ - ok_list = check_rate_limit(db, f"invite-list:{list_id}", limit=20, window_minutes=60) - ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60) + ok_list = check_rate_limit( + db, bucket_key(db, "invite-list", list_id), limit=20, window_minutes=60 + ) + ok_ip = check_rate_limit( + db, bucket_key(db, "invite-ip", client_ip(request)), + limit=30, window_minutes=60, + ) if not (ok_list and ok_ip): db.commit() raise HTTPException( @@ -159,6 +171,7 @@ def create_invite( ).all(): old.revoked_at = utcnow() + valid_days = _invite_days(db) raw = new_token() invite = ListInvite( list_id=lst.id, @@ -166,7 +179,7 @@ def create_invite( role=payload.role, token_hash=hash_token(raw), invited_by=user.id, - expires_at=utcnow() + timedelta(days=INVITE_DAYS), + expires_at=utcnow() + timedelta(days=valid_days), ) db.add(invite) db.commit() @@ -174,7 +187,7 @@ def create_invite( background.add_task( send_invitation, email, raw, - lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS, + lst.name, user.display_name or user.email.split("@")[0], valid_days, ) return _invite_out(invite) @@ -204,10 +217,11 @@ def resend_invite( _guard_invite_rate(db, request, lst.id) + valid_days = _invite_days(db) raw = new_token() invite.token_hash = hash_token(raw) invite.revoked_at = None - invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS) + invite.expires_at = utcnow() + timedelta(days=valid_days) invite.last_sent_at = utcnow() invite.send_count += 1 db.commit() @@ -215,7 +229,7 @@ def resend_invite( background.add_task( send_invitation, invite.email, raw, - lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS, + lst.name, user.display_name or user.email.split("@")[0], valid_days, ) return _invite_out(invite) diff --git a/backend/app/runtime_settings.py b/backend/app/runtime_settings.py new file mode 100644 index 0000000..f84ff2a --- /dev/null +++ b/backend/app/runtime_settings.py @@ -0,0 +1,183 @@ +"""Fristen, die sich zur Laufzeit einstellen lassen. + +Bis hierher standen sie verstreut: teils als Konstante im Quelltext +(`INVITE_DAYS = 14`), teils in der Umgebung (`CLEANUP_DELETED_DAYS`), +teils in der Einstellungstabelle. Wer nachsehen wollte, was tatsächlich +gilt, musste an drei Stellen suchen - und die Datenschutzerklärung +behauptete Werte, die niemand überprüfen konnte. + +Jetzt an einer Stelle, mit klarer Rangfolge: + + Einstellungstabelle → Umgebungsvariable → Vorgabewert hier + +Die Umgebungsvariable wirkt also als Startwert; sobald jemand in der +Verwaltung etwas ändert, gilt der gespeicherte Wert. Das entspricht dem +Verhalten der Selbstregistrierung. + +Fristen, die NICHT hierhergehören, weil sie außerhalb der Anwendung +liegen: die Aufbewahrung der Webserver-Protokolle (Sache der +Protokollrotation auf dem Server) und alles, was fremde Dienste mit +ihren eigenen Daten tun. +""" + +import logging +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.config import settings +from app.deps import get_setting, set_setting + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Duration: + key: str + label: str + unit: str # "Tage" | "Monate" | "Stunden" + default: int + minimum: int + maximum: int + # Was passiert, wenn 0 eingetragen wird? None = nicht erlaubt. + zero_means: str | None = None + note: str = "" + + +# Reihenfolge bestimmt die Darstellung in der Verwaltung. +DURATIONS: tuple[Duration, ...] = ( + # ---- Konten ---- + Duration( + "auto_deactivate_months", "Konto deaktivieren nach", "Monaten", + default=12, minimum=0, maximum=600, zero_means="abgeschaltet", + note="ohne Anmeldung; Administratorkonten sind ausgenommen", + ), + Duration( + "auto_delete_months", "Konto löschen nach", "Monaten", + default=12, minimum=0, maximum=600, zero_means="abgeschaltet", + note="Deaktivierung", + ), + # ---- Listen ---- + Duration( + "auto_archive_months", "Liste archivieren nach", "Monaten", + default=12, minimum=0, maximum=600, zero_means="abgeschaltet", + note="ohne Öffnen; gilt je Person getrennt", + ), + Duration( + "archive_delete_months", "Liste löschen nach", "Monaten", + default=12, minimum=0, maximum=600, zero_means="abgeschaltet", + note="im Archiv des Eigentümers; Vorwarnung 7 Tage und 1 Tag vorher", + ), + # ---- Aufbewahrung ---- + Duration( + "cleanup_deleted_days", "Gelöschtes endgültig entfernen nach", "Tagen", + default=30, minimum=1, maximum=365, + note="so lange kann ein Gerät offline bleiben und vom Löschen " + "noch erfahren", + ), + Duration( + "cleanup_ops_days", "Quittungen der Warteschlange aufbewahren", "Tage", + default=7, minimum=1, maximum=90, + note="verhindern, dass eine wiederholte Änderung doppelt ausgeführt wird", + ), + Duration( + "product_cache_days", "Produktabfragen gültig", "Tage", + default=180, minimum=1, maximum=3650, + note="danach wird derselbe Strichcode erneut abgefragt", + ), + # ---- Sitzungen und Links ---- + Duration( + "session_days", "Angemeldet bleiben", "Tage", + default=30, minimum=1, maximum=365, + note="danach ist eine erneute Anmeldung nötig", + ), + Duration( + "invite_days", "Einladung gültig", "Tage", + default=14, minimum=1, maximum=90, + note="danach muss sie erneut versendet werden", + ), + Duration( + "welcome_days", "Willkommenslink gültig", "Tage", + default=14, minimum=1, maximum=90, + note="Link zum Festlegen des ersten Passworts", + ), + Duration( + "verify_hours", "Bestätigungslink gültig", "Stunden", + default=24, minimum=1, maximum=720, + note="Bestätigung der E-Mail-Adresse nach der Registrierung", + ), + Duration( + "reset_hours", "Passwortlink gültig", "Stunden", + default=1, minimum=1, maximum=72, + note="kurz gehalten: Der Link erlaubt die Übernahme des Kontos", + ), + Duration( + "email_change_hours", "Adresswechsel bestätigen binnen", "Stunden", + default=48, minimum=1, maximum=336, + note="bei Administratorkonten müssen beide Adressen zustimmen", + ), +) + +BY_KEY = {d.key: d for d in DURATIONS} + + +def _env_default(entry: Duration) -> int: + """Startwert aus der Umgebung, falls es dort ein passendes Feld gibt.""" + value = getattr(settings, entry.key, None) + if isinstance(value, int) and not isinstance(value, bool): + return value + return entry.default + + +def get_duration(db: Session, key: str) -> int: + """Aktuell geltender Wert.""" + entry = BY_KEY[key] + raw = get_setting(db, key, "") + if not raw: + return _env_default(entry) + try: + value = int(raw) + except ValueError: + log.warning( + "Einstellung %s ist keine Zahl (%r) - verwende %d", + key, raw, _env_default(entry), + ) + return _env_default(entry) + + if entry.zero_means and value == 0: + return 0 + return max(entry.minimum, min(value, entry.maximum)) + + +def set_duration(db: Session, key: str, value: int) -> int: + entry = BY_KEY[key] + if entry.zero_means and value == 0: + clamped = 0 + else: + clamped = max(entry.minimum, min(int(value), entry.maximum)) + set_setting(db, key, str(clamped)) + return clamped + + +def all_durations(db: Session) -> dict[str, int]: + return {entry.key: get_duration(db, entry.key) for entry in DURATIONS} + + +def describe_durations(db: Session) -> list[dict]: + """Beschreibung für die Verwaltungsoberfläche - Beschriftung, + Einheit, Grenzen und Bedeutung der Null kommen aus dieser Datei, + damit die Oberfläche sie nicht doppelt vorhalten muss.""" + return [ + { + "key": entry.key, + "label": entry.label, + "unit": entry.unit, + "value": get_duration(db, entry.key), + "default": _env_default(entry), + "minimum": entry.minimum, + "maximum": entry.maximum, + "zero_means": entry.zero_means, + "note": entry.note, + } + for entry in DURATIONS + ] diff --git a/backend/app/schemas_admin.py b/backend/app/schemas_admin.py index fcf9d66..aaf66a7 100644 --- a/backend/app/schemas_admin.py +++ b/backend/app/schemas_admin.py @@ -58,23 +58,39 @@ class DeleteUserIn(BaseModel): confirm_email: EmailStr +class DurationOut(BaseModel): + """Eine einstellbare Frist samt Beschreibung. + + Beschriftung, Einheit und Grenzen kommen vom Server, damit die + Oberflaeche sie nicht doppelt vorhalten muss - und damit nicht das + eine steht und das andere gilt. + """ + + key: str + label: str + unit: str + value: int + default: int + minimum: int + maximum: int + # Was bedeutet 0? None = nicht erlaubt. + zero_means: str | None = None + note: str = "" + + class AdminSettingsOut(BaseModel): allow_self_registration: bool locked_by_env: bool - # 0 = abgeschaltet - auto_deactivate_months: int - auto_delete_months: int - # Listen: archivieren nach Nichtbenutzung, loeschen nach Archivdauer - auto_archive_months: int - archive_delete_months: int + durations: list[DurationOut] + # Fristen, die ausserhalb der Anwendung liegen - nur zur Anzeige, + # damit die Uebersicht nicht unvollstaendig wirkt. + fixed: dict[str, str] class AdminSettingsIn(BaseModel): allow_self_registration: bool | None = None - auto_deactivate_months: int | None = Field(default=None, ge=0, le=600) - auto_delete_months: int | None = Field(default=None, ge=0, le=600) - auto_archive_months: int | None = Field(default=None, ge=0, le=600) - archive_delete_months: int | None = Field(default=None, ge=0, le=600) + # Schluessel aus DURATIONS -> neuer Wert + durations: dict[str, int] | None = None class WelcomeCompleteIn(BaseModel): diff --git a/backend/app/security.py b/backend/app/security.py index 0e3bdae..7cb3955 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -59,6 +59,50 @@ def normalize_email(email: str) -> str: return email.strip().lower() +# -------------------------------------------------------------------------- +# Schluessel fuer die Missbrauchsbremse +# -------------------------------------------------------------------------- +# +# Die Zaehler brauchen einen Schluessel je Absender - IP-Adresse oder +# E-Mail-Adresse. Beides im Klartext abzulegen waere unnoetig: Fuer das +# Hochzaehlen genuegt ein Wert, der gleich bleibt; lesbar muss er nicht +# sein. +# +# Deshalb ein Streuwert mit einem zufaelligen, installationsweiten +# Zusatz. Das ist Pseudonymisierung, keine Anonymisierung - wer die +# Datenbank samt Zusatz hat, koennte eine vermutete Adresse +# nachrechnen. Es verhindert aber, dass beim Blick in die Tabelle +# Adressen mitgelesen werden, und die Werte verschwinden ohnehin nach +# 24 Stunden. + +_bucket_salt: str | None = None + + +def _salt(db: Session) -> bytes: + """Zufallszusatz, beim ersten Bedarf erzeugt und dann bestaendig.""" + global _bucket_salt + if _bucket_salt is None: + from app.models import Setting + + row = db.get(Setting, "ratelimit_salt") + if row is None: + value = secrets.token_hex(16) + db.add(Setting(key="ratelimit_salt", value=value)) + db.flush() + else: + value = row.value + _bucket_salt = value + return _bucket_salt.encode()[:32] + + +def bucket_key(db: Session, prefix: str, value: str) -> str: + """Erzeugt einen Zaehlerschluessel, ohne den Wert selbst abzulegen.""" + digest = hashlib.blake2s( + value.encode("utf-8"), key=_salt(db), digest_size=16 + ).hexdigest() + return f"{prefix}:{digest}" + + def check_rate_limit( db: Session, bucket: str, *, limit: int, window_minutes: int ) -> bool: diff --git a/backend/app/users.py b/backend/app/users.py index 3e62b8e..adaabb8 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -24,6 +24,7 @@ from app.security import hash_token, new_token, normalize_email, utcnow log = logging.getLogger(__name__) +# Vorgabewerte; maßgeblich ist, was in der Verwaltung eingestellt ist. WELCOME_DAYS = 14 EMAIL_CHANGE_HOURS = 48 @@ -32,22 +33,18 @@ EMAIL_CHANGE_HOURS = 48 # Einstellungen zur automatischen Bereinigung # ========================================================================== -def months_setting(db: Session, key: str, fallback: int) -> int: - """Liest einen Monatswert aus der Laufzeitkonfiguration. +def months_setting(db: Session, key: str, fallback: int = 0) -> int: + """Monatswert aus der Laufzeitkonfiguration. - 0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein - Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle - Konten verschwinden. + Nur noch eine Weiterleitung - die Fristen liegen gesammelt in + app/runtime_settings.py. Der Name bleibt, damit die vorhandenen + Aufrufstellen unverändert weiterlaufen. + + 0 bedeutet ausdrücklich "abgeschaltet", nicht "sofort". """ - raw = get_setting(db, key, "") - if not raw: - return fallback - try: - value = int(raw) - except ValueError: - log.warning("Einstellung %s ist keine Zahl (%r) - verwende %d", key, raw, fallback) - return fallback - return max(0, min(value, 600)) + from app.runtime_settings import get_duration + + return get_duration(db, key) # ========================================================================== @@ -77,6 +74,12 @@ def issue_token(db: Session, user: User, purpose: str, hours: int) -> str: return raw +def welcome_days(db: Session) -> int: + from app.runtime_settings import get_duration + + return get_duration(db, "welcome_days") + + def create_user( db: Session, *, email: str, display_name: str | None, is_admin: bool = False ) -> tuple[User, str]: @@ -99,7 +102,7 @@ def create_user( ) db.add(user) db.flush() - token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24) + token = issue_token(db, user, "welcome", hours=welcome_days(db) * 24) return user, token @@ -133,6 +136,10 @@ def start_email_change( ).all(): pending.cancelled_at = utcnow() + from app.runtime_settings import get_duration + + hours = get_duration(db, "email_change_hours") + raw_new = new_token() raw_old = new_token() if user.is_admin else None @@ -144,7 +151,7 @@ def start_email_change( token_old_hash=hash_token(raw_old) if raw_old else None, requires_old=user.is_admin, requested_by=requested_by, - expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS), + expires_at=utcnow() + timedelta(hours=hours), ) db.add(change) db.flush() diff --git a/docs/sicherheit.md b/docs/sicherheit.md index fdc7c2f..7290f68 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -213,6 +213,21 @@ sie ein Hinweis darauf, dass das Konto existiert. **Löschen verlangt die Eingabe der Adresse** als Bestätigung, und regelt die Listen des Kontos, statt sie mitzureißen. +## Datensparsamkeit im Betrieb + +**Keine Fremdressourcen.** Keine externen Schriften, Skripte, Karten oder +Symbole. Verwendet wird die Systemschrift des Geräts. Die CSP führt +`font-src 'self'` ausdrücklich auf. + +**Zugriffsprotokolle mit gekürzter IP-Adresse**, ohne `Referer` (dort stünde +bei öffentlichen Listenlinks der Zugangsschlüssel). + +**Missbrauchsbremse ohne Klartextadressen.** Bis zur Sicherheitsprüfung lagen +IP- und E-Mail-Adressen im Klartext in `rate_limit.bucket`. Jetzt ein +Streuwert mit installationsweitem Zufallszusatz – Pseudonymisierung, keine +Anonymisierung, aber die Adressen sind beim Blick in die Tabelle nicht mehr +mitzulesen. Aufbewahrung ohnehin höchstens 24 Stunden. + ## Bekannte Grenzen Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler – aber jeder ist eine diff --git a/tools/check-all.sh b/tools/check-all.sh index 78c3a66..5e23c32 100644 --- a/tools/check-all.sh +++ b/tools/check-all.sh @@ -30,6 +30,7 @@ run "Konfigurationsvorlage" bash tools/check-env.sh .env.example run "Berechtigungen" python3 tools/check-routes.py run "Datenbankschema" python3 tools/check-schema.py run "Bearbeitbare Felder" python3 tools/check-editable.py +run "Fristen" python3 tools/check-retention.py run "nginx-Konfiguration" python3 tools/check-nginx.py web run "JavaScript-Module" node tools/check-js.mjs run "Strichcode-Decoder" node tools/test-barcode.mjs diff --git a/tools/check-retention.py b/tools/check-retention.py new file mode 100644 index 0000000..924c8ba --- /dev/null +++ b/tools/check-retention.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Gleicht Fristen, Aufräumregeln und Datenschutzerklärung ab. + + python3 tools/check-retention.py + +Drei Fragen, die sich sonst nur von Hand beantworten lassen: + + 1. Wird jede Frist aus der Verwaltung auch tatsächlich irgendwo + angewandt? Eine Einstellung, die nichts bewirkt, ist schlimmer als + keine - sie behauptet eine Wirkung. + + 2. Wird jede Tabelle mit einem Ablauf- oder Löschzeitpunkt vom + Aufräumlauf erfasst? Sonst wächst sie unbegrenzt, und die + Datenschutzerklärung sagt die Unwahrheit. + + 3. Werden die Fristen im Aufräumlauf aus der Laufzeitkonfiguration + gelesen - oder stehen dort noch feste Zahlen? +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +APP = ROOT / "backend" / "app" +RUNTIME = APP / "runtime_settings.py" +MAINTENANCE = APP / "maintenance.py" +MODELS = APP / "models.py" + +# Tabellen mit Zeitfeldern, die BEWUSST nicht aufgeräumt werden. +NO_CLEANUP = { + # Preise sind der Zweck der Sammlung und ohne Personenbezug. + "price_point": "unbefristet, kein Personenbezug", + # Wird beim Zustellversuch aufgeräumt, nicht zeitgesteuert. + "push_subscription": "Aufräumen bei ungültigem Endpunkt", + # Haengt an der Mitgliedschaft, verschwindet per CASCADE. + "notify_state": "CASCADE mit der Liste", + # Laufzeitkonfiguration, kein Ablauf. + "setting": "Konfiguration", + # Kernbestand. + "user": "Kontenbereinigung in _retire_users", + "shopping_list": "Archivbereinigung in _archive_lists", + "list_member": "CASCADE", + "market": "Soft Delete, siehe cleanup_deleted_days", + "category": "Soft Delete, siehe cleanup_deleted_days", + "article": "Soft Delete, siehe cleanup_deleted_days", + "article_attribute": "CASCADE", + "article_market": "CASCADE", + "list_item": "Soft Delete, siehe cleanup_deleted_days", + "user_session": "über expires_at aufgeräumt", +} + + +def duration_keys() -> list[str]: + source = RUNTIME.read_text(encoding="utf-8") + return re.findall(r'^\s*"(\w+)",\s*"', source, re.M) + + +def main() -> int: + problems: list[str] = [] + notes: list[str] = [] + + keys = duration_keys() + if not keys: + print("Keine Fristen in runtime_settings.py gefunden.", file=sys.stderr) + return 2 + + # --- 1. Wird jede Frist irgendwo angewandt? --- + sources = { + path: path.read_text(encoding="utf-8") + for path in APP.rglob("*.py") + if path.name not in {"runtime_settings.py", "config.py"} + } + + for key in keys: + users = [ + path.relative_to(ROOT) + for path, text in sources.items() + if f'"{key}"' in text + ] + if not users: + problems.append( + f"Frist '{key}' ist einstellbar, wird aber nirgends verwendet" + ) + else: + notes.append(f" {key:26} -> {', '.join(str(u) for u in users)}") + + # --- 2. Erfasst der Aufräumlauf alle Tabellen mit Ablauf? --- + models = MODELS.read_text(encoding="utf-8") + maintenance = MAINTENANCE.read_text(encoding="utf-8") + + for match in re.finditer(r'__tablename__ = "(\w+)"', models): + table = match.group(1) + block = models[match.start():] + stop = block.find("\nclass ", 1) + if stop > 0: + block = block[:stop] + + has_expiry = bool( + re.search(r"^\s+(expires_at|deleted_at|fetched_at|created_at|window_start)", + block, re.M) + ) + if not has_expiry: + continue + + # Klassenname aus der Tabelle ableiten + class_match = re.search( + r"class (\w+)\(Base\):(?:(?!\nclass ).)*?__tablename__ = \"" + table + '"', + models, re.S, + ) + class_name = class_match.group(1) if class_match else None + + covered = class_name and class_name in maintenance + if not covered and table not in NO_CLEANUP: + problems.append( + f"Tabelle '{table}' hat ein Zeitfeld, wird aber vom " + "Aufräumlauf nicht erfasst" + ) + + # --- 3. Feste Zahlen im Aufräumlauf? --- + for line_no, line in enumerate(maintenance.splitlines(), 1): + if "timedelta(days=" not in line: + continue + # Erlaubt: aus Variablen berechnet, oder die fest dokumentierten + # Sonderfälle (7 Tage Puffer für Mail-Token, 1 Tag Rate Limit). + literal = re.search(r"timedelta\(days=(\d+)\)", line) + if literal and literal.group(1) not in {"1", "7"}: + problems.append( + f"maintenance.py Zeile {line_no}: feste Frist " + f"{literal.group(1)} Tage - gehört in runtime_settings.py" + ) + + print("Fristen und ihre Verwendung:") + print("\n".join(notes)) + + if problems: + print(f"\n{len(problems)} Problem(e):\n") + for entry in problems: + print(f" - {entry}") + return 1 + + print(f"\n{len(keys)} Fristen, alle angewandt; Aufräumlauf vollständig.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/html/app.css b/web/html/app.css index 32f4afc..b7fbcf3 100644 --- a/web/html/app.css +++ b/web/html/app.css @@ -1465,3 +1465,94 @@ button.archive-head .chev { color: var(--muted); } .reg-toggle .checkline { margin-top: 0; } @media print { ul.suggestions, .card.archive { display: none !important; } } + +/* =================================================================== + Fußzeile mit Impressum und Datenschutz + =================================================================== */ + +.app-footer { + max-width: 30rem; + margin: 0 auto; + padding: 1.5rem 1rem calc(1.5rem + env(safe-area-inset-bottom)); + text-align: center; + font-size: .82rem; + color: var(--muted); +} + +.app-footer a { color: var(--muted); } +.app-footer span { margin: 0 .4rem; } + +/* Über dem Sucher und über Dialogen darf sie nicht durchscheinen. */ +body.scanning .app-footer, +body.modal-open .app-footer { display: none; } + +/* =================================================================== + Rechtliche Seiten + =================================================================== */ + +.legal-text { line-height: 1.6; } + +.legal-text h1 { margin-bottom: .2rem; } + +.legal-text h2 { + margin: 2rem 0 .5rem; + font-size: 1.05rem; + padding-bottom: .3rem; + border-bottom: 1px solid var(--line); +} + +.legal-text h3 { margin: 1.4rem 0 .4rem; font-size: .95rem; } + +.legal-text p { margin: .6rem 0; } +.legal-text ul { margin: .6rem 0; padding-left: 1.2rem; } +.legal-text li { margin: .25rem 0; } + +.legal-text table { + width: 100%; + border-collapse: collapse; + margin: .8rem 0; + font-size: .9rem; +} + +.legal-text th, +.legal-text td { + text-align: left; + vertical-align: top; + padding: .45rem .5rem .45rem 0; + border-bottom: 1px solid var(--line); +} + +.legal-text th { font-size: .8rem; text-transform: uppercase; color: var(--muted); } + +/* Hinweis, dass es sich um einen Entwurf handelt - soll nach dem + Ausfüllen entfernt werden. */ +.legal-text .draft { + background: var(--warn-bg); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: .8rem 1rem; + margin: 0 0 1.5rem; + font-size: .9rem; +} + +/* Erläuterungen zur Rechtslage - gehören nicht in den fertigen Text. */ +.legal-text .hint { + font-size: .85rem; + color: var(--muted); + border-left: 2px solid var(--line); + padding-left: .8rem; + margin: .5rem 0 1rem; +} + +.legal-text code { + background: rgba(127, 127, 127, .18); + padding: .1em .35em; + border-radius: 3px; + font-size: .9em; +} + +@media print { + /* Im Ausdruck haben Rechtslinks nichts zu suchen - er soll die + Einkaufsliste zeigen, nicht die Fußzeile der Anwendung. */ + .app-footer { display: none !important; } +} diff --git a/web/html/datenschutz.html b/web/html/datenschutz.html new file mode 100644 index 0000000..d790574 --- /dev/null +++ b/web/html/datenschutz.html @@ -0,0 +1,316 @@ + + + + + + +Datenschutzerklärung + + + + +
+ +
+ ‹ Zurück zur App +
+ + +
+ + diff --git a/web/html/impressum.html b/web/html/impressum.html new file mode 100644 index 0000000..5005f6a --- /dev/null +++ b/web/html/impressum.html @@ -0,0 +1,130 @@ + + + + + + +Impressum + + + + +
+ +
+ ‹ Zurück zur App +
+ + +
+ + diff --git a/web/html/index.html b/web/html/index.html index 4390997..e54e6bc 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -28,6 +28,15 @@

Diese App benötigt JavaScript.

+ + + diff --git a/web/html/js/views/admin.js b/web/html/js/views/admin.js index 28867ec..86faa6f 100644 --- a/web/html/js/views/admin.js +++ b/web/html/js/views/admin.js @@ -296,57 +296,66 @@ export async function adminView(root, { back }) { // Automatik // ------------------------------------------------------------------- - function automationCard() { - const deactivate = el("input.months", { - type: "number", min: "0", max: "600", - value: String(config.auto_deactivate_months), - }); - const remove = el("input.months", { - type: "number", min: "0", max: "600", - value: String(config.auto_delete_months), - }); - const archive = el("input.months", { - type: "number", min: "0", max: "600", - value: String(config.auto_archive_months), - }); - const archiveDelete = el("input.months", { - type: "number", min: "0", max: "600", - value: String(config.archive_delete_months), - }); + /** Fristen aus dem Server: Beschriftung, Einheit und Grenzen kommen + * von dort, damit nicht das eine steht und das andere gilt. */ + function durationsCard() { + const fields = new Map(); + + const group = (title, keys, lead) => { + const rows = config.durations.filter((d) => keys.includes(d.key)); + if (!rows.length) return null; + + return el("div.duration-group", {}, + el("h3.device-heading", {}, title), + lead ? el("p.sub", {}, lead) : null, + rows.map((entry) => { + const input = el("input.months", { + type: "number", + min: String(entry.minimum), + max: String(entry.maximum), + value: String(entry.value), + title: `${entry.minimum} bis ${entry.maximum} ${entry.unit}`, + }); + fields.set(entry.key, input); + + return el("div.duration-row", {}, + el("div.duration-text", {}, + el("span.duration-label", {}, `${entry.label} … ${entry.unit}`), + el("span.duration-note", {}, + entry.note, + entry.zero_means ? ` · 0 = ${entry.zero_means}` : "", + entry.value !== entry.default + ? ` · Vorgabe ${entry.default}` : "")), + input); + })); + }; + return el("section.card", {}, - el("h2", {}, "Automatische Bereinigung"), + el("h2", {}, "Fristen"), el("p.lead", {}, - "Läuft einmal täglich. ", - el("strong", {}, "0 bedeutet abgeschaltet"), - ", nicht „sofort“. Administratorkonten sind ausgenommen."), + "Diese Werte gelten zur Laufzeit und werden auch für die " + + "Datenschutzerklärung gebraucht. Der Aufräumlauf setzt sie " + + "einmal täglich um."), - el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"), - deactivate, - el("label", {}, "Löschen nach … Monaten Deaktivierung"), - remove, - - el("h3.device-heading", {}, "Listen"), - el("label", {}, "Archivieren nach … Monaten ohne Öffnen"), - archive, - el("p.sub", {}, - "Gilt je Person: Die Liste verschwindet nur aus der Übersicht " + - "derjenigen, die sie nicht geöffnet hat."), - el("label", {}, "Löschen nach … Monaten im Archiv"), - archiveDelete, - el("p.sub", {}, - "Maßgeblich ist allein das Archivdatum des Eigentümers. Er wird " + - "7 Tage und 1 Tag vorher per E-Mail gewarnt."), + group("Konten", + ["auto_deactivate_months", "auto_delete_months"]), + group("Listen", + ["auto_archive_months", "archive_delete_months"]), + group("Aufbewahrung", + ["cleanup_deleted_days", "cleanup_ops_days", "product_cache_days"]), + group("Sitzungen und Links", + ["session_days", "invite_days", "welcome_days", "verify_hours", + "reset_hours", "email_change_hours"]), el("button.primary", { type: "button", onclick: () => guarded(async () => { - await put("/api/admin/settings", { - auto_deactivate_months: Number(deactivate.value) || 0, - auto_delete_months: Number(remove.value) || 0, - auto_archive_months: Number(archive.value) || 0, - archive_delete_months: Number(archiveDelete.value) || 0, - }); - say("Einstellungen gespeichert."); + const durations = {}; + for (const [key, input] of fields) { + durations[key] = Number(input.value) || 0; + } + await put("/api/admin/settings", { durations }); + say("Fristen gespeichert."); }), }, "Speichern"), @@ -354,7 +363,19 @@ export async function adminView(root, { back }) { el("button", { type: "button", onclick: () => guarded(() => post("/api/admin/cleanup")), - }, "Jetzt aufräumen")) + }, "Jetzt aufräumen")), + + // Was hier nicht einstellbar ist, soll trotzdem sichtbar sein - + // sonst wirkt die Übersicht vollständig, ohne es zu sein. + Object.keys(config.fixed || {}).length + ? el("div", {}, + el("h3.device-heading", {}, "Fest eingestellt"), + el("ul.fixed-list", {}, + Object.entries(config.fixed).map(([label, value]) => + el("li", {}, + el("span.name", {}, label), + el("span.sub", {}, value))))) + : null ); } @@ -375,7 +396,7 @@ export async function adminView(root, { back }) { registrationToggle(), el("ul.admin-users", {}, users.map(userRow))), createCard(), - automationCard() + durationsCard() ); } diff --git a/web/html/sw.js b/web/html/sw.js index 35818ff..39b8f40 100644 --- a/web/html/sw.js +++ b/web/html/sw.js @@ -5,12 +5,14 @@ // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // wann alte Zwischenspeicher verworfen werden. -const VERSION = "v25"; +const VERSION = "v27"; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL = [ "/", "/app.css", + "/impressum.html", + "/datenschutz.html", "/js/app.js", "/js/api.js", "/js/dom.js", diff --git a/web/nginx.conf b/web/nginx.conf index 6971eec..d3ab149 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -4,6 +4,25 @@ limit_req_zone $binary_remote_addr zone=auth_zone:10m rate=10r/m; limit_req_status 429; +# Zugriffsprotokoll ohne vollstaendige IP-Adresse. +# +# Fuer den Betrieb genuegt, WAS abgerufen wurde und ob es geklappt hat. +# Die letzte Stelle der Adresse traegt dazu nichts bei, macht den +# Eintrag aber personenbeziehbar - und Protokolle wandern in +# Sicherungen und leben laenger als der Anlass. +map $remote_addr $ip_gekuerzt { + ~(?\d+\.\d+\.\d+)\.\d+ $v4.0; + ~(?[0-9a-fA-F]+:[0-9a-fA-F]+):.* $v6::; + default 0.0.0.0; +} + +log_format gekuerzt '$ip_gekuerzt - [$time_local] "$request" $status ' + '$body_bytes_sent "$http_user_agent"'; + +# Kein $http_referer: Bei einem oeffentlichen Listenlink stuende dort +# der Token, sobald jemand von dort aus weiterklickt. +access_log /var/log/nginx/access.log gekuerzt; + server { listen 8080; server_name _; @@ -154,7 +173,7 @@ server { location = /robots.txt { include /etc/nginx/security_headers.conf; default_type text/plain; - return 200 "User-agent: *\nDisallow: /s/\nDisallow: /invite\nDisallow: /reset\nDisallow: /willkommen\n"; + return 200 "User-agent: *\nDisallow: /s/\nDisallow: /invite\nDisallow: /reset\nDisallow: /willkommen\nAllow: /impressum.html\nAllow: /datenschutz.html\n"; } # Aus demselben Grund wie bei /s/: Einladungs- und diff --git a/web/security_headers.conf b/web/security_headers.conf index a33fe34..2e726c4 100644 --- a/web/security_headers.conf +++ b/web/security_headers.conf @@ -10,4 +10,7 @@ add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "no-referrer" always; add_header Permissions-Policy "camera=(self), geolocation=(), microphone=()" always; -add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; +# font-src 'self' steht ausdrücklich da, obwohl default-src es schon +# abdeckt: Es soll beim Lesen der Datei erkennbar sein, dass keine +# Schriften von fremden Servern nachgeladen werden dürfen. +add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;