Ergänzung Datenschutzerklärung und Impressum, Fristenformular in Einstellungen

This commit is contained in:
2026-08-10 06:44:11 +02:00
parent 8ffbda875e
commit 777e3e5447
25 changed files with 1310 additions and 139 deletions

View File

@@ -35,8 +35,6 @@ PUBLIC_BASE_URL=http://einkauf.example.de:46600
# "*" ist in Ordnung, solange api keinen oeffentlichen Port hat. # "*" ist in Ordnung, solange api keinen oeffentlichen Port hat.
FORWARDED_ALLOW_IPS=* FORWARDED_ALLOW_IPS=*
SESSION_DAYS=30
# Cookie nur über HTTPS ausliefern. # Cookie nur über HTTPS ausliefern.
# false bei direktem HTTP-Zugriff auf Port 46600 # false bei direktem HTTP-Zugriff auf Port 46600
# true sobald ein Reverse Proxy mit TLS davorsteht (dann muss # true sobald ein Reverse Proxy mit TLS davorsteht (dann muss
@@ -114,6 +112,19 @@ CLEANUP_OPS_DAYS=7
# Abstand zwischen zwei Aufraeumdurchlaeufen. # Abstand zwischen zwei Aufraeumdurchlaeufen.
CLEANUP_INTERVAL_HOURS=24 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 # Push-Benachrichtigungen
# ========================================================================== # ==========================================================================

116
README.md
View File

@@ -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; aussperren. Wer einen Administrator entfernen will, nimmt ihm zuerst die Rechte;
das geht bewusst nur direkt in der Datenbank. 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 | | Konten | deaktivieren nach … Monaten ohne Anmeldung | 12 |
| Konto löschen nach … Monaten Deaktivierung | 12 | Konto wird endgültig entfernt | | | löschen nach … Monaten Deaktivierung | 12 |
| Liste archivieren nach … Monaten ohne Öffnen | 12 | **Je Person** die Liste verschwindet nur aus deren Übersicht | | Listen | archivieren nach … Monaten ohne Öffnen | 12 |
| Liste löschen nach … Monaten im Archiv | 12 | Nur nach dem Archivdatum des **Eigentümers** | | | 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 **0 bedeutet abgeschaltet, nicht „sofort".** Ein Tippfehler soll nicht dazu
führen, dass beim nächsten Durchlauf alle Konten verschwinden. 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 beim nächsten Start alle Migrationen erneut anwenden und an den vorhandenen
Tabellen scheitern. 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 ## Sicherheit
`docs/sicherheit.md` Ergebnisse der Prüfung auf SQL-Injection, XSS, `docs/sicherheit.md` Ergebnisse der Prüfung auf SQL-Injection, XSS,

View File

@@ -72,6 +72,13 @@ class Settings(BaseSettings):
cleanup_ops_days: int = 7 cleanup_ops_days: int = 7
# Abstand zwischen zwei Durchlaeufen. # Abstand zwischen zwei Durchlaeufen.
cleanup_interval_hours: int = 24 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 ---------------- # ---------------- Push-Benachrichtigungen ----------------
# Erzeugen mit: python3 tools/vapid-keys.py # Erzeugen mit: python3 tools/vapid-keys.py

View File

@@ -60,18 +60,24 @@ def self_registration_enabled(db: Session) -> bool:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def create_session(db: Session, user: User, response: Response) -> UserSession: 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() raw = new_token()
csrf = new_token() csrf = new_token()
sess = UserSession( sess = UserSession(
token_hash=hash_token(raw), token_hash=hash_token(raw),
user_id=user.id, user_id=user.id,
csrf_token=csrf, csrf_token=csrf,
expires_at=utcnow() + timedelta(days=settings.session_days), expires_at=utcnow() + timedelta(days=days),
) )
db.add(sess) db.add(sess)
db.flush() db.flush()
max_age = settings.session_days * 24 * 3600 max_age = days * 24 * 3600
# HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token # HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token
# nicht abgreifen kann. # nicht abgreifen kann.
response.set_cookie( response.set_cookie(

View File

@@ -38,6 +38,7 @@ from app.models import (
User, User,
UserSession, UserSession,
) )
from app.runtime_settings import get_duration
from app.security import utcnow from app.security import utcnow
from app.users import deactivate, delete_user, months_setting 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]: 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() now = utcnow()
counts: dict[str, int] = {} 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: def purge(model, condition, label: str) -> None:
result = db.execute(delete(model).where(condition)) result = db.execute(delete(model).where(condition))
if result.rowcount: if result.rowcount:
@@ -79,34 +89,33 @@ def run_cleanup(db: Session) -> dict[str, int]:
# längst über die Wiederholungsgrenze hinaus versucht. # längst über die Wiederholungsgrenze hinaus versucht.
purge( purge(
AppliedOp, AppliedOp,
AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days), AppliedOp.created_at < now - timedelta(days=ops_days),
"Outbox-Quittungen", "Outbox-Quittungen",
) )
# --- Abgelaufene Einladungen und öffentliche Links --- # --- Abgelaufene Einladungen und öffentliche Links ---
purge( purge(
ListInvite, ListInvite,
ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days), ListInvite.expires_at < now - timedelta(days=deleted_days),
"alte Einladungen", "alte Einladungen",
) )
purge( purge(
PublicShare, PublicShare,
PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days), PublicShare.expires_at < now - timedelta(days=deleted_days),
"abgelaufene öffentliche Links", "abgelaufene öffentliche Links",
) )
# --- Produktzwischenspeicher --- # --- Produktzwischenspeicher ---
purge( purge(
ProductCache, ProductCache,
ProductCache.fetched_at ProductCache.fetched_at < now - timedelta(days=max(product_days * 2, 365)),
< now - timedelta(days=max(settings.product_cache_days * 2, 365)),
"Produktzwischenspeicher", "Produktzwischenspeicher",
) )
# --- Endgültiges Löschen weicher Löschungen --- # --- Endgültiges Löschen weicher Löschungen ---
# Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten # Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten
# offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt. # 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") 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 --- # --- Abgeschlossene Adressänderungen ---
purge( purge(
EmailChange, EmailChange,
EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days), EmailChange.expires_at < now - timedelta(days=deleted_days),
"alte Adressänderungen", "alte Adressänderungen",
) )

