Ergänzung Datenschutzerklärung und Impressum, Fristenformular in Einstellungen
This commit is contained in:
15
.env.example
15
.env.example
@@ -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
116
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;
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}. "
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
183
backend/app/runtime_settings.py
Normal file
183
backend/app/runtime_settings.py
Normal 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
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
147
tools/check-retention.py
Normal 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())
|
||||||
@@ -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
316
web/html/datenschutz.html
Normal 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
130
web/html/impressum.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user