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
+
+
+
+
+
+
+
+
+
+
+
+ Entwurf. 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 […] markierten
+ Stellen müssen ausgefüllt werden.
+
+
+Datenschutzerklärung
+Stand: [Datum]
+
+1. Verantwortlicher
+
+ Verantwortlich für die Verarbeitung personenbezogener Daten im Sinne von
+ Art. 4 Nr. 7 DSGVO ist:
+
+
+ [Vorname Nachname]
+ [Straße und Hausnummer]
+ [PLZ Ort]
+ E-Mail: [E-Mail-Adresse]
+ Telefon: [Telefonnummer]
+
+
+ [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.
+
+
+2. Grundsatz
+
+ 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.
+
+
+3. Welche Daten verarbeitet werden
+
+3.1 Benutzerkonto
+
+ Angabe Pflicht Zweck
+ E-Mail-Adresse ja Anmeldung, Bestätigung, Einladungen, Passwort zurücksetzen
+ Passwort ja Wird ausschließlich als Streuwert (Argon2id) gespeichert, nie im Klartext
+ Anzeigename nein Darstellung gegenüber anderen Mitgliedern; ein Pseudonym genügt
+ Zeitpunkt der Registrierung und der letzten Nutzung automatisch Bestätigung des Kontos, automatische Bereinigung ungenutzter Konten
+
+
+ Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO – die
+ Verarbeitung ist zur Bereitstellung der Anwendung erforderlich.
+
+
+3.2 Inhalte, die Sie selbst eingeben
+
+ 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.
+
+
+ Bitte beachten: 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.
+
+Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO.
+
+3.3 Einladungen
+
+ 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 nach Ablauf gelöscht (zusammen also [44] Tage).
+
+
+ Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO – berechtigtes
+ Interesse an der gemeinsamen Nutzung. Die eingeladene Person wird mit der
+ Einladung über die Verarbeitung informiert.
+
+
+3.4 Sitzungen und Cookies
+Es werden ausschließlich zwei technisch notwendige Cookies gesetzt:
+
+ Name Zweck Dauer
+ ea_sessionErkennung der angemeldeten Sitzung; HttpOnly, für Skripte unlesbar [30] Tage
+ ea_csrfSchutz vor seitenübergreifend untergeschobenen Anfragen [30] Tage
+
+
+ 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.
+
+
+3.5 Speicherung auf Ihrem Gerät
+
+ 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.
+
+
+3.6 Protokolle und Missbrauchsabwehr
+
+ Der Webserver protokolliert jeden Abruf mit gekürzter
+ 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.
+
+
+ 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.
+
+Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO – berechtigtes
+ Interesse am sicheren Betrieb.
+
+3.7 Öffentlich geteilte Listen
+
+ 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 nicht 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.
+
+
+3.8 Push-Benachrichtigungen
+
+ 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.
+
+
+ Empfänger: Je nach Browser Google LLC (Chrome,
+ Android), Apple Inc. (Safari) oder die Mozilla Foundation
+ (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.
+
+
+ Rechtsgrundlage: Art. 6 Abs. 1 lit. a DSGVO – Einwilligung.
+ Sie können sie jederzeit unter „Einstellungen → Benachrichtigungen"
+ widerrufen; die Anmeldung wird dann gelöscht.
+
+
+4. Externe Dienste
+
+4.1 Hosting
+
+ Die Anwendung wird betrieben bei:
+
+
+ [Name des Anbieters]
+ [Anschrift]
+ [Land]
+
+
+ Der Anbieter verarbeitet die Daten ausschließlich weisungsgebunden. Es
+ besteht ein Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO.
+ [Falls der Serverstandort abweicht, hier angeben.]
+
+
+4.2 E-Mail-Versand
+
+ 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.
+
+
+4.3 Produktdatenbank (Strichcodes)
+
+ Beim Scannen eines Strichcodes wird die Ziffernfolge bei
+ Open Food Facts nachgeschlagen, um Bezeichnung und Packungsgröße
+ vorzuschlagen. Betreiber: Open Food Facts, 21 rue des Iles, 94100
+ Saint-Maur-des-Fossés, Frankreich.
+
+
+ Die Abfrage erfolgt durch den Server dieser Anwendung ,
+ 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 (PRODUCT_LOOKUP=off), diesen Abschnitt
+ streichen.]
+
+
+4.4 Keine weiteren Dienste
+
+ Es werden keine Analysedienste, keine Werbenetzwerke, keine sozialen
+ Netzwerke, keine Kartendienste und keine externen Schriftarten eingebunden.
+
+
+5. Empfänger innerhalb der Anwendung
+
+ 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.
+
+
+6. Speicherdauer
+
+ Daten Dauer
+ Konto und Inhalte Bis zur Löschung des Kontos
+ Konten ohne Anmeldung Deaktivierung nach [12] Monaten, Löschung [12] Monate danach
+ Listen ohne Nutzung Archivierung nach [12] Monaten, Löschung [12] Monate danach; der Eigentümer wird 7 Tage und 1 Tag vorher benachrichtigt
+ Gelöschte Einträge und Listen Endgültige Entfernung nach [30] Tagen – die Frist stellt sicher, dass auch länger offline gebliebene Geräte vom Löschen erfahren
+ Sitzungen [30] Tage, bei Abmeldung sofort
+ Bestätigungs- und Einladungslinks [1] Stunde (Passwort) bis [14] Tage (Einladung, Willkommensgruß); Adresswechsel [48] Stunden; Reste [30] Tage nach Ablauf entfernt
+ Zähler der Missbrauchsabwehr 24 Stunden
+ Zugriffsprotokolle [7] Tage
+ Erfasste Preise Unbefristet – sie enthalten keinen Personenbezug (nur Markt, Artikel, Betrag, Zeitpunkt)
+ Zwischenspeicher der Produktdatenbank [180] Tage, danach erneute Abfrage
+
+
+ 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.
+
+
+7. Ihre Rechte
+Sie haben nach der DSGVO das Recht auf:
+
+ Auskunft über die zu Ihnen gespeicherten Daten (Art. 15)
+ Berichtigung unrichtiger Daten (Art. 16)
+ Löschung (Art. 17)
+ Einschränkung der Verarbeitung (Art. 18)
+ Datenübertragbarkeit (Art. 20)
+ Widerspruch gegen Verarbeitungen auf Grundlage berechtigter Interessen (Art. 21)
+ Widerruf erteilter Einwilligungen mit Wirkung für die Zukunft (Art. 7 Abs. 3)
+
+
+ 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.
+
+
+ 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].
+
+
+8. Erforderlichkeit der Angaben
+
+ 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.
+
+
+9. Überprüfbarkeit der Fristen
+
+ 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.
+
+
+ Hinweis für den Betreiber: Nach jeder Änderung an den Fristen sind die mit
+ […] markierten Zahlen in dieser Erklärung anzupassen. Die
+ Aufstellung unter „Fristen" zeigt die geltenden Werte.
+
+
+10. Änderungen
+
+ Diese Erklärung wird angepasst, wenn sich die Anwendung ändert. Maßgeblich
+ ist die hier veröffentlichte Fassung; das Datum steht oben.
+
+
+Impressum · 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
+
+
+
+
+
+
+
+
+
+
+
+ Entwurf. Alle mit […] 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.
+
+
+Impressum
+
+Angaben gemäß § 5 DDG
+
+ [Vorname Nachname]
+ [ggf. Firmierung, z. B. „Muster GmbH"]
+ [Straße und Hausnummer]
+ [PLZ Ort]
+ [Land, falls nicht Deutschland]
+
+
+Kontakt
+
+ Telefon: [Telefonnummer]
+ E-Mail: [E-Mail-Adresse]
+
+
+ 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.
+
+
+Vertretungsberechtigt
+
+ [Nur bei juristischen Personen: Name der vertretungsberechtigten Person,
+ z. B. Geschäftsführer. Bei Einzelpersonen streichen.]
+
+
+Registereintrag
+
+ [Nur falls vorhanden:]
+ Registergericht: [Amtsgericht]
+ Registernummer: [HRB …]
+
+
+Umsatzsteuer-Identifikationsnummer
+
+ [Nur falls vorhanden:] Umsatzsteuer-Identifikationsnummer gemäß § 27 a
+ Umsatzsteuergesetz: [DE…]
+
+
+Aufsichtsbehörde
+
+ [Nur bei zulassungspflichtigen Tätigkeiten:] [Name und Anschrift der
+ zuständigen Aufsichtsbehörde]
+
+
+Verantwortlich für den Inhalt
+
+ [Vorname Nachname]
+ [Anschrift wie oben]
+
+
+ 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.
+
+
+Streitbeilegung
+
+ [Nur bei Angeboten gegenüber Verbrauchern im geschäftlichen Verkehr:]
+ Wir sind weder bereit noch verpflichtet, an Streitbeilegungsverfahren vor
+ einer Verbraucherschlichtungsstelle teilzunehmen.
+
+
+ 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.
+
+
+Haftung für Inhalte
+
+ 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.
+
+
+Haftung für Links
+
+ 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.
+
+
+Urheberrecht
+
+ 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).
+
+
+Datenschutzerklärung · 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.
+
+
+