View File

@@ -163,9 +163,11 @@ def lookup(db: Session, barcode: str) -> ProductCache | None:
cached = db.get(ProductCache, barcode) cached = db.get(ProductCache, barcode)
if cached is not None: if cached is not None:
from app.runtime_settings import get_duration
age = utcnow() - cached.fetched_at age = utcnow() - cached.fetched_at
max_age = timedelta( 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 else settings.product_miss_days
) )
if age < max_age: if age < max_age:

View File

@@ -26,16 +26,18 @@ from app.mail import (
from app.maintenance import describe, run_cleanup from app.maintenance import describe, run_cleanup
from app.models import EmailChange, User from app.models import EmailChange, User
from app.schemas import MailCheckOut, MailTestIn, MessageOut from app.schemas import MailCheckOut, MailTestIn, MessageOut
from app.runtime_settings import BY_KEY, describe_durations, set_duration
from app.schemas_admin import ( from app.schemas_admin import (
AdminSettingsIn, AdminSettingsIn,
AdminSettingsOut, AdminSettingsOut,
AdminStatsOut, AdminStatsOut,
AdminUserOut, AdminUserOut,
DeleteUserIn, DeleteUserIn,
DurationOut,
EmailChangeIn, EmailChangeIn,
UserCreateIn, 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 ( from app.users import (
EMAIL_CHANGE_HOURS, EMAIL_CHANGE_HOURS,
WELCOME_DAYS, WELCOME_DAYS,
@@ -66,18 +68,17 @@ def _settings_out(db) -> AdminSettingsOut:
return AdminSettingsOut( return AdminSettingsOut(
allow_self_registration=self_registration_enabled(db), allow_self_registration=self_registration_enabled(db),
locked_by_env=registration_locked_by_env(), locked_by_env=registration_locked_by_env(),
auto_deactivate_months=months_setting( durations=[DurationOut(**entry) for entry in describe_durations(db)],
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS fixed={
), "Zähler der Missbrauchsabwehr": "24 Stunden",
auto_delete_months=months_setting( "Fehlgeschlagene Produktabfragen":
db, "auto_delete_months", DEFAULT_DELETE_MONTHS f"{settings.product_miss_days} Tage (PRODUCT_MISS_DAYS)",
), "Aufräumlauf":
auto_archive_months=months_setting( f"alle {settings.cleanup_interval_hours} Stunden "
db, "auto_archive_months", DEFAULT_ARCHIVE_MONTHS "(CLEANUP_INTERVAL_HOURS)",
), "Zugriffsprotokolle des Webservers":
archive_delete_months=months_setting( "Sache der Protokollrotation auf dem Server, nicht der Anwendung",
db, "archive_delete_months", DEFAULT_ARCHIVE_DELETE_MONTHS },
),
) )
@@ -102,14 +103,12 @@ def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser):
"true" if payload.allow_self_registration else "false", "true" if payload.allow_self_registration else "false",
) )
if payload.auto_deactivate_months is not None: for key, value in (payload.durations or {}).items():
set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months)) if key not in BY_KEY:
if payload.auto_delete_months is not None: raise HTTPException(
set_setting(db, "auto_delete_months", str(payload.auto_delete_months)) status.HTTP_400_BAD_REQUEST, f"Unbekannte Frist: {key}"
if payload.auto_archive_months is not None: )
set_setting(db, "auto_archive_months", str(payload.auto_archive_months)) set_duration(db, key, value)
if payload.archive_delete_months is not None:
set_setting(db, "archive_delete_months", str(payload.archive_delete_months))
db.commit() db.commit()
return _settings_out(db) return _settings_out(db)
@@ -222,7 +221,8 @@ def add_user(
background: BackgroundTasks, background: BackgroundTasks,
): ):
if not check_rate_limit( 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() db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.") raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.")
@@ -241,7 +241,8 @@ def add_user(
background.add_task( background.add_task(
send_welcome, email, token, 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, {}, {}, {}) return _user_out(user, {}, {}, {})
@@ -268,16 +269,19 @@ def resend_welcome(
"nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.", "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() db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") 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() db.commit()
background.add_task( background.add_task(
send_welcome, user.email, token, 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.") 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) change, raw_new, raw_old = start_email_change(db, user, target, admin.id)
db.commit() db.commit()
change_hours = get_duration(db, "email_change_hours")
background.add_task( 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: if change.requires_old and raw_old:
background.add_task( 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 = ( detail = (
f"Bestätigungslinks an {target} und {old_email} versendet. " f"Bestätigungslinks an {target} und {old_email} versendet. "
@@ -324,7 +329,7 @@ def change_email(
) )
else: else:
background.add_task( background.add_task(
send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS send_email_change_notice, old_email, target, change_hours
) )
detail = ( detail = (
f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. " f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. "

View File

@@ -28,6 +28,7 @@ from app.schemas import (
RegisterIn, RegisterIn,
UserOut, UserOut,
) )
from app.runtime_settings import get_duration
from app.users import apply_if_complete from app.users import apply_if_complete
from app.security import ( from app.security import (
check_rate_limit, check_rate_limit,
@@ -96,7 +97,8 @@ def register(
) )
if not check_rate_limit( 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() db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
@@ -113,12 +115,14 @@ def register(
) )
db.add(user) db.add(user)
db.flush() 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) background.add_task(send_verification, email, raw)
elif existing.verified_at is None: elif existing.verified_at is None:
# Unbestaetigtes Konto: neuen Link schicken, statt zu verraten, # Unbestaetigtes Konto: neuen Link schicken, statt zu verraten,
# dass die Adresse schon vergeben ist. # 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) background.add_task(send_verification, email, raw)
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben. # 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): def login(payload: LoginIn, request: Request, response: Response, db: DbSession):
email = normalize_email(payload.email) email = normalize_email(payload.email)
ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15) ip_ok = check_rate_limit(
acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15) 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): if not (ip_ok and acct_ok):
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -266,7 +275,8 @@ def reset_request(
background: BackgroundTasks, background: BackgroundTasks,
): ):
if not check_rate_limit( 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() db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.") raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
@@ -274,7 +284,7 @@ def reset_request(
email = normalize_email(payload.email) email = normalize_email(payload.email)
user = db.scalar(select(User).where(User.email == 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: 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) background.add_task(send_password_reset, email, raw)
db.commit() db.commit()

View File

@@ -31,7 +31,7 @@ from app.schemas_shopping import (
PublicToggleIn, PublicToggleIn,
PublicViewOut, 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"]) 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 # bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben. # Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
if not check_rate_limit( 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() db.commit()
raise HTTPException( raise HTTPException(

View File

@@ -40,10 +40,17 @@ from app.security import (
router = APIRouter(tags=["sharing"]) router = APIRouter(tags=["sharing"])
# Vorgabewert; maßgeblich ist die Einstellung in der Verwaltung.
INVITE_DAYS = 14 INVITE_DAYS = 14
MAX_OPEN_INVITES = 50 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: def _status(invite: ListInvite) -> str:
if invite.revoked_at: if invite.revoked_at:
return "revoked" 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 unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
eine Nachricht an eine frei wählbare Adresse. 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_list = check_rate_limit(
ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60) 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): if not (ok_list and ok_ip):
db.commit() db.commit()
raise HTTPException( raise HTTPException(
@@ -159,6 +171,7 @@ def create_invite(
).all(): ).all():
old.revoked_at = utcnow() old.revoked_at = utcnow()
valid_days = _invite_days(db)
raw = new_token() raw = new_token()
invite = ListInvite( invite = ListInvite(
list_id=lst.id, list_id=lst.id,
@@ -166,7 +179,7 @@ def create_invite(
role=payload.role, role=payload.role,
token_hash=hash_token(raw), token_hash=hash_token(raw),
invited_by=user.id, invited_by=user.id,
expires_at=utcnow() + timedelta(days=INVITE_DAYS), expires_at=utcnow() + timedelta(days=valid_days),
) )
db.add(invite) db.add(invite)
db.commit() db.commit()
@@ -174,7 +187,7 @@ def create_invite(
background.add_task( background.add_task(
send_invitation, email, raw, 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) return _invite_out(invite)
@@ -204,10 +217,11 @@ def resend_invite(
_guard_invite_rate(db, request, lst.id) _guard_invite_rate(db, request, lst.id)
valid_days = _invite_days(db)
raw = new_token() raw = new_token()
invite.token_hash = hash_token(raw) invite.token_hash = hash_token(raw)
invite.revoked_at = None 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.last_sent_at = utcnow()
invite.send_count += 1 invite.send_count += 1
db.commit() db.commit()
@@ -215,7 +229,7 @@ def resend_invite(
background.add_task( background.add_task(
send_invitation, invite.email, raw, 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) return _invite_out(invite)

View File

@@ -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
]

View File

@@ -58,23 +58,39 @@ class DeleteUserIn(BaseModel):
confirm_email: EmailStr 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): class AdminSettingsOut(BaseModel):
allow_self_registration: bool allow_self_registration: bool
locked_by_env: bool locked_by_env: bool
# 0 = abgeschaltet durations: list[DurationOut]
auto_deactivate_months: int # Fristen, die ausserhalb der Anwendung liegen - nur zur Anzeige,
auto_delete_months: int # damit die Uebersicht nicht unvollstaendig wirkt.
# Listen: archivieren nach Nichtbenutzung, loeschen nach Archivdauer fixed: dict[str, str]
auto_archive_months: int
archive_delete_months: int
class AdminSettingsIn(BaseModel): class AdminSettingsIn(BaseModel):
allow_self_registration: bool | None = None allow_self_registration: bool | None = None
auto_deactivate_months: int | None = Field(default=None, ge=0, le=600) # Schluessel aus DURATIONS -> neuer Wert
auto_delete_months: int | None = Field(default=None, ge=0, le=600) durations: dict[str, int] | None = None
auto_archive_months: int | None = Field(default=None, ge=0, le=600)
archive_delete_months: int | None = Field(default=None, ge=0, le=600)
class WelcomeCompleteIn(BaseModel): class WelcomeCompleteIn(BaseModel):

View File

@@ -59,6 +59,50 @@ def normalize_email(email: str) -> str:
return email.strip().lower() 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( def check_rate_limit(
db: Session, bucket: str, *, limit: int, window_minutes: int db: Session, bucket: str, *, limit: int, window_minutes: int
) -> bool: ) -> bool:

View File

@@ -24,6 +24,7 @@ from app.security import hash_token, new_token, normalize_email, utcnow
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Vorgabewerte; maßgeblich ist, was in der Verwaltung eingestellt ist.
WELCOME_DAYS = 14 WELCOME_DAYS = 14
EMAIL_CHANGE_HOURS = 48 EMAIL_CHANGE_HOURS = 48
@@ -32,22 +33,18 @@ EMAIL_CHANGE_HOURS = 48
# Einstellungen zur automatischen Bereinigung # Einstellungen zur automatischen Bereinigung
# ========================================================================== # ==========================================================================
def months_setting(db: Session, key: str, fallback: int) -> int: def months_setting(db: Session, key: str, fallback: int = 0) -> int:
"""Liest einen Monatswert aus der Laufzeitkonfiguration. """Monatswert aus der Laufzeitkonfiguration.
0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein Nur noch eine Weiterleitung - die Fristen liegen gesammelt in
Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle app/runtime_settings.py. Der Name bleibt, damit die vorhandenen
Konten verschwinden. Aufrufstellen unverändert weiterlaufen.
0 bedeutet ausdrücklich "abgeschaltet", nicht "sofort".
""" """
raw = get_setting(db, key, "") from app.runtime_settings import get_duration
if not raw:
return fallback return get_duration(db, key)
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))
# ========================================================================== # ==========================================================================
@@ -77,6 +74,12 @@ def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
return raw return raw
def welcome_days(db: Session) -> int:
from app.runtime_settings import get_duration
return get_duration(db, "welcome_days")
def create_user( def create_user(
db: Session, *, email: str, display_name: str | None, is_admin: bool = False db: Session, *, email: str, display_name: str | None, is_admin: bool = False
) -> tuple[User, str]: ) -> tuple[User, str]:
@@ -99,7 +102,7 @@ def create_user(
) )
db.add(user) db.add(user)
db.flush() 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 return user, token
@@ -133,6 +136,10 @@ def start_email_change(
).all(): ).all():
pending.cancelled_at = utcnow() pending.cancelled_at = utcnow()
from app.runtime_settings import get_duration
hours = get_duration(db, "email_change_hours")
raw_new = new_token() raw_new = new_token()
raw_old = new_token() if user.is_admin else None 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, token_old_hash=hash_token(raw_old) if raw_old else None,
requires_old=user.is_admin, requires_old=user.is_admin,
requested_by=requested_by, requested_by=requested_by,
expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS), expires_at=utcnow() + timedelta(hours=hours),
) )
db.add(change) db.add(change)
db.flush() db.flush()

View File

@@ -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 **Löschen verlangt die Eingabe der Adresse** als Bestätigung, und regelt die
Listen des Kontos, statt sie mitzureißen. 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 ## Bekannte Grenzen
Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler aber jeder ist eine Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler aber jeder ist eine

View File

@@ -30,6 +30,7 @@ run "Konfigurationsvorlage" bash tools/check-env.sh .env.example
run "Berechtigungen" python3 tools/check-routes.py run "Berechtigungen" python3 tools/check-routes.py
run "Datenbankschema" python3 tools/check-schema.py run "Datenbankschema" python3 tools/check-schema.py
run "Bearbeitbare Felder" python3 tools/check-editable.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 "nginx-Konfiguration" python3 tools/check-nginx.py web
run "JavaScript-Module" node tools/check-js.mjs run "JavaScript-Module" node tools/check-js.mjs
run "Strichcode-Decoder" node tools/test-barcode.mjs run "Strichcode-Decoder" node tools/test-barcode.mjs

147
tools/check-retention.py Normal file
View File

@@ -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())

View File

@@ -1465,3 +1465,94 @@ button.archive-head .chev { color: var(--muted); }
.reg-toggle .checkline { margin-top: 0; } .reg-toggle .checkline { margin-top: 0; }
@media print { ul.suggestions, .card.archive { display: none !important; } } @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; }
}

316
web/html/datenschutz.html Normal file
View File

@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Datenschutzerklärung</title>
<link rel="stylesheet" href="/app.css">
<link rel="icon" href="/icons/favicon.ico" sizes="any">
</head>
<body class="legal">
<main id="app">
<header class="app-bar">
<a class="bar-back" href="/"> Zurück zur App</a>
</header>
<article class="card legal-text">
<p class="draft">
<strong>Entwurf.</strong> Dieser Text beschreibt die Verarbeitungen, die
diese Anwendung technisch vornimmt vollständig und nach bestem Wissen,
aber ohne Rechtsberatung. Vor der Veröffentlichung sollte er von jemandem
geprüft werden, der dazu befugt ist. Alle mit <code>[…]</code> markierten
Stellen müssen ausgefüllt werden.
</p>
<h1>Datenschutzerklärung</h1>
<p class="lead">Stand: [Datum]</p>
<h2>1. Verantwortlicher</h2>
<p>
Verantwortlich für die Verarbeitung personenbezogener Daten im Sinne von
Art. 4 Nr. 7 DSGVO ist:
</p>
<p>
[Vorname Nachname]<br>
[Straße und Hausnummer]<br>
[PLZ Ort]<br>
E-Mail: [E-Mail-Adresse]<br>
Telefon: [Telefonnummer]
</p>
<p>
[Falls benannt:] Datenschutzbeauftragte/r: [Name, Kontakt]. Ein
Datenschutzbeauftragter ist nur unter den Voraussetzungen des Art. 37 DSGVO
und § 38 BDSG zu benennen; für eine privat oder in kleinem Rahmen betriebene
Anwendung ist das regelmäßig nicht der Fall.
</p>
<h2>2. Grundsatz</h2>
<p>
Diese Anwendung ist auf Datensparsamkeit ausgelegt. Es findet keine
Reichweitenmessung, kein Tracking, keine Profilbildung und keine Werbung
statt. Es werden keine Inhalte von fremden Servern nachgeladen weder
Schriftarten noch Skripte, Karten oder Symbole. Alle Darstellungsmittel
liegen auf demselben Server wie die Anwendung; für die Schrift wird die
auf dem Gerät bereits vorhandene Systemschrift verwendet.
</p>
<h2>3. Welche Daten verarbeitet werden</h2>
<h3>3.1 Benutzerkonto</h3>
<table>
<tr><th>Angabe</th><th>Pflicht</th><th>Zweck</th></tr>
<tr><td>E-Mail-Adresse</td><td>ja</td><td>Anmeldung, Bestätigung, Einladungen, Passwort zurücksetzen</td></tr>
<tr><td>Passwort</td><td>ja</td><td>Wird ausschließlich als Streuwert (Argon2id) gespeichert, nie im Klartext</td></tr>
<tr><td>Anzeigename</td><td>nein</td><td>Darstellung gegenüber anderen Mitgliedern; ein Pseudonym genügt</td></tr>
<tr><td>Zeitpunkt der Registrierung und der letzten Nutzung</td><td>automatisch</td><td>Bestätigung des Kontos, automatische Bereinigung ungenutzter Konten</td></tr>
</table>
<p>
<strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. b DSGVO die
Verarbeitung ist zur Bereitstellung der Anwendung erforderlich.
</p>
<h3>3.2 Inhalte, die Sie selbst eingeben</h3>
<p>
Einkaufslisten, Artikelbezeichnungen, Mengen, Eigenschaften, Notizen,
Märkte, Warengruppen und Preise. Bei geteilten Listen ist für die übrigen
Mitglieder erkennbar, wer einen Eintrag angelegt hat.
</p>
<p>
<strong>Bitte beachten:</strong> Welche Angaben in einer Liste stehen,
bestimmen ausschließlich Sie. Tragen Sie dort Hinweise auf Gesundheit,
religiöse Überzeugungen oder Ähnliches ein etwa Medikamente oder
Speisevorschriften , verarbeiten Sie besondere Kategorien
personenbezogener Daten im Sinne des Art. 9 DSGVO. Der Betreiber hat darauf
keinen Einfluss und rät davon ab, solche Angaben aufzunehmen.
</p>
<p><strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. b DSGVO.</p>
<h3>3.3 Einladungen</h3>
<p>
Wenn Sie eine Liste mit einer anderen Person teilen, geben Sie deren
E-Mail-Adresse an. Diese wird gespeichert, um die Einladung zuzustellen und
ihren Stand nachvollziehbar zu machen (versendet, angenommen, widerrufen).
Nicht angenommene Einladungen laufen nach [14] Tagen ab; der Datensatz
wird [30] Tage <em>nach Ablauf</em> gelöscht (zusammen also [44] Tage).
</p>
<p>
<strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO berechtigtes
Interesse an der gemeinsamen Nutzung. Die eingeladene Person wird mit der
Einladung über die Verarbeitung informiert.
</p>
<h3>3.4 Sitzungen und Cookies</h3>
<p>Es werden ausschließlich zwei technisch notwendige Cookies gesetzt:</p>
<table>
<tr><th>Name</th><th>Zweck</th><th>Dauer</th></tr>
<tr><td><code>ea_session</code></td><td>Erkennung der angemeldeten Sitzung; <code>HttpOnly</code>, für Skripte unlesbar</td><td>[30] Tage</td></tr>
<tr><td><code>ea_csrf</code></td><td>Schutz vor seitenübergreifend untergeschobenen Anfragen</td><td>[30] Tage</td></tr>
</table>
<p>
Beide sind zur Erbringung des ausdrücklich gewünschten Dienstes unbedingt
erforderlich; eine Einwilligung ist nach § 25 Abs. 2 Nr. 2 TDDDG nicht
erforderlich. Es werden keine Cookies zu Analyse- oder Werbezwecken gesetzt.
</p>
<h3>3.5 Speicherung auf Ihrem Gerät</h3>
<p>
Damit die Anwendung ohne Verbindung nutzbar bleibt, legt sie den zuletzt
bekannten Stand Ihrer Listen sowie noch nicht übertragene Änderungen im
lokalen Speicher des Browsers (IndexedDB) ab. Diese Daten verlassen Ihr
Gerät nur, um sie mit dem Server abzugleichen, und lassen sich über die
Browsereinstellungen jederzeit löschen.
</p>
<h3>3.6 Protokolle und Missbrauchsabwehr</h3>
<p>
Der Webserver protokolliert jeden Abruf mit <strong>gekürzter</strong>
IP-Adresse (letzter Block auf Null gesetzt), Zeitpunkt, angefordertem Pfad,
Statuscode und Browserkennung. Aufrufe von Einladungs-, Freigabe- und
Passwortlinks werden gar nicht protokolliert, da deren Adresse den
Zugangsschlüssel enthält. Aufbewahrung: [7] Tage.
</p>
<p>
Zur Abwehr von Anmeldeversuchen in großer Zahl werden Zähler geführt. Als
Schlüssel dient ein Streuwert aus IP- beziehungsweise E-Mail-Adresse und
einem zufälligen, installationsweiten Zusatz; die Adressen selbst werden
dafür nicht gespeichert. Die Zähler werden spätestens nach 24 Stunden
gelöscht.
</p>
<p><strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO berechtigtes
Interesse am sicheren Betrieb.</p>
<h3>3.7 Öffentlich geteilte Listen</h3>
<p>
Sie können eine Liste über einen Link freigeben. Wer den Link hat, kann die
Liste ansehen und sofern beim Erzeugen erlaubt Artikel abhaken. In
dieser Ansicht wird <strong>nicht</strong> angezeigt, wer welchen Eintrag
angelegt hat. Der Link hat stets ein Ablaufdatum und ist jederzeit
widerrufbar. Erfasst wird lediglich, wie oft und wann zuletzt er geöffnet
wurde ohne IP-Adresse und ohne Zeitreihe.
</p>
<h3>3.8 Push-Benachrichtigungen</h3>
<p>
Nur wenn Sie sie ausdrücklich einschalten. Dabei wird eine vom Push-Dienst
Ihres Browserherstellers vergebene Adresse gespeichert, zusammen mit dem
Schlüsselmaterial zur Verschlüsselung und einer groben Gerätebezeichnung
(z. B. „Chrome auf Android"). Der Inhalt der Nachricht ist
Ende-zu-Ende-verschlüsselt und kann vom Push-Dienst nicht gelesen werden.
Übermittelt wird lediglich der Name der geänderten Liste und wer sie
geändert hat keine Artikelbezeichnungen.
</p>
<p>
<strong>Empfänger:</strong> Je nach Browser <em>Google LLC</em> (Chrome,
Android), <em>Apple Inc.</em> (Safari) oder die <em>Mozilla Foundation</em>
(Firefox). Dabei können Daten in die USA übermittelt werden; die Anbieter
stützen sich auf den EU-US Data Privacy Framework beziehungsweise auf
Standardvertragsklauseln nach Art. 46 DSGVO.
</p>
<p>
<strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. a DSGVO Einwilligung.
Sie können sie jederzeit unter „Einstellungen → Benachrichtigungen"
widerrufen; die Anmeldung wird dann gelöscht.
</p>
<h2>4. Externe Dienste</h2>
<h3>4.1 Hosting</h3>
<p>
Die Anwendung wird betrieben bei:
</p>
<p>
[Name des Anbieters]<br>
[Anschrift]<br>
[Land]
</p>
<p>
Der Anbieter verarbeitet die Daten ausschließlich weisungsgebunden. Es
besteht ein Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO.
[Falls der Serverstandort abweicht, hier angeben.]
</p>
<h3>4.2 E-Mail-Versand</h3>
<p>
Für Bestätigungen, Einladungen und Hinweise wird ein SMTP-Server genutzt:
[Name und Anschrift des Anbieters bei einem selbst betriebenen Server:
„Der Versand erfolgt über einen eigenen Server des Verantwortlichen; ein
Dritter ist nicht beteiligt."]. Übermittelt werden Empfängeradresse,
Betreff und Inhalt der Nachricht.
</p>
<h3>4.3 Produktdatenbank (Strichcodes)</h3>
<p>
Beim Scannen eines Strichcodes wird die Ziffernfolge bei
<em>Open Food Facts</em> nachgeschlagen, um Bezeichnung und Packungsgröße
vorzuschlagen. Betreiber: Open Food Facts, 21 rue des Iles, 94100
Saint-Maur-des-Fossés, Frankreich.
</p>
<p>
Die Abfrage erfolgt <strong>durch den Server dieser Anwendung</strong>,
nicht durch Ihr Gerät. Open Food Facts erfährt dadurch weder Ihre
IP-Adresse noch, wer den Code gescannt hat. Übermittelt wird ausschließlich
die Ziffernfolge. Ergebnisse werden zwischengespeichert, sodass derselbe
Code höchstens einmal je Gültigkeitszeitraum abgefragt wird.
[Falls abgeschaltet (<code>PRODUCT_LOOKUP=off</code>), diesen Abschnitt
streichen.]
</p>
<h3>4.4 Keine weiteren Dienste</h3>
<p>
Es werden keine Analysedienste, keine Werbenetzwerke, keine sozialen
Netzwerke, keine Kartendienste und keine externen Schriftarten eingebunden.
</p>
<h2>5. Empfänger innerhalb der Anwendung</h2>
<p>
Wenn Sie eine Liste teilen, sehen die eingeladenen Personen deren Inhalt,
Ihren Anzeigenamen beziehungsweise Ihre E-Mail-Adresse sowie welche
Einträge von Ihnen stammen. Der Eigentümer einer Liste sieht die
E-Mail-Adressen aller Mitglieder. Personen mit Administratorrechten haben
zur Verwaltung Zugriff auf die Liste aller Konten samt E-Mail-Adressen und
Nutzungszeitpunkten; Listeninhalte sehen sie nur, soweit sie selbst
Mitglied sind.
</p>
<h2>6. Speicherdauer</h2>
<table>
<tr><th>Daten</th><th>Dauer</th></tr>
<tr><td>Konto und Inhalte</td><td>Bis zur Löschung des Kontos</td></tr>
<tr><td>Konten ohne Anmeldung</td><td>Deaktivierung nach [12] Monaten, Löschung [12] Monate danach</td></tr>
<tr><td>Listen ohne Nutzung</td><td>Archivierung nach [12] Monaten, Löschung [12] Monate danach; der Eigentümer wird 7 Tage und 1 Tag vorher benachrichtigt</td></tr>
<tr><td>Gelöschte Einträge und Listen</td><td>Endgültige Entfernung nach [30] Tagen die Frist stellt sicher, dass auch länger offline gebliebene Geräte vom Löschen erfahren</td></tr>
<tr><td>Sitzungen</td><td>[30] Tage, bei Abmeldung sofort</td></tr>
<tr><td>Bestätigungs- und Einladungslinks</td><td>[1] Stunde (Passwort) bis [14] Tage (Einladung, Willkommensgruß); Adresswechsel [48] Stunden; Reste [30] Tage nach Ablauf entfernt</td></tr>
<tr><td>Zähler der Missbrauchsabwehr</td><td>24 Stunden</td></tr>
<tr><td>Zugriffsprotokolle</td><td>[7] Tage</td></tr>
<tr><td>Erfasste Preise</td><td>Unbefristet sie enthalten keinen Personenbezug (nur Markt, Artikel, Betrag, Zeitpunkt)</td></tr>
<tr><td>Zwischenspeicher der Produktdatenbank</td><td>[180] Tage, danach erneute Abfrage</td></tr>
</table>
<p>
Beim Löschen eines Kontos gehen Listen, die mit anderen geteilt sind, an
das dienstälteste andere Mitglied über; Listen ohne weitere Mitglieder
werden gelöscht. Bei Einträgen anderer Personen wird der Hinweis auf die
Urheberschaft entfernt, der Eintrag selbst bleibt bestehen.
</p>
<h2>7. Ihre Rechte</h2>
<p>Sie haben nach der DSGVO das Recht auf:</p>
<ul>
<li>Auskunft über die zu Ihnen gespeicherten Daten (Art. 15)</li>
<li>Berichtigung unrichtiger Daten (Art. 16)</li>
<li>Löschung (Art. 17)</li>
<li>Einschränkung der Verarbeitung (Art. 18)</li>
<li>Datenübertragbarkeit (Art. 20)</li>
<li>Widerspruch gegen Verarbeitungen auf Grundlage berechtigter Interessen (Art. 21)</li>
<li>Widerruf erteilter Einwilligungen mit Wirkung für die Zukunft (Art. 7 Abs. 3)</li>
</ul>
<p>
Wenden Sie sich dafür an die oben genannte Adresse. Anzeigename und
Passwort können Sie selbst unter „Einstellungen" ändern; für die Änderung
der E-Mail-Adresse und die Löschung des Kontos wenden Sie sich an die
Administration.
</p>
<p>
Unabhängig davon steht Ihnen ein Beschwerderecht bei einer
Datenschutz-Aufsichtsbehörde zu (Art. 77 DSGVO), etwa bei der für
[Bundesland] zuständigen Behörde: [Name und Anschrift der Behörde].
</p>
<h2>8. Erforderlichkeit der Angaben</h2>
<p>
Die Angabe der E-Mail-Adresse und eines Passworts ist zur Nutzung
erforderlich; ohne sie kann kein Konto geführt werden. Alle übrigen
Angaben sind freiwillig. Eine automatisierte Entscheidungsfindung
einschließlich Profilbildung nach Art. 22 DSGVO findet nicht statt.
</p>
<h2>9. Überprüfbarkeit der Fristen</h2>
<p>
Sämtliche in dieser Erklärung genannten Fristen sind in der Anwendung
hinterlegt und lassen sich vom Betreiber unter „Einstellungen →
Benutzerverwaltung → Fristen" einsehen und ändern. Ein Aufräumlauf setzt sie
einmal täglich um. Weichen die hier genannten Zahlen von den dort
eingestellten ab, gelten die eingestellten bitte melden Sie eine solche
Abweichung, damit dieser Text nachgezogen wird.
</p>
<p class="hint">
Hinweis für den Betreiber: Nach jeder Änderung an den Fristen sind die mit
<code>[…]</code> markierten Zahlen in dieser Erklärung anzupassen. Die
Aufstellung unter „Fristen" zeigt die geltenden Werte.
</p>
<h2>10. Änderungen</h2>
<p>
Diese Erklärung wird angepasst, wenn sich die Anwendung ändert. Maßgeblich
ist die hier veröffentlichte Fassung; das Datum steht oben.
</p>
<p class="switch"><a href="/impressum.html">Impressum</a> · <a href="/">Zurück zur App</a></p>
</article>
</main>
</body>
</html>

130
web/html/impressum.html Normal file
View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Impressum</title>
<link rel="stylesheet" href="/app.css">
<link rel="icon" href="/icons/favicon.ico" sizes="any">
</head>
<body class="legal">
<main id="app">
<header class="app-bar">
<a class="bar-back" href="/"> Zurück zur App</a>
</header>
<article class="card legal-text">
<p class="draft">
<strong>Entwurf.</strong> Alle mit <code>[…]</code> markierten Stellen
müssen ausgefüllt werden; nicht zutreffende Abschnitte sind zu streichen.
Dies ist keine Rechtsberatung lassen Sie den Text vor der
Veröffentlichung von jemandem prüfen, der dazu befugt ist.
</p>
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 DDG</h2>
<p>
[Vorname Nachname]<br>
[ggf. Firmierung, z. B. „Muster GmbH"]<br>
[Straße und Hausnummer]<br>
[PLZ Ort]<br>
[Land, falls nicht Deutschland]
</p>
<h2>Kontakt</h2>
<p>
Telefon: [Telefonnummer]<br>
E-Mail: [E-Mail-Adresse]
</p>
<p class="hint">
Eine E-Mail-Adresse und eine zweite Möglichkeit zur unmittelbaren
Kommunikation üblicherweise eine Telefonnummer sind nach § 5 Abs. 1
Nr. 2 DDG verpflichtend. Ein bloßes Kontaktformular genügt nicht.
</p>
<h2>Vertretungsberechtigt</h2>
<p>
[Nur bei juristischen Personen: Name der vertretungsberechtigten Person,
z. B. Geschäftsführer. Bei Einzelpersonen streichen.]
</p>
<h2>Registereintrag</h2>
<p>
[Nur falls vorhanden:]<br>
Registergericht: [Amtsgericht]<br>
Registernummer: [HRB …]
</p>
<h2>Umsatzsteuer-Identifikationsnummer</h2>
<p>
[Nur falls vorhanden:] Umsatzsteuer-Identifikationsnummer gemäß § 27 a
Umsatzsteuergesetz: [DE…]
</p>
<h2>Aufsichtsbehörde</h2>
<p>
[Nur bei zulassungspflichtigen Tätigkeiten:] [Name und Anschrift der
zuständigen Aufsichtsbehörde]
</p>
<h2>Verantwortlich für den Inhalt</h2>
<p>
[Vorname Nachname]<br>
[Anschrift wie oben]
</p>
<p class="hint">
Die frühere Angabe „nach § 55 Abs. 2 RStV" ist überholt; der
Rundfunkstaatsvertrag wurde 2020 durch den Medienstaatsvertrag abgelöst.
Eine gesonderte Angabe nach § 18 Abs. 2 MStV ist nur bei journalistisch
redaktionell gestalteten Angeboten erforderlich für eine Einkaufsliste
regelmäßig nicht. Bei Zweifeln kann die Angabe stehen bleiben; sie schadet
nicht.
</p>
<h2>Streitbeilegung</h2>
<p>
[Nur bei Angeboten gegenüber Verbrauchern im geschäftlichen Verkehr:]
Wir sind weder bereit noch verpflichtet, an Streitbeilegungsverfahren vor
einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
<p class="hint">
Die Plattform der EU-Kommission zur Online-Streitbeilegung wurde zum
20. Juli 2025 eingestellt; ein Hinweis darauf ist nicht mehr erforderlich
und sollte aus älteren Impressen entfernt werden. Wird die Anwendung rein
privat und ohne Entgelt betrieben, entfällt dieser Abschnitt ganz.
</p>
<h2>Haftung für Inhalte</h2>
<p>
Die Inhalte der Einkaufslisten werden ausschließlich von den Nutzerinnen
und Nutzern erstellt. Der Betreiber überwacht sie nicht und ist nach
§ 7 Abs. 2 DDG nicht verpflichtet, übermittelte oder gespeicherte fremde
Informationen zu überwachen. Verpflichtungen zur Entfernung oder Sperrung
bei Kenntnis einer konkreten Rechtsverletzung bleiben unberührt; ab
Kenntnis wird der entsprechende Inhalt umgehend entfernt.
</p>
<h2>Haftung für Links</h2>
<p>
Diese Anwendung verweist nicht auf fremde Seiten. Sollten Nutzerinnen und
Nutzer Verweise in Listeneinträge aufnehmen, hat der Betreiber darauf keinen
Einfluss und übernimmt für deren Inhalt keine Haftung.
</p>
<h2>Urheberrecht</h2>
<p>
Der Quelltext dieser Anwendung steht unter [Lizenz angeben, z. B. „MIT-Lizenz"
oder: „Alle Rechte vorbehalten"]. Produktdaten zu Strichcodes stammen von
Open Food Facts und stehen unter der Open Database License (ODbL).
</p>
<p class="switch"><a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="/">Zurück zur App</a></p>
</article>
</main>
</body>
</html>

View File

@@ -28,6 +28,15 @@
<p class="error">Diese App benötigt JavaScript.</p> <p class="error">Diese App benötigt JavaScript.</p>
</noscript> </noscript>
<!-- Außerhalb von #app: Die Ansichten tauschen nur den Inhalt von #app
aus, die Fußzeile bleibt dadurch überall stehen - auch auf der
Anmeldeseite und in der öffentlichen Listenansicht. -->
<footer class="app-footer">
<a href="/impressum.html">Impressum</a>
<span aria-hidden="true">·</span>
<a href="/datenschutz.html">Datenschutz</a>
</footer>
<script type="module" src="/js/app.js"></script> <script type="module" src="/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -296,57 +296,66 @@ export async function adminView(root, { back }) {
// Automatik // Automatik
// ------------------------------------------------------------------- // -------------------------------------------------------------------
function automationCard() { /** Fristen aus dem Server: Beschriftung, Einheit und Grenzen kommen
const deactivate = el("input.months", { * von dort, damit nicht das eine steht und das andere gilt. */
type: "number", min: "0", max: "600", function durationsCard() {
value: String(config.auto_deactivate_months), const fields = new Map();
});
const remove = el("input.months", { const group = (title, keys, lead) => {
type: "number", min: "0", max: "600", const rows = config.durations.filter((d) => keys.includes(d.key));
value: String(config.auto_delete_months), if (!rows.length) return null;
});
const archive = el("input.months", { return el("div.duration-group", {},
type: "number", min: "0", max: "600", el("h3.device-heading", {}, title),
value: String(config.auto_archive_months), lead ? el("p.sub", {}, lead) : null,
}); rows.map((entry) => {
const archiveDelete = el("input.months", { const input = el("input.months", {
type: "number", min: "0", max: "600", type: "number",
value: String(config.archive_delete_months), 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", {}, return el("section.card", {},
el("h2", {}, "Automatische Bereinigung"), el("h2", {}, "Fristen"),
el("p.lead", {}, el("p.lead", {},
"Läuft einmal täglich. ", "Diese Werte gelten zur Laufzeit und werden auch für die " +
el("strong", {}, "0 bedeutet abgeschaltet"), "Datenschutzerklärung gebraucht. Der Aufräumlauf setzt sie " +
", nicht „sofort“. Administratorkonten sind ausgenommen."), "einmal täglich um."),
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"), group("Konten",
deactivate, ["auto_deactivate_months", "auto_delete_months"]),
el("label", {}, "Löschen nach … Monaten Deaktivierung"), group("Listen",
remove, ["auto_archive_months", "archive_delete_months"]),
group("Aufbewahrung",
el("h3.device-heading", {}, "Listen"), ["cleanup_deleted_days", "cleanup_ops_days", "product_cache_days"]),
el("label", {}, "Archivieren nach … Monaten ohne Öffnen"), group("Sitzungen und Links",
archive, ["session_days", "invite_days", "welcome_days", "verify_hours",
el("p.sub", {}, "reset_hours", "email_change_hours"]),
"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."),
el("button.primary", { el("button.primary", {
type: "button", type: "button",
onclick: () => guarded(async () => { onclick: () => guarded(async () => {
await put("/api/admin/settings", { const durations = {};
auto_deactivate_months: Number(deactivate.value) || 0, for (const [key, input] of fields) {
auto_delete_months: Number(remove.value) || 0, durations[key] = Number(input.value) || 0;
auto_archive_months: Number(archive.value) || 0, }
archive_delete_months: Number(archiveDelete.value) || 0, await put("/api/admin/settings", { durations });
}); say("Fristen gespeichert.");
say("Einstellungen gespeichert.");
}), }),
}, "Speichern"), }, "Speichern"),
@@ -354,7 +363,19 @@ export async function adminView(root, { back }) {
el("button", { el("button", {
type: "button", type: "button",
onclick: () => guarded(() => post("/api/admin/cleanup")), 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(), registrationToggle(),
el("ul.admin-users", {}, users.map(userRow))), el("ul.admin-users", {}, users.map(userRow))),
createCard(), createCard(),
automationCard() durationsCard()
); );
} }

View File

@@ -5,12 +5,14 @@
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
// wann alte Zwischenspeicher verworfen werden. // wann alte Zwischenspeicher verworfen werden.
const VERSION = "v25"; const VERSION = "v27";
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
const SHELL = [ const SHELL = [
"/", "/",
"/app.css", "/app.css",
"/impressum.html",
"/datenschutz.html",
"/js/app.js", "/js/app.js",
"/js/api.js", "/js/api.js",
"/js/dom.js", "/js/dom.js",

View File

@@ -4,6 +4,25 @@
limit_req_zone $binary_remote_addr zone=auth_zone:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=auth_zone:10m rate=10r/m;
limit_req_status 429; 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 {
~(?<v4>\d+\.\d+\.\d+)\.\d+ $v4.0;
~(?<v6>[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 { server {
listen 8080; listen 8080;
server_name _; server_name _;
@@ -154,7 +173,7 @@ server {
location = /robots.txt { location = /robots.txt {
include /etc/nginx/security_headers.conf; include /etc/nginx/security_headers.conf;
default_type text/plain; 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 # Aus demselben Grund wie bei /s/: Einladungs- und

View File

@@ -10,4 +10,7 @@ add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always; add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(self), geolocation=(), microphone=()" 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;