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.
|
||||
FORWARDED_ALLOW_IPS=*
|
||||
|
||||
SESSION_DAYS=30
|
||||
|
||||
# Cookie nur über HTTPS ausliefern.
|
||||
# false bei direktem HTTP-Zugriff auf Port 46600
|
||||
# true sobald ein Reverse Proxy mit TLS davorsteht (dann muss
|
||||
@@ -114,6 +112,19 @@ CLEANUP_OPS_DAYS=7
|
||||
# Abstand zwischen zwei Aufraeumdurchlaeufen.
|
||||
CLEANUP_INTERVAL_HOURS=24
|
||||
|
||||
# ---- Startwerte fuer Fristen ----
|
||||
# Diese Werte gelten nur, solange in der Verwaltung nichts anderes
|
||||
# eingestellt wurde. Nach der ersten Aenderung dort ist der gespeicherte
|
||||
# Wert massgeblich - dasselbe Verhalten wie bei der Selbstregistrierung.
|
||||
# Einzusehen und zu aendern unter:
|
||||
# Einstellungen -> Benutzerverwaltung -> Fristen
|
||||
SESSION_DAYS=30
|
||||
INVITE_DAYS=14
|
||||
WELCOME_DAYS=14
|
||||
VERIFY_HOURS=24
|
||||
RESET_HOURS=1
|
||||
EMAIL_CHANGE_HOURS=48
|
||||
|
||||
# ==========================================================================
|
||||
# Push-Benachrichtigungen
|
||||
# ==========================================================================
|
||||
|
||||
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;
|
||||
das geht bewusst nur direkt in der Datenbank.
|
||||
|
||||
### Automatische Bereinigung
|
||||
### Fristen
|
||||
|
||||
Vier Fristen, in der Verwaltung einstellbar:
|
||||
**Alle** Fristen der Anwendung stehen unter *Einstellungen →
|
||||
Benutzerverwaltung → Fristen* und lassen sich dort einsehen und ändern.
|
||||
Zentral hinterlegt in `backend/app/runtime_settings.py`, Rangfolge:
|
||||
|
||||
| Einstellung | Voreinstellung | Wirkung |
|
||||
```
|
||||
Einstellungstabelle → Umgebungsvariable → Vorgabewert im Quelltext
|
||||
```
|
||||
|
||||
Die Umgebungsvariable ist also der Startwert; sobald in der Verwaltung etwas
|
||||
geändert wird, gilt der gespeicherte Wert.
|
||||
|
||||
| Gruppe | Frist | Vorgabe |
|
||||
|---|---|---|
|
||||
| Konto deaktivieren nach … Monaten ohne Anmeldung | 12 | Konto wird gesperrt, Person bekommt eine Nachricht |
|
||||
| Konto löschen nach … Monaten Deaktivierung | 12 | Konto wird endgültig entfernt |
|
||||
| Liste archivieren nach … Monaten ohne Öffnen | 12 | **Je Person** – die Liste verschwindet nur aus deren Übersicht |
|
||||
| Liste löschen nach … Monaten im Archiv | 12 | Nur nach dem Archivdatum des **Eigentümers** |
|
||||
| Konten | deaktivieren nach … Monaten ohne Anmeldung | 12 |
|
||||
| | löschen nach … Monaten Deaktivierung | 12 |
|
||||
| Listen | archivieren nach … Monaten ohne Öffnen | 12 |
|
||||
| | löschen nach … Monaten im Archiv | 12 |
|
||||
| Aufbewahrung | Gelöschtes endgültig entfernen nach … Tagen | 30 |
|
||||
| | Quittungen der Warteschlange … Tage | 7 |
|
||||
| | Produktabfragen gültig … Tage | 180 |
|
||||
| Sitzungen und Links | angemeldet bleiben … Tage | 30 |
|
||||
| | Einladung gültig … Tage | 14 |
|
||||
| | Willkommenslink gültig … Tage | 14 |
|
||||
| | Bestätigungslink gültig … Stunden | 24 |
|
||||
| | Passwortlink gültig … Stunden | 1 |
|
||||
| | Adresswechsel bestätigen binnen … Stunden | 48 |
|
||||
|
||||
Nicht dort einstellbar und deshalb in der Oberfläche gesondert aufgeführt:
|
||||
die 24 Stunden der Missbrauchsabwehr, der Abstand der Aufräumläufe
|
||||
(`CLEANUP_INTERVAL_HOURS`) und die Aufbewahrung der Webserver-Protokolle –
|
||||
Letztere ist Sache der Protokollrotation auf dem Server.
|
||||
|
||||
```bash
|
||||
python3 tools/check-retention.py
|
||||
```
|
||||
|
||||
Prüft dreierlei: dass jede einstellbare Frist auch tatsächlich irgendwo
|
||||
angewandt wird (eine Einstellung ohne Wirkung ist schlimmer als keine – sie
|
||||
behauptet eine), dass jede Tabelle mit Ablaufzeitpunkt vom Aufräumlauf erfasst
|
||||
wird, und dass dort keine festen Zahlen mehr stehen.
|
||||
|
||||
### Was der Aufräumlauf tut
|
||||
|
||||
Einmal täglich, im `api`-Container:
|
||||
|
||||
| Was | Wann |
|
||||
|---|---|
|
||||
| Abgelaufene Sitzungen | sofort nach Ablauf |
|
||||
| Verbrauchte Mail-Token | 7 Tage nach Ablauf |
|
||||
| Zähler der Missbrauchsabwehr | nach 24 Stunden |
|
||||
| Quittungen der Warteschlange | nach `cleanup_ops_days` |
|
||||
| Abgelaufene Einladungen und öffentliche Links | `cleanup_deleted_days` **nach Ablauf** |
|
||||
| Abgeschlossene Adresswechsel | `cleanup_deleted_days` nach Ablauf |
|
||||
| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | `cleanup_deleted_days` nach dem Löschen |
|
||||
| Produktzwischenspeicher | doppelte Gültigkeit, mindestens 1 Jahr |
|
||||
| Konten deaktivieren und löschen | `auto_deactivate_months`, `auto_delete_months` |
|
||||
| Listen archivieren, vorwarnen und löschen | `auto_archive_months`, `archive_delete_months` |
|
||||
|
||||
**Achtung bei den Einladungen:** Die Frist läuft *nach Ablauf*, nicht ab
|
||||
Versand. Eine Einladung mit 14 Tagen Gültigkeit verschwindet also erst nach
|
||||
44 Tagen aus der Datenbank – das gehört so in die Datenschutzerklärung.
|
||||
|
||||
**0 bedeutet abgeschaltet, nicht „sofort".** Ein Tippfehler soll nicht dazu
|
||||
führen, dass beim nächsten Durchlauf alle Konten verschwinden.
|
||||
@@ -1283,6 +1336,55 @@ Der Eintrag in `alembic_version` am Ende ist wichtig: Ohne ihn würde Alembic
|
||||
beim nächsten Start alle Migrationen erneut anwenden und an den vorhandenen
|
||||
Tabellen scheitern.
|
||||
|
||||
## Rechtliche Seiten
|
||||
|
||||
`web/html/impressum.html` und `web/html/datenschutz.html` – **Entwürfe**, die
|
||||
vor der Veröffentlichung ausgefüllt und geprüft werden müssen. Alle offenen
|
||||
Stellen sind mit `[…]` markiert; die Kästen mit der Klasse `draft` und die
|
||||
Erläuterungen mit der Klasse `hint` gehören danach entfernt.
|
||||
|
||||
Verlinkt sind beide in einer Fußzeile, die **außerhalb von `#app`** in
|
||||
`index.html` steht. Dadurch erscheint sie in jeder Ansicht – auch auf der
|
||||
Anmeldeseite und in der öffentlichen Listenansicht – ohne dass jede Ansicht
|
||||
sie selbst einbauen müsste. Im Ausdruck und über dem Kamerasucher ist sie
|
||||
ausgeblendet.
|
||||
|
||||
Die Datenschutzerklärung beschreibt, was diese Anwendung tatsächlich tut, nicht
|
||||
was üblicherweise in solchen Texten steht. Enthalten sind unter anderem: die
|
||||
beiden technisch notwendigen Cookies, die lokale Speicherung in IndexedDB, die
|
||||
Verarbeitung fremder E-Mail-Adressen bei Einladungen, die Push-Dienste der
|
||||
Browserhersteller als Empfänger in Drittländern, Open Food Facts als
|
||||
serverseitig aufgerufener Dienst, die vollständige Aufstellung der
|
||||
Speicherfristen und der Hinweis, dass Listeninhalte in den Bereich des
|
||||
Art. 9 DSGVO geraten können, wenn dort Medikamente stehen.
|
||||
|
||||
Auszufüllen sind: Verantwortlicher, Hosting-Anbieter, SMTP-Anbieter,
|
||||
Aufsichtsbehörde des Bundeslandes und die Fristen, falls du sie in der
|
||||
Verwaltung anders eingestellt hast.
|
||||
|
||||
## Datensparsamkeit im Betrieb
|
||||
|
||||
Drei Stellen, an denen bewusst weniger gespeichert wird, als technisch möglich
|
||||
wäre:
|
||||
|
||||
**Keine externen Schriften.** Verwendet wird die Systemschrift des Geräts
|
||||
(`system-ui`). Es wird nichts von fremden Servern nachgeladen – die CSP führt
|
||||
`font-src 'self'` ausdrücklich auf, obwohl `default-src` es schon abdeckt,
|
||||
damit die Sperre beim Lesen der Datei erkennbar ist.
|
||||
|
||||
**Zugriffsprotokolle mit gekürzter IP-Adresse.** Der letzte Block wird auf Null
|
||||
gesetzt (`map` in `nginx.conf`). Der `Referer` wird gar nicht protokolliert –
|
||||
bei einem öffentlichen Listenlink stünde dort der Zugangsschlüssel, sobald
|
||||
jemand von dort aus weiterklickt.
|
||||
|
||||
**Missbrauchsbremse ohne Klartextadressen.** Die Zähler brauchen einen
|
||||
gleichbleibenden Schlüssel je Absender, keinen lesbaren. Verwendet wird ein
|
||||
Streuwert aus IP- beziehungsweise E-Mail-Adresse und einem zufälligen,
|
||||
installationsweiten Zusatz. Das ist Pseudonymisierung, keine Anonymisierung –
|
||||
wer Datenbank samt Zusatz hat, könnte eine vermutete Adresse nachrechnen. Es
|
||||
verhindert aber, dass beim Blick in die Tabelle Adressen mitgelesen werden, und
|
||||
die Werte verschwinden ohnehin nach 24 Stunden.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
`docs/sicherheit.md` – Ergebnisse der Prüfung auf SQL-Injection, XSS,
|
||||
|
||||
@@ -72,6 +72,13 @@ class Settings(BaseSettings):
|
||||
cleanup_ops_days: int = 7
|
||||
# Abstand zwischen zwei Durchlaeufen.
|
||||
cleanup_interval_hours: int = 24
|
||||
# Startwerte fuer Fristen, die zur Laufzeit in der Verwaltung
|
||||
# geaendert werden koennen (siehe app/runtime_settings.py).
|
||||
invite_days: int = 14
|
||||
welcome_days: int = 14
|
||||
verify_hours: int = 24
|
||||
reset_hours: int = 1
|
||||
email_change_hours: int = 48
|
||||
|
||||
# ---------------- Push-Benachrichtigungen ----------------
|
||||
# Erzeugen mit: python3 tools/vapid-keys.py
|
||||
|
||||
@@ -60,18 +60,24 @@ def self_registration_enabled(db: Session) -> bool:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def create_session(db: Session, user: User, response: Response) -> UserSession:
|
||||
# Spät importiert: runtime_settings greift auf get_setting in dieser
|
||||
# Datei zu, ein Import auf Modulebene wäre ein Kreis.
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
days = get_duration(db, "session_days")
|
||||
|
||||
raw = new_token()
|
||||
csrf = new_token()
|
||||
sess = UserSession(
|
||||
token_hash=hash_token(raw),
|
||||
user_id=user.id,
|
||||
csrf_token=csrf,
|
||||
expires_at=utcnow() + timedelta(days=settings.session_days),
|
||||
expires_at=utcnow() + timedelta(days=days),
|
||||
)
|
||||
db.add(sess)
|
||||
db.flush()
|
||||
|
||||
max_age = settings.session_days * 24 * 3600
|
||||
max_age = days * 24 * 3600
|
||||
# HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token
|
||||
# nicht abgreifen kann.
|
||||
response.set_cookie(
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.models import (
|
||||
User,
|
||||
UserSession,
|
||||
)
|
||||
from app.runtime_settings import get_duration
|
||||
from app.security import utcnow
|
||||
from app.users import deactivate, delete_user, months_setting
|
||||
|
||||
@@ -45,10 +46,19 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_cleanup(db: Session) -> dict[str, int]:
|
||||
"""Räumt auf und gibt zurück, was entfernt wurde."""
|
||||
"""Räumt auf und gibt zurück, was entfernt wurde.
|
||||
|
||||
Alle Fristen kommen aus der Laufzeitkonfiguration, damit das, was
|
||||
hier geschieht, dem entspricht, was in der Verwaltung steht - und
|
||||
dem, was die Datenschutzerklärung behauptet.
|
||||
"""
|
||||
now = utcnow()
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
deleted_days = get_duration(db, "cleanup_deleted_days")
|
||||
ops_days = get_duration(db, "cleanup_ops_days")
|
||||
product_days = get_duration(db, "product_cache_days")
|
||||
|
||||
def purge(model, condition, label: str) -> None:
|
||||
result = db.execute(delete(model).where(condition))
|
||||
if result.rowcount:
|
||||
@@ -79,34 +89,33 @@ def run_cleanup(db: Session) -> dict[str, int]:
|
||||
# längst über die Wiederholungsgrenze hinaus versucht.
|
||||
purge(
|
||||
AppliedOp,
|
||||
AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days),
|
||||
AppliedOp.created_at < now - timedelta(days=ops_days),
|
||||
"Outbox-Quittungen",
|
||||
)
|
||||
|
||||
# --- Abgelaufene Einladungen und öffentliche Links ---
|
||||
purge(
|
||||
ListInvite,
|
||||
ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
ListInvite.expires_at < now - timedelta(days=deleted_days),
|
||||
"alte Einladungen",
|
||||
)
|
||||
purge(
|
||||
PublicShare,
|
||||
PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
PublicShare.expires_at < now - timedelta(days=deleted_days),
|
||||
"abgelaufene öffentliche Links",
|
||||
)
|
||||
|
||||
# --- Produktzwischenspeicher ---
|
||||
purge(
|
||||
ProductCache,
|
||||
ProductCache.fetched_at
|
||||
< now - timedelta(days=max(settings.product_cache_days * 2, 365)),
|
||||
ProductCache.fetched_at < now - timedelta(days=max(product_days * 2, 365)),
|
||||
"Produktzwischenspeicher",
|
||||
)
|
||||
|
||||
# --- Endgültiges Löschen weicher Löschungen ---
|
||||
# Erst jetzt verschwinden die Daten wirklich. Bis dahin konnten
|
||||
# offline gebliebene Geräte erfahren, dass es sie nicht mehr gibt.
|
||||
cutoff = now - timedelta(days=settings.cleanup_deleted_days)
|
||||
cutoff = now - timedelta(days=deleted_days)
|
||||
|
||||
purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge")
|
||||
|
||||
@@ -135,7 +144,7 @@ def run_cleanup(db: Session) -> dict[str, int]:
|
||||
# --- Abgeschlossene Adressänderungen ---
|
||||
purge(
|
||||
EmailChange,
|
||||
EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
|
||||
EmailChange.expires_at < now - timedelta(days=deleted_days),
|
||||
"alte Adressänderungen",
|
||||
)
|
||||
|
||||
|
||||
@@ -163,9 +163,11 @@ def lookup(db: Session, barcode: str) -> ProductCache | None:
|
||||
|
||||
cached = db.get(ProductCache, barcode)
|
||||
if cached is not None:
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
age = utcnow() - cached.fetched_at
|
||||
max_age = timedelta(
|
||||
days=settings.product_cache_days if cached.found
|
||||
days=get_duration(db, "product_cache_days") if cached.found
|
||||
else settings.product_miss_days
|
||||
)
|
||||
if age < max_age:
|
||||
|
||||
@@ -26,16 +26,18 @@ from app.mail import (
|
||||
from app.maintenance import describe, run_cleanup
|
||||
from app.models import EmailChange, User
|
||||
from app.schemas import MailCheckOut, MailTestIn, MessageOut
|
||||
from app.runtime_settings import BY_KEY, describe_durations, set_duration
|
||||
from app.schemas_admin import (
|
||||
AdminSettingsIn,
|
||||
AdminSettingsOut,
|
||||
AdminStatsOut,
|
||||
AdminUserOut,
|
||||
DeleteUserIn,
|
||||
DurationOut,
|
||||
EmailChangeIn,
|
||||
UserCreateIn,
|
||||
)
|
||||
from app.security import check_rate_limit, normalize_email, utcnow
|
||||
from app.security import bucket_key, check_rate_limit, normalize_email, utcnow
|
||||
from app.users import (
|
||||
EMAIL_CHANGE_HOURS,
|
||||
WELCOME_DAYS,
|
||||
@@ -66,18 +68,17 @@ def _settings_out(db) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
allow_self_registration=self_registration_enabled(db),
|
||||
locked_by_env=registration_locked_by_env(),
|
||||
auto_deactivate_months=months_setting(
|
||||
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS
|
||||
),
|
||||
auto_delete_months=months_setting(
|
||||
db, "auto_delete_months", DEFAULT_DELETE_MONTHS
|
||||
),
|
||||
auto_archive_months=months_setting(
|
||||
db, "auto_archive_months", DEFAULT_ARCHIVE_MONTHS
|
||||
),
|
||||
archive_delete_months=months_setting(
|
||||
db, "archive_delete_months", DEFAULT_ARCHIVE_DELETE_MONTHS
|
||||
),
|
||||
durations=[DurationOut(**entry) for entry in describe_durations(db)],
|
||||
fixed={
|
||||
"Zähler der Missbrauchsabwehr": "24 Stunden",
|
||||
"Fehlgeschlagene Produktabfragen":
|
||||
f"{settings.product_miss_days} Tage (PRODUCT_MISS_DAYS)",
|
||||
"Aufräumlauf":
|
||||
f"alle {settings.cleanup_interval_hours} Stunden "
|
||||
"(CLEANUP_INTERVAL_HOURS)",
|
||||
"Zugriffsprotokolle des Webservers":
|
||||
"Sache der Protokollrotation auf dem Server, nicht der Anwendung",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -102,14 +103,12 @@ def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser):
|
||||
"true" if payload.allow_self_registration else "false",
|
||||
)
|
||||
|
||||
if payload.auto_deactivate_months is not None:
|
||||
set_setting(db, "auto_deactivate_months", str(payload.auto_deactivate_months))
|
||||
if payload.auto_delete_months is not None:
|
||||
set_setting(db, "auto_delete_months", str(payload.auto_delete_months))
|
||||
if payload.auto_archive_months is not None:
|
||||
set_setting(db, "auto_archive_months", str(payload.auto_archive_months))
|
||||
if payload.archive_delete_months is not None:
|
||||
set_setting(db, "archive_delete_months", str(payload.archive_delete_months))
|
||||
for key, value in (payload.durations or {}).items():
|
||||
if key not in BY_KEY:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, f"Unbekannte Frist: {key}"
|
||||
)
|
||||
set_duration(db, key, value)
|
||||
|
||||
db.commit()
|
||||
return _settings_out(db)
|
||||
@@ -222,7 +221,8 @@ def add_user(
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not check_rate_limit(
|
||||
db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60
|
||||
db, bucket_key(db, "admin-create", client_ip(request)),
|
||||
limit=30, window_minutes=60,
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.")
|
||||
@@ -241,7 +241,8 @@ def add_user(
|
||||
|
||||
background.add_task(
|
||||
send_welcome, email, token,
|
||||
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||
admin.display_name or admin.email.split("@")[0],
|
||||
get_duration(db, "welcome_days"),
|
||||
)
|
||||
return _user_out(user, {}, {}, {})
|
||||
|
||||
@@ -268,16 +269,19 @@ def resend_welcome(
|
||||
"nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.",
|
||||
)
|
||||
|
||||
if not check_rate_limit(db, f"admin-welcome:{user.id}", limit=5, window_minutes=60):
|
||||
if not check_rate_limit(
|
||||
db, bucket_key(db, "admin-welcome", user.id), limit=5, window_minutes=60
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
|
||||
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||
days = get_duration(db, "welcome_days")
|
||||
token = issue_token(db, user, "welcome", hours=days * 24)
|
||||
db.commit()
|
||||
|
||||
background.add_task(
|
||||
send_welcome, user.email, token,
|
||||
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
|
||||
admin.display_name or admin.email.split("@")[0], days,
|
||||
)
|
||||
return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.")
|
||||
|
||||
@@ -310,12 +314,13 @@ def change_email(
|
||||
change, raw_new, raw_old = start_email_change(db, user, target, admin.id)
|
||||
db.commit()
|
||||
|
||||
change_hours = get_duration(db, "email_change_hours")
|
||||
background.add_task(
|
||||
send_email_change_verify, target, raw_new, old_email, EMAIL_CHANGE_HOURS
|
||||
send_email_change_verify, target, raw_new, old_email, change_hours
|
||||
)
|
||||
if change.requires_old and raw_old:
|
||||
background.add_task(
|
||||
send_email_change_verify_old, old_email, raw_old, target, EMAIL_CHANGE_HOURS
|
||||
send_email_change_verify_old, old_email, raw_old, target, change_hours
|
||||
)
|
||||
detail = (
|
||||
f"Bestätigungslinks an {target} und {old_email} versendet. "
|
||||
@@ -324,7 +329,7 @@ def change_email(
|
||||
)
|
||||
else:
|
||||
background.add_task(
|
||||
send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS
|
||||
send_email_change_notice, old_email, target, change_hours
|
||||
)
|
||||
detail = (
|
||||
f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. "
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.schemas import (
|
||||
RegisterIn,
|
||||
UserOut,
|
||||
)
|
||||
from app.runtime_settings import get_duration
|
||||
from app.users import apply_if_complete
|
||||
from app.security import (
|
||||
check_rate_limit,
|
||||
@@ -96,7 +97,8 @@ def register(
|
||||
)
|
||||
|
||||
if not check_rate_limit(
|
||||
db, f"register:{client_ip(request)}", limit=5, window_minutes=60
|
||||
db, bucket_key(db, "register", client_ip(request)),
|
||||
limit=5, window_minutes=60,
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
@@ -113,12 +115,14 @@ def register(
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
raw = _issue_token(db, user, "verify", hours=24)
|
||||
raw = _issue_token(db, user, "verify", hours=get_duration(db, "verify_hours"))
|
||||
background.add_task(send_verification, email, raw)
|
||||
elif existing.verified_at is None:
|
||||
# Unbestaetigtes Konto: neuen Link schicken, statt zu verraten,
|
||||
# dass die Adresse schon vergeben ist.
|
||||
raw = _issue_token(db, existing, "verify", hours=24)
|
||||
raw = _issue_token(
|
||||
db, existing, "verify", hours=get_duration(db, "verify_hours")
|
||||
)
|
||||
background.add_task(send_verification, email, raw)
|
||||
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben.
|
||||
|
||||
@@ -148,8 +152,13 @@ def verify(token: str, db: DbSession):
|
||||
def login(payload: LoginIn, request: Request, response: Response, db: DbSession):
|
||||
email = normalize_email(payload.email)
|
||||
|
||||
ip_ok = check_rate_limit(db, f"login-ip:{client_ip(request)}", limit=20, window_minutes=15)
|
||||
acct_ok = check_rate_limit(db, f"login-acct:{email}", limit=8, window_minutes=15)
|
||||
ip_ok = check_rate_limit(
|
||||
db, bucket_key(db, "login-ip", client_ip(request)),
|
||||
limit=20, window_minutes=15,
|
||||
)
|
||||
acct_ok = check_rate_limit(
|
||||
db, bucket_key(db, "login-acct", email), limit=8, window_minutes=15
|
||||
)
|
||||
if not (ip_ok and acct_ok):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
@@ -266,7 +275,8 @@ def reset_request(
|
||||
background: BackgroundTasks,
|
||||
):
|
||||
if not check_rate_limit(
|
||||
db, f"reset:{client_ip(request)}", limit=5, window_minutes=60
|
||||
db, bucket_key(db, "reset", client_ip(request)),
|
||||
limit=5, window_minutes=60,
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
|
||||
@@ -274,7 +284,7 @@ def reset_request(
|
||||
email = normalize_email(payload.email)
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is not None and user.is_active and user.verified_at is not None:
|
||||
raw = _issue_token(db, user, "reset", hours=1)
|
||||
raw = _issue_token(db, user, "reset", hours=get_duration(db, "reset_hours"))
|
||||
background.add_task(send_password_reset, email, raw)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -31,7 +31,7 @@ from app.schemas_shopping import (
|
||||
PublicToggleIn,
|
||||
PublicViewOut,
|
||||
)
|
||||
from app.security import check_rate_limit, hash_token, new_token, utcnow
|
||||
from app.security import bucket_key, check_rate_limit, hash_token, new_token, utcnow
|
||||
|
||||
router = APIRouter(tags=["public"])
|
||||
|
||||
@@ -189,7 +189,8 @@ def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
|
||||
# bremsen, bevor jemand Token durchprobiert. Bei 256 Bit Zufall ist
|
||||
# Raten aussichtslos, aber die Last soll trotzdem begrenzt bleiben.
|
||||
if not check_rate_limit(
|
||||
db, f"public:{client_ip(request)}", limit=120, window_minutes=15
|
||||
db, bucket_key(db, "public", client_ip(request)),
|
||||
limit=120, window_minutes=15,
|
||||
):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
|
||||
@@ -40,10 +40,17 @@ from app.security import (
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
# Vorgabewert; maßgeblich ist die Einstellung in der Verwaltung.
|
||||
INVITE_DAYS = 14
|
||||
MAX_OPEN_INVITES = 50
|
||||
|
||||
|
||||
def _invite_days(db) -> int:
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
return get_duration(db, "invite_days")
|
||||
|
||||
|
||||
def _status(invite: ListInvite) -> str:
|
||||
if invite.revoked_at:
|
||||
return "revoked"
|
||||
@@ -78,8 +85,13 @@ def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
|
||||
unerwünschte Mails missbrauchen - jede Einladung erzeugt schließlich
|
||||
eine Nachricht an eine frei wählbare Adresse.
|
||||
"""
|
||||
ok_list = check_rate_limit(db, f"invite-list:{list_id}", limit=20, window_minutes=60)
|
||||
ok_ip = check_rate_limit(db, f"invite-ip:{client_ip(request)}", limit=30, window_minutes=60)
|
||||
ok_list = check_rate_limit(
|
||||
db, bucket_key(db, "invite-list", list_id), limit=20, window_minutes=60
|
||||
)
|
||||
ok_ip = check_rate_limit(
|
||||
db, bucket_key(db, "invite-ip", client_ip(request)),
|
||||
limit=30, window_minutes=60,
|
||||
)
|
||||
if not (ok_list and ok_ip):
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
@@ -159,6 +171,7 @@ def create_invite(
|
||||
).all():
|
||||
old.revoked_at = utcnow()
|
||||
|
||||
valid_days = _invite_days(db)
|
||||
raw = new_token()
|
||||
invite = ListInvite(
|
||||
list_id=lst.id,
|
||||
@@ -166,7 +179,7 @@ def create_invite(
|
||||
role=payload.role,
|
||||
token_hash=hash_token(raw),
|
||||
invited_by=user.id,
|
||||
expires_at=utcnow() + timedelta(days=INVITE_DAYS),
|
||||
expires_at=utcnow() + timedelta(days=valid_days),
|
||||
)
|
||||
db.add(invite)
|
||||
db.commit()
|
||||
@@ -174,7 +187,7 @@ def create_invite(
|
||||
|
||||
background.add_task(
|
||||
send_invitation, email, raw,
|
||||
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||
lst.name, user.display_name or user.email.split("@")[0], valid_days,
|
||||
)
|
||||
return _invite_out(invite)
|
||||
|
||||
@@ -204,10 +217,11 @@ def resend_invite(
|
||||
|
||||
_guard_invite_rate(db, request, lst.id)
|
||||
|
||||
valid_days = _invite_days(db)
|
||||
raw = new_token()
|
||||
invite.token_hash = hash_token(raw)
|
||||
invite.revoked_at = None
|
||||
invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS)
|
||||
invite.expires_at = utcnow() + timedelta(days=valid_days)
|
||||
invite.last_sent_at = utcnow()
|
||||
invite.send_count += 1
|
||||
db.commit()
|
||||
@@ -215,7 +229,7 @@ def resend_invite(
|
||||
|
||||
background.add_task(
|
||||
send_invitation, invite.email, raw,
|
||||
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
|
||||
lst.name, user.display_name or user.email.split("@")[0], valid_days,
|
||||
)
|
||||
return _invite_out(invite)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DurationOut(BaseModel):
|
||||
"""Eine einstellbare Frist samt Beschreibung.
|
||||
|
||||
Beschriftung, Einheit und Grenzen kommen vom Server, damit die
|
||||
Oberflaeche sie nicht doppelt vorhalten muss - und damit nicht das
|
||||
eine steht und das andere gilt.
|
||||
"""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
unit: str
|
||||
value: int
|
||||
default: int
|
||||
minimum: int
|
||||
maximum: int
|
||||
# Was bedeutet 0? None = nicht erlaubt.
|
||||
zero_means: str | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class AdminSettingsOut(BaseModel):
|
||||
allow_self_registration: bool
|
||||
locked_by_env: bool
|
||||
# 0 = abgeschaltet
|
||||
auto_deactivate_months: int
|
||||
auto_delete_months: int
|
||||
# Listen: archivieren nach Nichtbenutzung, loeschen nach Archivdauer
|
||||
auto_archive_months: int
|
||||
archive_delete_months: int
|
||||
durations: list[DurationOut]
|
||||
# Fristen, die ausserhalb der Anwendung liegen - nur zur Anzeige,
|
||||
# damit die Uebersicht nicht unvollstaendig wirkt.
|
||||
fixed: dict[str, str]
|
||||
|
||||
|
||||
class AdminSettingsIn(BaseModel):
|
||||
allow_self_registration: bool | None = None
|
||||
auto_deactivate_months: int | None = Field(default=None, ge=0, le=600)
|
||||
auto_delete_months: int | None = Field(default=None, ge=0, le=600)
|
||||
auto_archive_months: int | None = Field(default=None, ge=0, le=600)
|
||||
archive_delete_months: int | None = Field(default=None, ge=0, le=600)
|
||||
# Schluessel aus DURATIONS -> neuer Wert
|
||||
durations: dict[str, int] | None = None
|
||||
|
||||
|
||||
class WelcomeCompleteIn(BaseModel):
|
||||
|
||||
@@ -59,6 +59,50 @@ def normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Schluessel fuer die Missbrauchsbremse
|
||||
# --------------------------------------------------------------------------
|
||||
#
|
||||
# Die Zaehler brauchen einen Schluessel je Absender - IP-Adresse oder
|
||||
# E-Mail-Adresse. Beides im Klartext abzulegen waere unnoetig: Fuer das
|
||||
# Hochzaehlen genuegt ein Wert, der gleich bleibt; lesbar muss er nicht
|
||||
# sein.
|
||||
#
|
||||
# Deshalb ein Streuwert mit einem zufaelligen, installationsweiten
|
||||
# Zusatz. Das ist Pseudonymisierung, keine Anonymisierung - wer die
|
||||
# Datenbank samt Zusatz hat, koennte eine vermutete Adresse
|
||||
# nachrechnen. Es verhindert aber, dass beim Blick in die Tabelle
|
||||
# Adressen mitgelesen werden, und die Werte verschwinden ohnehin nach
|
||||
# 24 Stunden.
|
||||
|
||||
_bucket_salt: str | None = None
|
||||
|
||||
|
||||
def _salt(db: Session) -> bytes:
|
||||
"""Zufallszusatz, beim ersten Bedarf erzeugt und dann bestaendig."""
|
||||
global _bucket_salt
|
||||
if _bucket_salt is None:
|
||||
from app.models import Setting
|
||||
|
||||
row = db.get(Setting, "ratelimit_salt")
|
||||
if row is None:
|
||||
value = secrets.token_hex(16)
|
||||
db.add(Setting(key="ratelimit_salt", value=value))
|
||||
db.flush()
|
||||
else:
|
||||
value = row.value
|
||||
_bucket_salt = value
|
||||
return _bucket_salt.encode()[:32]
|
||||
|
||||
|
||||
def bucket_key(db: Session, prefix: str, value: str) -> str:
|
||||
"""Erzeugt einen Zaehlerschluessel, ohne den Wert selbst abzulegen."""
|
||||
digest = hashlib.blake2s(
|
||||
value.encode("utf-8"), key=_salt(db), digest_size=16
|
||||
).hexdigest()
|
||||
return f"{prefix}:{digest}"
|
||||
|
||||
|
||||
def check_rate_limit(
|
||||
db: Session, bucket: str, *, limit: int, window_minutes: int
|
||||
) -> bool:
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.security import hash_token, new_token, normalize_email, utcnow
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Vorgabewerte; maßgeblich ist, was in der Verwaltung eingestellt ist.
|
||||
WELCOME_DAYS = 14
|
||||
EMAIL_CHANGE_HOURS = 48
|
||||
|
||||
@@ -32,22 +33,18 @@ EMAIL_CHANGE_HOURS = 48
|
||||
# Einstellungen zur automatischen Bereinigung
|
||||
# ==========================================================================
|
||||
|
||||
def months_setting(db: Session, key: str, fallback: int) -> int:
|
||||
"""Liest einen Monatswert aus der Laufzeitkonfiguration.
|
||||
def months_setting(db: Session, key: str, fallback: int = 0) -> int:
|
||||
"""Monatswert aus der Laufzeitkonfiguration.
|
||||
|
||||
0 bedeutet ausdrücklich "abgeschaltet" - nicht "sofort". Ein
|
||||
Tippfehler soll nicht dazu führen, dass beim nächsten Durchlauf alle
|
||||
Konten verschwinden.
|
||||
Nur noch eine Weiterleitung - die Fristen liegen gesammelt in
|
||||
app/runtime_settings.py. Der Name bleibt, damit die vorhandenen
|
||||
Aufrufstellen unverändert weiterlaufen.
|
||||
|
||||
0 bedeutet ausdrücklich "abgeschaltet", nicht "sofort".
|
||||
"""
|
||||
raw = get_setting(db, key, "")
|
||||
if not raw:
|
||||
return fallback
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
log.warning("Einstellung %s ist keine Zahl (%r) - verwende %d", key, raw, fallback)
|
||||
return fallback
|
||||
return max(0, min(value, 600))
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
return get_duration(db, key)
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
@@ -77,6 +74,12 @@ def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
|
||||
return raw
|
||||
|
||||
|
||||
def welcome_days(db: Session) -> int:
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
return get_duration(db, "welcome_days")
|
||||
|
||||
|
||||
def create_user(
|
||||
db: Session, *, email: str, display_name: str | None, is_admin: bool = False
|
||||
) -> tuple[User, str]:
|
||||
@@ -99,7 +102,7 @@ def create_user(
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
|
||||
token = issue_token(db, user, "welcome", hours=welcome_days(db) * 24)
|
||||
return user, token
|
||||
|
||||
|
||||
@@ -133,6 +136,10 @@ def start_email_change(
|
||||
).all():
|
||||
pending.cancelled_at = utcnow()
|
||||
|
||||
from app.runtime_settings import get_duration
|
||||
|
||||
hours = get_duration(db, "email_change_hours")
|
||||
|
||||
raw_new = new_token()
|
||||
raw_old = new_token() if user.is_admin else None
|
||||
|
||||
@@ -144,7 +151,7 @@ def start_email_change(
|
||||
token_old_hash=hash_token(raw_old) if raw_old else None,
|
||||
requires_old=user.is_admin,
|
||||
requested_by=requested_by,
|
||||
expires_at=utcnow() + timedelta(hours=EMAIL_CHANGE_HOURS),
|
||||
expires_at=utcnow() + timedelta(hours=hours),
|
||||
)
|
||||
db.add(change)
|
||||
db.flush()
|
||||
|
||||
@@ -213,6 +213,21 @@ sie ein Hinweis darauf, dass das Konto existiert.
|
||||
**Löschen verlangt die Eingabe der Adresse** als Bestätigung, und regelt die
|
||||
Listen des Kontos, statt sie mitzureißen.
|
||||
|
||||
## Datensparsamkeit im Betrieb
|
||||
|
||||
**Keine Fremdressourcen.** Keine externen Schriften, Skripte, Karten oder
|
||||
Symbole. Verwendet wird die Systemschrift des Geräts. Die CSP führt
|
||||
`font-src 'self'` ausdrücklich auf.
|
||||
|
||||
**Zugriffsprotokolle mit gekürzter IP-Adresse**, ohne `Referer` (dort stünde
|
||||
bei öffentlichen Listenlinks der Zugangsschlüssel).
|
||||
|
||||
**Missbrauchsbremse ohne Klartextadressen.** Bis zur Sicherheitsprüfung lagen
|
||||
IP- und E-Mail-Adressen im Klartext in `rate_limit.bucket`. Jetzt ein
|
||||
Streuwert mit installationsweitem Zufallszusatz – Pseudonymisierung, keine
|
||||
Anonymisierung, aber die Adressen sind beim Blick in die Tabelle nicht mehr
|
||||
mitzulesen. Aufbewahrung ohnehin höchstens 24 Stunden.
|
||||
|
||||
## Bekannte Grenzen
|
||||
|
||||
Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler – aber jeder ist eine
|
||||
|
||||
@@ -30,6 +30,7 @@ run "Konfigurationsvorlage" bash tools/check-env.sh .env.example
|
||||
run "Berechtigungen" python3 tools/check-routes.py
|
||||
run "Datenbankschema" python3 tools/check-schema.py
|
||||
run "Bearbeitbare Felder" python3 tools/check-editable.py
|
||||
run "Fristen" python3 tools/check-retention.py
|
||||
run "nginx-Konfiguration" python3 tools/check-nginx.py web
|
||||
run "JavaScript-Module" node tools/check-js.mjs
|
||||
run "Strichcode-Decoder" node tools/test-barcode.mjs
|
||||
|
||||
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; }
|
||||
|
||||
@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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -296,57 +296,66 @@ export async function adminView(root, { back }) {
|
||||
// Automatik
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function automationCard() {
|
||||
const deactivate = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_deactivate_months),
|
||||
});
|
||||
const remove = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_delete_months),
|
||||
});
|
||||
const archive = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_archive_months),
|
||||
});
|
||||
const archiveDelete = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.archive_delete_months),
|
||||
/** Fristen aus dem Server: Beschriftung, Einheit und Grenzen kommen
|
||||
* von dort, damit nicht das eine steht und das andere gilt. */
|
||||
function durationsCard() {
|
||||
const fields = new Map();
|
||||
|
||||
const group = (title, keys, lead) => {
|
||||
const rows = config.durations.filter((d) => keys.includes(d.key));
|
||||
if (!rows.length) return null;
|
||||
|
||||
return el("div.duration-group", {},
|
||||
el("h3.device-heading", {}, title),
|
||||
lead ? el("p.sub", {}, lead) : null,
|
||||
rows.map((entry) => {
|
||||
const input = el("input.months", {
|
||||
type: "number",
|
||||
min: String(entry.minimum),
|
||||
max: String(entry.maximum),
|
||||
value: String(entry.value),
|
||||
title: `${entry.minimum} bis ${entry.maximum} ${entry.unit}`,
|
||||
});
|
||||
fields.set(entry.key, input);
|
||||
|
||||
return el("div.duration-row", {},
|
||||
el("div.duration-text", {},
|
||||
el("span.duration-label", {}, `${entry.label} … ${entry.unit}`),
|
||||
el("span.duration-note", {},
|
||||
entry.note,
|
||||
entry.zero_means ? ` · 0 = ${entry.zero_means}` : "",
|
||||
entry.value !== entry.default
|
||||
? ` · Vorgabe ${entry.default}` : "")),
|
||||
input);
|
||||
}));
|
||||
};
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Automatische Bereinigung"),
|
||||
el("h2", {}, "Fristen"),
|
||||
el("p.lead", {},
|
||||
"Läuft einmal täglich. ",
|
||||
el("strong", {}, "0 bedeutet abgeschaltet"),
|
||||
", nicht „sofort“. Administratorkonten sind ausgenommen."),
|
||||
"Diese Werte gelten zur Laufzeit und werden auch für die " +
|
||||
"Datenschutzerklärung gebraucht. Der Aufräumlauf setzt sie " +
|
||||
"einmal täglich um."),
|
||||
|
||||
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
|
||||
deactivate,
|
||||
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
|
||||
remove,
|
||||
|
||||
el("h3.device-heading", {}, "Listen"),
|
||||
el("label", {}, "Archivieren nach … Monaten ohne Öffnen"),
|
||||
archive,
|
||||
el("p.sub", {},
|
||||
"Gilt je Person: Die Liste verschwindet nur aus der Übersicht " +
|
||||
"derjenigen, die sie nicht geöffnet hat."),
|
||||
el("label", {}, "Löschen nach … Monaten im Archiv"),
|
||||
archiveDelete,
|
||||
el("p.sub", {},
|
||||
"Maßgeblich ist allein das Archivdatum des Eigentümers. Er wird " +
|
||||
"7 Tage und 1 Tag vorher per E-Mail gewarnt."),
|
||||
group("Konten",
|
||||
["auto_deactivate_months", "auto_delete_months"]),
|
||||
group("Listen",
|
||||
["auto_archive_months", "archive_delete_months"]),
|
||||
group("Aufbewahrung",
|
||||
["cleanup_deleted_days", "cleanup_ops_days", "product_cache_days"]),
|
||||
group("Sitzungen und Links",
|
||||
["session_days", "invite_days", "welcome_days", "verify_hours",
|
||||
"reset_hours", "email_change_hours"]),
|
||||
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await put("/api/admin/settings", {
|
||||
auto_deactivate_months: Number(deactivate.value) || 0,
|
||||
auto_delete_months: Number(remove.value) || 0,
|
||||
auto_archive_months: Number(archive.value) || 0,
|
||||
archive_delete_months: Number(archiveDelete.value) || 0,
|
||||
});
|
||||
say("Einstellungen gespeichert.");
|
||||
const durations = {};
|
||||
for (const [key, input] of fields) {
|
||||
durations[key] = Number(input.value) || 0;
|
||||
}
|
||||
await put("/api/admin/settings", { durations });
|
||||
say("Fristen gespeichert.");
|
||||
}),
|
||||
}, "Speichern"),
|
||||
|
||||
@@ -354,7 +363,19 @@ export async function adminView(root, { back }) {
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||||
}, "Jetzt aufräumen"))
|
||||
}, "Jetzt aufräumen")),
|
||||
|
||||
// Was hier nicht einstellbar ist, soll trotzdem sichtbar sein -
|
||||
// sonst wirkt die Übersicht vollständig, ohne es zu sein.
|
||||
Object.keys(config.fixed || {}).length
|
||||
? el("div", {},
|
||||
el("h3.device-heading", {}, "Fest eingestellt"),
|
||||
el("ul.fixed-list", {},
|
||||
Object.entries(config.fixed).map(([label, value]) =>
|
||||
el("li", {},
|
||||
el("span.name", {}, label),
|
||||
el("span.sub", {}, value)))))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,7 +396,7 @@ export async function adminView(root, { back }) {
|
||||
registrationToggle(),
|
||||
el("ul.admin-users", {}, users.map(userRow))),
|
||||
createCard(),
|
||||
automationCard()
|
||||
durationsCard()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
|
||||
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
|
||||
// wann alte Zwischenspeicher verworfen werden.
|
||||
const VERSION = "v25";
|
||||
const VERSION = "v27";
|
||||
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
|
||||
|
||||
const SHELL = [
|
||||
"/",
|
||||
"/app.css",
|
||||
"/impressum.html",
|
||||
"/datenschutz.html",
|
||||
"/js/app.js",
|
||||
"/js/api.js",
|
||||
"/js/dom.js",
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
limit_req_zone $binary_remote_addr zone=auth_zone:10m rate=10r/m;
|
||||
limit_req_status 429;
|
||||
|
||||
# Zugriffsprotokoll ohne vollstaendige IP-Adresse.
|
||||
#
|
||||
# Fuer den Betrieb genuegt, WAS abgerufen wurde und ob es geklappt hat.
|
||||
# Die letzte Stelle der Adresse traegt dazu nichts bei, macht den
|
||||
# Eintrag aber personenbeziehbar - und Protokolle wandern in
|
||||
# Sicherungen und leben laenger als der Anlass.
|
||||
map $remote_addr $ip_gekuerzt {
|
||||
~(?<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 {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
@@ -154,7 +173,7 @@ server {
|
||||
location = /robots.txt {
|
||||
include /etc/nginx/security_headers.conf;
|
||||
default_type text/plain;
|
||||
return 200 "User-agent: *\nDisallow: /s/\nDisallow: /invite\nDisallow: /reset\nDisallow: /willkommen\n";
|
||||
return 200 "User-agent: *\nDisallow: /s/\nDisallow: /invite\nDisallow: /reset\nDisallow: /willkommen\nAllow: /impressum.html\nAllow: /datenschutz.html\n";
|
||||
}
|
||||
|
||||
# Aus demselben Grund wie bei /s/: Einladungs- und
|
||||
|
||||
@@ -10,4 +10,7 @@ add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
# font-src 'self' steht ausdrücklich da, obwohl default-src es schon
|
||||
# abdeckt: Es soll beim Lesen der Datei erkennbar sein, dass keine
|
||||
# Schriften von fremden Servern nachgeladen werden dürfen.
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
Reference in New Issue
Block a user