Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

181
.env.example Normal file
View File

@@ -0,0 +1,181 @@
# ==========================================================================
# Datenbank
# ==========================================================================
DB_NAME=einkaufsapp
DB_USER=einkaufsapp
DB_PASSWORD=BITTE-AENDERN-langes-zufaelliges-passwort
DB_ROOT_PASSWORD=BITTE-AENDERN-anderes-langes-passwort
# ==========================================================================
# Anwendung
# ==========================================================================
# ---- Anwendungsname ----
# Erscheint in der Oberflaeche, im Browsertitel, auf dem Startbildschirm
# und in allen Mails. Nach einer Aenderung genuegt:
# docker compose up -d api
# Der web-Container muss dafuer NICHT neu gebaut werden.
APP_NAME=Einkaufsliste
# Kuerzere Fassung fuer das Symbol auf dem Startbildschirm. Leer = APP_NAME.
APP_SHORT_NAME=Einkauf
# Einziger nach aussen veroeffentlichter Port. Dahinter liegt nginx,
# das sowohl die Oberflaeche als auch /api/ ausliefert. Der api-Container
# hat bewusst KEINEN eigenen Port nach aussen.
HTTP_PORT=46600
# Mailpit-Weboberflaeche, nur mit "--profile dev". An 127.0.0.1 gebunden.
MAILPIT_PORT=8025
# Basis-URL, wie Nutzer die App im Browser erreichen - inklusive Port,
# falls kein vorgelagerter Reverse Proxy auf 80/443 steht.
# Landet in Verifikations- und Einladungsmails, muss also stimmen.
PUBLIC_BASE_URL=http://einkauf.example.de:46600
# Welchen Absendern von X-Forwarded-For die API glauben darf.
# "*" 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
# PUBLIC_BASE_URL ebenfalls auf https:// zeigen)
# Ein Secure-Cookie über reines HTTP wird vom Browser verworfen - die
# Anmeldung scheitert dann kommentarlos.
COOKIE_SECURE=false
# Dieses Konto wird als Administrator markiert, sobald es sich registriert.
ADMIN_EMAIL=admin@example.de
# Optional: Startpasswort für das erste Administratorkonto.
#
# - Wird NUR beim allerersten Start verwendet, und auch dann nur, wenn
# die Datenbank noch gar keinen Nutzer enthält.
# - Das angelegte Konto muss das Passwort bei der ersten Anmeldung
# ändern. Bis dahin sind alle Funktionen außer "Profil lesen" und
# "Passwort ändern" gesperrt.
# - Danach diesen Wert wieder entfernen. Er steht im Klartext auf der
# Platte, in jedem Backup und ist über "docker inspect" für jeden
# lesbar, der auf den Host kommt.
#
# Leer lassen, um stattdessen den regulären Registrierungsweg zu nutzen.
ADMIN_INITIAL_PASSWORD=
# Alternative für Docker Secrets - hat Vorrang vor der Variablen oben:
# ADMIN_INITIAL_PASSWORD_FILE=/run/secrets/admin_password
# ==========================================================================
# Selbstregistrierung
# ==========================================================================
# true Immer erlaubt. Admin-API kann es NICHT abschalten.
# false Immer gesperrt. Zugang nur noch per Einladung (ab Phase 5).
# Admin-API kann es NICHT einschalten.
# admin Die Datenbankeinstellung entscheidet, der Administrator darf
# sie zur Laufzeit umschalten. (Voreinstellung)
ALLOW_SELF_REGISTRATION=admin
# Nur relevant bei ALLOW_SELF_REGISTRATION=admin: Startwert beim
# allerersten Start. Danach wird dieser Wert ignoriert.
SELF_REGISTRATION_DEFAULT=true
# ==========================================================================
# Produktdatenbank
# ==========================================================================
# Nachschlagen gescannter Strichcodes bei Open Food Facts.
# openfoodfacts eingeschaltet (Voreinstellung)
# off aus - nur der eigene Artikelstamm
#
# Die Abfrage laeuft ueber den api-Container, nicht aus dem Browser:
# So erfaehrt der Dienst die IP-Adressen deiner Nutzer nicht, die
# Content-Security-Policy bleibt bei connect-src 'self', und jeder Code
# wird nur einmal abgefragt.
#
# Voraussetzung: Der api-Container muss world.openfoodfacts.org
# erreichen koennen.
PRODUCT_LOOKUP=openfoodfacts
# Gueltigkeit der zwischengespeicherten Antworten.
PRODUCT_CACHE_DAYS=180
# Fehlschlaege kuerzer merken - das Produkt kann spaeter eingepflegt sein.
PRODUCT_MISS_DAYS=14
PRODUCT_LOOKUP_TIMEOUT=6
# ==========================================================================
# Aufraeumen
# ==========================================================================
# Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann ein
# Geraet offline bleiben und beim naechsten Abgleich noch erfahren, dass
# etwas verschwunden ist. Kuerzer einzustellen fuehrt dazu, dass
# geloeschte Listen auf selten benutzten Geraeten stehenbleiben.
CLEANUP_DELETED_DAYS=30
# Quittungen der Outbox - verhindern doppeltes Ausfuehren.
CLEANUP_OPS_DAYS=7
# Abstand zwischen zwei Aufraeumdurchlaeufen.
CLEANUP_INTERVAL_HOURS=24
# ==========================================================================
# Push-Benachrichtigungen
# ==========================================================================
# Schluesselpaar erzeugen mit:
# docker compose exec api python3 /app/../tools/vapid-keys.py
# oder lokal:
# python3 tools/vapid-keys.py
#
# Leer lassen = Push abgeschaltet. Ein Wechsel des privaten Schluessels
# entwertet alle bestehenden Geraeteanmeldungen.
# ACHTUNG: Jede Variable darf nur EINMAL in dieser Datei stehen. Bei
# doppelten Eintraegen nimmt Docker Compose den letzten - eine
# vergessene leere Vorlagenzeile unterhalb des eingefuegten Werts
# schaltet die Funktion damit still wieder ab.
VAPID_PRIVATE_KEY=
VAPID_PUBLIC_KEY=
# Kontaktadresse fuer die Push-Dienste der Browserhersteller.
# Leer = SMTP_ENVELOPE_FROM wird verwendet.
VAPID_SUBJECT=mailto:admin@example.de
# Hoechstens eine Benachrichtigung je Liste und Person in diesem Zeitraum.
PUSH_THROTTLE_HOURS=2
PUSH_TIMEOUT=10
# ==========================================================================
# SMTP-Relay
# ==========================================================================
# --- Beispiel: eigener Postfix/mailcow-Relay ---
SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=einkaufsapp@example.de
SMTP_PASSWORD=BITTE-AENDERN
# --- Beispiel: Relay mit implizitem TLS ---
# SMTP_PORT=465
# SMTP_SECURITY=ssl
# --- Beispiel: lokaler Mailpit-Container zum Testen ---
# SMTP_HOST=mailpit
# SMTP_PORT=1025
# SMTP_SECURITY=none
# SMTP_USER=
# SMTP_PASSWORD=
# Hostname im EHLO-Kommando. Sollte ein FQDN sein, der zur sendenden
# Domain passt - sonst meldet sich der Container mit seiner ID, was bei
# strengen Empfängern Spampunkte kostet.
SMTP_HELO_HOSTNAME=einkauf.example.de
# Absender, wie der Empfänger ihn sieht.
SMTP_FROM=einkaufsliste@example.de
# Leer lassen, um APP_NAME zu verwenden.
SMTP_FROM_NAME=
# Envelope-Absender (Return-Path). SPF wird gegen DIESE Adresse geprüft,
# nicht gegen SMTP_FROM. Leer lassen = identisch mit SMTP_FROM.
# Eine eigene Bounce-Adresse ist sinnvoll, wenn du Rückläufer auswerten willst.
SMTP_ENVELOPE_FROM=bounces@example.de
# Optional: Antworten sollen an ein Postfach gehen, das jemand liest.
SMTP_REPLY_TO=
SMTP_TIMEOUT=20
SMTP_MAX_RETRIES=3

447
.gitignore vendored
View File

@@ -1,416 +1,43 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# ==========================================================================
# Geheimnisse - NIEMALS ins Repository
# ==========================================================================
# Enthält Datenbankpasswort, SMTP-Zugangsdaten und den privaten
# VAPID-Schlüssel. Gehört in den Passwortmanager oder in eine
# verschlüsselte Sicherung.
.env
.env.*
!.env.example
# Local History for Visual Studio Code
.history/
# Docker-Secrets-Dateien
secrets/
*.key
*.pem
# Built Visual Studio Code Extensions
*.vsix
# ==========================================================================
# Laufzeitdaten
# ==========================================================================
data/
*.sql.gz
*.sql.bz2
backups/
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
# ==========================================================================
# Werkzeuge
# ==========================================================================
__pycache__/
*.pyc
*.py[cod]
.venv/
venv/
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
node_modules/
dist/
# Editor und Betriebssystem
.idea/
.vscode/
*.swp
.DS_Store
*.zip

232
LICENSE
View File

@@ -1,232 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
einkaufsapp
Copyright (C) 2026 marco.morath
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
einkaufsapp Copyright (C) 2026 marco.morath
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.

1157
README.md

File diff suppressed because it is too large Load Diff

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Nicht als root laufen lassen.
RUN useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
# Ueber "sh" aufrufen: das Bind-Mount ./backend:/app ueberschreibt /app
# samt Dateirechten, ein x-Bit aus dem Image waere dann wirkungslos.
CMD ["sh", "/app/entrypoint.sh"]

40
backend/alembic.ini Normal file
View File

@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
# Kein sqlalchemy.url hier: die Verbindungsdaten kommen aus der
# Umgebung und werden in alembic/env.py direkt an create_engine()
# uebergeben. configparser wuerde sonst an "%" im Passwort scheitern.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

52
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,52 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine, pool
from app.config import settings
from app.db import Base
import app.models # noqa: F401 - registriert die Tabellen bei Base
config = context.config
# WICHTIG: die Datenbank-URL NICHT ueber config.set_main_option() setzen.
# alembic.ini wird von configparser mit BasicInterpolation gelesen, und
# der deutet jedes "%" als Variablenreferenz. Ein URL-kodiertes Passwort
# (z.B. "%21" fuer "!") laesst den Aufruf mit
# ValueError: invalid interpolation syntax
# scheitern. Die Engine wird deshalb hier direkt gebaut.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(settings.database_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,102 @@
"""Phase 1: Benutzer, Sessions, Mail-Token, Einstellungen, Rate Limiting
Revision ID: 0001
Revises:
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(80), nullable=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("verified_at", sa.DateTime(), nullable=True),
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.UniqueConstraint("email", name="uq_user_email"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_table(
"user_session",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("csrf_token", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_user_session_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_user_session_expires", "user_session", ["expires_at"])
op.create_table(
"email_token",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("purpose", sa.String(32), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("token_hash", name="uq_email_token_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_email_token_user_purpose", "email_token", ["user_id", "purpose"])
op.create_table(
"setting",
sa.Column("key", sa.String(64), primary_key=True),
sa.Column("value", sa.String(255), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_table(
"rate_limit",
sa.Column("bucket", sa.String(160), primary_key=True),
sa.Column("window_start", sa.DateTime(), primary_key=True),
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("0")),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("rate_limit")
op.drop_table("setting")
op.drop_index("ix_email_token_user_purpose", table_name="email_token")
op.drop_table("email_token")
op.drop_index("ix_user_session_expires", table_name="user_session")
op.drop_table("user_session")
op.drop_table("user")

View File

@@ -0,0 +1,153 @@
"""Phase 2: Listen, Mitgliedschaften, Märkte, Warengruppen, Artikel, Einträge
Revision ID: 0002
Revises: 0001
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
MYSQL = {
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
def upgrade() -> None:
op.create_table(
"shopping_list",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("owner_id", sa.String(36), nullable=False),
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="RESTRICT"),
**MYSQL,
)
op.create_table(
"list_member",
sa.Column("list_id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), primary_key=True),
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
sa.Column("joined_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
**MYSQL,
)
op.create_index("ix_list_member_user", "list_member", ["user_id"])
op.create_table(
"market",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.UniqueConstraint("list_id", "name", name="uq_market_list_name"),
**MYSQL,
)
op.create_index("ix_market_list_rev", "market", ["list_id", "row_rev"])
op.create_table(
"category",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.UniqueConstraint("list_id", "name", name="uq_category_list_name"),
**MYSQL,
)
op.create_index("ix_category_list_rev", "category", ["list_id", "row_rev"])
op.create_table(
"article",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("barcode", sa.String(64), nullable=True),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("default_market_id", sa.String(36), nullable=True),
sa.Column("default_category_id", sa.String(36), nullable=True),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["default_market_id"], ["market.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["default_category_id"], ["category.id"], ondelete="SET NULL"),
sa.UniqueConstraint("list_id", "name", name="uq_article_list_name"),
**MYSQL,
)
op.create_index("ix_article_list_rev", "article", ["list_id", "row_rev"])
op.create_index("ix_article_barcode", "article", ["list_id", "barcode"])
op.create_table(
"article_attribute",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("attr_name", sa.String(80), nullable=False),
sa.Column("attr_value", sa.String(300), nullable=False),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
**MYSQL,
)
op.create_table(
"article_market",
sa.Column("article_id", sa.String(36), primary_key=True),
sa.Column("market_id", sa.String(36), primary_key=True),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
**MYSQL,
)
op.create_table(
"list_item",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("market_id", sa.String(36), nullable=True),
sa.Column("category_id", sa.String(36), nullable=True),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("note", sa.String(500), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="open"),
sa.Column("price_cents", sa.Integer(), nullable=True),
sa.Column("row_rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["category_id"], ["category.id"], ondelete="SET NULL"),
**MYSQL,
)
op.create_index("ix_list_item_list_rev", "list_item", ["list_id", "row_rev"])
op.create_index("ix_list_item_list_status", "list_item", ["list_id", "status"])
def downgrade() -> None:
op.drop_table("list_item")
op.drop_table("article_market")
op.drop_table("article_attribute")
op.drop_table("article")
op.drop_table("category")
op.drop_table("market")
op.drop_index("ix_list_member_user", table_name="list_member")
op.drop_table("list_member")
op.drop_table("shopping_list")

View File

@@ -0,0 +1,31 @@
"""Phase 2b: Zwang zum Passwortwechsel beim initial angelegten Admin
Revision ID: 0003
Revises: 0002
Create Date: 2026-08-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"must_change_password",
sa.Boolean(),
nullable=False,
server_default=sa.text("0"),
),
)
def downgrade() -> None:
op.drop_column("user", "must_change_password")

View File

@@ -0,0 +1,60 @@
"""Phase 5: Einladungen, Eigentümerwechsel, Ersteller je Eintrag
Revision ID: 0004
Revises: 0003
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: str | None = "0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_item",
sa.Column("created_by", sa.String(36), nullable=True),
)
op.create_foreign_key(
"fk_list_item_created_by", "list_item", "user",
["created_by"], ["id"], ondelete="SET NULL",
)
op.create_table(
"list_invite",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("role", sa.String(16), nullable=False, server_default="editor"),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("invited_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("send_count", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("accepted_by", sa.String(36), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["invited_by"], ["user.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["accepted_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_hash", name="uq_list_invite_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_list_invite_list", "list_invite", ["list_id"])
op.create_index("ix_list_invite_email", "list_invite", ["email"])
def downgrade() -> None:
op.drop_index("ix_list_invite_email", table_name="list_invite")
op.drop_index("ix_list_invite_list", table_name="list_invite")
op.drop_table("list_invite")
op.drop_constraint("fk_list_item_created_by", "list_item", type_="foreignkey")
op.drop_column("list_item", "created_by")

View File

@@ -0,0 +1,56 @@
"""Phase 5b: Öffentliche Ansichtslinks und Zusatzrecht zum Teilen
Revision ID: 0005
Revises: 0004
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: str | None = "0004"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_member",
sa.Column(
"may_share_public", sa.Boolean(), nullable=False,
server_default=sa.text("0"),
),
)
# Eigentuemer haben das Recht immer - der Vollstaendigkeit halber
# auch in der Spalte, damit Abfragen einheitlich bleiben.
op.execute("UPDATE list_member SET may_share_public = 1 WHERE role = 'owner'")
op.create_table(
"public_share",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("label", sa.String(120), nullable=True),
sa.Column("allow_check", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("created_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
sa.Column("last_access_at", sa.DateTime(), nullable=True),
sa.Column("access_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_hash", name="uq_public_share_token"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_public_share_list", "public_share", ["list_id"])
def downgrade() -> None:
op.drop_index("ix_public_share_list", table_name="public_share")
op.drop_table("public_share")
op.drop_column("list_member", "may_share_public")

View File

@@ -0,0 +1,39 @@
"""Phase 4: Idempotenz-Quittungen für die Outbox
Revision ID: 0006
Revises: 0005
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: str | None = "0005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"applied_op",
sa.Column("op_id", sa.String(64), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("user_id", sa.String(36), nullable=True),
sa.Column("kind", sa.String(32), nullable=False),
sa.Column("result_id", sa.String(36), nullable=True),
sa.Column("rev", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="SET NULL"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_applied_op_list", "applied_op", ["list_id", "created_at"])
def downgrade() -> None:
op.drop_index("ix_applied_op_list", table_name="applied_op")
op.drop_table("applied_op")

View File

@@ -0,0 +1,23 @@
"""Phase 6b: Eigenschaft je Listeneintrag
Revision ID: 0007
Revises: 0006
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("list_item", sa.Column("variant", sa.String(200), nullable=True))
def downgrade() -> None:
op.drop_column("list_item", "variant")

View File

@@ -0,0 +1,37 @@
"""Phase 6c: Zwischenspeicher für Produktabfragen
Revision ID: 0008
Revises: 0007
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0008"
down_revision: str | None = "0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"product_cache",
sa.Column("barcode", sa.String(64), primary_key=True),
sa.Column("found", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("name", sa.String(300), nullable=True),
sa.Column("brand", sa.String(200), nullable=True),
sa.Column("package", sa.String(120), nullable=True),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("source", sa.String(32), nullable=False, server_default="openfoodfacts"),
sa.Column("fetched_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("product_cache")

View File

@@ -0,0 +1,45 @@
"""Phase 7: Preisdatenbank
Revision ID: 0009
Revises: 0008
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0009"
down_revision: str | None = "0008"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"price_point",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("list_id", sa.String(36), nullable=False),
sa.Column("article_id", sa.String(36), nullable=False),
sa.Column("market_id", sa.String(36), nullable=False),
sa.Column("price_cents", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Numeric(10, 3), nullable=True),
sa.Column("unit", sa.String(32), nullable=True),
sa.Column("recorded_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["article_id"], ["article.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["market_id"], ["market.id"], ondelete="CASCADE"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_price_article_market", "price_point",
["article_id", "market_id", "recorded_at"])
op.create_index("ix_price_list", "price_point", ["list_id", "recorded_at"])
def downgrade() -> None:
op.drop_index("ix_price_list", table_name="price_point")
op.drop_index("ix_price_article_market", table_name="price_point")
op.drop_table("price_point")

View File

@@ -0,0 +1,62 @@
"""Phase 7b: Stückzahl und Gebinde trennen
Bis hierher steckten beide in `quantity`/`unit`. Bei "500 ml" zu 2,99 EUR
rechnete die Summenbildung 500 × 2,99 = 1495 EUR.
Die vorhandenen Werte werden als Gebinde übernommen (aus "500 ml" wird
pack_size=500, pack_unit=ml) und die Stückzahl auf 1 gesetzt. Das ist die
sichere Richtung: Wo bisher tatsächlich eine Stückzahl gemeint war,
stimmt die Summe danach - sie wird nur nicht mehr vervielfacht.
Revision ID: 0010
Revises: 0009
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0010"
down_revision: str | None = "0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"list_item",
sa.Column("count", sa.Integer(), nullable=False, server_default=sa.text("1")),
)
op.alter_column("list_item", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("list_item", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
op.alter_column("price_point", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("price_point", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
op.add_column("product_cache", sa.Column("count", sa.Integer(), nullable=True))
op.alter_column("product_cache", "quantity",
new_column_name="pack_size", existing_type=sa.Numeric(10, 3))
op.alter_column("product_cache", "unit",
new_column_name="pack_unit", existing_type=sa.String(32))
def downgrade() -> None:
op.alter_column("product_cache", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("product_cache", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.drop_column("product_cache", "count")
op.alter_column("price_point", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("price_point", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.alter_column("list_item", "pack_unit",
new_column_name="unit", existing_type=sa.String(32))
op.alter_column("list_item", "pack_size",
new_column_name="quantity", existing_type=sa.Numeric(10, 3))
op.drop_column("list_item", "count")

View File

@@ -0,0 +1,55 @@
"""Phase 8: Push-Benachrichtigungen
Revision ID: 0011
Revises: 0010
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0011"
down_revision: str | None = "0010"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"push_subscription",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("endpoint", sa.String(500), nullable=False),
sa.Column("p256dh", sa.String(200), nullable=False),
sa.Column("auth", sa.String(100), nullable=False),
sa.Column("label", sa.String(80), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("last_success_at", sa.DateTime(), nullable=True),
sa.Column("failure_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.UniqueConstraint("endpoint", name="uq_push_subscription_endpoint"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_push_subscription_user", "push_subscription", ["user_id"])
op.create_table(
"notify_state",
sa.Column("list_id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), primary_key=True),
sa.Column("last_notified_at", sa.DateTime(), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(["list_id"], ["shopping_list.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
def downgrade() -> None:
op.drop_table("notify_state")
op.drop_index("ix_push_subscription_user", table_name="push_subscription")
op.drop_table("push_subscription")

View File

@@ -0,0 +1,59 @@
"""Phase 10: Benutzerverwaltung, Adressänderung, automatische Bereinigung
Revision ID: 0012
Revises: 0011
Create Date: 2026-08-08
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0012"
down_revision: str | None = "0011"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("user", sa.Column("last_seen_at", sa.DateTime(), nullable=True))
op.add_column("user", sa.Column("deactivated_at", sa.DateTime(), nullable=True))
# Bestandskonten nicht sofort der automatischen Deaktivierung
# aussetzen: Ohne Startwert waere last_seen_at NULL und damit
# scheinbar "nie benutzt". Das Erstellungsdatum ist die
# konservativere Annahme.
op.execute("UPDATE `user` SET last_seen_at = created_at WHERE last_seen_at IS NULL")
op.create_table(
"email_change",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("old_email", sa.String(255), nullable=False),
sa.Column("new_email", sa.String(255), nullable=False),
sa.Column("token_new_hash", sa.String(64), nullable=False),
sa.Column("token_old_hash", sa.String(64), nullable=True),
sa.Column("requires_old", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("confirmed_new_at", sa.DateTime(), nullable=True),
sa.Column("confirmed_old_at", sa.DateTime(), nullable=True),
sa.Column("applied_at", sa.DateTime(), nullable=True),
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
sa.Column("requested_by", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["requested_by"], ["user.id"], ondelete="SET NULL"),
sa.UniqueConstraint("token_new_hash", name="uq_email_change_token_new"),
sa.UniqueConstraint("token_old_hash", name="uq_email_change_token_old"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_unicode_ci",
)
op.create_index("ix_email_change_user", "email_change", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_email_change_user", table_name="email_change")
op.drop_table("email_change")
op.drop_column("user", "deactivated_at")
op.drop_column("user", "last_seen_at")

0
backend/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,78 @@
"""Anlegen des ersten Administratorkontos beim Start.
Bewusst eng gefasst: Das Konto entsteht nur, wenn die Datenbank noch
*gar keinen* Nutzer enthält. Damit kann ein vergessener Eintrag in der
.env kein bestehendes Konto überschreiben und auch kein zweites
Admin-Konto nachschieben.
Das angelegte Konto trägt `must_change_password`. Bis zum Wechsel sind
alle Routen gesperrt, die an `verified_user` hängen - also alles außer
"eigenes Profil lesen" und "Passwort ändern". Der Wert in der .env ist
nach der ersten Anmeldung damit wertlos.
"""
import logging
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models import User
from app.security import hash_password, normalize_email, utcnow
log = logging.getLogger(__name__)
MIN_LENGTH = 12
def ensure_initial_admin(db: Session) -> None:
password = settings.initial_admin_password
user_count = db.scalar(select(func.count()).select_from(User)) or 0
email = normalize_email(settings.admin_email)
if user_count > 0:
if password:
log.warning(
"ADMIN_INITIAL_PASSWORD ist gesetzt, es existieren aber bereits "
"%d Konten - der Wert wird ignoriert. Entferne ihn aus der .env, "
"damit kein Passwort unnötig in der Umgebung steht.",
user_count,
)
return
if not password:
log.info(
"Keine Konten vorhanden und kein ADMIN_INITIAL_PASSWORD gesetzt. "
"Erstes Konto über POST /api/auth/register anlegen (%s wird dabei "
"automatisch Administrator).",
email,
)
return
if len(password) < MIN_LENGTH:
raise RuntimeError(
f"ADMIN_INITIAL_PASSWORD ist zu kurz (mindestens {MIN_LENGTH} Zeichen)."
)
admin = User(
email=email,
display_name="Administrator",
password_hash=hash_password(password),
# Die Adresse gilt als bestätigt: Wer die .env schreiben kann,
# hat ohnehin vollen Zugriff. Ein Verifikationsumweg brächte hier
# keinen Sicherheitsgewinn, nur eine Abhängigkeit vom Mailversand
# bei der Ersteinrichtung.
verified_at=utcnow(),
is_admin=True,
must_change_password=True,
)
db.add(admin)
db.flush()
log.warning(
"Administratorkonto %s aus ADMIN_INITIAL_PASSWORD angelegt. "
"Das Passwort MUSS bei der ersten Anmeldung geändert werden; bis dahin "
"sind alle übrigen Funktionen gesperrt. Danach den Wert aus der .env "
"entfernen.",
email,
)

196
backend/app/config.py Normal file
View File

@@ -0,0 +1,196 @@
from email.utils import parseaddr
from functools import lru_cache
from typing import Literal
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore")
# ---------------- Datenbank ----------------
db_host: str = "db"
db_port: int = 3306
db_name: str = "einkaufsapp"
db_user: str = "einkaufsapp"
db_password: str = ""
# ---------------- Anwendung ----------------
# Erscheint in der Oberflaeche, im Browsertitel, auf dem
# Startbildschirm und in allen Mails. Eine Stelle fuer alles.
app_name: str = "Einkaufsliste"
# Kuerzere Fassung fuer das Symbol auf dem Startbildschirm - dort ist
# nur wenig Platz. Leer = app_name.
app_short_name: str = ""
# Nachschlagen von Strichcodes in einer oeffentlichen Datenbank.
# "off" aus - nur der eigene Artikelstamm
# "openfoodfacts" Open Food Facts, ueber diesen Server abgefragt
product_lookup: Literal["off", "openfoodfacts"] = "openfoodfacts"
# Wie lange ein Treffer gilt, bevor erneut gefragt wird.
product_cache_days: int = 180
# Wie lange ein Fehlschlag gilt. Kuerzer, weil ein Produkt spaeter
# eingepflegt worden sein kann.
product_miss_days: int = 14
product_lookup_timeout: int = 6
public_base_url: str = "http://localhost:8000"
# Kein SECRET_KEY: Sitzungen und CSRF arbeiten mit serverseitig
# gespeicherten Zufallstoken (32 Byte aus secrets.token_urlsafe),
# nicht mit signierten Werten. Ein Signierschlüssel hätte hier keine
# Aufgabe - und eine Einstellung, die Wichtigkeit vortäuscht, ist
# schlechter als keine. Falls später etwas signiert werden soll,
# gehört er wieder hinein.
session_days: int = 30
cookie_secure: bool = False
admin_email: str = "admin@example.com"
# Nur beim allerersten Start verwendet - und auch dann nur, wenn noch
# kein einziger Nutzer existiert. Das angelegte Konto muss das
# Passwort bei der ersten Anmeldung wechseln, danach ist der Wert
# hier wertlos und sollte entfernt werden.
admin_initial_password: str = ""
# Alternative fuer Docker Secrets: Pfad zu einer Datei, deren Inhalt
# als Passwort dient. Hat Vorrang vor admin_initial_password.
admin_initial_password_file: str = ""
# "true" = Selbstregistrierung fest an, Admin kann nichts aendern
# "false" = fest aus, Admin kann nichts aendern
# "admin" = die Datenbankeinstellung entscheidet, Admin darf umschalten
allow_self_registration: Literal["true", "false", "admin"] = "admin"
# Startwert fuer den Modus "admin", greift nur beim allerersten Start.
self_registration_default: bool = True
# ---------------- Aufraeumen ----------------
# Wie lange weich geloeschte Daten aufbewahrt werden. Solange kann
# ein Geraet offline bleiben und beim naechsten Abgleich noch
# erfahren, dass etwas verschwunden ist.
cleanup_deleted_days: int = 30
# Quittungen der Outbox. Kuerzer, weil ein Geraet seine Operationen
# nach so langer Zeit ohnehin aufgegeben haette.
cleanup_ops_days: int = 7
# Abstand zwischen zwei Durchlaeufen.
cleanup_interval_hours: int = 24
# ---------------- Push-Benachrichtigungen ----------------
# Erzeugen mit: python3 tools/vapid-keys.py
# Leer = Push abgeschaltet.
vapid_public_key: str = ""
vapid_private_key: str = ""
# Kontaktadresse fuer die Push-Dienste der Browserhersteller.
vapid_subject: str = ""
# Innerhalb dieser Spanne bekommt ein Geraet hoechstens eine
# Benachrichtigung je Liste - nicht bei jeder einzelnen Aenderung.
push_throttle_hours: int = 2
push_timeout: int = 10
# ---------------- SMTP-Relay ----------------
smtp_host: str = "mailpit"
smtp_port: int = 1025
smtp_user: str = ""
smtp_password: str = ""
# "none" = unverschluesselt (nur fuer lokale Testrelays)
# "starttls" = Klartextverbindung, dann Upgrade (typisch Port 587)
# "ssl" = TLS von Anfang an, "implicit TLS" (typisch Port 465)
smtp_security: Literal["none", "starttls", "ssl"] = "none"
smtp_timeout: int = 20
# Hostname im EHLO. Viele Relays und Spamfilter erwarten hier einen
# FQDN, der zur sendenden Domain passt - nicht die Container-ID.
smtp_helo_hostname: str = ""
# Absender in der Kopfzeile (das, was der Empfaenger sieht)
smtp_from: str = "einkaufsapp@example.com"
# Leer = app_name wird verwendet.
smtp_from_name: str = ""
# Envelope-Absender / Return-Path fuer Bounces. Leer = wie smtp_from.
# SPF wird gegen DIESE Adresse geprueft, nicht gegen den From-Header.
smtp_envelope_from: str = ""
# Optional: Antworten sollen woanders hingehen als an den Absender.
smtp_reply_to: str = ""
smtp_max_retries: int = 3
@field_validator("allow_self_registration", mode="before")
@classmethod
def _normalize_registration(cls, v: object) -> str:
s = str(v).strip().lower()
if s in {"1", "yes", "on", "enabled"}:
return "true"
if s in {"0", "no", "off", "disabled"}:
return "false"
if s in {"", "db", "database", "runtime", "managed"}:
return "admin"
return s
@model_validator(mode="after")
def _check_mail(self) -> "Settings":
if not parseaddr(self.smtp_from)[1]:
raise ValueError("SMTP_FROM ist keine gueltige E-Mail-Adresse")
if self.smtp_user and self.smtp_security == "none":
raise ValueError(
"SMTP_USER gesetzt, aber SMTP_SECURITY=none - "
"Zugangsdaten duerfen nicht unverschluesselt uebertragen werden. "
"Setze SMTP_SECURITY auf starttls oder ssl."
)
return self
@property
def push_enabled(self) -> bool:
return bool(self.vapid_public_key and self.vapid_private_key)
@property
def vapid_contact(self) -> str:
# Die Push-Dienste verlangen eine erreichbare Kontaktangabe.
# Ohne eigene Angabe die Absenderadresse verwenden.
return self.vapid_subject or f"mailto:{self.envelope_from}"
@property
def from_name(self) -> str:
return self.smtp_from_name or self.app_name
@property
def short_name(self) -> str:
return self.app_short_name or self.app_name
@property
def initial_admin_password(self) -> str:
"""Passwort aus Datei (Docker Secret) oder Umgebungsvariable."""
if self.admin_initial_password_file:
from pathlib import Path
path = Path(self.admin_initial_password_file)
if path.is_file():
return path.read_text(encoding="utf-8").strip()
raise ValueError(
f"ADMIN_INITIAL_PASSWORD_FILE zeigt auf {path}, "
"die Datei existiert nicht."
)
return self.admin_initial_password
@property
def envelope_from(self) -> str:
return parseaddr(self.smtp_envelope_from or self.smtp_from)[1]
@property
def mail_domain(self) -> str:
"""Domain fuer die Message-ID. Sollte zur DKIM-signierten Domain
passen, sonst werten manche Filter das ab."""
return self.envelope_from.rpartition("@")[2] or "localhost"
@property
def database_url(self) -> str:
from urllib.parse import quote_plus
return (
f"mysql+pymysql://{quote_plus(self.db_user)}:"
f"{quote_plus(self.db_password)}@{self.db_host}:{self.db_port}/"
f"{self.db_name}?charset=utf8mb4"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

27
backend/app/db.py Normal file
View File

@@ -0,0 +1,27 @@
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(
settings.database_url,
pool_pre_ping=True, # tote Verbindungen nach DB-Neustart erkennen
pool_recycle=1800, # unter MariaDBs wait_timeout bleiben
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Iterator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()

187
backend/app/deps.py Normal file
View File

@@ -0,0 +1,187 @@
from datetime import timedelta
from typing import Annotated
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.models import Setting, User, UserSession
from app.security import (
hash_token,
new_token,
tokens_equal,
utcnow,
)
SESSION_COOKIE = "ea_session"
CSRF_COOKIE = "ea_csrf"
CSRF_HEADER = "X-CSRF-Token"
DbSession = Annotated[Session, Depends(get_db)]
# --------------------------------------------------------------------------
# Laufzeit-Einstellungen
# --------------------------------------------------------------------------
def get_setting(db: Session, key: str, default: str = "") -> str:
row = db.get(Setting, key)
return row.value if row else default
def set_setting(db: Session, key: str, value: str) -> None:
row = db.get(Setting, key)
if row is None:
db.add(Setting(key=key, value=value))
else:
row.value = value
def registration_locked_by_env() -> bool:
"""True, wenn ALLOW_SELF_REGISTRATION hart auf true/false steht.
Dann darf die Admin-Oberflaeche den Wert nicht aendern."""
return settings.allow_self_registration in ("true", "false")
def self_registration_enabled(db: Session) -> bool:
mode = settings.allow_self_registration
if mode == "true":
return True
if mode == "false":
return False
# mode == "admin": die Laufzeiteinstellung entscheidet.
return get_setting(db, "allow_self_registration", "true") == "true"
# --------------------------------------------------------------------------
# Sessions
# --------------------------------------------------------------------------
def create_session(db: Session, user: User, response: Response) -> UserSession:
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),
)
db.add(sess)
db.flush()
max_age = settings.session_days * 24 * 3600
# HttpOnly: fuer JavaScript unsichtbar, damit ein XSS-Fund das Token
# nicht abgreifen kann.
response.set_cookie(
SESSION_COOKIE, raw, max_age=max_age, httponly=True,
secure=settings.cookie_secure, samesite="lax", path="/",
)
# Bewusst NICHT HttpOnly: der Client muss den Wert lesen und als
# Header zurueckschicken koennen (Double-Submit-Verfahren).
response.set_cookie(
CSRF_COOKIE, csrf, max_age=max_age, httponly=False,
secure=settings.cookie_secure, samesite="lax", path="/",
)
return sess
def destroy_session(db: Session, request: Request, response: Response) -> None:
raw = request.cookies.get(SESSION_COOKIE)
if raw:
sess = db.scalar(
select(UserSession).where(UserSession.token_hash == hash_token(raw))
)
if sess:
db.delete(sess)
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def _load_session(db: Session, request: Request) -> UserSession | None:
raw = request.cookies.get(SESSION_COOKIE)
if not raw:
return None
sess = db.scalar(
select(UserSession).where(UserSession.token_hash == hash_token(raw))
)
if sess is None:
return None
if sess.expires_at <= utcnow():
db.delete(sess)
db.commit()
return None
return sess
# --------------------------------------------------------------------------
# Abhaengigkeiten fuer Routen
# --------------------------------------------------------------------------
_UNSAFE = {"POST", "PUT", "PATCH", "DELETE"}
def current_user(request: Request, db: DbSession) -> User:
sess = _load_session(db, request)
if sess is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet")
if request.method in _UNSAFE:
supplied = request.headers.get(CSRF_HEADER, "")
if not supplied or not tokens_equal(supplied, sess.csrf_token):
raise HTTPException(status.HTTP_403_FORBIDDEN, "CSRF-Token fehlt oder ungültig")
user = db.get(User, sess.user_id)
if user is None or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Konto nicht aktiv")
# Nur einmal pro Stunde schreiben, sonst erzeugt jeder Request ein
# UPDATE. Der Zeitstempel am Konto ist die Grundlage der
# automatischen Deaktivierung - er muss auch dann mitlaufen, wenn
# sich jemand monatelang nicht neu anmeldet, weil die Sitzung hält.
if utcnow() - sess.last_seen_at > timedelta(hours=1):
sess.last_seen_at = utcnow()
user.last_seen_at = utcnow()
db.commit()
return user
CurrentUser = Annotated[User, Depends(current_user)]
def verified_user(user: CurrentUser) -> User:
if user.verified_at is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "E-Mail-Adresse noch nicht bestätigt"
)
# Alles ausser GET /api/auth/me und POST /api/auth/password/change
# haengt an dieser Abhaengigkeit - der Zwang wirkt also flaechendeckend,
# ohne dass jede Route ihn einzeln pruefen muesste.
if user.must_change_password:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Das Startpasswort muss zuerst geändert werden: "
"POST /api/auth/password/change",
)
return user
VerifiedUser = Annotated[User, Depends(verified_user)]
def admin_user(user: VerifiedUser) -> User:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Administratorrechte erforderlich")
return user
AdminUser = Annotated[User, Depends(admin_user)]
def client_ip(request: Request) -> str:
"""Fuer Rate Limiting. Hinter einem Reverse Proxy liefert
request.client.host die Proxy-IP - ab Phase 3 setzen wir dafuer
ProxyHeadersMiddleware mit einer Liste vertrauenswuerdiger Hosts."""
return request.client.host if request.client else "unknown"

156
backend/app/list_view.py Normal file
View File

@@ -0,0 +1,156 @@
"""Aufbereitung der gruppierten Listenansicht: Markt → Warengruppe → Artikel.
Liegt bewusst außerhalb der Router, weil drei Stellen dieselbe Gliederung
brauchen: die App, der öffentliche Link und der Ausdruck. Läge die Logik
im Router, würden die drei mit der Zeit auseinanderlaufen.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Category, ListItem, Market, ShoppingList
from app.schemas_shopping import ItemOut, ListView, ViewCategory, ViewMarket
NO_MARKET = "Ohne Markt"
NO_CATEGORY = "Ohne Warengruppe"
def creator_label(item: ListItem) -> str | None:
"""Anzeigename, sonst der lokale Teil der Adresse. Die vollständige
E-Mail-Adresse anderer Mitglieder wird nie ausgeliefert."""
if item.creator is None:
return None
return item.creator.display_name or item.creator.email.split("@")[0]
def item_out(item: ListItem, *, anonymous: bool = False) -> ItemOut:
"""`anonymous=True` für öffentliche Links: Dort ist der Empfängerkreis
unbestimmt, deshalb darf nicht erkennbar sein, wer was eingetragen hat."""
return ItemOut(
id=item.id,
article_id=item.article_id,
article_name=item.article.name,
created_by=None if anonymous else item.created_by,
created_by_name=None if anonymous else creator_label(item),
market_id=item.market_id,
category_id=item.category_id,
count=item.count,
pack_size=item.pack_size,
pack_unit=item.pack_unit,
variant=item.variant,
note=item.note,
status=item.status,
price_cents=item.price_cents,
total_cents=(item.price_cents * item.count) if item.price_cents else None,
row_rev=item.row_rev,
updated_at=item.updated_at,
)
def build_view(
db: Session,
lst: ShoppingList,
*,
include_bought: bool = True,
include_deferred: bool = True,
anonymous: bool = False,
) -> ListView:
"""Märkte und Warengruppen ordnen sich nach `sort_order`, dann nach
Namen - so lässt sich die Reihenfolge an den tatsächlichen Weg durch
den Laden anpassen. Artikel innerhalb einer Gruppe alphabetisch.
Einträge ohne Zuordnung landen in Sammelgruppen am Ende, damit nichts
unsichtbar wird.
"""
wanted = {"open"}
if include_bought:
wanted.add("bought")
if include_deferred:
wanted.add("deferred")
items = db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.deleted_at.is_(None),
ListItem.status.in_(wanted),
)
).all()
markets = {
m.id: m
for m in db.scalars(
select(Market).where(Market.list_id == lst.id, Market.deleted_at.is_(None))
).all()
}
categories = {
c.id: c
for c in db.scalars(
select(Category).where(
Category.list_id == lst.id, Category.deleted_at.is_(None)
)
).all()
}
buckets: dict[str | None, dict[str | None, list[ListItem]]] = {}
for item in items:
mid = item.market_id if item.market_id in markets else None
cid = item.category_id if item.category_id in categories else None
buckets.setdefault(mid, {}).setdefault(cid, []).append(item)
def market_key(mid: str | None) -> tuple[int, int, str]:
if mid is None:
return (1, 0, "") # "Ohne Markt" ganz nach hinten
m = markets[mid]
return (0, m.sort_order, m.name.casefold())
def category_key(cid: str | None) -> tuple[int, int, str]:
if cid is None:
return (1, 0, "")
c = categories[cid]
return (0, c.sort_order, c.name.casefold())
out_markets: list[ViewMarket] = []
grand_total = 0
for mid in sorted(buckets, key=market_key):
out_categories: list[ViewCategory] = []
market_total = 0
open_count = 0
for cid in sorted(buckets[mid], key=category_key):
group = sorted(buckets[mid][cid], key=lambda i: i.article.name.casefold())
for item in group:
if item.status == "open":
open_count += 1
if item.price_cents:
# Preis gilt je Gebinde, multipliziert wird mit der
# Stückzahl - nicht mit der Packungsgröße. Vorher
# ergaben 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
market_total += item.price_cents * item.count
out_categories.append(
ViewCategory(
category_id=cid,
category_name=categories[cid].name if cid else NO_CATEGORY,
items=[item_out(i, anonymous=anonymous) for i in group],
)
)
grand_total += market_total
out_markets.append(
ViewMarket(
market_id=mid,
market_name=markets[mid].name if mid else NO_MARKET,
categories=out_categories,
open_count=open_count,
total_cents=market_total,
)
)
return ListView(
list_id=lst.id,
list_name=lst.name,
rev=lst.rev,
markets=out_markets,
grand_total_cents=grand_total,
)

270
backend/app/mail.py Normal file
View File

@@ -0,0 +1,270 @@
import logging
import smtplib
import ssl
import time
from email.headerregistry import Address
from email.message import EmailMessage
from email.utils import format_datetime, make_msgid, parseaddr
from app.config import settings
from app.security import utcnow
log = logging.getLogger(__name__)
# Fehler, bei denen ein erneuter Versuch sinnlos ist: das Relay hat die
# Nachricht endgueltig abgelehnt (4xx waere temporaer, 5xx ist permanent).
_PERMANENT = (smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused,
smtplib.SMTPAuthenticationError)
def _connect() -> smtplib.SMTP:
"""Baut die Verbindung zum Relay auf. local_hostname landet im EHLO;
ohne diesen Wert schickt Python den Container-Hostnamen, was bei
strengen Filtern Punkte kostet."""
helo = settings.smtp_helo_hostname or None
context = ssl.create_default_context()
if settings.smtp_security == "ssl":
server = smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port,
local_hostname=helo, context=context, timeout=settings.smtp_timeout,
)
else:
server = smtplib.SMTP(
settings.smtp_host, settings.smtp_port,
local_hostname=helo, timeout=settings.smtp_timeout,
)
if settings.smtp_security == "starttls":
server.ehlo()
server.starttls(context=context)
server.ehlo()
if settings.smtp_user:
server.login(settings.smtp_user, settings.smtp_password)
return server
def build_message(to: str, subject: str, body: str) -> EmailMessage:
"""Setzt die Kopfzeilen, die Spamfilter erwarten. Fehlendes Date oder
eine Message-ID mit fremder Domain sind zwei der haeufigsten Gruende,
warum sonst harmlose Transaktionsmails im Spam landen."""
msg = EmailMessage()
local, _, domain = parseaddr(settings.smtp_from)[1].partition("@")
msg["From"] = Address(settings.from_name, local, domain)
msg["To"] = to
msg["Subject"] = subject
msg["Date"] = format_datetime(utcnow().astimezone())
# Domain der Message-ID sollte zur DKIM-Signatur passen.
msg["Message-ID"] = make_msgid(domain=settings.mail_domain)
if settings.smtp_reply_to:
msg["Reply-To"] = settings.smtp_reply_to
# RFC 3834: automatisch erzeugte Nachricht. Verhindert Abwesenheits-
# Autoresponder und Ping-Pong-Schleifen.
msg["Auto-Submitted"] = "auto-generated"
msg["X-Auto-Response-Suppress"] = "All"
msg.set_content(body, charset="utf-8")
return msg
def send_mail(to: str, subject: str, body: str) -> bool:
"""Blockierender Versand - immer ueber BackgroundTasks aufrufen,
nie direkt im Request-Pfad.
Fehler werden geloggt, aber nicht nach oben gereicht: aus einem 500er
koennte man sonst ableiten, ob ein Konto existiert.
"""
msg = build_message(to, subject, body)
envelope = settings.envelope_from
last: Exception | None = None
for attempt in range(1, settings.smtp_max_retries + 1):
try:
with _connect() as server:
# from_addr getrennt vom From-Header: hierueber laeuft die
# SPF-Pruefung und hierhin gehen Bounces (Return-Path).
server.send_message(msg, from_addr=envelope, to_addrs=[to])
log.info("Mail an %s versendet (Versuch %d).", to, attempt)
return True
except _PERMANENT as exc:
log.error("Relay hat die Mail an %s endgueltig abgelehnt: %s", to, exc)
return False
except Exception as exc:
last = exc
if attempt < settings.smtp_max_retries:
delay = 2 ** attempt
log.warning(
"Versand an %s fehlgeschlagen (Versuch %d/%d): %s - "
"neuer Versuch in %ds",
to, attempt, settings.smtp_max_retries, exc, delay,
)
time.sleep(delay)
log.error("Versand an %s endgueltig fehlgeschlagen: %s", to, last)
return False
def check_connection() -> tuple[bool, str]:
"""Verbindungstest ohne Versand - fuer /api/admin/mail/check."""
try:
with _connect() as server:
code, _ = server.noop()
return True, f"Verbindung zu {settings.smtp_host}:{settings.smtp_port} ok (NOOP {code})"
except Exception as exc:
return False, f"{type(exc).__name__}: {exc}"
def send_verification(to: str, token: str) -> None:
url = f"{settings.public_base_url.rstrip('/')}/api/auth/verify?token={token}"
send_mail(
to,
f"{settings.app_name}: E-Mail-Adresse bestätigen",
"Hallo,\n\n"
"bitte bestätige deine E-Mail-Adresse über den folgenden Link:\n\n"
f"{url}\n\n"
"Der Link ist 24 Stunden gültig.\n\n"
"Wenn du dich nicht registriert hast, ignoriere diese Nachricht.\n",
)
def send_password_reset(to: str, token: str) -> None:
url = f"{settings.public_base_url.rstrip('/')}/reset?token={token}"
send_mail(
to,
f"{settings.app_name}: Passwort zurücksetzen",
"Hallo,\n\n"
"über den folgenden Link kannst du ein neues Passwort setzen:\n\n"
f"{url}\n\n"
"Der Link ist 1 Stunde gültig.\n\n"
"Wenn du das nicht angefordert hast, ignoriere diese Nachricht -\n"
"dein Passwort bleibt dann unverändert.\n",
)
def send_invitation(
to: str, token: str, list_name: str, inviter: str, valid_days: int
) -> None:
url = f"{settings.public_base_url.rstrip('/')}/invite?token={token}"
send_mail(
to,
f"{settings.app_name}: {inviter} teilt die Liste „{list_name}“ mit dir",
"Hallo,\n\n"
f"{inviter} möchte die Einkaufsliste „{list_name}“ mit dir teilen.\n\n"
"Über den folgenden Link kannst du die Einladung annehmen:\n\n"
f"{url}\n\n"
f"Der Link ist {valid_days} Tage gültig und gilt nur für diese "
"E-Mail-Adresse.\n"
"Falls du noch kein Konto hast, kannst du dir beim Öffnen des Links "
"eines anlegen.\n\n"
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht -\n"
"ohne den Link passiert nichts.\n",
)
def send_welcome(to: str, token: str, inviter: str, valid_days: int) -> None:
"""Willkommensnachricht mit Link zum Setzen des Passworts.
Bewusst kein vom Administrator vergebenes Passwort: Das wäre ihm
bekannt und ginge zudem im Klartext per Mail.
"""
url = f"{settings.public_base_url.rstrip('/')}/willkommen?token={token}"
send_mail(
to,
f"{settings.app_name}: Dein Zugang steht bereit",
"Hallo,\n\n"
f"{inviter} hat für dich einen Zugang zu {settings.app_name} "
"eingerichtet.\n\n"
"Über den folgenden Link legst du dein Passwort fest und schaltest "
"den Zugang frei:\n\n"
f"{url}\n\n"
f"Der Link ist {valid_days} Tage gültig.\n\n"
"Wenn du damit nichts anfangen kannst, ignoriere diese Nachricht - "
"ohne den Link\npassiert nichts.\n",
)
def send_email_change_verify(to: str, token: str, old_email: str, hours: int) -> None:
"""An die NEUE Adresse: Bestätigung, dass sie erreichbar ist."""
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
send_mail(
to,
f"{settings.app_name}: Neue E-Mail-Adresse bestätigen",
"Hallo,\n\n"
f"für das Konto {old_email} soll künftig diese Adresse verwendet "
"werden.\n\n"
"Bitte bestätige das über den folgenden Link:\n\n"
f"{url}\n\n"
f"Der Link ist {hours} Stunden gültig.\n\n"
"Wenn du das nicht veranlasst hast, ignoriere diese Nachricht.\n",
)
def send_email_change_notice(to: str, new_email: str, hours: int) -> None:
"""An die ALTE Adresse: Hinweis, damit eine untergeschobene Änderung
auffällt."""
send_mail(
to,
f"{settings.app_name}: Änderung deiner E-Mail-Adresse angefordert",
"Hallo,\n\n"
"für dein Konto wurde eine neue E-Mail-Adresse angefordert:\n\n"
f" {new_email}\n\n"
f"Sobald die neue Adresse bestätigt ist (innerhalb von {hours} "
"Stunden), gilt sie\nfür die Anmeldung. Diese Adresse hier "
"funktioniert dann nicht mehr.\n\n"
"Wenn du das nicht veranlasst hast, wende dich bitte umgehend an "
"die Administration -\ndann versucht jemand, dir den Zugang zu "
"entziehen.\n",
)
def send_email_change_verify_old(
to: str, token: str, new_email: str, hours: int
) -> None:
"""An die ALTE Adresse eines Administratorkontos: hier reicht der
Hinweis nicht, es wird eine ausdrückliche Zustimmung verlangt."""
url = f"{settings.public_base_url.rstrip('/')}/adresswechsel?token={token}"
send_mail(
to,
f"{settings.app_name}: Adressänderung deines Administratorkontos bestätigen",
"Hallo,\n\n"
"für dein Administratorkonto wurde eine neue E-Mail-Adresse "
"angefordert:\n\n"
f" {new_email}\n\n"
"Bei Administratorkonten muss die Änderung von BEIDEN Adressen "
"bestätigt werden.\n"
"Bitte bestätige hier von deiner bisherigen Adresse aus:\n\n"
f"{url}\n\n"
f"Der Link ist {hours} Stunden gültig.\n\n"
"Wenn du das nicht veranlasst hast, klicke NICHT auf den Link. Ohne "
"deine\nBestätigung bleibt die Adresse unverändert - und du "
"solltest dein Passwort ändern.\n",
)
def send_deactivation_notice(to: str, months: int) -> None:
send_mail(
to,
f"{settings.app_name}: Dein Zugang wurde deaktiviert",
"Hallo,\n\n"
f"dein Zugang zu {settings.app_name} wurde deaktiviert, weil er "
f"länger als {months} Monate\nnicht genutzt wurde.\n\n"
"Deine Listen bleiben erhalten. Wende dich an die Administration, "
"wenn du den\nZugang wieder brauchst.\n",
)
def send_test_mail(to: str) -> None:
send_mail(
to,
f"{settings.app_name}: Testnachricht",
"Diese Nachricht bestätigt, dass der SMTP-Versand funktioniert.\n\n"
f"Relay: {settings.smtp_host}:{settings.smtp_port}\n"
f"Verschlüsselung: {settings.smtp_security}\n"
f"From-Header: {settings.smtp_from}\n"
f"Envelope-From: {settings.envelope_from}\n"
f"EHLO-Hostname: {settings.smtp_helo_hostname or '(automatisch)'}\n",
)

184
backend/app/main.py Normal file
View File

@@ -0,0 +1,184 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from sqlalchemy import select, text
from app.bootstrap_admin import ensure_initial_admin
from app.config import settings
from app.db import SessionLocal, engine
from app.deps import get_setting, set_setting
from app.maintenance import describe, run_cleanup
from app.models import User
from app.routers import (
admin, appinfo, auth, catalog, items, lists, prices, public, push,
sharing, sync,
)
from app.security import normalize_email, purge_rate_limits, utcnow
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("einkaufsapp")
def bootstrap() -> None:
"""Beim Start: Startwerte setzen, abgelaufene Sessions und
Rate-Limit-Zaehler aufraeumen."""
with SessionLocal() as db:
ensure_initial_admin(db)
if get_setting(db, "allow_self_registration") == "":
set_setting(
db,
"allow_self_registration",
"true" if settings.self_registration_default else "false",
)
if settings.allow_self_registration == "admin":
log.info(
"Selbstregistrierung: über die Admin-API steuerbar (aktuell %s).",
"an" if get_setting(db, "allow_self_registration") == "true" else "aus",
)
else:
log.info(
"Selbstregistrierung: per ALLOW_SELF_REGISTRATION fest auf '%s' "
"gesetzt, über die Admin-API nicht änderbar.",
settings.allow_self_registration,
)
# Nur ein gesetzter Schlüssel ist immer ein Fehler - dann bleibt
# Push still abgeschaltet, und niemand weiß warum.
has_public = bool(settings.vapid_public_key)
has_private = bool(settings.vapid_private_key)
if has_public != has_private:
fehlend = "VAPID_PRIVATE_KEY" if has_public else "VAPID_PUBLIC_KEY"
log.error(
"Push-Benachrichtigungen sind ABGESCHALTET: %s fehlt oder ist "
"leer, während der andere Schlüssel gesetzt ist. Häufigste "
"Ursache: die Variable steht mehrfach in der .env - Docker "
"Compose nimmt die letzte Definition, und das ist oft die "
"leere Vorlagenzeile. Prüfen mit: grep -n VAPID .env",
fehlend,
)
elif has_public:
log.info(
"Push-Benachrichtigungen: aktiv, Drosselung %d Stunde(n), "
"Kontakt %s",
settings.push_throttle_hours, settings.vapid_contact,
)
else:
log.info(
"Push-Benachrichtigungen: abgeschaltet (kein VAPID-Schlüsselpaar). "
"Erzeugen mit: python3 tools/vapid-keys.py"
)
log.info(
"SMTP-Relay: %s:%s, Verschlüsselung=%s, Auth=%s, Envelope-From=%s",
settings.smtp_host, settings.smtp_port, settings.smtp_security,
"ja" if settings.smtp_user else "nein", settings.envelope_from,
)
if settings.smtp_security == "none" and settings.smtp_host not in ("mailpit", "localhost"):
log.warning(
"SMTP_SECURITY=none bei externem Relay %s - Mails gehen "
"unverschlüsselt über das Netz.", settings.smtp_host,
)
# Konfiguriertes Admin-Konto markieren, falls es schon existiert.
admin_mail = normalize_email(settings.admin_email)
user = db.scalar(select(User).where(User.email == admin_mail))
if user is not None and not user.is_admin:
user.is_admin = True
log.info("Konto %s als Administrator markiert.", admin_mail)
db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"),
{"now": utcnow()})
purge_rate_limits(db)
db.commit()
def cleanup_once() -> None:
"""Laeuft im Threadpool - SQLAlchemy ist hier synchron konfiguriert,
und ein blockierender Aufruf in der Ereignisschleife wuerde den
gesamten Server anhalten."""
try:
with SessionLocal() as db:
counts = run_cleanup(db)
log.info("Aufräumen: %s", describe(counts))
except Exception:
log.exception("Aufräumen fehlgeschlagen")
async def cleanup_loop() -> None:
"""Einmal kurz nach dem Start, danach im eingestellten Abstand.
Bewusst kein zusaetzlicher Cron-Container: Das waere eine weitere
Stelle, an der etwas kaputtgehen kann, fuer eine Aufgabe, die
einmal am Tag ein paar Zeilen loescht.
"""
import anyio
# Kurz warten, damit der Start nicht durch Aufräumarbeiten
# verzögert wird.
await asyncio.sleep(30)
while True:
await anyio.to_thread.run_sync(cleanup_once)
await asyncio.sleep(settings.cleanup_interval_hours * 3600)
@asynccontextmanager
async def lifespan(app: FastAPI):
bootstrap()
task = asyncio.create_task(cleanup_loop())
try:
yield
finally:
task.cancel()
engine.dispose()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
lifespan=lifespan,
# Kein CORS-Middleware: PWA und API laufen ab Phase 3 unter derselben
# Origin hinter nginx. Damit entfaellt eine ganze Fehlerklasse.
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Permissions-Policy"] = "camera=(self), geolocation=(), microphone=()"
if settings.cookie_secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@app.get("/healthz", tags=["system"])
def healthz():
"""Liveness: sagt nur, dass der Prozess antwortet."""
return {"status": "ok"}
@app.get("/readyz", tags=["system"])
def readyz():
"""Readiness: prueft zusaetzlich die Datenbankverbindung."""
with SessionLocal() as db:
db.execute(text("SELECT 1"))
return {"status": "ok", "database": "ok"}
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(lists.router)
app.include_router(catalog.router)
app.include_router(items.router)
app.include_router(sharing.router)
app.include_router(public.router)
app.include_router(sync.router)
app.include_router(prices.router)
app.include_router(push.router)
app.include_router(appinfo.router)

212
backend/app/maintenance.py Normal file
View File

@@ -0,0 +1,212 @@
"""Regelmäßiges Aufräumen.
Die Anwendung sammelt an mehreren Stellen bewusst mehr Daten, als für den
Augenblick nötig sind - Soft Delete, damit offline gebliebene Geräte vom
Löschen erfahren; Quittungen für die Outbox, damit Wiederholungen erkannt
werden; Zwischenspeicher für Produktabfragen. Ohne Aufräumen wächst das
unbegrenzt.
Läuft ohne zusätzlichen Dienst: eine Hintergrundaufgabe im api-Container,
einmal beim Start und danach täglich. Kein Cron-Container, kein Redis, und
damit auch keine weitere Stelle, an der etwas kaputtgehen kann.
"""
import logging
from datetime import timedelta
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.config import settings
from app.mail import send_deactivation_notice
from app.models import (
AppliedOp,
Article,
ArticleAttribute,
ArticleMarket,
Category,
EmailChange,
EmailToken,
ListInvite,
ListItem,
Market,
ProductCache,
PublicShare,
RateLimit,
ShoppingList,
User,
UserSession,
)
from app.security import utcnow
from app.users import deactivate, delete_user, months_setting
log = logging.getLogger(__name__)
def run_cleanup(db: Session) -> dict[str, int]:
"""Räumt auf und gibt zurück, was entfernt wurde."""
now = utcnow()
counts: dict[str, int] = {}
def purge(model, condition, label: str) -> None:
result = db.execute(delete(model).where(condition))
if result.rowcount:
counts[label] = result.rowcount
# --- Sitzungen und Token ---
purge(UserSession, UserSession.expires_at <= now, "abgelaufene Sitzungen")
# Verbrauchte oder abgelaufene Mail-Token: nach sieben Tagen weg. Der
# Puffer erlaubt es, im Zweifelsfall nachzusehen, warum ein Link nicht
# mehr ging.
purge(
EmailToken,
EmailToken.expires_at <= now - timedelta(days=7),
"abgelaufene Mail-Token",
)
# --- Rate-Limit-Zähler ---
purge(
RateLimit,
RateLimit.window_start < now - timedelta(days=1),
"Rate-Limit-Zähler",
)
# --- Quittungen der Outbox ---
# Sie verhindern doppeltes Ausführen. Ein Gerät, das länger als die
# Aufbewahrungsfrist offline war, hätte seine Operationen ohnehin
# längst über die Wiederholungsgrenze hinaus versucht.
purge(
AppliedOp,
AppliedOp.created_at < now - timedelta(days=settings.cleanup_ops_days),
"Outbox-Quittungen",
)
# --- Abgelaufene Einladungen und öffentliche Links ---
purge(
ListInvite,
ListInvite.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
"alte Einladungen",
)
purge(
PublicShare,
PublicShare.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
"abgelaufene öffentliche Links",
)
# --- Produktzwischenspeicher ---
purge(
ProductCache,
ProductCache.fetched_at
< now - timedelta(days=max(settings.product_cache_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)
purge(ListItem, ListItem.deleted_at < cutoff, "gelöschte Einträge")
# Artikel erst nach ihren Einträgen: Die Fremdschlüssel räumen zwar
# per CASCADE mit auf, aber die Reihenfolge macht die Zählung ehrlich.
stale_articles = db.scalars(
select(Article.id).where(Article.deleted_at < cutoff)
).all()
if stale_articles:
db.execute(
delete(ArticleAttribute).where(
ArticleAttribute.article_id.in_(stale_articles))
)
db.execute(
delete(ArticleMarket).where(ArticleMarket.article_id.in_(stale_articles))
)
db.execute(delete(Article).where(Article.id.in_(stale_articles)))
counts["gelöschte Artikel"] = len(stale_articles)
purge(Market, Market.deleted_at < cutoff, "gelöschte Märkte")
purge(Category, Category.deleted_at < cutoff, "gelöschte Warengruppen")
# Gelöschte Listen zuletzt - daran hängt per CASCADE alles Übrige.
purge(ShoppingList, ShoppingList.deleted_at < cutoff, "gelöschte Listen")
# --- Abgeschlossene Adressänderungen ---
purge(
EmailChange,
EmailChange.expires_at < now - timedelta(days=settings.cleanup_deleted_days),
"alte Adressänderungen",
)
db.commit()
counts.update(_retire_users(db))
db.commit()
return counts
def _retire_users(db: Session) -> dict[str, int]:
"""Deaktiviert lange untätige Konten und löscht lange deaktivierte.
Administratorkonten bleiben ausgenommen - sonst könnte sich die
Verwaltung selbst aussperren, und zwar unbemerkt, weil niemand
hinsieht, solange alles läuft.
Ein Wert von 0 bedeutet "abgeschaltet", nicht "sofort".
"""
now = utcnow()
counts: dict[str, int] = {}
deactivate_months = months_setting(db, "auto_deactivate_months", 12)
delete_months = months_setting(db, "auto_delete_months", 12)
if deactivate_months:
cutoff = now - timedelta(days=deactivate_months * 30)
candidates = db.scalars(
select(User).where(
User.is_active.is_(True),
User.is_admin.is_(False),
User.last_seen_at.is_not(None),
User.last_seen_at < cutoff,
)
).all()
for user in candidates:
log.info(
"Deaktiviere %s - seit %s nicht mehr gesehen",
user.email, user.last_seen_at.date(),
)
address = user.email
deactivate(db, user)
# Der Versand blockiert; hier ist das vertretbar, weil der
# Aufräumlauf ohnehin im Hintergrund läuft und selten
# mehr als eine Handvoll Konten betrifft.
send_deactivation_notice(address, deactivate_months)
if candidates:
counts["deaktivierte Konten"] = len(candidates)
if delete_months:
cutoff = now - timedelta(days=delete_months * 30)
candidates = db.scalars(
select(User).where(
User.is_active.is_(False),
User.is_admin.is_(False),
User.deactivated_at.is_not(None),
User.deactivated_at < cutoff,
)
).all()
for user in candidates:
log.info(
"Lösche %s - seit %s deaktiviert",
user.email, user.deactivated_at.date(),
)
delete_user(db, user)
if candidates:
counts["gelöschte Konten"] = len(candidates)
return counts
def describe(counts: dict[str, int]) -> str:
if not counts:
return "nichts zu tun"
return ", ".join(f"{value} {label}" for label, value in sorted(counts.items()))

632
backend/app/models.py Normal file
View File

@@ -0,0 +1,632 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
BigInteger,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def new_uuid() -> str:
return str(uuid.uuid4())
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
# Normalisiert (lowercase, getrimmt) - eindeutig ueber diesen Wert.
email: Mapped[str] = mapped_column(String(255), unique=True)
display_name: Mapped[str | None] = mapped_column(String(80), default=None)
password_hash: Mapped[str] = mapped_column(String(255))
verified_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Sperrt alles ausser "Profil lesen" und "Passwort aendern".
# Wird beim initial angelegten Admin gesetzt.
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
# Grundlage der automatischen Deaktivierung. Wird bei der Anmeldung
# gesetzt und danach hoechstens stuendlich nachgefuehrt - jeder
# Request zu schreiben waere unnoetige Last.
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
# Seit wann ist das Konto deaktiviert. Grundlage der automatischen
# Loeschung; is_active allein sagt nichts ueber die Dauer.
deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
sessions: Mapped[list["UserSession"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base):
"""Serverseitige Session. Der Client haelt nur das Klartext-Token im
HttpOnly-Cookie; hier liegt ausschliesslich dessen SHA-256-Hash."""
__tablename__ = "user_session"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE")
)
# Double-Submit-CSRF: dieser Wert steht zusaetzlich in einem lesbaren
# Cookie und muss bei schreibenden Requests im Header wiederkommen.
csrf_token: Mapped[str] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_seen_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime)
user: Mapped[User] = relationship(back_populates="sessions")
__table_args__ = (Index("ix_user_session_expires", "expires_at"),)
class EmailToken(Base):
"""Einmal-Token fuer Mailverifikation und Passwort-Reset.
Auch hier liegt nur der Hash in der Datenbank."""
__tablename__ = "email_token"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE")
)
purpose: Mapped[str] = mapped_column(String(32)) # "verify" | "reset"
expires_at: Mapped[datetime] = mapped_column(DateTime)
used_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (Index("ix_email_token_user_purpose", "user_id", "purpose"),)
class Setting(Base):
"""Zur Laufzeit aenderbare Konfiguration (Admin-Oberflaeche)."""
__tablename__ = "setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255))
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
class RateLimit(Base):
"""Zaehlerbasiertes Rate Limiting ohne Redis.
Ein Datensatz pro (Schluessel, Zeitfenster)."""
__tablename__ = "rate_limit"
bucket: Mapped[str] = mapped_column(String(160), primary_key=True)
window_start: Mapped[datetime] = mapped_column(DateTime, primary_key=True)
count: Mapped[int] = mapped_column(Integer, default=0)
# ==========================================================================
# Phase 2: Listen, Katalog, Eintraege
# ==========================================================================
#
# Zwei Konventionen ziehen sich durch alle folgenden Tabellen:
#
# rev / row_rev Pro Liste laeuft ein monoton steigender Zaehler
# (shopping_list.rev). Jede Aenderung erhoeht ihn und
# schreibt den neuen Wert in row_rev der geaenderten
# Zeile. Ein Client fragt "gib mir alles mit
# row_rev > N" und bekommt genau das Delta.
#
# deleted_at Soft Delete. Geloeschte Zeilen bleiben stehen, damit
# auch ein Client, der tagelang offline war, vom
# Loeschen erfaehrt. Aufraeumen per Cron nach 30 Tagen.
class ShoppingList(Base):
__tablename__ = "shopping_list"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
name: Mapped[str] = mapped_column(String(120))
owner_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="RESTRICT")
)
rev: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
members: Mapped[list["ListMember"]] = relationship(
back_populates="shopping_list", cascade="all, delete-orphan"
)
class ListMember(Base):
__tablename__ = "list_member"
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
)
# "owner" | "editor" | "viewer"
role: Mapped[str] = mapped_column(String(16), default="editor")
# Zusatzrecht, unabhaengig von der Rolle: oeffentliche Links erzeugen
# und widerrufen. Der Eigentuemer hat es immer, alle anderen nur,
# wenn er es ausdruecklich erteilt.
may_share_public: Mapped[bool] = mapped_column(Boolean, default=False)
joined_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
shopping_list: Mapped[ShoppingList] = relationship(back_populates="members")
user: Mapped[User] = relationship()
class Market(Base):
"""Ein Markt, z.B. 'Edeka Neustadt'. Gehoert zu genau einer Liste -
so entsteht kein listenuebergreifender Datenbestand ueber Nutzer."""
__tablename__ = "market"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
__table_args__ = (
Index("ix_market_list_rev", "list_id", "row_rev"),
UniqueConstraint("list_id", "name", name="uq_market_list_name"),
)
class Category(Base):
"""Warengruppe, z.B. 'Molkerei'. Bestimmt die Gliederung innerhalb
eines Marktes - in der App wie im Ausdruck."""
__tablename__ = "category"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(120))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
__table_args__ = (
Index("ix_category_list_rev", "list_id", "row_rev"),
UniqueConstraint("list_id", "name", name="uq_category_list_name"),
)
class Article(Base):
"""Stammdaten eines Artikels. Der Listeneintrag (list_item) verweist
darauf; derselbe Artikel kann also mehrfach auf der Liste landen,
ohne dass Name und Attribute doppelt gepflegt werden."""
__tablename__ = "article"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(200))
barcode: Mapped[str | None] = mapped_column(String(64), default=None)
note: Mapped[str | None] = mapped_column(String(500), default=None)
default_market_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None
)
default_category_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None
)
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
attributes: Mapped[list["ArticleAttribute"]] = relationship(
back_populates="article", cascade="all, delete-orphan", lazy="selectin"
)
availability: Mapped[list["ArticleMarket"]] = relationship(
cascade="all, delete-orphan", lazy="selectin"
)
__table_args__ = (
Index("ix_article_list_rev", "list_id", "row_rev"),
Index("ix_article_barcode", "list_id", "barcode"),
UniqueConstraint("list_id", "name", name="uq_article_list_name"),
)
class ArticleAttribute(Base):
"""Frei definierbare Eigenschaft: Verpackungseinheit, Farbe, Groesse.
Spalte heisst attr_name, weil "key" in MariaDB reserviert ist."""
__tablename__ = "article_attribute"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
article_id: Mapped[str] = mapped_column(
String(36), ForeignKey("article.id", ondelete="CASCADE")
)
attr_name: Mapped[str] = mapped_column(String(80))
attr_value: Mapped[str] = mapped_column(String(300))
article: Mapped[Article] = relationship(back_populates="attributes")
__table_args__ = (
UniqueConstraint("article_id", "attr_name", name="uq_article_attr"),
)
class ArticleMarket(Base):
"""In welchen Maerkten ist der Artikel erhaeltlich."""
__tablename__ = "article_market"
article_id: Mapped[str] = mapped_column(
String(36), ForeignKey("article.id", ondelete="CASCADE"), primary_key=True
)
market_id: Mapped[str] = mapped_column(
String(36), ForeignKey("market.id", ondelete="CASCADE"), primary_key=True
)
class ListItem(Base):
"""Ein konkreter Eintrag auf der Einkaufsliste."""
__tablename__ = "list_item"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
article_id: Mapped[str] = mapped_column(
String(36), ForeignKey("article.id", ondelete="CASCADE")
)
# Ueberschreibt die Vorgabe aus dem Artikel, falls gesetzt.
market_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("market.id", ondelete="SET NULL"), default=None
)
category_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("category.id", ondelete="SET NULL"), default=None
)
# Wer den Eintrag angelegt hat. SET NULL beim Löschen des Kontos:
# der Eintrag bleibt bestehen, der Personenbezug verschwindet.
created_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
# Stueckzahl: wie viele Packungen sollen es sein. Der Preis am
# Eintrag gilt fuer EINE davon - die Summe ist Preis mal Anzahl.
count: Mapped[int] = mapped_column(Integer, default=1)
# Gebinde: wie gross ist eine Packung. Getrennt von der Stueckzahl,
# weil beides sonst verwechselt wird: Bei "500 ml" zu 2,99 EUR
# ergaebe eine gemeinsame Zahl eine Summe von 1495 EUR statt 2,99.
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
# Naehere Bestimmung des gewuenschten Artikels, z.B. bei Pfeffer
# "bunt, ganz" oder "schwarz, gemahlen". Bewusst getrennt von `note`:
# die Eigenschaft gehoert zum Produkt und steht deshalb neben dem
# Namen, waehrend die Notiz eine Bemerkung fuer den Einkaufenden ist
# ("beim Metzger fragen").
variant: Mapped[str | None] = mapped_column(String(200), default=None)
note: Mapped[str | None] = mapped_column(String(500), default=None)
# "open" | "bought" | "deferred"
status: Mapped[str] = mapped_column(String(16), default="open")
# Preis in Cent - niemals als Float rechnen.
price_cents: Mapped[int | None] = mapped_column(Integer, default=None)
row_rev: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
article: Mapped[Article] = relationship(lazy="joined")
creator: Mapped["User | None"] = relationship(
foreign_keys=[created_by], lazy="joined"
)
__table_args__ = (
Index("ix_list_item_list_rev", "list_id", "row_rev"),
Index("ix_list_item_list_status", "list_id", "status"),
)
class ListInvite(Base):
"""Einladung zu einer Liste, adressiert an eine E-Mail-Adresse.
Bewusst unabhaengig davon, ob unter der Adresse schon ein Konto
existiert: So verraet das Anlegen einer Einladung nicht, wer bei uns
registriert ist, und derselbe Ablauf gilt fuer neue wie fuer
bestehende Nutzer.
In der Datenbank steht nur der Hash des Tokens. Ein erneuter Versand
erzeugt deshalb ein neues Token und entwertet das alte - der
Klartext laesst sich nicht rekonstruieren.
"""
__tablename__ = "list_invite"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
email: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(16), default="editor")
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
invited_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_sent_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
send_count: Mapped[int] = mapped_column(Integer, default=1)
expires_at: Mapped[datetime] = mapped_column(DateTime)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
accepted_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
inviter: Mapped["User | None"] = relationship(
foreign_keys=[invited_by], lazy="joined"
)
__table_args__ = (
Index("ix_list_invite_list", "list_id"),
Index("ix_list_invite_email", "email"),
)
class PublicShare(Base):
"""Oeffentlicher Ansichtslink auf eine Liste.
Wer den Link hat, darf sehen und abhaken - mehr nicht. Kein Konto
noetig. Ablaufdatum ist Pflicht: ein unbefristeter Link waere ein
dauerhaft offenes Fenster in fremde Daten.
In der Datenbank steht nur der Hash des Tokens. Der Klartext wird
einmal bei der Erzeugung ausgeliefert und ist danach nicht mehr
rekonstruierbar - ein Datenbankleck gibt also keinen Zugriff.
"""
__tablename__ = "public_share"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
# Freie Bezeichnung, damit mehrere Links unterscheidbar bleiben
# ("fuer Oma", "Vereinsfest").
label: Mapped[str | None] = mapped_column(String(120), default=None)
# Darf ueber diesen Link abgehakt werden?
allow_check: Mapped[bool] = mapped_column(Boolean, default=True)
created_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
# Grobe Nutzungsanzeige fuer den Eigentuemer. Bewusst ohne IP-Adressen
# und ohne Zeitreihe - es soll erkennbar sein, DASS ein Link benutzt
# wird, nicht von wem.
last_access_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
access_count: Mapped[int] = mapped_column(Integer, default=0)
creator: Mapped["User | None"] = relationship(
foreign_keys=[created_by], lazy="joined"
)
__table_args__ = (Index("ix_public_share_list", "list_id"),)
class AppliedOp(Base):
"""Quittung fuer eine bereits verarbeitete Operation aus einer
Outbox-Warteschlange.
Der Client vergibt die `op_id` selbst, bevor er sendet. Kommt dieselbe
Operation ein zweites Mal an - weil die Antwort unterwegs verloren
ging, das Telefon neu gestartet wurde oder der Hintergrundversand sie
erneut zugestellt hat -, wird sie hier erkannt und nicht noch einmal
ausgefuehrt.
Ohne diese Tabelle wuerde aus einem verlorenen "Butter hinzufuegen"
beim naechsten Versuch zweimal Butter.
"""
__tablename__ = "applied_op"
op_id: Mapped[str] = mapped_column(String(64), primary_key=True)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
kind: Mapped[str] = mapped_column(String(32))
# Bei item.create: die vergebene ID, damit der Client seinen
# vorlaeufigen Eintrag zuordnen kann.
result_id: Mapped[str | None] = mapped_column(String(36), default=None)
rev: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (Index("ix_applied_op_list", "list_id", "created_at"),)
class ProductCache(Base):
"""Zwischenspeicher fuer Abfragen bei Open Food Facts.
Zweck ist nicht nur Geschwindigkeit: Ohne Zwischenspeicher ginge bei
jedem Scan eine Anfrage nach draussen. So wird jeder Code hoechstens
einmal je Gueltigkeitszeitraum abgefragt - und zwar von diesem
Server, nicht vom Geraet des Nutzers. Dessen IP-Adresse erfaehrt der
Dritte damit gar nicht erst.
Fehlschlaege werden ebenfalls vermerkt, sonst laeuft jeder Scan eines
unbekannten Codes erneut ins Leere.
"""
__tablename__ = "product_cache"
barcode: Mapped[str] = mapped_column(String(64), primary_key=True)
found: Mapped[bool] = mapped_column(Boolean, default=False)
name: Mapped[str | None] = mapped_column(String(300), default=None)
brand: Mapped[str | None] = mapped_column(String(200), default=None)
# Rohtext wie "500 g", "1,5 l" oder "6 x 33 cl"
package: Mapped[str | None] = mapped_column(String(120), default=None)
# Aus dem Rohtext zerlegt: "6 x 33 cl" -> count 6, Gebinde 33 cl
count: Mapped[int | None] = mapped_column(Integer, default=None)
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
source: Mapped[str] = mapped_column(String(32), default="openfoodfacts")
fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class PricePoint(Base):
"""Ein beobachteter Preis: Artikel, Markt, Betrag, Zeitpunkt.
Bewusst OHNE Nutzerbezug. Aus "wer hat wann wo was zu welchem Preis
gekauft" liesse sich ein Bewegungs- und Konsumprofil bilden - genau
das soll die Preisdatenbank nicht ermoeglichen. Fuer den Zweck
(Preise vergleichen) genuegt Markt, Artikel und Zeitpunkt.
Menge und Einheit werden mitgefuehrt, weil ein Preis ohne
Bezugsgroesse nicht vergleichbar ist: 1,29 EUR fuer einen Liter ist
etwas anderes als 1,29 EUR fuer 200 Milliliter.
"""
__tablename__ = "price_point"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE")
)
article_id: Mapped[str] = mapped_column(
String(36), ForeignKey("article.id", ondelete="CASCADE")
)
market_id: Mapped[str] = mapped_column(
String(36), ForeignKey("market.id", ondelete="CASCADE")
)
# Preis fuer EIN Gebinde.
price_cents: Mapped[int] = mapped_column(Integer)
pack_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), default=None)
pack_unit: Mapped[str | None] = mapped_column(String(32), default=None)
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
__table_args__ = (
Index("ix_price_article_market", "article_id", "market_id", "recorded_at"),
Index("ix_price_list", "list_id", "recorded_at"),
)
class PushSubscription(Base):
"""Anmeldung eines Geraets fuer Push-Benachrichtigungen.
Der Endpunkt ist eine vom Browserhersteller vergebene URL. Sie ist
geraetebezogen, aber nicht personenbezogen im engeren Sinn - wir
speichern sie, weil ohne sie keine Zustellung moeglich ist, und
loeschen sie, sobald der Push-Dienst sie als ungueltig meldet.
"""
__tablename__ = "push_subscription"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE")
)
endpoint: Mapped[str] = mapped_column(String(500), unique=True)
# Schluesselmaterial fuer die Ende-zu-Ende-Verschluesselung der
# Nachricht. Auch der Push-Dienst kann den Inhalt nicht lesen.
p256dh: Mapped[str] = mapped_column(String(200))
auth: Mapped[str] = mapped_column(String(100))
# Grobe Geraetekennung, damit der Nutzer seine Anmeldungen
# auseinanderhalten kann. Bewusst gekuerzt.
label: Mapped[str | None] = mapped_column(String(80), default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
failure_count: Mapped[int] = mapped_column(Integer, default=0)
__table_args__ = (Index("ix_push_subscription_user", "user_id"),)
class NotifyState(Base):
"""Wann wurde diese Person zuletzt ueber diese Liste benachrichtigt.
Grundlage der Drosselung: Wer gerade eine Liste abarbeitet, erzeugt
Dutzende Aenderungen. Ohne diesen Vermerk bekaemen die anderen
Mitglieder ebenso viele Benachrichtigungen.
"""
__tablename__ = "notify_state"
list_id: Mapped[str] = mapped_column(
String(36), ForeignKey("shopping_list.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
)
last_notified_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class EmailChange(Base):
"""Laufende Aenderung einer E-Mail-Adresse.
Bei gewoehnlichen Konten muss nur die NEUE Adresse bestaetigen; die
alte bekommt eine Benachrichtigung, damit eine untergeschobene
Aenderung auffaellt.
Bei Administratorkonten muessen BEIDE bestaetigen. Der Grund: Wer
Zugriff auf ein Administratorkonto erlangt, koennte sonst die
Adresse auf eine eigene umstellen und sich damit dauerhaft
einnisten - der rechtmaessige Inhaber verloere den Weg zurueck ueber
"Passwort vergessen". Die Bestaetigung von der alten Adresse macht
das unmoeglich, solange der Angreifer nicht auch das Postfach hat.
"""
__tablename__ = "email_change"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("user.id", ondelete="CASCADE")
)
old_email: Mapped[str] = mapped_column(String(255))
new_email: Mapped[str] = mapped_column(String(255))
# Nur Hashes, wie bei allen Token in dieser Anwendung
token_new_hash: Mapped[str] = mapped_column(String(64), unique=True)
token_old_hash: Mapped[str | None] = mapped_column(
String(64), unique=True, default=None
)
# True bei Administratorkonten
requires_old: Mapped[bool] = mapped_column(Boolean, default=False)
confirmed_new_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
confirmed_old_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
applied_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
requested_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("user.id", ondelete="SET NULL"), default=None
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime)
__table_args__ = (Index("ix_email_change_user", "user_id"),)

View File

@@ -0,0 +1,80 @@
"""Zugriffsrechte auf Listen und Fortschreibung des Revisionszählers.
Rollen, aufsteigend:
viewer darf lesen
editor darf Einträge, Artikel, Märkte und Warengruppen ändern
owner darf zusätzlich die Liste umbenennen, löschen und
Mitgliedschaften verwalten
Der Eigentümer steht doppelt fest: in `shopping_list.owner_id` und als
Mitglied mit der Rolle "owner". Das ist bewusst redundant - die Spalte
verhindert, dass eine Liste durch Löschen des letzten Mitglieds
verwaist, die Mitgliedschaft macht Abfragen einheitlich.
"""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.deps import DbSession, VerifiedUser
from app.models import ListMember, ShoppingList
ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3}
def bump_rev(db: Session, list_id: str) -> int:
"""Erhöht den Revisionszähler der Liste und gibt den neuen Wert zurück.
SELECT ... FOR UPDATE sperrt die Zeile bis zum Commit. Ohne die Sperre
könnten zwei gleichzeitige Änderungen denselben Wert vergeben - dann
verpasst ein synchronisierender Client eine davon.
"""
lst = db.scalar(
select(ShoppingList).where(ShoppingList.id == list_id).with_for_update()
)
if lst is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
lst.rev += 1
db.flush()
return lst.rev
def _access(db: Session, list_id: str, user_id: str, minimum: str) -> ShoppingList:
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
membership = db.get(ListMember, (list_id, user_id))
# Gleiche Antwort für "gibt es nicht" und "darfst du nicht sehen".
# Sonst ließe sich über die Statuscodes herausfinden, welche
# Listen-IDs existieren.
if lst is None or membership is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
if ROLE_RANK.get(membership.role, 0) < ROLE_RANK[minimum]:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Für diese Aktion ist mindestens die Rolle '{minimum}' erforderlich.",
)
return lst
def list_reader(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
return _access(db, list_id, user.id, "viewer")
def list_editor(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
return _access(db, list_id, user.id, "editor")
def list_owner(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
return _access(db, list_id, user.id, "owner")
ReadableList = Annotated[ShoppingList, Depends(list_reader)]
EditableList = Annotated[ShoppingList, Depends(list_editor)]
OwnedList = Annotated[ShoppingList, Depends(list_owner)]

232
backend/app/prices.py Normal file
View File

@@ -0,0 +1,232 @@
"""Preiserfassung und Preisvergleich.
Ein Preis wird festgehalten, sobald er an einem Eintrag steht, der einem
Markt zugeordnet ist. Ohne Markt ergibt er keinen Vergleichswert und wird
deshalb nicht aufgenommen.
"""
from datetime import timedelta
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Article, ListItem, Market, PricePoint
from app.schemas_shopping import (
ArticlePrices,
MarketPrice,
PriceEntry,
PriceHint,
PriceOverview,
PriceOverviewRow,
)
from app.security import utcnow
# Innerhalb dieser Spanne gilt ein gleicher Preis am selben Markt als
# derselbe Beobachtungswert. Ohne das entstünde bei jedem Tippen im
# Preisfeld ein neuer Eintrag.
DEDUPE_WINDOW = timedelta(hours=12)
def record_price(db: Session, item: ListItem) -> None:
"""Hält den Preis eines Eintrags fest, sofern sinnvoll.
Wird nach jeder Änderung an einem Eintrag aufgerufen; die Prüfungen
hier entscheiden, ob daraus wirklich ein Datenpunkt wird.
"""
if not item.price_cents or not item.market_id:
return
latest = db.scalar(
select(PricePoint)
.where(
PricePoint.article_id == item.article_id,
PricePoint.market_id == item.market_id,
)
.order_by(PricePoint.recorded_at.desc())
.limit(1)
)
if latest is not None:
same_price = latest.price_cents == item.price_cents
same_size = (latest.pack_size == item.pack_size
and latest.pack_unit == item.pack_unit)
if same_price and same_size and utcnow() - latest.recorded_at < DEDUPE_WINDOW:
return
db.add(
PricePoint(
list_id=item.list_id,
article_id=item.article_id,
market_id=item.market_id,
price_cents=item.price_cents,
pack_size=item.pack_size,
pack_unit=item.pack_unit,
)
)
def _unit_price(price_cents: int, pack_size: Decimal | None) -> int | None:
"""Preis je Mengeneinheit des Gebindes, in Zehntelcent - sonst gingen
bei kleinen Mengen zu viele Stellen verloren. Ohne Gebindeangabe
nicht bestimmbar."""
if not pack_size or pack_size <= 0:
return None
return int(round(price_cents * 10 / float(pack_size)))
def article_prices(db: Session, list_id: str, article: Article) -> ArticlePrices:
"""Preisverlauf eines Artikels, gegliedert nach Markt."""
points = db.scalars(
select(PricePoint)
.where(PricePoint.article_id == article.id)
.order_by(PricePoint.recorded_at.desc())
.limit(500)
).all()
markets = {
m.id: m
for m in db.scalars(
select(Market).where(Market.list_id == list_id)
).all()
}
grouped: dict[str, list[PricePoint]] = {}
for point in points:
grouped.setdefault(point.market_id, []).append(point)
rows: list[MarketPrice] = []
for market_id, entries in grouped.items():
market = markets.get(market_id)
if market is None or market.deleted_at is not None:
continue
amounts = [e.price_cents for e in entries]
newest = entries[0]
rows.append(
MarketPrice(
market_id=market_id,
market_name=market.name,
latest_cents=newest.price_cents,
latest_at=newest.recorded_at,
latest_pack_size=newest.pack_size,
latest_pack_unit=newest.pack_unit,
unit_price_deci=_unit_price(newest.price_cents, newest.pack_size),
min_cents=min(amounts),
max_cents=max(amounts),
observations=len(entries),
)
)
rows.sort(key=lambda r: r.latest_cents)
history = [
PriceEntry(
market_id=p.market_id,
market_name=markets[p.market_id].name if p.market_id in markets else "?",
price_cents=p.price_cents,
pack_size=p.pack_size,
pack_unit=p.pack_unit,
recorded_at=p.recorded_at,
)
for p in points[:100]
if p.market_id in markets
]
return ArticlePrices(
article_id=article.id,
article_name=article.name,
markets=rows,
history=history,
)
def overview(db: Session, list_id: str) -> PriceOverview:
"""Vergleichstabelle über alle Artikel mit erfassten Preisen."""
points = db.scalars(
select(PricePoint)
.where(PricePoint.list_id == list_id)
.order_by(PricePoint.recorded_at.desc())
.limit(5000)
).all()
markets = {
m.id: m
for m in db.scalars(
select(Market).where(
Market.list_id == list_id, Market.deleted_at.is_(None)
).order_by(Market.sort_order, Market.name)
).all()
}
articles = {
a.id: a
for a in db.scalars(
select(Article).where(
Article.list_id == list_id, Article.deleted_at.is_(None)
)
).all()
}
# Je Artikel und Markt nur der jüngste Wert - die Liste ist bereits
# absteigend sortiert, der erste Treffer gewinnt.
latest: dict[tuple[str, str], PricePoint] = {}
for point in points:
key = (point.article_id, point.market_id)
if key not in latest:
latest[key] = point
rows: list[PriceOverviewRow] = []
for article_id, article in articles.items():
prices = {
market_id: point.price_cents
for (aid, market_id), point in latest.items()
if aid == article_id and market_id in markets
}
if not prices:
continue
cheapest = min(prices.values())
best = [mid for mid, cents in prices.items() if cents == cheapest]
dearest = max(prices.values())
rows.append(
PriceOverviewRow(
article_id=article_id,
article_name=article.name,
prices=prices,
best_market_ids=best,
best_cents=cheapest,
spread_cents=dearest - cheapest,
)
)
rows.sort(key=lambda r: r.article_name.casefold())
return PriceOverview(
markets=[
MarketPrice(
market_id=m.id, market_name=m.name, latest_cents=0,
latest_at=utcnow(), min_cents=0, max_cents=0, observations=0,
)
for m in markets.values()
],
market_names={m.id: m.name for m in markets.values()},
rows=rows,
)
def hints(db: Session, list_id: str) -> list[PriceHint]:
"""Kurzhinweise für die Listenansicht: Wo war dieser Artikel zuletzt
am günstigsten?"""
data = overview(db, list_id)
return [
PriceHint(
article_id=row.article_id,
best_market_ids=row.best_market_ids,
best_cents=row.best_cents,
spread_cents=row.spread_cents,
prices=row.prices,
)
for row in data.rows
if row.spread_cents > 0
]

127
backend/app/print_view.py Normal file
View File

@@ -0,0 +1,127 @@
"""Druckansicht als eigenständige HTML-Seite.
Warum serverseitig, obwohl die App schon `@media print` mitbringt: Der
Ausdruck soll auch ohne geöffnete App möglich sein - aus einem Lesezeichen,
über einen öffentlichen Link, oder von einem Rechner, auf dem niemand die
PWA installiert hat. Die Gliederung stammt aus `app.list_view`, also
derselben Quelle wie die Bildschirmansicht; ein zweiter Sortieralgorithmus
würde mit der Zeit abweichen.
"""
from datetime import datetime
from jinja2 import Environment, select_autoescape
from app.config import settings
from app.schemas_shopping import ListView
# autoescape ist hier keine Formsache: Artikelnamen und Notizen sind
# freie Nutzereingaben und landen direkt im HTML.
_env = Environment(autoescape=select_autoescape(default=True, default_for_string=True))
_TEMPLATE = _env.from_string("""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>{{ view.list_name }} {{ app_name }}</title>
<!-- Stil und Skript liegen als eigene Dateien vor, nicht im Dokument:
Die Content-Security-Policy erlaubt weder unsafe-inline noch
Ereignisbehandler im Markup. Eingebetteter Stil käme unformatiert
an, ohne dass irgendwo ein Fehler sichtbar wäre. -->
<link rel="stylesheet" href="/print.css">
</head>
<body>
<div class="toolbar">
<button type="button" id="print-button">Drucken</button>
</div>
<header>
<h1>{{ view.list_name }}</h1>
<div class="meta">
Stand {{ printed_at }}
{%- if open_count %} · {{ open_count }} offene Position{{ "en" if open_count != 1 }}{% endif %}
{%- if not include_bought %} · gekaufte Artikel ausgeblendet{% endif %}
</div>
</header>
{% if not view.markets %}
<p class="empty">Die Liste ist leer.</p>
{% endif %}
{% for market in view.markets %}
<section class="market">
<h2>
<span>{{ market.market_name }}</span>
{% if market.total_cents %}<span class="sum">{{ money(market.total_cents) }}</span>{% endif %}
</h2>
{% for category in market.categories %}
<div class="category">
<h3>{{ category.category_name }}</h3>
<table>
<tbody>
{% for item in category.items %}
<tr class="{{ 'bought' if item.status == 'bought' }}">
<td class="box"><span></span></td>
<td class="count {{ 'many' if item.count > 1 }}">{{ item.count }}&times;</td>
<td class="name">
<span class="article">{{ item.article_name }}</span>
{% set detail = [] %}
{%- if item.pack_size %}{% set _ = detail.append(pack(item)) %}{% endif %}
{%- if item.variant %}{% set _ = detail.append(item.variant) %}{% endif %}
{%- if item.note %}{% set _ = detail.append(item.note) %}{% endif %}
{%- if detail %}<span class="detail">{{ detail | join(" · ") }}</span>{% endif %}
</td>
<td class="price">{{ money(item.total_cents) if item.total_cents else "" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</section>
{% endfor %}
<footer>
<span>Gesamt</span>
<span class="total">{{ money(view.grand_total_cents) }}</span>
</footer>
<p class="note">{{ app_name }} · erzeugt {{ printed_at }}</p>
<script src="/print.js"></script>
</body>
</html>
""")
def _money(cents: int | None) -> str:
value = (cents or 0) / 100
return f"{value:,.2f}".replace(",", "\u00a0").replace(".", ",")
def _pack(item) -> str:
"""Gebindegröße lesbar machen: 250 statt 250.000, 1,5 statt 1.500.
Nicht über rstrip("0"): Bei einem Wert ohne Dezimalpunkt - und den
liefert Decimal("250") - würde das die letzte Null abschneiden und
aus 250 g plötzlich 25 g machen. `normalize()` entfernt nur
tatsächlich überflüssige Nachkommastellen.
"""
value = item.pack_size
text = format(value.normalize(), "f") if hasattr(value, "normalize") else str(value)
return f"{text.replace('.', ',')} {item.pack_unit or ''}".strip()
def render_print(view: ListView, *, include_bought: bool = True) -> str:
open_count = sum(market.open_count for market in view.markets)
return _TEMPLATE.render(
view=view,
app_name=settings.app_name,
printed_at=datetime.now().strftime("%d.%m.%Y, %H:%M"),
open_count=open_count,
include_bought=include_bought,
money=_money,
pack=_pack,
)

View File

@@ -0,0 +1,193 @@
"""Nachschlagen von Strichcodes bei Open Food Facts.
Bewusst über diesen Server statt direkt aus dem Browser:
* Die IP-Adresse der Nutzer geht nicht an einen Dritten. Bei einer
Abfrage aus dem Browser wüsste Open Food Facts, wer wann welches
Produkt scannt - genau die Art von Datenspur, die vermieden werden
soll.
* Die Content-Security-Policy bleibt bei `connect-src 'self'`. Eine
Ausnahme für eine fremde Domain zu öffnen, wäre eine dauerhafte
Schwächung für eine gelegentliche Abfrage.
* Derselbe Code wird nur einmal abgefragt, egal wie viele Geräte ihn
scannen. Das schont auch den fremden Dienst.
Abschaltbar über PRODUCT_LOOKUP=off in der .env. Dann bleibt der eigene
Artikelstamm die einzige Quelle.
Die Daten stammen aus Open Food Facts und stehen unter der Open Database
License (ODbL). Für die Verwendung in einer privaten Einkaufsliste ist
das unproblematisch; wer sie weiterverbreitet, muss die Lizenz beachten.
"""
import logging
import re
from datetime import timedelta
from decimal import Decimal, InvalidOperation
import httpx
from sqlalchemy.orm import Session
from app.config import settings
from app.models import ProductCache
from app.security import utcnow
log = logging.getLogger(__name__)
API_URL = "https://world.openfoodfacts.org/api/v2/product/{barcode}.json"
FIELDS = "product_name,product_name_de,generic_name_de,generic_name,brands,quantity"
# Open Food Facts verlangt eine aussagekraeftige Kennung. Ohne sie
# werden Anfragen abgewiesen.
def _user_agent() -> str:
return f"{settings.app_name}/1.0 ({settings.public_base_url})"
_UNITS = r"kg|g|mg|l|ml|cl|dl|stk|stück|st"
# Mehrstueckpackung zuerst pruefen: "6 x 33 cl", "4x500g"
_MULTIPACK = re.compile(
rf"(?P<count>\d+)\s*[x×*]\s*(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
re.IGNORECASE,
)
# "500 g", "1,5 l", "250ml"
_QUANTITY = re.compile(
rf"(?P<value>\d+(?:[.,]\d+)?)\s*(?P<unit>{_UNITS})\b",
re.IGNORECASE,
)
def parse_package(
text: str | None,
) -> tuple[int | None, Decimal | None, str | None]:
"""Zerlegt eine Mengenangabe in Stueckzahl, Gebinde und Einheit.
"500 g" -> (None, 500, "g") eine Packung zu 500 g
"6 x 33 cl" -> (6, 33, "cl") sechs Flaschen zu je 33 cl
Gibt (None, None, None) zurueck, wenn nichts Verwertbares drinsteht -
dann werden die Felder nicht vorbelegt, statt zu raten.
"""
if not text:
return None, None, None
def normalize(raw: str) -> str:
return {"stück": "Stk", "stk": "Stk", "st": "Stk"}.get(raw.lower(), raw.lower())
# Mehrstueckpackung: Stueckzahl und Gebinde getrennt.
multi = _MULTIPACK.search(text)
if multi:
try:
count = int(multi.group("count"))
size = Decimal(multi.group("value").replace(",", "."))
except (InvalidOperation, ValueError):
return None, None, None
return count, size, normalize(multi.group("unit"))
match = _QUANTITY.search(text)
if not match:
return None, None, None
try:
value = Decimal(match.group("value").replace(",", "."))
except InvalidOperation:
return None, None, None
return None, value, normalize(match.group("unit"))
def _fetch(barcode: str) -> dict | None:
"""Einzelne Abfrage. Gibt None zurueck, wenn nichts gefunden wurde
oder der Dienst nicht erreichbar war - der Aufrufer unterscheidet
das nicht, weil es fuer ihn dasselbe Ergebnis bedeutet."""
try:
response = httpx.get(
API_URL.format(barcode=barcode),
params={"fields": FIELDS},
headers={"User-Agent": _user_agent()},
timeout=settings.product_lookup_timeout,
follow_redirects=True,
)
except httpx.HTTPError as exc:
log.warning("Produktabfrage für %s fehlgeschlagen: %s", barcode, exc)
return None
if response.status_code != 200:
return None
try:
data = response.json()
except ValueError:
return None
if data.get("status") != 1:
return None
product = data.get("product") or {}
# Deutsche Bezeichnung bevorzugen, dann die allgemeine.
name = (
product.get("product_name_de")
or product.get("product_name")
or product.get("generic_name_de")
or product.get("generic_name")
or ""
).strip()
if not name:
return None
brands = (product.get("brands") or "").split(",")[0].strip()
package = (product.get("quantity") or "").strip()
return {
"name": name[:300],
"brand": brands[:200] or None,
"package": package[:120] or None,
}
def lookup(db: Session, barcode: str) -> ProductCache | None:
"""Nachschlagen mit Zwischenspeicher.
@returns None, wenn die Funktion abgeschaltet ist. Sonst immer einen
Datensatz - auch bei Misserfolg, damit derselbe Code nicht bei jedem
Scan erneut nach draußen geht.
"""
if settings.product_lookup == "off":
return None
barcode = barcode.strip()
if not barcode.isdigit() or not 6 <= len(barcode) <= 20:
return None
cached = db.get(ProductCache, barcode)
if cached is not None:
age = utcnow() - cached.fetched_at
max_age = timedelta(
days=settings.product_cache_days if cached.found
else settings.product_miss_days
)
if age < max_age:
return cached
result = _fetch(barcode)
count, pack_size, pack_unit = parse_package(
result.get("package") if result else None)
if cached is None:
cached = ProductCache(barcode=barcode)
db.add(cached)
cached.found = result is not None
cached.name = result["name"] if result else None
cached.brand = result["brand"] if result else None
cached.package = result["package"] if result else None
cached.count = count
cached.pack_size = pack_size
cached.pack_unit = pack_unit
cached.source = "openfoodfacts"
cached.fetched_at = utcnow()
db.commit()
db.refresh(cached)
return cached

153
backend/app/push.py Normal file
View File

@@ -0,0 +1,153 @@
"""Push-Benachrichtigungen mit Zwei-Stunden-Drosselung.
Regel: Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine
Benachrichtigung aus - aber höchstens eine je Liste und Person innerhalb
des eingestellten Zeitraums.
Der Grund: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten
Dutzende Änderungen. Ohne Drosselung bekämen die anderen ebenso viele
Meldungen und würden die Funktion nach dem ersten Einkauf abschalten.
Was in der Nachricht steht, ist bewusst knapp: Listenname und wer sie
geändert hat. Keine Artikelnamen. Eine Benachrichtigung erscheint auf
dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das
Gerät gerade in der Hand hält.
"""
import json
import logging
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.db import SessionLocal
from app.models import ListMember, NotifyState, PushSubscription, ShoppingList, User
from app.security import utcnow
log = logging.getLogger(__name__)
# Fehlercodes, bei denen die Anmeldung endgültig ungültig ist.
_GONE = (404, 410)
def _send_one(subscription: PushSubscription, payload: dict) -> str:
"""Zustellversuch an einen Endpunkt.
@returns "ok" | "gone" | "error"
"""
try:
from pywebpush import WebPushException, webpush
except ImportError:
log.error("pywebpush ist nicht installiert - Push nicht möglich.")
return "error"
try:
webpush(
subscription_info={
"endpoint": subscription.endpoint,
"keys": {"p256dh": subscription.p256dh, "auth": subscription.auth},
},
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_contact},
timeout=settings.push_timeout,
ttl=3600,
)
return "ok"
except WebPushException as exc:
status = getattr(exc.response, "status_code", None)
if status in _GONE:
# Der Browser hat die Anmeldung verworfen - typisch nach
# Deinstallation oder Löschen der Websitedaten.
return "gone"
log.warning("Push an %s… fehlgeschlagen (%s): %s",
subscription.endpoint[:40], status, exc)
return "error"
except Exception:
log.exception("Push an %s… fehlgeschlagen", subscription.endpoint[:40])
return "error"
def send_to_user(db: Session, user_id: str, payload: dict) -> int:
"""Zustellung an alle Geräte einer Person. Gibt die Zahl der
erfolgreichen Zustellungen zurück."""
if not settings.push_enabled:
return 0
subscriptions = db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user_id)
).all()
delivered = 0
for subscription in subscriptions:
result = _send_one(subscription, payload)
if result == "ok":
subscription.last_success_at = utcnow()
subscription.failure_count = 0
delivered += 1
elif result == "gone":
db.delete(subscription)
else:
subscription.failure_count += 1
# Nach genug Fehlschlägen aufräumen: Ein Endpunkt, der
# dauerhaft nicht antwortet, wird nicht wieder gut.
if subscription.failure_count >= 10:
db.delete(subscription)
db.commit()
return delivered
def notify_list_changed(list_id: str, actor_id: str) -> None:
"""Benachrichtigt die übrigen Mitglieder über eine Änderung.
Läuft im Hintergrund (BackgroundTasks) und öffnet dafür eine eigene
Sitzung: Die Sitzung des Requests ist zu dem Zeitpunkt bereits
geschlossen.
"""
if not settings.push_enabled:
return
with SessionLocal() as db:
lst = db.get(ShoppingList, list_id)
if lst is None or lst.deleted_at is not None:
return
actor = db.get(User, actor_id)
actor_name = (
(actor.display_name or actor.email.split("@")[0]) if actor else "Jemand"
)
members = db.scalars(
select(ListMember).where(
ListMember.list_id == list_id, ListMember.user_id != actor_id
)
).all()
threshold = utcnow() - timedelta(hours=settings.push_throttle_hours)
for member in members:
state = db.get(NotifyState, (list_id, member.user_id))
if state is not None and state.last_notified_at > threshold:
continue # innerhalb der Sperrfrist
payload = {
"title": lst.name,
"body": f"{actor_name} hat die Liste geändert.",
"list_id": list_id,
"tag": f"list-{list_id}",
}
delivered = send_to_user(db, member.user_id, payload)
if delivered == 0:
# Nichts zugestellt - dann auch nicht die Sperrfrist
# starten, sonst verpasst die Person die nächste Änderung.
continue
if state is None:
db.add(NotifyState(list_id=list_id, user_id=member.user_id,
last_notified_at=utcnow()))
else:
state.last_notified_at = utcnow()
db.commit()

View File

View File

@@ -0,0 +1,458 @@
"""Administration: Einstellungen, Mailprüfung, Benutzerverwaltung."""
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from sqlalchemy import select
from app.config import settings
from app.deps import (
AdminUser,
DbSession,
client_ip,
get_setting,
registration_locked_by_env,
self_registration_enabled,
set_setting,
)
from app.mail import (
check_connection,
send_email_change_notice,
send_email_change_verify,
send_email_change_verify_old,
send_test_mail,
send_welcome,
)
from app.maintenance import describe, run_cleanup
from app.models import EmailChange, User
from app.schemas import MailCheckOut, MailTestIn, MessageOut
from app.schemas_admin import (
AdminSettingsIn,
AdminSettingsOut,
AdminStatsOut,
AdminUserOut,
DeleteUserIn,
EmailChangeIn,
UserCreateIn,
)
from app.security import check_rate_limit, normalize_email, utcnow
from app.users import (
EMAIL_CHANGE_HOURS,
WELCOME_DAYS,
create_user,
deactivate,
delete_user,
issue_token,
membership_counts,
months_setting,
owned_list_counts,
reactivate,
start_email_change,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
DEFAULT_DEACTIVATE_MONTHS = 12
DEFAULT_DELETE_MONTHS = 12
# ==========================================================================
# Einstellungen
# ==========================================================================
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
),
)
@router.get("/settings", response_model=AdminSettingsOut)
def read_settings(db: DbSession, admin: AdminUser):
return _settings_out(db)
@router.put("/settings", response_model=AdminSettingsOut)
def write_settings(payload: AdminSettingsIn, db: DbSession, admin: AdminUser):
if payload.allow_self_registration is not None:
if registration_locked_by_env():
raise HTTPException(
status.HTTP_409_CONFLICT,
"Die Selbstregistrierung ist über die Umgebungsvariable "
f"ALLOW_SELF_REGISTRATION={settings.allow_self_registration} "
"festgelegt und lässt sich hier nicht ändern. Setze die Variable "
"auf 'admin', um sie über diese Schnittstelle steuerbar zu machen.",
)
set_setting(
db, "allow_self_registration",
"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))
db.commit()
return _settings_out(db)
# ==========================================================================
# Benutzerübersicht
# ==========================================================================
def _user_out(
user: User,
owned: dict[str, int],
memberships: dict[str, int],
pending: dict[str, str],
) -> AdminUserOut:
return AdminUserOut(
id=user.id,
email=user.email,
display_name=user.display_name,
is_admin=user.is_admin,
is_active=user.is_active,
verified=user.verified_at is not None,
last_seen_at=user.last_seen_at,
deactivated_at=user.deactivated_at,
created_at=user.created_at,
owned_lists=owned.get(user.id, 0),
memberships=memberships.get(user.id, 0),
pending_email=pending.get(user.id),
)
def _pending_changes(db) -> dict[str, str]:
rows = db.scalars(
select(EmailChange).where(
EmailChange.applied_at.is_(None),
EmailChange.cancelled_at.is_(None),
EmailChange.expires_at > utcnow(),
)
).all()
return {row.user_id: row.new_email for row in rows}
@router.get("/users", response_model=list[AdminUserOut])
def list_users(db: DbSession, admin: AdminUser):
users = db.scalars(select(User).order_by(User.created_at)).all()
owned = owned_list_counts(db)
memberships = membership_counts(db)
pending = _pending_changes(db)
return [_user_out(u, owned, memberships, pending) for u in users]
@router.get("/stats", response_model=AdminStatsOut)
def stats(db: DbSession, admin: AdminUser):
users = db.scalars(select(User)).all()
now = utcnow()
deactivate_months = months_setting(
db, "auto_deactivate_months", DEFAULT_DEACTIVATE_MONTHS)
delete_months = months_setting(db, "auto_delete_months", DEFAULT_DELETE_MONTHS)
due_deactivation = 0
due_deletion = 0
for user in users:
# Administratorkonten unterliegen der Automatik nicht.
if user.is_admin:
continue
if (
deactivate_months
and user.is_active
and user.last_seen_at
and user.last_seen_at < now - timedelta(days=deactivate_months * 30)
):
due_deactivation += 1
if (
delete_months
and not user.is_active
and user.deactivated_at
and user.deactivated_at < now - timedelta(days=delete_months * 30)
):
due_deletion += 1
return AdminStatsOut(
total=len(users),
active=sum(1 for u in users if u.is_active),
inactive=sum(1 for u in users if not u.is_active),
unverified=sum(1 for u in users if u.verified_at is None),
admins=sum(1 for u in users if u.is_admin),
due_deactivation=due_deactivation,
due_deletion=due_deletion,
)
# ==========================================================================
# Benutzer anlegen
# ==========================================================================
def _target(db, user_id: str) -> User:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Konto nicht gefunden")
return user
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
def add_user(
payload: UserCreateIn,
request: Request,
db: DbSession,
admin: AdminUser,
background: BackgroundTasks,
):
if not check_rate_limit(
db, f"admin-create:{client_ip(request)}", limit=30, window_minutes=60
):
db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anlagen.")
email = normalize_email(payload.email)
if db.scalar(select(User).where(User.email == email)) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
)
user, token = create_user(
db, email=email, display_name=payload.display_name, is_admin=payload.is_admin
)
db.commit()
db.refresh(user)
background.add_task(
send_welcome, email, token,
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
)
return _user_out(user, {}, {}, {})
@router.post("/users/{user_id}/welcome", response_model=MessageOut)
def resend_welcome(
user_id: str,
db: DbSession,
admin: AdminUser,
background: BackgroundTasks,
):
"""Erneuter Versand der Willkommensnachricht.
Nur solange das Konto noch nicht bestätigt ist. Danach wäre es kein
Willkommensgruß mehr, sondern ein vom Administrator ausgelöstes
Zurücksetzen des Passworts - das soll vom Kontoinhaber ausgehen und
läuft über "Passwort vergessen".
"""
user = _target(db, user_id)
if user.verified_at is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Dieses Konto ist bereits eingerichtet. Für ein neues Passwort "
"nutzt die Person „Passwort vergessen“ auf der Anmeldeseite.",
)
if not check_rate_limit(db, f"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)
db.commit()
background.add_task(
send_welcome, user.email, token,
admin.display_name or admin.email.split("@")[0], WELCOME_DAYS,
)
return MessageOut(detail=f"Willkommensnachricht erneut an {user.email} versendet.")
# ==========================================================================
# Adressänderung
# ==========================================================================
@router.post("/users/{user_id}/email", response_model=MessageOut)
def change_email(
user_id: str,
payload: EmailChangeIn,
db: DbSession,
admin: AdminUser,
background: BackgroundTasks,
):
user = _target(db, user_id)
target = normalize_email(payload.new_email)
if target == user.email:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Das ist bereits die aktuelle Adresse."
)
if db.scalar(select(User).where(User.email == target)) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT, "Unter dieser Adresse existiert bereits ein Konto."
)
old_email = user.email
change, raw_new, raw_old = start_email_change(db, user, target, admin.id)
db.commit()
background.add_task(
send_email_change_verify, target, raw_new, old_email, 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
)
detail = (
f"Bestätigungslinks an {target} und {old_email} versendet. "
"Bei Administratorkonten müssen beide Adressen zustimmen; die "
"Änderung wird erst danach wirksam."
)
else:
background.add_task(
send_email_change_notice, old_email, target, EMAIL_CHANGE_HOURS
)
detail = (
f"Bestätigungslink an {target} versendet, Hinweis an {old_email}. "
"Die Änderung wird wirksam, sobald die neue Adresse bestätigt hat."
)
return MessageOut(detail=detail)
@router.delete("/users/{user_id}/email", response_model=MessageOut)
def cancel_email_change(user_id: str, db: DbSession, admin: AdminUser):
user = _target(db, user_id)
count = 0
for change in db.scalars(
select(EmailChange).where(
EmailChange.user_id == user.id,
EmailChange.applied_at.is_(None),
EmailChange.cancelled_at.is_(None),
)
).all():
change.cancelled_at = utcnow()
count += 1
db.commit()
return MessageOut(
detail=f"{count} offene(r) Adresswechsel zurückgezogen."
if count else "Es lief kein Adresswechsel."
)
# ==========================================================================
# Deaktivieren, Reaktivieren, Löschen
# ==========================================================================
def _guard_admin_target(user: User, admin: User, action: str) -> None:
"""Administratorkonten sind vor Deaktivierung und Löschung geschützt.
Sonst könnte ein Administrator alle anderen aussperren - oder sich
selbst, und dann käme niemand mehr an die Verwaltung. Wer einen
Administrator entfernen will, nimmt ihm zuerst die Rechte; das geht
bewusst nur direkt in der Datenbank und ist damit ein Schritt, den
man nicht versehentlich tut.
"""
if user.is_admin:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Administratorkonten können nicht {action} werden. Entziehe die "
"Administratorrechte zuerst - das geht bewusst nur direkt in der "
"Datenbank.",
)
if user.id == admin.id:
raise HTTPException(
status.HTTP_409_CONFLICT, "Das eigene Konto lässt sich nicht ändern."
)
@router.post("/users/{user_id}/deactivate", response_model=AdminUserOut)
def deactivate_user(user_id: str, db: DbSession, admin: AdminUser):
user = _target(db, user_id)
_guard_admin_target(user, admin, "deaktiviert")
deactivate(db, user)
db.commit()
db.refresh(user)
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
@router.post("/users/{user_id}/activate", response_model=AdminUserOut)
def activate_user(user_id: str, db: DbSession, admin: AdminUser):
user = _target(db, user_id)
reactivate(db, user)
db.commit()
db.refresh(user)
return _user_out(user, owned_list_counts(db), membership_counts(db), {})
@router.post("/users/{user_id}/delete", response_model=MessageOut)
def remove_user(user_id: str, payload: DeleteUserIn, db: DbSession, admin: AdminUser):
"""Löschen als POST mit Rumpf, nicht als DELETE.
Der Vorgang braucht zwei Angaben: was mit den Listen geschehen soll
und eine Bestätigung der Adresse. Ein DELETE mit Rumpf ist in
Zwischenschichten unzuverlässig.
"""
user = _target(db, user_id)
_guard_admin_target(user, admin, "gelöscht")
if normalize_email(payload.confirm_email) != user.email:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Die Bestätigungsadresse stimmt nicht mit dem Konto überein.",
)
owned = owned_list_counts(db).get(user.id, 0)
if owned and payload.lists == "refuse":
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Diesem Konto gehören {owned} Liste(n). Wähle „übergeben“, um "
"geteilte Listen an das dienstälteste andere Mitglied zu "
"übertragen und die übrigen zu löschen.",
)
email = user.email
counts = delete_user(db, user)
db.commit()
return MessageOut(
detail=f"Konto {email} gelöscht. "
f"{counts['übertragene Listen']} Liste(n) übertragen, "
f"{counts['gelöschte Listen']} gelöscht."
)
# ==========================================================================
# Betrieb
# ==========================================================================
@router.post("/cleanup", response_model=MessageOut)
def trigger_cleanup(db: DbSession, admin: AdminUser):
"""Räumt sofort auf, statt auf den täglichen Durchlauf zu warten."""
counts = run_cleanup(db)
return MessageOut(detail=f"Aufgeräumt: {describe(counts)}.")
@router.get("/mail/check", response_model=MailCheckOut)
def mail_check(admin: AdminUser):
"""Verbindungstest zum Relay, ohne eine Nachricht zu versenden."""
ok, detail = check_connection()
return MailCheckOut(
ok=ok,
detail=detail,
host=settings.smtp_host,
port=settings.smtp_port,
security=settings.smtp_security,
envelope_from=settings.envelope_from,
)
@router.post("/mail/test", response_model=MessageOut,
status_code=status.HTTP_202_ACCEPTED)
def mail_test(payload: MailTestIn, background: BackgroundTasks, admin: AdminUser):
background.add_task(send_test_mail, str(payload.to))
return MessageOut(
detail=f"Testnachricht an {payload.to} in Auftrag gegeben. "
"Ergebnis steht im Log des api-Containers."
)

View File

@@ -0,0 +1,56 @@
"""Angaben zur Anwendung, die die Oberflaeche zur Laufzeit braucht.
Die statischen Dateien im web-Container kennen den Anwendungsnamen nicht -
er steht in der .env. Statt ihn beim Bauen einzusetzen (was ein
Neubau-Erfordernis bei jeder Umbenennung bedeutete), liefert ihn die API.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.config import settings
router = APIRouter(tags=["app"])
@router.get("/api/config")
def public_config():
"""Ohne Anmeldung erreichbar: Das Anmeldeformular braucht den Namen,
bevor jemand angemeldet ist. Enthaelt bewusst nichts Vertrauliches."""
return {
"app_name": settings.app_name,
"app_short_name": settings.short_name,
}
@router.get("/manifest.webmanifest", include_in_schema=False)
def manifest():
"""Erzeugt statt statisch ausgeliefert, damit der Name aus der .env
auch auf dem Startbildschirm erscheint."""
return JSONResponse(
{
"name": settings.app_name,
"short_name": settings.short_name,
"description": "Gemeinsame Einkaufslisten mit Märkten, "
"Warengruppen und Preisen.",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f6f6f4",
"theme_color": "#2f6f4e",
"categories": ["shopping", "productivity"],
"icons": [
{"src": "/icons/icon-192.png", "sizes": "192x192",
"type": "image/png", "purpose": "any"},
{"src": "/icons/icon-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "any"},
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512",
"type": "image/png", "purpose": "maskable"},
],
},
media_type="application/manifest+json",
headers={"Cache-Control": "no-cache"},
)

396
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,396 @@
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.config import settings
from app.deps import (
CSRF_COOKIE,
SESSION_COOKIE,
CurrentUser,
DbSession,
client_ip,
create_session,
destroy_session,
self_registration_enabled,
)
from app.mail import send_password_reset, send_verification
from app.models import EmailChange, EmailToken, User, UserSession
from app.schemas_admin import WelcomeCompleteIn, WelcomePreviewOut
from app.schemas import (
LoginIn,
MessageOut,
PasswordChangeIn,
PasswordResetIn,
PasswordResetRequestIn,
ProfileUpdateIn,
RegisterIn,
UserOut,
)
from app.users import apply_if_complete
from app.security import (
check_rate_limit,
hash_password,
hash_token,
needs_rehash,
new_token,
normalize_email,
utcnow,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Bewusst identische Antwort fuer "Konto existiert" und "Konto existiert nicht".
_NEUTRAL = "Falls die Adresse bei uns registriert ist, wurde eine E-Mail versendet."
def _issue_token(db, user: User, purpose: str, hours: int) -> str:
# Aeltere, noch offene Token desselben Zwecks entwerten.
for old in db.scalars(
select(EmailToken).where(
EmailToken.user_id == user.id,
EmailToken.purpose == purpose,
EmailToken.used_at.is_(None),
)
).all():
old.used_at = utcnow()
raw = new_token()
db.add(
EmailToken(
token_hash=hash_token(raw),
user_id=user.id,
purpose=purpose,
expires_at=utcnow() + timedelta(hours=hours),
)
)
return raw
def _consume_token(db, raw: str, purpose: str) -> User | None:
row = db.scalar(
select(EmailToken).where(
EmailToken.token_hash == hash_token(raw),
EmailToken.purpose == purpose,
)
)
if row is None or row.used_at is not None or row.expires_at <= utcnow():
return None
row.used_at = utcnow()
return db.get(User, row.user_id)
@router.post("/register", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
def register(
payload: RegisterIn,
request: Request,
db: DbSession,
background: BackgroundTasks,
):
if not self_registration_enabled(db):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Die Selbstregistrierung ist deaktiviert. Bitte lass dich einladen.",
)
if not check_rate_limit(
db, f"register:{client_ip(request)}", limit=5, window_minutes=60
):
db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
email = normalize_email(payload.email)
existing = db.scalar(select(User).where(User.email == email))
if existing is None:
user = User(
email=email,
display_name=(payload.display_name or None),
password_hash=hash_password(payload.password),
is_admin=(email == normalize_email(settings.admin_email)),
)
db.add(user)
db.flush()
raw = _issue_token(db, user, "verify", hours=24)
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)
background.add_task(send_verification, email, raw)
# Bestaetigtes Konto: nichts tun, aber gleiche Antwort geben.
db.commit()
return MessageOut(detail=_NEUTRAL)
@router.get("/verify", include_in_schema=True)
def verify(token: str, db: DbSession):
"""Ziel des Links aus der Bestätigungsmail.
Antwortet mit einer Weiterleitung auf die Oberfläche, nicht mit JSON:
Der Nutzer klickt hier im Browser, nicht per API-Aufruf.
"""
user = _consume_token(db, token, "verify")
if user is None:
db.commit()
return RedirectResponse("/?verified=invalid", status_code=303)
if user.verified_at is None:
user.verified_at = utcnow()
db.commit()
return RedirectResponse("/?verified=ok", status_code=303)
@router.post("/login", response_model=UserOut)
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)
if not (ip_ok and acct_ok):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"Zu viele Anmeldeversuche. Bitte warte einen Moment.",
)
user = db.scalar(select(User).where(User.email == email))
# Laeuft auch ohne Treffer gegen einen Dummy-Hash - gleiche Laufzeit.
ok = verify_password(payload.password, user.password_hash if user else None)
if not ok or user is None:
db.commit()
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch."
)
# Erst NACH erfolgreicher Passwortprüfung über die Deaktivierung
# informieren. Vorher wäre es ein Hinweis darauf, dass es das Konto
# gibt - danach weiß die Person das ohnehin, weil sie das Passwort
# kennt. Und wer nur "falsches Passwort" liest, obwohl es stimmt,
# sucht den Fehler an der falschen Stelle.
if not user.is_active:
db.commit()
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Dieses Konto ist deaktiviert. Deine Listen bleiben erhalten "
"wende dich an die Administration, wenn du den Zugang wieder "
"brauchst.",
)
if user.verified_at is None:
db.commit()
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Bitte bestätige zuerst deine E-Mail-Adresse.",
)
if needs_rehash(user.password_hash):
user.password_hash = hash_password(payload.password)
# Grundlage der automatischen Deaktivierung nach langer Untätigkeit.
user.last_seen_at = utcnow()
create_session(db, user, response)
db.commit()
return UserOut.of(user)
@router.post("/logout", response_model=MessageOut)
def logout(request: Request, response: Response, db: DbSession, user: CurrentUser):
destroy_session(db, request, response)
db.commit()
return MessageOut(detail="Abgemeldet.")
@router.post("/logout-all", response_model=MessageOut)
def logout_all(request: Request, response: Response, db: DbSession, user: CurrentUser):
"""Meldet alle Geräte ab - z.B. nach Verdacht auf Kompromittierung."""
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
db.commit()
return MessageOut(detail="Auf allen Geräten abgemeldet.")
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser):
return UserOut.of(user)
@router.patch("/me", response_model=UserOut)
def update_me(payload: ProfileUpdateIn, db: DbSession, user: CurrentUser):
user.display_name = payload.display_name or None
db.commit()
return UserOut.of(user)
@router.post("/password/change", response_model=MessageOut)
def change_password(
payload: PasswordChangeIn, request: Request, response: Response,
db: DbSession, user: CurrentUser,
):
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Aktuelles Passwort ist falsch.")
if verify_password(payload.new_password, user.password_hash):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Das neue Passwort muss sich vom bisherigen unterscheiden.",
)
user.password_hash = hash_password(payload.new_password)
user.must_change_password = False
# Alle bestehenden Sessions verwerfen und eine neue ausstellen.
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
db.flush()
create_session(db, user, response)
db.commit()
return MessageOut(detail="Passwort geändert. Andere Geräte wurden abgemeldet.")
@router.post("/password/reset-request", response_model=MessageOut)
def reset_request(
payload: PasswordResetRequestIn,
request: Request,
db: DbSession,
background: BackgroundTasks,
):
if not check_rate_limit(
db, f"reset:{client_ip(request)}", limit=5, window_minutes=60
):
db.commit()
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Versuche.")
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)
background.add_task(send_password_reset, email, raw)
db.commit()
return MessageOut(detail=_NEUTRAL)
# ==========================================================================
# Willkommensnachricht: Passwort setzen und Konto freischalten
# ==========================================================================
@router.get("/welcome/{token}", response_model=WelcomePreviewOut)
def welcome_preview(token: str, db: DbSession):
"""Zeigt, für welche Adresse der Link gilt - damit die Person sieht,
worauf sie sich einlässt, bevor sie ein Passwort vergibt."""
row = db.scalar(
select(EmailToken).where(
EmailToken.token_hash == hash_token(token),
EmailToken.purpose == "welcome",
)
)
if row is None or row.used_at is not None or row.expires_at <= utcnow():
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Der Link ist ungültig oder abgelaufen. Bitte die Administration "
"um eine neue Einladung.",
)
user = db.get(User, row.user_id)
if user is None or not user.is_active:
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
return WelcomePreviewOut(email=user.email, display_name=user.display_name)
@router.post("/welcome/complete", response_model=MessageOut)
def welcome_complete(payload: WelcomeCompleteIn, db: DbSession):
"""Setzt das erste Passwort und bestätigt die Adresse in einem Schritt.
Der Klick auf den Link aus der Willkommensnachricht beweist, dass das
Postfach erreichbar ist - eine zusätzliche Verifikationsmail wäre nur
ein weiterer Schritt ohne Erkenntnisgewinn.
"""
user = _consume_token(db, payload.token, "welcome")
if user is None:
db.commit()
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
)
if not user.is_active:
db.commit()
raise HTTPException(status.HTTP_410_GONE, "Das Konto steht nicht mehr bereit.")
user.password_hash = hash_password(payload.password)
user.must_change_password = False
user.verified_at = user.verified_at or utcnow()
user.last_seen_at = utcnow()
if payload.display_name is not None:
user.display_name = payload.display_name.strip() or None
db.commit()
return MessageOut(detail="Zugang eingerichtet. Du kannst dich jetzt anmelden.")
# ==========================================================================
# Bestätigung einer Adressänderung
# ==========================================================================
@router.get("/email-change/{token}", include_in_schema=False)
def confirm_email_change(token: str, db: DbSession):
"""Ziel der Links aus den Bestätigungsmails.
Antwortet mit einer Weiterleitung auf die Oberfläche - hier klickt
jemand im Browser, nicht per API-Aufruf.
"""
digest = hash_token(token)
change = db.scalar(
select(EmailChange).where(
(EmailChange.token_new_hash == digest)
| (EmailChange.token_old_hash == digest)
)
)
if change is None:
return RedirectResponse("/?adresswechsel=unbekannt", status_code=303)
if change.cancelled_at or change.applied_at:
return RedirectResponse("/?adresswechsel=erledigt", status_code=303)
if change.expires_at <= utcnow():
return RedirectResponse("/?adresswechsel=abgelaufen", status_code=303)
if change.token_new_hash == digest:
change.confirmed_new_at = change.confirmed_new_at or utcnow()
else:
change.confirmed_old_at = change.confirmed_old_at or utcnow()
done = apply_if_complete(db, change)
db.commit()
if done:
return RedirectResponse("/?adresswechsel=fertig", status_code=303)
# Bei Administratorkonten fehlt jetzt noch die zweite Bestätigung.
return RedirectResponse("/?adresswechsel=teilweise", status_code=303)
@router.post("/password/reset", response_model=MessageOut)
def reset_password(payload: PasswordResetIn, db: DbSession):
user = _consume_token(db, payload.token, "reset")
if user is None:
db.commit()
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Der Link ist ungültig oder abgelaufen."
)
user.password_hash = hash_password(payload.password)
user.must_change_password = False
for sess in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(sess)
db.commit()
return MessageOut(detail="Passwort gesetzt. Du kannst dich jetzt anmelden.")

View File

@@ -0,0 +1,413 @@
"""Katalogdaten einer Liste: Märkte, Warengruppen, Artikel.
Alle Schreibzugriffe erhöhen den Revisionszähler der Liste und schreiben
den neuen Wert in `row_rev` der geänderten Zeile.
"""
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.deps import DbSession
from app.models import (
Article,
ArticleAttribute,
ArticleMarket,
Category,
ListItem,
Market,
)
from app.permissions import EditableList, ReadableList, bump_rev
from app.schemas import MessageOut
from app.product_lookup import lookup
from app.schemas_shopping import (
ArticleIn,
ArticleOut,
ArticleUpdateIn,
AttributeOut,
CategoryIn,
CategoryOut,
MarketIn,
MarketOut,
ProductLookupOut,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["catalog"])
def _check_belongs(db: Session, model, obj_id: str | None, list_id: str, label: str):
"""Verhindert, dass eine Liste auf Objekte einer fremden Liste zeigt.
Ohne diese Prüfung könnte ein Mitglied durch Angabe einer fremden ID
Rückschlüsse auf andere Listen ziehen."""
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{label} nicht gefunden")
return obj
# ==========================================================================
# Märkte
# ==========================================================================
@router.get("/markets", response_model=list[MarketOut])
def get_markets(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Market)
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
.order_by(Market.sort_order, Market.name)
).all()
return [MarketOut.model_validate(m) for m in rows]
@router.post("/markets", response_model=MarketOut, status_code=status.HTTP_201_CREATED)
def create_market(payload: MarketIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
market = Market(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(market)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.put("/markets/{market_id}", response_model=MarketOut)
def update_market(market_id: str, payload: MarketIn, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
market.name = payload.name.strip()
market.sort_order = payload.sort_order
market.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Markt mit diesem Namen existiert bereits."
) from None
return MarketOut.model_validate(market)
@router.delete("/markets/{market_id}", response_model=MessageOut)
def delete_market(market_id: str, lst: EditableList, db: DbSession):
market = _check_belongs(db, Market, market_id, lst.id, "Markt")
rev = bump_rev(db, lst.id)
market.deleted_at = utcnow()
market.row_rev = rev
# Einträge nicht mitlöschen, nur den Marktbezug lösen - sie landen
# dann in der Gruppe "Ohne Markt" und gehen nicht verloren.
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.market_id == market_id,
ListItem.deleted_at.is_(None),
)
).all():
item.market_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Markt gelöscht. Betroffene Einträge sind ohne Markt.")
# ==========================================================================
# Warengruppen
# ==========================================================================
@router.get("/categories", response_model=list[CategoryOut])
def get_categories(lst: ReadableList, db: DbSession):
rows = db.scalars(
select(Category)
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
.order_by(Category.sort_order, Category.name)
).all()
return [CategoryOut.model_validate(c) for c in rows]
@router.post("/categories", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
def create_category(payload: CategoryIn, lst: EditableList, db: DbSession):
rev = bump_rev(db, lst.id)
cat = Category(
list_id=lst.id, name=payload.name.strip(),
sort_order=payload.sort_order, row_rev=rev,
)
db.add(cat)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.put("/categories/{category_id}", response_model=CategoryOut)
def update_category(
category_id: str, payload: CategoryIn, lst: EditableList, db: DbSession
):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
cat.name = payload.name.strip()
cat.sort_order = payload.sort_order
cat.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Warengruppe existiert bereits."
) from None
return CategoryOut.model_validate(cat)
@router.delete("/categories/{category_id}", response_model=MessageOut)
def delete_category(category_id: str, lst: EditableList, db: DbSession):
cat = _check_belongs(db, Category, category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
cat.deleted_at = utcnow()
cat.row_rev = rev
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.category_id == category_id,
ListItem.deleted_at.is_(None),
)
).all():
item.category_id = None
item.row_rev = rev
db.commit()
return MessageOut(detail="Warengruppe gelöscht.")
# ==========================================================================
# Artikel
# ==========================================================================
def _article_out(a: Article) -> ArticleOut:
return ArticleOut(
id=a.id, name=a.name, barcode=a.barcode, note=a.note,
default_market_id=a.default_market_id,
default_category_id=a.default_category_id,
attributes=[
AttributeOut(name=x.attr_name, value=x.attr_value) for x in a.attributes
],
available_market_ids=[x.market_id for x in a.availability],
row_rev=a.row_rev,
)
def _apply_attributes(db: Session, article: Article, attributes) -> None:
"""Ersetzt die Attributmenge vollständig."""
for old in list(article.attributes):
db.delete(old)
article.attributes = []
db.flush()
seen = set()
for attr in attributes:
name = attr.name.strip()
if not name or name in seen:
continue
seen.add(name)
db.add(
ArticleAttribute(
article_id=article.id, attr_name=name, attr_value=attr.value.strip()
)
)
def _apply_availability(db: Session, article: Article, market_ids, list_id: str) -> None:
for old in db.scalars(
select(ArticleMarket).where(ArticleMarket.article_id == article.id)
).all():
db.delete(old)
db.flush()
for mid in dict.fromkeys(market_ids):
_check_belongs(db, Market, mid, list_id, "Markt")
db.add(ArticleMarket(article_id=article.id, market_id=mid))
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(
lst: ReadableList,
db: DbSession,
q: str | None = None,
with_barcode: bool | None = None,
):
stmt = select(Article).where(
Article.list_id == lst.id, Article.deleted_at.is_(None)
)
if q:
# Nur der LIKE-Operator, kein zusammengebautes SQL - der Suchtext
# geht als gebundener Parameter in die Abfrage. Sonderzeichen von
# LIKE werden maskiert, damit "50%" nicht alles findet.
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
if with_barcode is True:
stmt = stmt.where(Article.barcode.is_not(None))
elif with_barcode is False:
stmt = stmt.where(Article.barcode.is_(None))
rows = db.scalars(stmt.order_by(Article.name).limit(500)).all()
return [_article_out(a) for a in rows]
@router.get("/barcode/{barcode}", response_model=ProductLookupOut)
def resolve_barcode(barcode: str, lst: ReadableList, db: DbSession):
"""Strichcode auflösen - erst im eigenen Bestand, dann außerhalb.
Die Reihenfolge ist wichtig: Was hier schon gepflegt wurde, ist
verlässlicher als eine Fremdquelle, und ein eigener Name soll nicht
von einer Datenbank überschrieben werden.
"""
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is not None:
return ProductLookupOut(
barcode=barcode, found=True, source="catalog",
article_id=article.id, name=article.name,
)
hit = lookup(db, barcode)
if hit is None or not hit.found:
return ProductLookupOut(barcode=barcode, found=False, source="none")
return ProductLookupOut(
barcode=barcode,
found=True,
source="openfoodfacts",
name=hit.name,
brand=hit.brand,
package=hit.package,
count=hit.count,
pack_size=hit.pack_size,
pack_unit=hit.pack_unit,
)
@router.get("/articles/by-barcode/{barcode}", response_model=ArticleOut)
def article_by_barcode(barcode: str, lst: ReadableList, db: DbSession):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == barcode,
Article.deleted_at.is_(None),
)
)
if article is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Kein Artikel mit diesem Barcode"
)
return _article_out(article)
@router.post("/articles", response_model=ArticleOut, status_code=status.HTTP_201_CREATED)
def create_article(payload: ArticleIn, lst: EditableList, db: DbSession):
_check_belongs(db, Market, payload.default_market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.default_category_id, lst.id, "Warengruppe")
rev = bump_rev(db, lst.id)
article = Article(
list_id=lst.id, name=payload.name.strip(),
barcode=(payload.barcode or None), note=(payload.note or None),
default_market_id=payload.default_market_id,
default_category_id=payload.default_category_id,
row_rev=rev,
)
db.add(article)
db.flush()
_apply_attributes(db, article, payload.attributes)
_apply_availability(db, article, payload.available_market_ids, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.patch("/articles/{article_id}", response_model=ArticleOut)
def update_article(
article_id: str, payload: ArticleUpdateIn, lst: EditableList, db: DbSession
):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
data = payload.model_dump(exclude_unset=True)
if "default_market_id" in data:
_check_belongs(db, Market, data["default_market_id"], lst.id, "Markt")
article.default_market_id = data["default_market_id"]
if "default_category_id" in data:
_check_belongs(db, Category, data["default_category_id"], lst.id, "Warengruppe")
article.default_category_id = data["default_category_id"]
if "name" in data and data["name"]:
article.name = data["name"].strip()
if "barcode" in data:
article.barcode = data["barcode"] or None
if "note" in data:
article.note = data["note"] or None
if payload.attributes is not None:
_apply_attributes(db, article, payload.attributes)
if payload.available_market_ids is not None:
_apply_availability(db, article, payload.available_market_ids, lst.id)
article.row_rev = bump_rev(db, lst.id)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Artikel mit diesem Namen existiert bereits."
) from None
db.refresh(article)
return _article_out(article)
@router.delete("/articles/{article_id}", response_model=MessageOut)
def delete_article(article_id: str, lst: EditableList, db: DbSession):
article = _check_belongs(db, Article, article_id, lst.id, "Artikel")
rev = bump_rev(db, lst.id)
article.deleted_at = utcnow()
article.row_rev = rev
# Offene Einträge dieses Artikels verschwinden mit. Alles andere
# hinterließe Einträge, die auf einen gelöschten Artikel zeigen.
removed = 0
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.article_id == article_id,
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = utcnow()
item.row_rev = rev
removed += 1
db.commit()
return MessageOut(
detail=f"Artikel gelöscht, dazu {removed} Eintrag/Einträge auf der Liste."
)

View File

@@ -0,0 +1,245 @@
"""Listeneinträge: anlegen, ändern, verschieben, zurückstellen, löschen.
Dazu die gruppierte Ansicht Markt -> Warengruppe -> Artikel, die sowohl
die App als auch der Druck in Phase 9 verwendet. Die Sortierlogik liegt
bewusst nur hier, damit Bildschirm und Papier nicht auseinanderlaufen.
"""
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.deps import DbSession, VerifiedUser
from app.models import Article, Category, ListItem, Market
from app.list_view import build_view, item_out
from app.print_view import render_print
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.routers.catalog import _check_belongs
from app.schemas import MessageOut
from app.schemas_shopping import (
ClearBoughtOut,
ItemCreateIn,
ItemOut,
ItemStatus,
ItemUpdateIn,
ListView,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists/{list_id}", tags=["items"])
@router.get("/items", response_model=list[ItemOut])
def get_items(
lst: ReadableList,
db: DbSession,
status_filter: ItemStatus | None = Query(default=None, alias="status"),
):
stmt = select(ListItem).where(
ListItem.list_id == lst.id, ListItem.deleted_at.is_(None)
)
if status_filter:
stmt = stmt.where(ListItem.status == status_filter)
rows = db.scalars(stmt.order_by(ListItem.created_at)).all()
return [item_out(i) for i in rows]
@router.post("/items", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(
payload: ItemCreateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
if not payload.article_id and not payload.article_name:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entweder article_id oder article_name angeben.",
)
rev = bump_rev(db, lst.id)
if payload.article_id:
article = _check_belongs(db, Article, payload.article_id, lst.id, "Artikel")
else:
name = payload.article_name.strip()
# Vorhandenen Artikel wiederverwenden. Der Vergleich ist dank
# utf8mb4_unicode_ci von Haus aus unabhaengig von Gross- und
# Kleinschreibung.
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(list_id=lst.id, name=name, row_rev=rev)
db.add(article)
db.flush()
_check_belongs(db, Market, payload.market_id, lst.id, "Markt")
_check_belongs(db, Category, payload.category_id, lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
# Ohne Angabe die Vorgaben aus dem Artikel übernehmen.
market_id=payload.market_id or article.default_market_id,
category_id=payload.category_id or article.default_category_id,
count=payload.count,
pack_size=payload.pack_size,
pack_unit=(payload.pack_unit or None),
variant=(payload.variant or None),
note=(payload.note or None),
status="open",
created_by=user.id,
row_rev=rev,
)
db.add(item)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT, "Eintrag konnte nicht angelegt werden."
) from None
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: str,
payload: ItemUpdateIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Deckt alles ab, was während des Einkaufs passiert: abhaken,
zurückstellen, Menge ändern, Preis erfassen, in einen anderen Markt
verschieben."""
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if payload.clear_market:
item.market_id = None
elif "market_id" in data:
_check_belongs(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = data["market_id"]
if payload.clear_category:
item.category_id = None
elif "category_id" in data:
_check_belongs(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = data["category_id"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field in data:
setattr(item, field, data[field])
item.row_rev = bump_rev(db, lst.id)
# Nach dem Setzen der Felder: Der Preis wird nur festgehalten, wenn
# auch ein Markt zugeordnet ist - ohne den ergibt er keinen
# Vergleichswert.
record_price(db, item)
db.commit()
db.refresh(item)
background.add_task(notify_list_changed, lst.id, user.id)
return item_out(item)
@router.delete("/items/{item_id}", response_model=MessageOut)
def delete_item(
item_id: str, lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
item = db.get(ListItem, item_id)
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
item.deleted_at = utcnow()
item.row_rev = bump_rev(db, lst.id)
db.commit()
background.add_task(notify_list_changed, lst.id, user.id)
return MessageOut(detail="Eintrag gelöscht.")
@router.post("/items/clear-bought", response_model=ClearBoughtOut)
def clear_bought(
lst: EditableList, db: DbSession, user: VerifiedUser,
background: BackgroundTasks,
):
"""Entfernt alle als gekauft markierten Einträge in einem Zug.
Ein einziger Revisionsschritt für den gesamten Vorgang - so sieht ein
synchronisierender Client eine geschlossene Änderung statt dutzender
einzelner.
"""
rev = bump_rev(db, lst.id)
now = utcnow()
rows = db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all()
for item in rows:
item.deleted_at = now
item.row_rev = rev
db.commit()
if rows:
background.add_task(notify_list_changed, lst.id, user.id)
return ClearBoughtOut(removed=len(rows), rev=rev)
# ==========================================================================
# Gruppierte Ansicht
# ==========================================================================
@router.get("/print", response_class=HTMLResponse, include_in_schema=True)
def print_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=False),
):
"""Druckfertige Seite auf A4.
Standardmäßig ohne zurückgestellte Artikel: Wer ausdruckt, will die
Einkaufsliste, nicht die Merkliste.
"""
view = build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)
return HTMLResponse(render_print(view, include_bought=include_bought))
@router.get("/view", response_model=ListView)
def grouped_view(
lst: ReadableList,
db: DbSession,
include_bought: bool = Query(default=True),
include_deferred: bool = Query(default=True),
):
"""Markt -> Warengruppe -> Artikel, alphabetisch innerhalb der Gruppe.
Die Aufbereitung liegt in app/list_view.py, weil der oeffentliche Link
und der Ausdruck dieselbe Gliederung brauchen.
"""
return build_view(
db, lst, include_bought=include_bought, include_deferred=include_deferred
)

View File

@@ -0,0 +1,205 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import func, select
from app.deps import DbSession, VerifiedUser
from app.models import Category, ListMember, Market, ShoppingList, User
from app.permissions import OwnedList, ReadableList
from app.schemas import MessageOut
from app.list_view import build_view
from app.schemas_shopping import (
CategoryOut,
ListCreateIn,
ListOut,
ListSnapshot,
ListUpdateIn,
MarketOut,
MemberOut,
MemberRoleIn,
)
from app.security import utcnow
router = APIRouter(prefix="/api/lists", tags=["lists"])
MAX_LISTS_PER_USER = 100
def _to_out(db, lst: ShoppingList, role: str, may_share: bool = False) -> ListOut:
count = db.scalar(
select(func.count()).select_from(ListMember).where(ListMember.list_id == lst.id)
)
return ListOut(
id=lst.id, name=lst.name, owner_id=lst.owner_id, rev=lst.rev,
role=role, may_share_public=(role == "owner") or may_share,
member_count=count or 0, created_at=lst.created_at,
)
@router.get("", response_model=list[ListOut])
def my_lists(db: DbSession, user: VerifiedUser):
rows = db.execute(
select(ShoppingList, ListMember.role, ListMember.may_share_public)
.join(ListMember, ListMember.list_id == ShoppingList.id)
.where(ListMember.user_id == user.id, ShoppingList.deleted_at.is_(None))
.order_by(ShoppingList.created_at)
).all()
return [_to_out(db, lst, role, may) for lst, role, may in rows]
@router.post("", response_model=ListOut, status_code=status.HTTP_201_CREATED)
def create_list(payload: ListCreateIn, db: DbSession, user: VerifiedUser):
owned = db.scalar(
select(func.count()).select_from(ShoppingList).where(
ShoppingList.owner_id == user.id, ShoppingList.deleted_at.is_(None)
)
)
if (owned or 0) >= MAX_LISTS_PER_USER:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Höchstens {MAX_LISTS_PER_USER} eigene Listen.",
)
lst = ShoppingList(name=payload.name.strip(), owner_id=user.id, rev=1)
db.add(lst)
db.flush()
db.add(ListMember(
list_id=lst.id, user_id=user.id, role="owner", may_share_public=True
))
db.commit()
return _to_out(db, lst, "owner")
@router.get("/{list_id}", response_model=ListOut)
def read_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
member = db.get(ListMember, (lst.id, user.id))
return _to_out(db, lst, member.role, member.may_share_public)
@router.get("/{list_id}/snapshot", response_model=ListSnapshot)
def snapshot(lst: ReadableList, db: DbSession, user: VerifiedUser):
"""Gesamtstand in einem Aufruf: Liste, gruppierte Ansicht, Märkte,
Warengruppen. Ersetzt vier einzelne Abfragen."""
member = db.get(ListMember, (lst.id, user.id))
markets = db.scalars(
select(Market)
.where(Market.list_id == lst.id, Market.deleted_at.is_(None))
.order_by(Market.sort_order, Market.name)
).all()
categories = db.scalars(
select(Category)
.where(Category.list_id == lst.id, Category.deleted_at.is_(None))
.order_by(Category.sort_order, Category.name)
).all()
return ListSnapshot(
list=_to_out(db, lst, member.role, member.may_share_public),
view=build_view(db, lst),
markets=[MarketOut.model_validate(m) for m in markets],
categories=[CategoryOut.model_validate(c) for c in categories],
)
@router.patch("/{list_id}", response_model=ListOut)
def rename_list(payload: ListUpdateIn, lst: OwnedList, db: DbSession, user: VerifiedUser):
lst.name = payload.name.strip()
lst.rev += 1
db.commit()
return _to_out(db, lst, "owner")
@router.delete("/{list_id}", response_model=MessageOut)
def delete_list(lst: OwnedList, db: DbSession):
# Soft Delete: offline-Clients sollen das Verschwinden noch mitbekommen.
lst.deleted_at = utcnow()
lst.rev += 1
db.commit()
return MessageOut(detail="Liste gelöscht.")
# --------------------------------------------------------------------------
# Mitgliedschaften
# Einladungen per Mail und QR-Code folgen in Phase 5. Hier lassen sich
# bestehende Mitgliedschaften nur ansehen, ändern und beenden.
# --------------------------------------------------------------------------
@router.get("/{list_id}/members", response_model=list[MemberOut])
def list_members(lst: ReadableList, db: DbSession):
rows = db.execute(
select(ListMember, User)
.join(User, User.id == ListMember.user_id)
.where(ListMember.list_id == lst.id)
.order_by(ListMember.joined_at)
).all()
return [
MemberOut(
user_id=u.id, email=u.email, display_name=u.display_name,
role=m.role, may_share_public=m.may_share_public,
joined_at=m.joined_at,
)
for m, u in rows
]
@router.put("/{list_id}/members/{user_id}", response_model=MemberOut)
def set_member_role(
user_id: str, payload: MemberRoleIn, lst: OwnedList, db: DbSession,
user: VerifiedUser,
):
if user_id == lst.owner_id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Die Rolle des Eigentümers lässt sich nicht ändern. "
"Übertrage stattdessen die Liste.",
)
member = db.get(ListMember, (lst.id, user_id))
if member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
member.role = payload.role
if payload.may_share_public is not None:
member.may_share_public = payload.may_share_public
lst.rev += 1
db.commit()
target = db.get(User, user_id)
return MemberOut(
user_id=target.id, email=target.email, display_name=target.display_name,
role=member.role, may_share_public=member.may_share_public,
joined_at=member.joined_at,
)
@router.delete("/{list_id}/members/{user_id}", response_model=MessageOut)
def remove_member(user_id: str, list_id: str, db: DbSession, user: VerifiedUser):
"""Der Eigentümer entzieht anderen den Zugriff; jedes Mitglied kann
sich selbst entfernen (bequemer über POST /api/lists/{id}/leave).
Der Eigentümer kann sich nicht selbst entfernen - die Liste stünde
sonst ohne Verwaltung da. Dafür gibt es den Eigentümerwechsel."""
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
own = db.get(ListMember, (list_id, user.id))
if lst is None or own is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
if user_id != user.id and own.role != "owner":
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Nur der Eigentümer kann andere entfernen."
)
if user_id == lst.owner_id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Der Eigentümer kann die Liste nicht verlassen. Lösche sie oder "
"übertrage sie zuvor.",
)
member = db.get(ListMember, (list_id, user_id))
if member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Mitglied dieser Liste")
db.delete(member)
lst.rev += 1
db.commit()
return MessageOut(detail="Mitgliedschaft beendet.")

View File

@@ -0,0 +1,39 @@
"""Preisvergleich: Übersicht, Verlauf je Artikel, Kurzhinweise."""
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.deps import DbSession
from app.models import Article
from app.permissions import ReadableList
from app.prices import article_prices, hints, overview
from app.schemas_shopping import ArticlePrices, PriceHint, PriceOverview
router = APIRouter(prefix="/api/lists/{list_id}", tags=["prices"])
@router.get("/prices", response_model=PriceOverview)
def price_overview(lst: ReadableList, db: DbSession):
"""Vergleichstabelle: je Artikel der jüngste Preis in jedem Markt."""
return overview(db, lst.id)
@router.get("/price-hints", response_model=list[PriceHint])
def price_hints(lst: ReadableList, db: DbSession):
"""Kompakte Fassung für die Listenansicht - nur Artikel, bei denen
sich die Märkte tatsächlich unterscheiden."""
return hints(db, lst.id)
@router.get("/articles/{article_id}/prices", response_model=ArticlePrices)
def article_price_history(article_id: str, lst: ReadableList, db: DbSession):
article = db.scalar(
select(Article).where(
Article.id == article_id,
Article.list_id == lst.id,
Article.deleted_at.is_(None),
)
)
if article is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
return article_prices(db, lst.id, article)

View File

@@ -0,0 +1,297 @@
"""Öffentliche Ansichtslinks.
Wer den Link hat, darf die Liste sehen und - sofern erlaubt - Artikel
abhaken. Sonst nichts: kein Anlegen, kein Löschen, keine Preise ändern,
keine Mitgliederliste, keine Namen.
Sicherheitsmodell: Der Link *ist* die Berechtigung. Wer ihn
weitergibt, gibt den Zugriff weiter. Deshalb ist ein Ablaufdatum Pflicht,
der Widerruf jederzeit möglich, und in der Datenbank steht nur der Hash
des Tokens.
"""
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from app.config import settings
from app.deps import DbSession, VerifiedUser, client_ip
from app.list_view import build_view
from app.print_view import render_print
from app.models import ListItem, ListMember, PublicShare, ShoppingList
from app.permissions import bump_rev
from app.schemas import MessageOut
from app.schemas_shopping import (
PublicShareCreatedOut,
PublicShareIn,
PublicShareOut,
PublicToggleIn,
PublicViewOut,
)
from app.security import check_rate_limit, hash_token, new_token, utcnow
router = APIRouter(tags=["public"])
MAX_DAYS = 365
MAX_ACTIVE_LINKS = 10
# ==========================================================================
# Verwaltung (angemeldet)
# ==========================================================================
def sharer(list_id: str, db: DbSession, user: VerifiedUser) -> ShoppingList:
"""Eigentümer oder Mitglied mit dem Zusatzrecht `may_share_public`."""
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
member = db.get(ListMember, (list_id, user.id))
if lst is None or member is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Liste nicht gefunden")
if member.role != "owner" and not member.may_share_public:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Für öffentliche Links fehlt dir die Berechtigung. Der Eigentümer "
"der Liste kann sie dir erteilen.",
)
return lst
SharableList = Annotated[ShoppingList, Depends(sharer)]
def _status(share: PublicShare) -> str:
if share.revoked_at:
return "revoked"
if share.expires_at <= utcnow():
return "expired"
return "active"
def _share_out(share: PublicShare) -> PublicShareOut:
creator = share.creator
return PublicShareOut(
id=share.id,
label=share.label,
allow_check=share.allow_check,
status=_status(share),
created_by_name=(
(creator.display_name or creator.email.split("@")[0]) if creator else None
),
created_at=share.created_at,
expires_at=share.expires_at,
last_access_at=share.last_access_at,
access_count=share.access_count,
)
@router.get("/api/lists/{list_id}/public-links", response_model=list[PublicShareOut])
def get_public_links(lst: SharableList, db: DbSession):
rows = db.scalars(
select(PublicShare)
.where(PublicShare.list_id == lst.id)
.order_by(PublicShare.created_at.desc())
).all()
return [_share_out(s) for s in rows]
@router.post(
"/api/lists/{list_id}/public-links",
response_model=PublicShareCreatedOut,
status_code=status.HTTP_201_CREATED,
)
def create_public_link(
payload: PublicShareIn, lst: SharableList, db: DbSession, user: VerifiedUser
):
expires = payload.expires_at.replace(tzinfo=None)
now = utcnow()
if expires <= now:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Das Ablaufdatum muss in der Zukunft liegen."
)
if expires > now + timedelta(days=MAX_DAYS):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Ein Link darf höchstens {MAX_DAYS} Tage gültig sein.",
)
active = [
s
for s in db.scalars(
select(PublicShare).where(PublicShare.list_id == lst.id)
).all()
if _status(s) == "active"
]
if len(active) >= MAX_ACTIVE_LINKS:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Es sind bereits {MAX_ACTIVE_LINKS} Links aktiv. Widerrufe zuerst "
"einen davon.",
)
raw = new_token()
share = PublicShare(
list_id=lst.id,
token_hash=hash_token(raw),
label=(payload.label or None),
allow_check=payload.allow_check,
created_by=user.id,
expires_at=expires,
)
db.add(share)
db.commit()
db.refresh(share)
return PublicShareCreatedOut(
share=_share_out(share),
url=f"{settings.public_base_url.rstrip('/')}/s/{raw}",
)
@router.delete("/api/lists/{list_id}/public-links/{share_id}", response_model=MessageOut)
def revoke_public_link(share_id: str, lst: SharableList, db: DbSession):
share = db.get(PublicShare, share_id)
if share is None or share.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Link nicht gefunden")
if share.revoked_at is None:
share.revoked_at = utcnow()
db.commit()
return MessageOut(detail="Link widerrufen. Er funktioniert ab sofort nicht mehr.")
@router.post("/api/lists/{list_id}/public-links/revoke-all", response_model=MessageOut)
def revoke_all_public_links(lst: SharableList, db: DbSession):
count = 0
for share in db.scalars(
select(PublicShare).where(
PublicShare.list_id == lst.id, PublicShare.revoked_at.is_(None)
)
).all():
share.revoked_at = utcnow()
count += 1
db.commit()
return MessageOut(detail=f"{count} Link(s) widerrufen.")
# ==========================================================================
# Zugriff ohne Konto
# ==========================================================================
def _load_share(db: DbSession, token: str, request: Request) -> PublicShare:
# Ohne Anmeldung ist der Endpunkt für jeden erreichbar - deshalb
# 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.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anfragen. Bitte kurz warten."
)
share = db.scalar(
select(PublicShare).where(PublicShare.token_hash == hash_token(token))
)
db.commit()
# Gleiche Antwort für "gibt es nicht" und "abgelaufen"? Nein: Der
# Empfänger soll erfahren, warum der Link nicht mehr geht, sonst
# sucht er den Fehler bei sich.
if share is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Dieser Link ist unbekannt.")
state = _status(share)
if state == "revoked":
raise HTTPException(status.HTTP_410_GONE, "Dieser Link wurde widerrufen.")
if state == "expired":
raise HTTPException(status.HTTP_410_GONE, "Dieser Link ist abgelaufen.")
return share
@router.get("/api/public/{token}", response_model=PublicViewOut)
def public_view(token: str, request: Request, response: Response, db: DbSession):
share = _load_share(db, token, request)
lst = db.get(ShoppingList, share.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
# Grobe Nutzungsanzeige für den Eigentümer: nur Zähler und Zeitpunkt,
# keine IP-Adresse, keine Zeitreihe. Erkennbar soll sein, DASS der
# Link benutzt wird, nicht von wem.
share.access_count += 1
share.last_access_at = utcnow()
db.commit()
response.headers["X-Robots-Tag"] = "noindex, nofollow"
view = build_view(db, lst, anonymous=True)
return PublicViewOut(
list_name=view.list_name,
rev=view.rev,
allow_check=share.allow_check,
expires_at=share.expires_at,
markets=view.markets,
grand_total_cents=view.grand_total_cents,
)
@router.get("/api/public/{token}/print", response_class=HTMLResponse)
def public_print(token: str, request: Request, db: DbSession):
"""Druckansicht über den öffentlichen Link - ohne Konto.
Wie die Bildschirmansicht anonymisiert: Wer welchen Artikel
eingetragen hat, steht dort ohnehin nicht drin, und im Ausdruck
erst recht nicht.
"""
share = _load_share(db, token, request)
lst = db.get(ShoppingList, share.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
share.access_count += 1
share.last_access_at = utcnow()
db.commit()
view = build_view(db, lst, include_deferred=False, anonymous=True)
return HTMLResponse(
render_print(view),
headers={"X-Robots-Tag": "noindex, nofollow"},
)
@router.post("/api/public/{token}/items/{item_id}", response_model=MessageOut)
def public_toggle(
token: str,
item_id: str,
payload: PublicToggleIn,
request: Request,
db: DbSession,
):
"""Die einzige Änderung, die über einen öffentlichen Link möglich ist.
Absichtlich auf offen/gekauft begrenzt: Wer mit dem Link einkaufen
geht, soll abhaken können. Alles Weitere - anlegen, löschen, Preise,
Märkte - bleibt den Mitgliedern vorbehalten.
"""
share = _load_share(db, token, request)
if not share.allow_check:
raise HTTPException(
status.HTTP_403_FORBIDDEN, "Über diesen Link ist nur Ansehen erlaubt."
)
item = db.get(ListItem, item_id)
if item is None or item.list_id != share.list_id or item.deleted_at is not None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
item.status = payload.status
item.row_rev = bump_rev(db, share.list_id)
db.commit()
return MessageOut(detail="Gespeichert.")

160
backend/app/routers/push.py Normal file
View File

@@ -0,0 +1,160 @@
"""An- und Abmeldung von Geräten für Push-Benachrichtigungen."""
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
from sqlalchemy import select
from app.config import settings
from app.deps import DbSession, VerifiedUser
from app.models import PushSubscription
from app.push import send_to_user
from app.schemas import MessageOut
from app.schemas_push import (
PushConfigOut,
PushSubscribeIn,
PushSubscriptionOut,
PushUnsubscribeIn,
)
from app.security import utcnow
router = APIRouter(prefix="/api/push", tags=["push"])
MAX_DEVICES_PER_USER = 20
@router.get("/config", response_model=PushConfigOut)
def push_config():
"""Ohne Anmeldung erreichbar: Der öffentliche Schlüssel ist dafür da,
veröffentlicht zu werden."""
return PushConfigOut(
enabled=settings.push_enabled,
public_key=settings.vapid_public_key or None,
throttle_hours=settings.push_throttle_hours,
)
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
def my_subscriptions(db: DbSession, user: VerifiedUser):
rows = db.scalars(
select(PushSubscription)
.where(PushSubscription.user_id == user.id)
.order_by(PushSubscription.created_at)
).all()
return [
PushSubscriptionOut(
id=s.id, label=s.label, created_at=s.created_at,
last_success_at=s.last_success_at,
)
for s in rows
]
@router.post("/subscribe", response_model=PushSubscriptionOut,
status_code=status.HTTP_201_CREATED)
def subscribe(payload: PushSubscribeIn, db: DbSession, user: VerifiedUser):
if not settings.push_enabled:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
)
existing = db.scalar(
select(PushSubscription).where(PushSubscription.endpoint == payload.endpoint)
)
if existing is not None:
# Derselbe Endpunkt kann nach einem Kontowechsel auf demselben
# Geraet auftauchen - dann uebernehmen statt abweisen.
existing.user_id = user.id
existing.p256dh = payload.keys.p256dh
existing.auth = payload.keys.auth
existing.label = payload.label or existing.label
existing.failure_count = 0
db.commit()
db.refresh(existing)
return PushSubscriptionOut(
id=existing.id, label=existing.label, created_at=existing.created_at,
last_success_at=existing.last_success_at,
)
count = len(db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user.id)
).all())
if count >= MAX_DEVICES_PER_USER:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Höchstens {MAX_DEVICES_PER_USER} Geräte je Konto.",
)
subscription = PushSubscription(
user_id=user.id,
endpoint=payload.endpoint,
p256dh=payload.keys.p256dh,
auth=payload.keys.auth,
label=(payload.label or None),
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return PushSubscriptionOut(
id=subscription.id, label=subscription.label,
created_at=subscription.created_at, last_success_at=None,
)
@router.post("/unsubscribe", response_model=MessageOut)
def unsubscribe(payload: PushUnsubscribeIn, db: DbSession, user: VerifiedUser):
subscription = db.scalar(
select(PushSubscription).where(
PushSubscription.endpoint == payload.endpoint,
PushSubscription.user_id == user.id,
)
)
if subscription is not None:
db.delete(subscription)
db.commit()
# Auch wenn nichts gefunden wurde: Fuer den Aufrufer ist das Ziel
# erreicht - dieses Geraet bekommt keine Benachrichtigungen mehr.
return MessageOut(detail="Benachrichtigungen für dieses Gerät abgeschaltet.")
@router.delete("/subscriptions/{subscription_id}", response_model=MessageOut)
def remove_subscription(subscription_id: str, db: DbSession, user: VerifiedUser):
subscription = db.get(PushSubscription, subscription_id)
if subscription is None or subscription.user_id != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gerät nicht gefunden")
db.delete(subscription)
db.commit()
return MessageOut(detail="Gerät entfernt.")
@router.post("/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
def send_test(background: BackgroundTasks, db: DbSession, user: VerifiedUser):
"""Testnachricht an die eigenen Geräte - umgeht die Drosselung."""
if not settings.push_enabled:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
)
count = len(db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user.id)
).all())
if count == 0:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Für dieses Konto ist kein Gerät angemeldet.",
)
def run() -> None:
from app.db import SessionLocal
with SessionLocal() as session:
send_to_user(session, user.id, {
"title": settings.app_name,
"body": "Testbenachrichtigung die Zustellung funktioniert.",
"tag": "test",
})
background.add_task(run)
return MessageOut(
detail=f"Testnachricht an {count} Gerät(e) in Auftrag gegeben. "
"Ergebnis steht im Log des api-Containers."
)

View File

@@ -0,0 +1,412 @@
"""Teilen von Listen: Einladungen, Widerruf, Eigentümerwechsel.
Ablauf einer Einladung:
1. Der Eigentümer gibt eine E-Mail-Adresse ein.
2. Es entsteht ein Datensatz mit Zufallstoken; versendet wird ein Link
auf /invite?token=…
3. Der Empfänger meldet sich an oder registriert sich und nimmt an.
4. Beim Annehmen entsteht die Mitgliedschaft.
Bewusst unabhängig davon, ob unter der Adresse schon ein Konto besteht.
Damit verrät das Einladen nicht, wer registriert ist, und neue wie
bestehende Nutzer durchlaufen denselben Weg.
"""
from datetime import timedelta
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from sqlalchemy import select
from app.deps import DbSession, VerifiedUser, client_ip
from app.mail import send_invitation
from app.models import ListInvite, ListMember, ShoppingList, User
from app.permissions import OwnedList, ReadableList
from app.schemas import MessageOut
from app.schemas_shopping import (
InviteCreateIn,
InviteOut,
InvitePreviewOut,
MemberOut,
TransferOwnershipIn,
)
from app.security import (
check_rate_limit,
hash_token,
new_token,
normalize_email,
utcnow,
)
router = APIRouter(tags=["sharing"])
INVITE_DAYS = 14
MAX_OPEN_INVITES = 50
def _status(invite: ListInvite) -> str:
if invite.revoked_at:
return "revoked"
if invite.accepted_at:
return "accepted"
if invite.expires_at <= utcnow():
return "expired"
return "pending"
def _invite_out(invite: ListInvite) -> InviteOut:
inviter = invite.inviter
return InviteOut(
id=invite.id,
email=invite.email,
role=invite.role,
status=_status(invite),
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
created_at=invite.created_at,
last_sent_at=invite.last_sent_at,
send_count=invite.send_count,
expires_at=invite.expires_at,
)
def _guard_invite_rate(db: DbSession, request: Request, list_id: str) -> None:
"""Zwei Bremsen: pro Liste und pro absendender IP.
Ohne die zweite ließe sich die Anwendung als Versandhilfe für
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)
if not (ok_list and ok_ip):
db.commit()
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"Zu viele Einladungen in kurzer Zeit. Bitte später erneut versuchen.",
)
# ==========================================================================
# Einladungen verwalten (nur Eigentümer)
# ==========================================================================
@router.get("/api/lists/{list_id}/invites", response_model=list[InviteOut])
def get_invites(lst: OwnedList, db: DbSession):
rows = db.scalars(
select(ListInvite)
.where(ListInvite.list_id == lst.id)
.order_by(ListInvite.created_at.desc())
).all()
return [_invite_out(i) for i in rows]
@router.post(
"/api/lists/{list_id}/invites",
response_model=InviteOut,
status_code=status.HTTP_201_CREATED,
)
def create_invite(
payload: InviteCreateIn,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
email = normalize_email(payload.email)
if email == normalize_email(user.email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Du bist bereits Eigentümer dieser Liste."
)
# Ist unter der Adresse jemand bereits Mitglied?
existing_user = db.scalar(select(User).where(User.email == email))
if existing_user is not None:
member = db.get(ListMember, (lst.id, existing_user.id))
if member is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Person hat bereits Zugriff auf die Liste.",
)
open_count = sum(
1
for i in db.scalars(
select(ListInvite).where(ListInvite.list_id == lst.id)
).all()
if _status(i) == "pending"
)
if open_count >= MAX_OPEN_INVITES:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"Es sind bereits {MAX_OPEN_INVITES} Einladungen offen.",
)
_guard_invite_rate(db, request, lst.id)
# Eine noch offene Einladung an dieselbe Adresse ersetzen, statt eine
# zweite danebenzulegen.
for old in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.email == email,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
old.revoked_at = utcnow()
raw = new_token()
invite = ListInvite(
list_id=lst.id,
email=email,
role=payload.role,
token_hash=hash_token(raw),
invited_by=user.id,
expires_at=utcnow() + timedelta(days=INVITE_DAYS),
)
db.add(invite)
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, email, raw,
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
)
return _invite_out(invite)
@router.post("/api/lists/{list_id}/invites/{invite_id}/resend", response_model=InviteOut)
def resend_invite(
invite_id: str,
request: Request,
lst: OwnedList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Erneuter Versand erzeugt ein NEUES Token und entwertet das alte.
In der Datenbank steht nur der Hash, der Klartext des ursprünglichen
Tokens lässt sich also nicht rekonstruieren. Nebeneffekt: Ein Link,
der versehentlich weitergeleitet wurde, verliert damit seine Gültigkeit.
"""
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
_guard_invite_rate(db, request, lst.id)
raw = new_token()
invite.token_hash = hash_token(raw)
invite.revoked_at = None
invite.expires_at = utcnow() + timedelta(days=INVITE_DAYS)
invite.last_sent_at = utcnow()
invite.send_count += 1
db.commit()
db.refresh(invite)
background.add_task(
send_invitation, invite.email, raw,
lst.name, user.display_name or user.email.split("@")[0], INVITE_DAYS,
)
return _invite_out(invite)
@router.delete("/api/lists/{list_id}/invites/{invite_id}", response_model=MessageOut)
def revoke_invite(invite_id: str, lst: OwnedList, db: DbSession):
invite = db.get(ListInvite, invite_id)
if invite is None or invite.list_id != lst.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einladung nicht gefunden")
if invite.accepted_at:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Diese Einladung wurde bereits angenommen. Entziehe stattdessen "
"die Mitgliedschaft.",
)
invite.revoked_at = utcnow()
db.commit()
return MessageOut(detail="Einladung widerrufen. Der Link funktioniert nicht mehr.")
# ==========================================================================
# Freigabe insgesamt aufheben
# ==========================================================================
@router.post("/api/lists/{list_id}/unshare", response_model=MessageOut)
def unshare(lst: OwnedList, db: DbSession):
"""Entfernt alle Mitglieder außer dem Eigentümer und widerruft alle
offenen Einladungen."""
members = db.scalars(
select(ListMember).where(
ListMember.list_id == lst.id, ListMember.user_id != lst.owner_id
)
).all()
for member in members:
db.delete(member)
revoked = 0
for invite in db.scalars(
select(ListInvite).where(
ListInvite.list_id == lst.id,
ListInvite.accepted_at.is_(None),
ListInvite.revoked_at.is_(None),
)
).all():
invite.revoked_at = utcnow()
revoked += 1
lst.rev += 1
db.commit()
return MessageOut(
detail=f"{len(members)} Zugriff(e) entzogen, {revoked} offene Einladung(en) "
"widerrufen."
)
# ==========================================================================
# Eigentümerwechsel
# ==========================================================================
@router.post("/api/lists/{list_id}/transfer", response_model=list[MemberOut])
def transfer_ownership(
payload: TransferOwnershipIn, lst: OwnedList, db: DbSession, user: VerifiedUser
):
"""Der bisherige Eigentümer wird zum Bearbeiter - so verliert er nicht
versehentlich den Zugang zu einer Liste, an der er mitarbeitet."""
if payload.user_id == lst.owner_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Diese Person ist bereits Eigentümer."
)
target = db.get(ListMember, (lst.id, payload.user_id))
if target is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"Diese Person ist kein Mitglied der Liste. Lade sie zuerst ein.",
)
previous = db.get(ListMember, (lst.id, lst.owner_id))
if previous is not None:
previous.role = "editor"
target.role = "owner"
target.may_share_public = True
lst.owner_id = payload.user_id
lst.rev += 1
db.commit()
rows = db.execute(
select(ListMember, User)
.join(User, User.id == ListMember.user_id)
.where(ListMember.list_id == lst.id)
.order_by(ListMember.joined_at)
).all()
return [
MemberOut(
user_id=u.id, email=u.email, display_name=u.display_name,
role=m.role, may_share_public=m.may_share_public,
joined_at=m.joined_at,
)
for m, u in rows
]
# ==========================================================================
# Einladung ansehen und annehmen (Empfängerseite)
# ==========================================================================
def _load_invite(db: DbSession, token: str) -> ListInvite:
invite = db.scalar(
select(ListInvite).where(ListInvite.token_hash == hash_token(token))
)
if invite is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Diese Einladung ist unbekannt."
)
state = _status(invite)
if state == "revoked":
raise HTTPException(status.HTTP_410_GONE, "Diese Einladung wurde widerrufen.")
if state == "expired":
raise HTTPException(
status.HTTP_410_GONE,
"Diese Einladung ist abgelaufen. Bitte um eine neue.",
)
if state == "accepted":
raise HTTPException(
status.HTTP_409_CONFLICT, "Diese Einladung wurde bereits angenommen."
)
return invite
@router.get("/api/invites/{token}", response_model=InvitePreviewOut)
def preview_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
inviter = invite.inviter
return InvitePreviewOut(
list_name=lst.name,
invited_by_name=(
(inviter.display_name or inviter.email.split("@")[0]) if inviter else None
),
role=invite.role,
email=invite.email,
matches_current_user=normalize_email(user.email) == invite.email,
)
@router.post("/api/invites/{token}/accept", response_model=MessageOut)
def accept_invite(token: str, db: DbSession, user: VerifiedUser):
invite = _load_invite(db, token)
# Die Adresse muss zum angemeldeten Konto passen. Sonst würde ein
# weitergeleiteter Link jedem den Zugang öffnen.
if normalize_email(user.email) != invite.email:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Diese Einladung ist an {invite.email} gerichtet. "
"Melde dich mit diesem Konto an oder bitte um eine neue Einladung.",
)
lst = db.get(ShoppingList, invite.list_id)
if lst is None or lst.deleted_at is not None:
raise HTTPException(status.HTTP_410_GONE, "Die Liste existiert nicht mehr.")
if db.get(ListMember, (lst.id, user.id)) is None:
db.add(ListMember(list_id=lst.id, user_id=user.id, role=invite.role))
lst.rev += 1
invite.accepted_at = utcnow()
invite.accepted_by = user.id
db.commit()
return MessageOut(detail=f"Du hast jetzt Zugriff auf „{lst.name}“.")
# ==========================================================================
# Eigene Mitgliedschaft beenden
# ==========================================================================
@router.post("/api/lists/{list_id}/leave", response_model=MessageOut)
def leave_list(lst: ReadableList, db: DbSession, user: VerifiedUser):
if lst.owner_id == user.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Als Eigentümer kannst du die Liste nicht verlassen. Übertrage sie "
"zuvor oder lösche sie.",
)
member = db.get(ListMember, (lst.id, user.id))
if member is not None:
db.delete(member)
lst.rev += 1
db.commit()
return MessageOut(detail="Du hast die Liste verlassen.")

354
backend/app/routers/sync.py Normal file
View File

@@ -0,0 +1,354 @@
"""Synchronisation: Stapelverarbeitung aus der Outbox und Ereigniskanal.
Zwei Endpunkte:
POST /api/lists/{id}/ops Der Client schickt seine gesammelten
Operationen. Jede trägt eine selbst
vergebene `op_id`; bereits verarbeitete
werden erkannt und übersprungen.
GET /api/lists/{id}/events Server-Sent Events. Meldet nur den neuen
Revisionsstand, keine Nutzdaten - der
Client holt die Ansicht dann selbst.
Warum kein Delta-Endpunkt: Eine Einkaufsliste hat Dutzende Einträge, keine
Zehntausende. Die vollständige Ansicht neu zu holen kostet ein paar
Kilobyte und spart eine ganze Klasse von Fehlern, die beim Zusammensetzen
von Teilständen entsteht. Der Revisionszähler sorgt dafür, dass das nur
passiert, wenn sich wirklich etwas geändert hat.
"""
import asyncio
import json
import logging
import anyio
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.deps import DbSession, VerifiedUser, current_user
from app.list_view import build_view
from app.models import AppliedOp, Article, Category, ListItem, ListMember, Market, ShoppingList
from app.permissions import EditableList, ReadableList, bump_rev
from app.prices import record_price
from app.push import notify_list_changed
from app.schemas_sync import OpBatchIn, OpBatchOut, OpResult
from app.security import utcnow
log = logging.getLogger(__name__)
router = APIRouter(tags=["sync"])
MAX_OPS_PER_BATCH = 200
POLL_SECONDS = 2
HEARTBEAT_SECONDS = 20
# ==========================================================================
# Operationen anwenden
# ==========================================================================
def _ref(db: Session, model, obj_id: str | None, list_id: str, label: str):
if obj_id is None:
return None
obj = db.get(model, obj_id)
if obj is None or obj.list_id != list_id or obj.deleted_at is not None:
raise ValueError(f"{label} nicht gefunden")
return obj
def _apply(db: Session, lst: ShoppingList, user_id: str, op, rev: int) -> str | None:
"""Führt eine einzelne Operation aus und gibt bei item.create die
vergebene ID zurück. Wirft ValueError bei fachlichen Fehlern - die
lässt der Aufrufer als abgelehnte Operation durchgehen, ohne den
ganzen Stapel scheitern zu lassen."""
data = op.payload
kind = op.kind
if kind == "item.create":
article = None
# Bekannter Artikel, etwa nach einem Strichcode-Treffer.
if data.get("article_id"):
article = db.get(Article, data["article_id"])
if (article is None or article.list_id != lst.id
or article.deleted_at is not None):
raise ValueError("Artikel nicht gefunden")
# Sonst über den Barcode suchen ...
if article is None and data.get("barcode"):
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.barcode == data["barcode"],
Article.deleted_at.is_(None),
)
)
# ... und zuletzt über den Namen.
if article is None:
name = (data.get("article_name") or "").strip()
if not name:
raise ValueError("Artikelname fehlt")
article = db.scalar(
select(Article).where(
Article.list_id == lst.id,
Article.name == name,
Article.deleted_at.is_(None),
)
)
if article is None:
article = Article(
list_id=lst.id,
name=name[:200],
barcode=(data.get("barcode") or None),
row_rev=rev,
)
db.add(article)
db.flush()
elif data.get("barcode") and not article.barcode:
# Der Artikel war schon da, hatte aber noch keinen Code -
# beim nächsten Scan wird er direkt gefunden.
article.barcode = data["barcode"]
article.row_rev = rev
market = _ref(db, Market, data.get("market_id"), lst.id, "Markt")
category = _ref(db, Category, data.get("category_id"), lst.id, "Warengruppe")
item = ListItem(
list_id=lst.id,
article_id=article.id,
market_id=(market.id if market else article.default_market_id),
category_id=(category.id if category else article.default_category_id),
count=int(data.get("count") or 1),
# "quantity"/"unit" sind die alten Feldnamen. Ein Geraet, das
# zum Zeitpunkt der Umstellung noch Operationen in der Outbox
# hatte, schickt sie weiterhin - die landen jetzt als Gebinde,
# was der bisherigen Bedeutung entspricht.
pack_size=data.get("pack_size", data.get("quantity")),
pack_unit=(data.get("pack_unit") or data.get("unit") or None),
variant=(data.get("variant") or None),
note=(data.get("note") or None),
status="open",
created_by=user_id,
row_rev=rev,
)
db.add(item)
db.flush()
return item.id
if kind == "item.update":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id or item.deleted_at is not None:
raise ValueError("Eintrag nicht gefunden")
if data.get("clear_market"):
item.market_id = None
elif "market_id" in data:
market = _ref(db, Market, data["market_id"], lst.id, "Markt")
item.market_id = market.id if market else None
if data.get("clear_category"):
item.category_id = None
elif "category_id" in data:
category = _ref(db, Category, data["category_id"], lst.id, "Warengruppe")
item.category_id = category.id if category else None
# Alte Feldnamen aus einer Outbox von vor der Umstellung.
if "quantity" in data and "pack_size" not in data:
data["pack_size"] = data["quantity"]
if "unit" in data and "pack_unit" not in data:
data["pack_unit"] = data["unit"]
for field in ("count", "pack_size", "pack_unit", "variant", "note",
"status", "price_cents"):
if field not in data:
continue
# count und status sind nicht leerbar - ein Eintrag ohne
# Stückzahl oder Status waere unvollstaendig.
if field in ("count", "status") and data[field] is None:
continue
setattr(item, field, data[field])
item.row_rev = rev
record_price(db, item)
return item.id
if kind == "item.delete":
item = db.get(ListItem, data.get("item_id"))
if item is None or item.list_id != lst.id:
raise ValueError("Eintrag nicht gefunden")
if item.deleted_at is None:
item.deleted_at = utcnow()
item.row_rev = rev
return item.id
if kind == "items.clear_bought":
now = utcnow()
for item in db.scalars(
select(ListItem).where(
ListItem.list_id == lst.id,
ListItem.status == "bought",
ListItem.deleted_at.is_(None),
)
).all():
item.deleted_at = now
item.row_rev = rev
return None
raise ValueError(f"Unbekannte Operation: {kind}")
@router.post("/api/lists/{list_id}/ops", response_model=OpBatchOut)
def apply_ops(
payload: OpBatchIn,
lst: EditableList,
db: DbSession,
user: VerifiedUser,
background: BackgroundTasks,
):
"""Verarbeitet einen Stapel Operationen.
Der gesamte Stapel bekommt EINEN Revisionsschritt. So sieht ein
anderes Gerät eine geschlossene Änderung statt dutzender einzelner -
und die Anzeige flackert nicht.
Fachlich fehlgeschlagene Operationen werden einzeln als abgelehnt
zurückgemeldet, statt den ganzen Stapel scheitern zu lassen. Sonst
würde ein Eintrag, den jemand anders inzwischen gelöscht hat, alle
übrigen Änderungen des Geräts blockieren.
"""
if len(payload.ops) > MAX_OPS_PER_BATCH:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Höchstens {MAX_OPS_PER_BATCH} Operationen je Aufruf.",
)
results: list[OpResult] = []
rev = lst.rev
changed = False
for op in payload.ops:
known = db.get(AppliedOp, op.op_id)
if known is not None:
# Schon verarbeitet - Quittung wiederholen, nichts tun.
results.append(
OpResult(op_id=op.op_id, status="duplicate", result_id=known.result_id)
)
continue
if not changed:
rev = bump_rev(db, lst.id)
changed = True
try:
result_id = _apply(db, lst, user.id, op, rev)
except ValueError as exc:
db.rollback()
# Nach dem Rollback ist der Revisionsschritt weg - beim
# nächsten erfolgreichen Vorgang wird neu gezogen.
changed = False
results.append(OpResult(op_id=op.op_id, status="rejected", error=str(exc)))
continue
db.add(
AppliedOp(
op_id=op.op_id, list_id=lst.id, user_id=user.id,
kind=op.kind, result_id=result_id, rev=rev,
)
)
db.flush()
results.append(OpResult(op_id=op.op_id, status="applied", result_id=result_id))
db.commit()
db.refresh(lst)
# Nur benachrichtigen, wenn wirklich etwas passiert ist. Ein Stapel,
# der nur aus Wiederholungen bestand, aendert nichts.
if any(r.status == "applied" for r in results):
background.add_task(notify_list_changed, lst.id, user.id)
return OpBatchOut(rev=lst.rev, results=results)
# ==========================================================================
# Ereigniskanal
# ==========================================================================
def _read_rev(list_id: str, user_id: str) -> int | None:
"""Läuft im Threadpool: SQLAlchemy ist hier synchron konfiguriert,
und ein blockierender Aufruf in der Ereignisschleife würde den
gesamten Server anhalten."""
with SessionLocal() as db:
if db.get(ListMember, (list_id, user_id)) is None:
return None
lst = db.scalar(
select(ShoppingList).where(
ShoppingList.id == list_id, ShoppingList.deleted_at.is_(None)
)
)
return lst.rev if lst else None
@router.get("/api/lists/{list_id}/events")
async def events(list_id: str, request: Request, lst: ReadableList, user: VerifiedUser):
"""Meldet Änderungen an der Liste als Server-Sent Events.
Ohne Redis wird der Revisionsstand abgefragt statt verteilt - bei
dieser Größenordnung völlig ausreichend. Wichtig ist nur, dass die
Abfrage im Threadpool läuft.
Achtung beim Reverse Proxy: `proxy_buffering off` ist Pflicht, sonst
sammelt nginx die Ereignisse und liefert sie gebündelt aus.
"""
user_id = user.id
last_rev = lst.rev
async def stream():
nonlocal last_rev
# Erstes Ereignis sofort, damit der Client seinen Stand abgleicht.
yield f"event: rev\ndata: {json.dumps({'rev': last_rev})}\n\n"
# Wiederverbindungsabstand für den Browser.
yield "retry: 5000\n\n"
since_heartbeat = 0.0
while True:
if await request.is_disconnected():
break
await asyncio.sleep(POLL_SECONDS)
since_heartbeat += POLL_SECONDS
try:
rev = await anyio.to_thread.run_sync(_read_rev, list_id, user_id)
except Exception:
log.exception("Ereigniskanal: Abfrage fehlgeschlagen")
break
if rev is None:
# Liste gelöscht oder Zugriff entzogen.
yield "event: gone\ndata: {}\n\n"
break
if rev != last_rev:
last_rev = rev
since_heartbeat = 0.0
yield f"event: rev\ndata: {json.dumps({'rev': rev})}\n\n"
elif since_heartbeat >= HEARTBEAT_SECONDS:
since_heartbeat = 0.0
# Kommentarzeile: hält die Verbindung durch Proxys offen,
# löst beim Client aber kein Ereignis aus.
yield ": ping\n\n"
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)

74
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,74 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterIn(BaseModel):
email: EmailStr
# Untergrenze nach BSI/NIST-Empfehlung: Laenge statt Zeichenklassen.
password: str = Field(min_length=12, max_length=256)
display_name: str | None = Field(default=None, max_length=80)
class LoginIn(BaseModel):
email: EmailStr
password: str = Field(max_length=256)
class PasswordResetRequestIn(BaseModel):
email: EmailStr
class PasswordResetIn(BaseModel):
token: str = Field(max_length=128)
password: str = Field(min_length=12, max_length=256)
class PasswordChangeIn(BaseModel):
current_password: str = Field(max_length=256)
new_password: str = Field(min_length=12, max_length=256)
class ProfileUpdateIn(BaseModel):
display_name: str | None = Field(default=None, max_length=80)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
email: EmailStr
display_name: str | None
is_admin: bool
verified: bool
must_change_password: bool
created_at: datetime
@classmethod
def of(cls, user) -> "UserOut":
return cls(
id=user.id,
email=user.email,
display_name=user.display_name,
is_admin=user.is_admin,
verified=user.verified_at is not None,
must_change_password=user.must_change_password,
created_at=user.created_at,
)
class MessageOut(BaseModel):
detail: str
class MailCheckOut(BaseModel):
ok: bool
detail: str
host: str
port: int
security: str
envelope_from: str
class MailTestIn(BaseModel):
to: EmailStr

View File

@@ -0,0 +1,83 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
class AdminUserOut(BaseModel):
id: str
email: EmailStr
display_name: str | None
is_admin: bool
is_active: bool
verified: bool
# Nie angemeldet, oder seit wann nicht mehr
last_seen_at: datetime | None
deactivated_at: datetime | None
created_at: datetime
# Wie viele Listen gehoeren dem Konto, in wie vielen ist es Mitglied
owned_lists: int
memberships: int
# Laeuft gerade eine Adressaenderung?
pending_email: str | None = None
class AdminStatsOut(BaseModel):
total: int
active: int
inactive: int
unverified: int
admins: int
# Konten, die beim naechsten Durchlauf deaktiviert bzw. geloescht
# wuerden - damit die Automatik nicht ueberrascht.
due_deactivation: int
due_deletion: int
class UserCreateIn(BaseModel):
email: EmailStr
display_name: str | None = Field(default=None, max_length=80)
is_admin: bool = False
class EmailChangeIn(BaseModel):
new_email: EmailStr
class DeleteUserIn(BaseModel):
"""Wie mit den Listen des Kontos verfahren werden soll.
"refuse" bricht ab, wenn dem Konto Listen gehoeren - die
Voreinstellung, damit nichts unbeabsichtigt verschwindet.
"handover" uebertraegt geteilte Listen an das diensthaelteste andere
Mitglied und loescht nur die, die niemand sonst nutzt.
"""
lists: Literal["refuse", "handover"] = "refuse"
# Sicherheitsabfrage: die Adresse muss zum Konto passen
confirm_email: EmailStr
class AdminSettingsOut(BaseModel):
allow_self_registration: bool
locked_by_env: bool
# 0 = abgeschaltet
auto_deactivate_months: int
auto_delete_months: int
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)
class WelcomeCompleteIn(BaseModel):
token: str = Field(max_length=128)
password: str = Field(min_length=12, max_length=256)
display_name: str | None = Field(default=None, max_length=80)
class WelcomePreviewOut(BaseModel):
email: EmailStr
display_name: str | None

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from pydantic import BaseModel, Field
class PushConfigOut(BaseModel):
enabled: bool
# Der oeffentliche VAPID-Schluessel, base64url ohne Auffuellzeichen.
public_key: str | None = None
throttle_hours: int
class PushKeys(BaseModel):
p256dh: str = Field(max_length=200)
auth: str = Field(max_length=100)
class PushSubscribeIn(BaseModel):
# Vom Push-Dienst des Browserherstellers vergebene URL.
endpoint: str = Field(min_length=10, max_length=500)
keys: PushKeys
# Grobe Geraetebezeichnung zur Unterscheidung mehrerer Anmeldungen.
label: str | None = Field(default=None, max_length=80)
class PushUnsubscribeIn(BaseModel):
endpoint: str = Field(min_length=10, max_length=500)
class PushSubscriptionOut(BaseModel):
id: str
label: str | None
created_at: datetime
last_success_at: datetime | None

View File

@@ -0,0 +1,407 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
Role = Literal["viewer", "editor", "owner"]
ItemStatus = Literal["open", "bought", "deferred"]
# --------------------------------------------------------------------------
# Listen
# --------------------------------------------------------------------------
class ListCreateIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
class ListUpdateIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
class ListOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
owner_id: str
rev: int
role: Role
may_share_public: bool
member_count: int
created_at: datetime
class MemberOut(BaseModel):
user_id: str
email: str
display_name: str | None
role: Role
may_share_public: bool
joined_at: datetime
class MemberRoleIn(BaseModel):
role: Role
# Zusatzrecht "darf öffentliche Links erzeugen". Nicht gesetzt =
# unverändert lassen.
may_share_public: bool | None = None
class TransferOwnershipIn(BaseModel):
"""Der bisherige Eigentümer wird dabei zum Bearbeiter."""
user_id: str = Field(min_length=36, max_length=36)
# --------------------------------------------------------------------------
# Einladungen
# --------------------------------------------------------------------------
class InviteCreateIn(BaseModel):
email: EmailStr
role: Literal["viewer", "editor"] = "editor"
class InviteOut(BaseModel):
id: str
email: str
role: Role
status: Literal["pending", "accepted", "revoked", "expired"]
invited_by_name: str | None
created_at: datetime
last_sent_at: datetime
send_count: int
expires_at: datetime
class InvitePreviewOut(BaseModel):
"""Was der Eingeladene vor dem Annehmen sieht."""
list_name: str
invited_by_name: str | None
role: Role
email: str
# Passt die Einladung zum angemeldeten Konto?
matches_current_user: bool
# --------------------------------------------------------------------------
# Märkte und Warengruppen
# --------------------------------------------------------------------------
class MarketIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
sort_order: int = Field(default=0, ge=-9999, le=9999)
class MarketOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
sort_order: int
row_rev: int
class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=120)
sort_order: int = Field(default=0, ge=-9999, le=9999)
class CategoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
name: str
sort_order: int
row_rev: int
# --------------------------------------------------------------------------
# Artikel
# --------------------------------------------------------------------------
class AttributeIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
value: str = Field(max_length=300)
class AttributeOut(BaseModel):
name: str
value: str
class ArticleIn(BaseModel):
name: str = Field(min_length=1, max_length=200)
barcode: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, max_length=500)
default_market_id: str | None = Field(default=None, max_length=36)
default_category_id: str | None = Field(default=None, max_length=36)
attributes: list[AttributeIn] = Field(default_factory=list, max_length=40)
# Märkte, in denen es den Artikel gibt.
available_market_ids: list[str] = Field(default_factory=list, max_length=60)
class ArticleUpdateIn(BaseModel):
"""Alle Felder optional - nur Gesetztes wird geändert.
`attributes` und `available_market_ids` ersetzen jeweils die
komplette bisherige Menge, sie werden nicht zusammengeführt."""
name: str | None = Field(default=None, min_length=1, max_length=200)
barcode: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, max_length=500)
default_market_id: str | None = Field(default=None, max_length=36)
default_category_id: str | None = Field(default=None, max_length=36)
attributes: list[AttributeIn] | None = Field(default=None, max_length=40)
available_market_ids: list[str] | None = Field(default=None, max_length=60)
class ArticleOut(BaseModel):
id: str
name: str
barcode: str | None
note: str | None
default_market_id: str | None
default_category_id: str | None
attributes: list[AttributeOut]
available_market_ids: list[str]
row_rev: int
# --------------------------------------------------------------------------
# Listeneinträge
# --------------------------------------------------------------------------
class ItemCreateIn(BaseModel):
"""Entweder `article_id` für einen bekannten Artikel, oder
`article_name` - dann wird der Artikel bei Bedarf angelegt."""
article_id: str | None = Field(default=None, max_length=36)
article_name: str | None = Field(default=None, min_length=1, max_length=200)
market_id: str | None = Field(default=None, max_length=36)
category_id: str | None = Field(default=None, max_length=36)
# Stückzahl - der Preis gilt für EIN Gebinde.
count: int = Field(default=1, ge=1, le=999)
# Gebinde, z. B. 500 ml
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
pack_unit: str | None = Field(default=None, max_length=32)
variant: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
class ItemUpdateIn(BaseModel):
market_id: str | None = Field(default=None, max_length=36)
category_id: str | None = Field(default=None, max_length=36)
count: int | None = Field(default=None, ge=1, le=999)
pack_size: Decimal | None = Field(default=None, ge=0, le=999999)
pack_unit: str | None = Field(default=None, max_length=32)
variant: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
status: ItemStatus | None = None
# Preis in Cent. Die Preishistorie kommt in Phase 7.
price_cents: int | None = Field(default=None, ge=0, le=100_000_000)
# Explizit auf null setzen statt "unverändert lassen":
clear_market: bool = False
clear_category: bool = False
class ItemOut(BaseModel):
id: str
article_id: str
article_name: str
created_by: str | None
created_by_name: str | None
market_id: str | None
category_id: str | None
count: int
pack_size: Decimal | None
pack_unit: str | None
variant: str | None
note: str | None
status: ItemStatus
# Preis für EIN Gebinde
price_cents: int | None
# Preis mal Stückzahl - vom Server gerechnet, damit App, öffentliche
# Ansicht und Ausdruck nicht auseinanderlaufen.
total_cents: int | None
row_rev: int
updated_at: datetime
class ClearBoughtOut(BaseModel):
removed: int
rev: int
# --------------------------------------------------------------------------
# Gruppierte Ansicht (Markt -> Warengruppe -> Artikel)
# --------------------------------------------------------------------------
class ViewCategory(BaseModel):
category_id: str | None
category_name: str
items: list[ItemOut]
class ViewMarket(BaseModel):
market_id: str | None
market_name: str
categories: list[ViewCategory]
open_count: int
total_cents: int
class ListView(BaseModel):
list_id: str
list_name: str
rev: int
markets: list[ViewMarket]
grand_total_cents: int
# --------------------------------------------------------------------------
# Öffentliche Ansichtslinks
# --------------------------------------------------------------------------
class PublicShareIn(BaseModel):
label: str | None = Field(default=None, max_length=120)
# Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster.
expires_at: datetime
allow_check: bool = True
class PublicShareOut(BaseModel):
id: str
label: str | None
allow_check: bool
status: Literal["active", "expired", "revoked"]
created_by_name: str | None
created_at: datetime
expires_at: datetime
last_access_at: datetime | None
access_count: int
class PublicShareCreatedOut(BaseModel):
"""Antwort beim Erzeugen - die einzige Gelegenheit, den Link zu sehen.
In der Datenbank steht nur der Hash; später lässt sich die Adresse
nicht mehr anzeigen, nur ein neuer Link erzeugen.
"""
share: PublicShareOut
url: str
class PublicViewOut(BaseModel):
"""Was ein Empfänger ohne Konto sieht. Bewusst ohne Angaben dazu, wer
was eingetragen hat, und ohne Mitgliederliste."""
list_name: str
rev: int
allow_check: bool
expires_at: datetime
markets: list["ViewMarket"]
grand_total_cents: int
class PublicToggleIn(BaseModel):
status: Literal["open", "bought"]
class ListSnapshot(BaseModel):
"""Alles, was die Detailansicht braucht, in einer Antwort.
Vorher waren das vier Aufrufe. Bei jeder Synchronisation - also nach
jedem Abhaken - summieren sich vier Rundreisen zu einer spürbaren
Verzögerung, besonders im Mobilfunknetz.
"""
list: "ListOut"
view: "ListView"
markets: list["MarketOut"]
categories: list["CategoryOut"]
class ProductLookupOut(BaseModel):
"""Ergebnis der Strichcode-Abfrage.
`source` unterscheidet, woher die Angaben stammen - der eigene
Artikelstamm ist verlässlicher als eine Fremdquelle, und der Nutzer
soll das sehen können.
"""
barcode: str
found: bool
source: Literal["catalog", "openfoodfacts", "none"]
# Bei einem Treffer im eigenen Bestand: die Artikel-ID
article_id: str | None = None
name: str | None = None
brand: str | None = None
package: str | None = None
# "6 x 33 cl" -> count 6, Gebinde 33 cl
count: int | None = None
pack_size: Decimal | None = None
pack_unit: str | None = None
# --------------------------------------------------------------------------
# Preise
# --------------------------------------------------------------------------
class MarketPrice(BaseModel):
market_id: str
market_name: str
latest_cents: int
latest_at: datetime
latest_pack_size: Decimal | None = None
latest_pack_unit: str | None = None
# Preis je Mengeneinheit in Zehntelcent - bei kleinen Mengen gingen
# sonst zu viele Stellen verloren.
unit_price_deci: int | None = None
min_cents: int
max_cents: int
observations: int
class PriceEntry(BaseModel):
market_id: str
market_name: str
price_cents: int
pack_size: Decimal | None
pack_unit: str | None
recorded_at: datetime
class ArticlePrices(BaseModel):
article_id: str
article_name: str
markets: list[MarketPrice]
history: list[PriceEntry]
class PriceOverviewRow(BaseModel):
article_id: str
article_name: str
# market_id -> juengster Preis in Cent
prices: dict[str, int]
best_market_ids: list[str]
best_cents: int
# Abstand zwischen teuerstem und guenstigstem Markt
spread_cents: int
class PriceOverview(BaseModel):
markets: list[MarketPrice]
market_names: dict[str, str]
rows: list[PriceOverviewRow]
class PriceHint(BaseModel):
article_id: str
best_market_ids: list[str]
best_cents: int
spread_cents: int
prices: dict[str, int]

View File

@@ -0,0 +1,29 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
OpKind = Literal["item.create", "item.update", "item.delete", "items.clear_bought"]
class OpIn(BaseModel):
# Vom Client vergeben, bevor gesendet wird. Grundlage der Idempotenz:
# dieselbe op_id wird nie zweimal ausgefuehrt.
op_id: str = Field(min_length=8, max_length=64)
kind: OpKind
payload: dict[str, Any] = Field(default_factory=dict)
class OpBatchIn(BaseModel):
ops: list[OpIn] = Field(default_factory=list, max_length=200)
class OpResult(BaseModel):
op_id: str
status: Literal["applied", "duplicate", "rejected"]
result_id: str | None = None
error: str | None = None
class OpBatchOut(BaseModel):
rev: int
results: list[OpResult]

91
backend/app/security.py Normal file
View File

@@ -0,0 +1,91 @@
import hashlib
import hmac
import secrets
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import RateLimit
_hasher = PasswordHasher()
# Dummy-Hash gegen Timing-Angriffe: bei unbekanntem Konto wird trotzdem
# eine Verifikation durchgefuehrt, damit die Antwortzeit gleich bleibt.
_DUMMY_HASH = _hasher.hash("dummy-password-for-constant-time-comparison")
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str | None) -> bool:
try:
_hasher.verify(password_hash or _DUMMY_HASH, password)
except (VerifyMismatchError, InvalidHashError):
return False
return password_hash is not None
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def new_token() -> str:
"""256 Bit Zufall, URL-tauglich."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""Token landen nur als Hash in der Datenbank. Kein Salt noetig -
der Eingabewert ist bereits hochentropisch."""
return hashlib.sha256(token.encode()).hexdigest()
def tokens_equal(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
def utcnow() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def normalize_email(email: str) -> str:
return email.strip().lower()
def check_rate_limit(
db: Session, bucket: str, *, limit: int, window_minutes: int
) -> bool:
"""True = Anfrage erlaubt. Zaehlt hoch und gibt False zurueck,
sobald das Limit im aktuellen Fenster erreicht ist."""
now = utcnow()
window = now.replace(second=0, microsecond=0)
window = window - timedelta(minutes=window.minute % window_minutes)
row = db.get(RateLimit, (bucket[:160], window))
if row is None:
row = RateLimit(bucket=bucket[:160], window_start=window, count=1)
db.add(row)
db.flush()
return True
if row.count >= limit:
return False
row.count += 1
db.flush()
return True
def purge_rate_limits(db: Session, older_than_hours: int = 24) -> None:
cutoff = utcnow() - timedelta(hours=older_than_hours)
for row in db.scalars(
select(RateLimit).where(RateLimit.window_start < cutoff)
).all():
db.delete(row)

285
backend/app/users.py Normal file
View File

@@ -0,0 +1,285 @@
"""Benutzerverwaltung: Anlegen, Adressänderung, Deaktivieren, Löschen.
Liegt außerhalb der Router, weil dieselben Vorgänge an zwei Stellen
gebraucht werden: über die Administrationsoberfläche und über die
automatische Bereinigung.
"""
import logging
from datetime import timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.deps import get_setting
from app.models import (
EmailChange,
EmailToken,
ListMember,
ShoppingList,
User,
UserSession,
)
from app.security import hash_token, new_token, normalize_email, utcnow
log = logging.getLogger(__name__)
WELCOME_DAYS = 14
EMAIL_CHANGE_HOURS = 48
# ==========================================================================
# Einstellungen zur automatischen Bereinigung
# ==========================================================================
def months_setting(db: Session, key: str, fallback: int) -> int:
"""Liest einen 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.
"""
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))
# ==========================================================================
# Anlegen und Willkommensnachricht
# ==========================================================================
def issue_token(db: Session, user: User, purpose: str, hours: int) -> str:
"""Erzeugt ein Einmal-Token und entwertet ältere desselben Zwecks."""
for old in db.scalars(
select(EmailToken).where(
EmailToken.user_id == user.id,
EmailToken.purpose == purpose,
EmailToken.used_at.is_(None),
)
).all():
old.used_at = utcnow()
raw = new_token()
db.add(
EmailToken(
token_hash=hash_token(raw),
user_id=user.id,
purpose=purpose,
expires_at=utcnow() + timedelta(hours=hours),
)
)
return raw
def create_user(
db: Session, *, email: str, display_name: str | None, is_admin: bool = False
) -> tuple[User, str]:
"""Legt ein Konto ohne Passwort an.
Das Passwort setzt die eingeladene Person selbst über den Link aus
der Willkommensnachricht. Ein vom Administrator vergebenes Passwort
wäre ihm bekannt - und würde per Mail verschickt.
@returns (Konto, Klartext-Token für die Willkommensnachricht)
"""
user = User(
email=normalize_email(email),
display_name=(display_name or None),
# Platzhalter: Mit diesem Wert lässt sich nicht anmelden, weil er
# kein gültiger Argon2-Hash ist und die Prüfung fehlschlägt.
password_hash="!",
is_admin=is_admin,
is_active=True,
)
db.add(user)
db.flush()
token = issue_token(db, user, "welcome", hours=WELCOME_DAYS * 24)
return user, token
# ==========================================================================
# Adressänderung
# ==========================================================================
def start_email_change(
db: Session, user: User, new_email: str, requested_by: str | None
) -> tuple[EmailChange, str, str | None]:
"""Beginnt eine Adressänderung.
@returns (Vorgang, Token für die neue Adresse, Token für die alte
Adresse oder None)
Bei Administratorkonten wird auch von der alten Adresse eine
Bestätigung verlangt. Sonst könnte, wer Zugriff auf ein
Administratorkonto erlangt, die Adresse auf eine eigene umstellen und
sich dauerhaft einnisten - der rechtmäßige Inhaber verlöre den Weg
zurück über "Passwort vergessen".
"""
target = normalize_email(new_email)
# Offene Vorgänge desselben Kontos zurückziehen.
for pending in db.scalars(
select(EmailChange).where(
EmailChange.user_id == user.id,
EmailChange.applied_at.is_(None),
EmailChange.cancelled_at.is_(None),
)
).all():
pending.cancelled_at = utcnow()
raw_new = new_token()
raw_old = new_token() if user.is_admin else None
change = EmailChange(
user_id=user.id,
old_email=user.email,
new_email=target,
token_new_hash=hash_token(raw_new),
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),
)
db.add(change)
db.flush()
return change, raw_new, raw_old
def apply_if_complete(db: Session, change: EmailChange) -> bool:
"""Übernimmt die neue Adresse, sobald alle Bestätigungen vorliegen."""
if change.applied_at or change.cancelled_at:
return False
if change.confirmed_new_at is None:
return False
if change.requires_old and change.confirmed_old_at is None:
return False
user = db.get(User, change.user_id)
if user is None:
change.cancelled_at = utcnow()
return False
# Zwischenzeitlich vergeben? Dann Vorgang verwerfen statt eine
# Eindeutigkeitsverletzung zu produzieren.
taken = db.scalar(
select(User).where(User.email == change.new_email, User.id != user.id)
)
if taken is not None:
change.cancelled_at = utcnow()
return False
user.email = change.new_email
# Die neue Adresse hat sich gerade selbst bestätigt.
user.verified_at = utcnow()
change.applied_at = utcnow()
# Alle Sitzungen beenden: Ein Adresswechsel ist ein guter Anlass,
# sich überall neu anzumelden - besonders, wenn er nicht vom
# Kontoinhaber ausging.
for session in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(session)
return True
# ==========================================================================
# Deaktivieren, Reaktivieren, Löschen
# ==========================================================================
def deactivate(db: Session, user: User) -> None:
"""Sperrt ein Konto und beendet alle Sitzungen."""
if not user.is_active:
return
user.is_active = False
user.deactivated_at = utcnow()
for session in db.scalars(
select(UserSession).where(UserSession.user_id == user.id)
).all():
db.delete(session)
def reactivate(db: Session, user: User) -> None:
user.is_active = True
user.deactivated_at = None
# Zähler zurücksetzen, sonst wäre das Konto beim nächsten Durchlauf
# sofort wieder fällig.
user.last_seen_at = utcnow()
def dispose_lists(db: Session, user: User) -> tuple[int, int]:
"""Regelt die Listen eines zu löschenden Kontos.
`shopping_list.owner_id` steht auf RESTRICT - ohne diese Vorarbeit
schlüge das Löschen fehl.
Geteilte Listen gehen an das dienstälteste andere Mitglied über;
Listen ohne weitere Mitglieder werden gelöscht. Eine geteilte Liste
mitzulöschen würde anderen Leuten Daten wegnehmen, mit denen das
ausscheidende Konto nichts mehr zu tun hat.
@returns (übertragen, gelöscht)
"""
transferred = 0
removed = 0
lists = db.scalars(
select(ShoppingList).where(ShoppingList.owner_id == user.id)
).all()
for lst in lists:
successor = db.scalars(
select(ListMember)
.where(ListMember.list_id == lst.id, ListMember.user_id != user.id)
.order_by(ListMember.joined_at)
).first()
if successor is None:
db.delete(lst)
removed += 1
continue
successor.role = "owner"
successor.may_share_public = True
lst.owner_id = successor.user_id
lst.rev += 1
transferred += 1
# Eigene Mitgliedschaften enden ohnehin per CASCADE.
db.flush()
return transferred, removed
def delete_user(db: Session, user: User) -> dict[str, int]:
"""Löscht ein Konto samt Vorarbeit an seinen Listen."""
transferred, removed = dispose_lists(db, user)
db.delete(user)
db.flush()
return {"übertragene Listen": transferred, "gelöschte Listen": removed}
# ==========================================================================
# Kennzahlen
# ==========================================================================
def owned_list_counts(db: Session) -> dict[str, int]:
rows = db.execute(
select(ShoppingList.owner_id, func.count())
.where(ShoppingList.deleted_at.is_(None))
.group_by(ShoppingList.owner_id)
).all()
return {owner_id: count for owner_id, count in rows}
def membership_counts(db: Session) -> dict[str, int]:
rows = db.execute(
select(ListMember.user_id, func.count()).group_by(ListMember.user_id)
).all()
return {user_id: count for user_id, count in rows}

View File

@@ -0,0 +1,31 @@
"""Wartet, bis MariaDB Verbindungen annimmt. Wird vom entrypoint.sh
aufgerufen, bevor Alembic laeuft."""
import sys
import time
from sqlalchemy import text
from app.db import engine
DEADLINE_SECONDS = 90
def main() -> int:
start = time.monotonic()
while True:
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("[wait_for_db] Datenbank erreichbar.")
return 0
except Exception as exc:
if time.monotonic() - start > DEADLINE_SECONDS:
print(f"[wait_for_db] Aufgegeben nach {DEADLINE_SECONDS}s: {exc}",
file=sys.stderr)
return 1
print(f"[wait_for_db] noch nicht bereit: {type(exc).__name__}")
time.sleep(2)
if __name__ == "__main__":
raise SystemExit(main())

18
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -e
echo "[entrypoint] Warte auf Datenbank ${DB_HOST}:${DB_PORT} ..."
python -m app.wait_for_db
echo "[entrypoint] Migrationen einspielen ..."
alembic upgrade head
echo "[entrypoint] Starte API."
# --proxy-headers: X-Forwarded-For auswerten, damit das Rate Limiting
# die echte Client-IP sieht und nicht die des nginx-Containers.
# --forwarded-allow-ips: nur diesen Absendern glauben. Der Wert ist
# unkritisch, solange der api-Container KEINEN oeffentlichen Port hat.
exec uvicorn app.main:app \
--host 0.0.0.0 --port 8000 \
--proxy-headers --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
--reload

17
backend/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pymysql==1.1.1
alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.0
email-validator==2.2.0
argon2-cffi==23.1.0
# Transitiv über Starlette, hier explizit wegen to_thread.run_sync
anyio==4.7.0
# Ausgehende Abfragen bei Open Food Facts
httpx==0.28.1
# Web Push (VAPID, aes128gcm). Bringt cryptography und http-ece mit.
pywebpush==2.0.3
# Druckansicht - mit autoescape, weil Artikelnamen freie Eingaben sind
jinja2==3.1.5

509
db/schema.sql Normal file
View File

@@ -0,0 +1,509 @@
-- ==========================================================================
-- Einkaufsliste vollständiges Datenbankschema
-- Entspricht dem Stand nach Alembic-Revision 0012.
-- ==========================================================================
--
-- Normalerweise wird diese Datei NICHT gebraucht: Der api-Container
-- führt beim Start `alembic upgrade head` aus und legt alles selbst an.
--
-- Sie ist da für:
-- * Nachvollziehbarkeit im Versionsverwaltungssystem man sieht auf
-- einen Blick, wie das Schema aussieht, ohne elf Migrationen zu lesen
-- * Aufsetzen ohne Alembic, etwa auf einer verwalteten Datenbank
-- * Vergleich, wenn eine Migration irgendwo halb durchgelaufen ist
--
-- Einspielen:
-- docker compose up -d db
-- docker compose exec -T db mariadb -u root -p einkaufsapp < db/schema.sql
--
-- 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 bereits vorhandenen Tabellen scheitern.
--
-- Alle Bezeichner in Backticks, weil `key` und `count` in MariaDB
-- Schlüsselwörter sind.
-- ==========================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- --------------------------------------------------------------------------
-- Konten und Anmeldung
-- --------------------------------------------------------------------------
CREATE TABLE `user` (
`id` VARCHAR(36) NOT NULL,
-- Normalisiert (kleingeschrieben, getrimmt). Die Eindeutigkeit steht
-- auf diesem Wert, deshalb muss die Anwendung konsequent normalisieren.
`email` VARCHAR(255) NOT NULL,
`display_name` VARCHAR(80) NULL,
-- Argon2id
`password_hash` VARCHAR(255) NOT NULL,
`verified_at` DATETIME NULL,
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
-- Sperrt alles außer "Profil lesen" und "Passwort ändern"
`must_change_password` TINYINT(1) NOT NULL DEFAULT 0,
-- Grundlage der automatischen Deaktivierung. Wird bei der Anmeldung
-- gesetzt und danach höchstens stündlich nachgeführt.
`last_seen_at` DATETIME NULL,
-- Seit wann deaktiviert. Grundlage der automatischen Löschung;
-- is_active allein sagt nichts über die Dauer.
`deactivated_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `user_session` (
`id` VARCHAR(36) NOT NULL,
-- Nur der SHA-256-Hash des Sitzungstokens. Der Klartext steht
-- ausschließlich im HttpOnly-Cookie des Browsers.
`token_hash` VARCHAR(64) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
-- Double-Submit-Verfahren: steht zusätzlich in einem lesbaren Cookie
-- und muss bei schreibenden Anfragen im Header wiederkommen.
`csrf_token` VARCHAR(64) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_user_session_token` (`token_hash`),
KEY `ix_user_session_expires` (`expires_at`),
KEY `fk_user_session_user` (`user_id`),
CONSTRAINT `fk_user_session_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `email_token` (
`id` VARCHAR(36) NOT NULL,
`token_hash` VARCHAR(64) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
-- "verify" | "reset"
`purpose` VARCHAR(32) NOT NULL,
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_email_token_token` (`token_hash`),
KEY `ix_email_token_user_purpose` (`user_id`, `purpose`),
CONSTRAINT `fk_email_token_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Laufende Änderung einer E-Mail-Adresse.
--
-- Bei gewöhnlichen Konten muss nur die NEUE Adresse bestätigen; die alte
-- bekommt eine Benachrichtigung. Bei Administratorkonten müssen BEIDE
-- bestätigen: Wer Zugriff auf ein Administratorkonto erlangt, könnte
-- sonst die Adresse auf eine eigene umstellen und sich dauerhaft
-- einnisten - der rechtmäßige Inhaber verlöre den Weg zurück über
-- "Passwort vergessen".
CREATE TABLE `email_change` (
`id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`old_email` VARCHAR(255) NOT NULL,
`new_email` VARCHAR(255) NOT NULL,
`token_new_hash` VARCHAR(64) NOT NULL,
`token_old_hash` VARCHAR(64) NULL,
-- True bei Administratorkonten
`requires_old` TINYINT(1) NOT NULL DEFAULT 0,
`confirmed_new_at` DATETIME NULL,
`confirmed_old_at` DATETIME NULL,
`applied_at` DATETIME NULL,
`cancelled_at` DATETIME NULL,
`requested_by` VARCHAR(36) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_email_change_token_new` (`token_new_hash`),
UNIQUE KEY `uq_email_change_token_old` (`token_old_hash`),
KEY `ix_email_change_user` (`user_id`),
KEY `fk_email_change_requested_by` (`requested_by`),
CONSTRAINT `fk_email_change_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_email_change_requested_by` FOREIGN KEY (`requested_by`)
REFERENCES `user` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Betrieb
-- --------------------------------------------------------------------------
CREATE TABLE `setting` (
`key` VARCHAR(64) NOT NULL,
`value` VARCHAR(255) NOT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Zählerbasiertes Rate Limiting ohne Redis: ein Datensatz je
-- (Schlüssel, Zeitfenster).
CREATE TABLE `rate_limit` (
`bucket` VARCHAR(160) NOT NULL,
`window_start` DATETIME NOT NULL,
`count` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`bucket`, `window_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Listen und Mitgliedschaften
-- --------------------------------------------------------------------------
CREATE TABLE `shopping_list` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(120) NOT NULL,
`owner_id` VARCHAR(36) NOT NULL,
-- Monoton steigender Zähler je Liste. Jede Änderung erhöht ihn und
-- schreibt den neuen Wert in row_rev der geänderten Zeile.
`rev` BIGINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
-- Soft Delete: Offline gebliebene Geräte sollen vom Löschen erfahren.
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `fk_shopping_list_owner` (`owner_id`),
-- RESTRICT: Ein Konto lässt sich nicht löschen, solange ihm Listen
-- gehören. Sonst verwaisen sie.
CONSTRAINT `fk_shopping_list_owner` FOREIGN KEY (`owner_id`)
REFERENCES `user` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `list_member` (
`list_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
-- "owner" | "editor" | "viewer"
`role` VARCHAR(16) NOT NULL DEFAULT 'editor',
-- Zusatzrecht, unabhängig von der Rolle
`may_share_public` TINYINT(1) NOT NULL DEFAULT 0,
`joined_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`list_id`, `user_id`),
KEY `ix_list_member_user` (`user_id`),
CONSTRAINT `fk_list_member_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_list_member_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Katalog: Märkte, Warengruppen, Artikel
-- --------------------------------------------------------------------------
CREATE TABLE `market` (
`list_id` VARCHAR(36) NOT NULL,
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(120) NOT NULL,
-- Bestimmt die Reihenfolge in Liste und Ausdruck
`sort_order` INT NOT NULL DEFAULT 0,
`row_rev` BIGINT NOT NULL DEFAULT 0,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_market_list_name` (`list_id`, `name`),
KEY `ix_market_list_rev` (`list_id`, `row_rev`),
CONSTRAINT `fk_market_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `category` (
`list_id` VARCHAR(36) NOT NULL,
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(120) NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`row_rev` BIGINT NOT NULL DEFAULT 0,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_category_list_name` (`list_id`, `name`),
KEY `ix_category_list_rev` (`list_id`, `row_rev`),
CONSTRAINT `fk_category_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `article` (
`id` VARCHAR(36) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
`name` VARCHAR(200) NOT NULL,
`barcode` VARCHAR(64) NULL,
`note` VARCHAR(500) NULL,
-- Vorgaben für neue Listeneinträge
`default_market_id` VARCHAR(36) NULL,
`default_category_id` VARCHAR(36) NULL,
`row_rev` BIGINT NOT NULL DEFAULT 0,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_article_list_name` (`list_id`, `name`),
KEY `ix_article_list_rev` (`list_id`, `row_rev`),
KEY `ix_article_barcode` (`list_id`, `barcode`),
KEY `fk_article_market` (`default_market_id`),
KEY `fk_article_category` (`default_category_id`),
CONSTRAINT `fk_article_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_article_market` FOREIGN KEY (`default_market_id`)
REFERENCES `market` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_article_category` FOREIGN KEY (`default_category_id`)
REFERENCES `category` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Frei definierbare Eigenschaften: Verpackungseinheit, Farbe, Größe.
-- Spalte heißt attr_name, weil `key` reserviert ist.
CREATE TABLE `article_attribute` (
`id` VARCHAR(36) NOT NULL,
`article_id` VARCHAR(36) NOT NULL,
`attr_name` VARCHAR(80) NOT NULL,
`attr_value` VARCHAR(300) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_article_attr` (`article_id`, `attr_name`),
CONSTRAINT `fk_article_attribute_article` FOREIGN KEY (`article_id`)
REFERENCES `article` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- In welchen Märkten ist der Artikel erhältlich
CREATE TABLE `article_market` (
`article_id` VARCHAR(36) NOT NULL,
`market_id` VARCHAR(36) NOT NULL,
PRIMARY KEY (`article_id`, `market_id`),
KEY `fk_article_market_market` (`market_id`),
CONSTRAINT `fk_article_market_article` FOREIGN KEY (`article_id`)
REFERENCES `article` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_article_market_market` FOREIGN KEY (`market_id`)
REFERENCES `market` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Listeneinträge
-- --------------------------------------------------------------------------
CREATE TABLE `list_item` (
`id` VARCHAR(36) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
`article_id` VARCHAR(36) NOT NULL,
-- Überschreiben die Vorgaben aus dem Artikel, falls gesetzt
`market_id` VARCHAR(36) NULL,
`category_id` VARCHAR(36) NULL,
-- Stückzahl: wie viele Gebinde. price_cents gilt für EINES davon,
-- die Summe ist price_cents * count.
`count` INT NOT NULL DEFAULT 1,
-- Gebinde: wie groß eine Packung ist (500 ml, 250 g)
`pack_size` DECIMAL(10,3) NULL,
`pack_unit` VARCHAR(32) NULL,
-- Nähere Bestimmung, z. B. "bunt, ganz"
`variant` VARCHAR(200) NULL,
-- Bemerkung für den Einkaufenden
`note` VARCHAR(500) NULL,
-- "open" | "bought" | "deferred"
`status` VARCHAR(16) NOT NULL DEFAULT 'open',
-- Preis in Cent für EIN Gebinde. Niemals als Fließkommazahl rechnen.
`price_cents` INT NULL,
`created_by` VARCHAR(36) NULL,
`row_rev` BIGINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME NULL,
PRIMARY KEY (`id`),
KEY `ix_list_item_list_rev` (`list_id`, `row_rev`),
KEY `ix_list_item_list_status` (`list_id`, `status`),
KEY `fk_list_item_article` (`article_id`),
KEY `fk_list_item_market` (`market_id`),
KEY `fk_list_item_category` (`category_id`),
KEY `fk_list_item_created_by` (`created_by`),
CONSTRAINT `fk_list_item_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_list_item_article` FOREIGN KEY (`article_id`)
REFERENCES `article` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_list_item_market` FOREIGN KEY (`market_id`)
REFERENCES `market` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_list_item_category` FOREIGN KEY (`category_id`)
REFERENCES `category` (`id`) ON DELETE SET NULL,
-- SET NULL: Der Eintrag bleibt, der Personenbezug verschwindet
CONSTRAINT `fk_list_item_created_by` FOREIGN KEY (`created_by`)
REFERENCES `user` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Teilen
-- --------------------------------------------------------------------------
CREATE TABLE `list_invite` (
`id` VARCHAR(36) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`role` VARCHAR(16) NOT NULL DEFAULT 'editor',
-- Nur der Hash. Erneutes Senden erzeugt deshalb ein neues Token.
`token_hash` VARCHAR(64) NOT NULL,
`invited_by` VARCHAR(36) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_sent_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`send_count` INT NOT NULL DEFAULT 1,
`expires_at` DATETIME NOT NULL,
`accepted_at` DATETIME NULL,
`accepted_by` VARCHAR(36) NULL,
`revoked_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_list_invite_token` (`token_hash`),
KEY `ix_list_invite_list` (`list_id`),
KEY `ix_list_invite_email` (`email`),
KEY `fk_list_invite_invited_by` (`invited_by`),
KEY `fk_list_invite_accepted_by` (`accepted_by`),
CONSTRAINT `fk_list_invite_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_list_invite_invited_by` FOREIGN KEY (`invited_by`)
REFERENCES `user` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_list_invite_accepted_by` FOREIGN KEY (`accepted_by`)
REFERENCES `user` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `public_share` (
`id` VARCHAR(36) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
-- Nur der Hash: Ein Datenbankleck gibt keinen Zugriff auf die Listen.
`token_hash` VARCHAR(64) NOT NULL,
`label` VARCHAR(120) NULL,
-- Darf über diesen Link abgehakt werden?
`allow_check` TINYINT(1) NOT NULL DEFAULT 1,
`created_by` VARCHAR(36) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Pflicht: ein unbefristeter Link wäre ein dauerhaft offenes Fenster
`expires_at` DATETIME NOT NULL,
`revoked_at` DATETIME NULL,
-- Grobe Nutzungsanzeige ohne IP-Adressen und ohne Zeitreihe
`last_access_at` DATETIME NULL,
`access_count` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_public_share_token` (`token_hash`),
KEY `ix_public_share_list` (`list_id`),
KEY `fk_public_share_created_by` (`created_by`),
CONSTRAINT `fk_public_share_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_public_share_created_by` FOREIGN KEY (`created_by`)
REFERENCES `user` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Synchronisation
-- --------------------------------------------------------------------------
-- Quittung für eine bereits verarbeitete Operation aus einer
-- Outbox-Warteschlange. Verhindert, dass aus einem verlorenen
-- "Butter hinzufügen" beim nächsten Versuch zweimal Butter wird.
CREATE TABLE `applied_op` (
`op_id` VARCHAR(64) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NULL,
`kind` VARCHAR(32) NOT NULL,
-- Bei item.create: die vergebene ID, damit der Client seinen
-- vorläufigen Eintrag zuordnen kann
`result_id` VARCHAR(36) NULL,
`rev` BIGINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`op_id`),
KEY `ix_applied_op_list` (`list_id`, `created_at`),
KEY `fk_applied_op_user` (`user_id`),
CONSTRAINT `fk_applied_op_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_applied_op_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Produktdaten und Preise
-- --------------------------------------------------------------------------
-- Zwischenspeicher für Abfragen bei Open Food Facts. Sorgt dafür, dass
-- jeder Strichcode höchstens einmal je Gültigkeitszeitraum nach außen
-- geht und zwar vom Server, nicht vom Gerät des Nutzers.
CREATE TABLE `product_cache` (
`barcode` VARCHAR(64) NOT NULL,
`found` TINYINT(1) NOT NULL DEFAULT 0,
`name` VARCHAR(300) NULL,
`brand` VARCHAR(200) NULL,
-- Rohtext wie "500 g" oder "6 x 33 cl"
`package` VARCHAR(120) NULL,
-- Daraus zerlegt
`count` INT NULL,
`pack_size` DECIMAL(10,3) NULL,
`pack_unit` VARCHAR(32) NULL,
`source` VARCHAR(32) NOT NULL DEFAULT 'openfoodfacts',
`fetched_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`barcode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Beobachteter Preis. Bewusst OHNE Nutzerbezug: Aus "wer hat wann wo was
-- zu welchem Preis gekauft" ließe sich ein Bewegungs- und Konsumprofil
-- bilden. Für den Preisvergleich genügt Markt, Artikel und Zeitpunkt.
CREATE TABLE `price_point` (
`id` VARCHAR(36) NOT NULL,
`list_id` VARCHAR(36) NOT NULL,
`article_id` VARCHAR(36) NOT NULL,
`market_id` VARCHAR(36) NOT NULL,
-- Preis für EIN Gebinde
`price_cents` INT NOT NULL,
`pack_size` DECIMAL(10,3) NULL,
`pack_unit` VARCHAR(32) NULL,
`recorded_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `ix_price_article_market` (`article_id`, `market_id`, `recorded_at`),
KEY `ix_price_list` (`list_id`, `recorded_at`),
KEY `fk_price_point_market` (`market_id`),
CONSTRAINT `fk_price_point_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_price_point_article` FOREIGN KEY (`article_id`)
REFERENCES `article` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_price_point_market` FOREIGN KEY (`market_id`)
REFERENCES `market` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Push-Benachrichtigungen
-- --------------------------------------------------------------------------
CREATE TABLE `push_subscription` (
`id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
-- Vom Push-Dienst des Browserherstellers vergebene URL
`endpoint` VARCHAR(500) NOT NULL,
-- Schlüsselmaterial für die Ende-zu-Ende-Verschlüsselung. Auch der
-- Push-Dienst kann den Inhalt der Nachricht nicht lesen.
`p256dh` VARCHAR(200) NOT NULL,
`auth` VARCHAR(100) NOT NULL,
`label` VARCHAR(80) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_success_at` DATETIME NULL,
`failure_count` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_push_subscription_endpoint` (`endpoint`),
KEY `ix_push_subscription_user` (`user_id`),
CONSTRAINT `fk_push_subscription_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Grundlage der Zwei-Stunden-Drosselung
CREATE TABLE `notify_state` (
`list_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`last_notified_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`list_id`, `user_id`),
KEY `fk_notify_state_user` (`user_id`),
CONSTRAINT `fk_notify_state_list` FOREIGN KEY (`list_id`)
REFERENCES `shopping_list` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_notify_state_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------
-- Migrationsstand
-- --------------------------------------------------------------------------
--
-- Ohne diesen Eintrag würde Alembic beim nächsten Start alle
-- Migrationen erneut anwenden und an den vorhandenen Tabellen
-- scheitern.
CREATE TABLE `alembic_version` (
`version_num` VARCHAR(32) NOT NULL,
PRIMARY KEY (`version_num`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `alembic_version` (`version_num`) VALUES ('0012');
SET FOREIGN_KEY_CHECKS = 1;

70
docker-compose.yml Normal file
View File

@@ -0,0 +1,70 @@
services:
db:
image: mariadb:11.4
container_name: einkaufsapp_db
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?bitte in .env setzen}
MARIADB_DATABASE: ${DB_NAME:?bitte in .env setzen}
MARIADB_USER: ${DB_USER:?bitte in .env setzen}
MARIADB_PASSWORD: ${DB_PASSWORD:?bitte in .env setzen}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
restart: unless-stopped
networks: [einkaufsapp]
api:
build: ./backend
container_name: einkaufsapp_api
env_file: .env
environment:
DB_HOST: db
DB_PORT: "3306"
# KEIN ports:-Eintrag. Die API ist ausschliesslich ueber den
# web-Container erreichbar. Wer sie direkt braucht, nutzt
# docker compose exec oder bindet testweise an 127.0.0.1 (s. README).
expose:
- "8000"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
restart: "no"
networks: [einkaufsapp]
web:
build: ./web
container_name: einkaufsapp_web
ports:
- "${HTTP_PORT:?bitte in .env setzen}:8080"
depends_on:
- api
restart: unless-stopped
networks: [einkaufsapp]
# Nur fuer die Entwicklung: faengt allen ausgehenden SMTP-Verkehr ab.
# Aktivieren mit: docker compose --profile dev up -d
mailpit:
image: axllent/mailpit:v1.21
container_name: einkaufsapp_mailpit
profiles: [dev]
ports:
- "127.0.0.1:${MAILPIT_PORT:-8025}:8025"
restart: unless-stopped
networks: [einkaufsapp]
volumes:
db_data:
networks:
einkaufsapp:
name: einkaufsapp

174
docs/betrieb.md Normal file
View File

@@ -0,0 +1,174 @@
# Betrieb: Sicherung, Wiederherstellung, Aufräumen
## Was gesichert werden muss
| Was | Wo | Ohne das … |
|---|---|---|
| Datenbank | Volume `einkaufsapp_db_data` | ist alles weg |
| `.env` | Projektverzeichnis | Sessions ungültig, Push-Anmeldungen tot, DB-Zugang verloren |
| Projektverzeichnis | Quelltext | neu aus dem Archiv holen kein Datenverlust |
Der Quelltext gehört ins Versionsverwaltungssystem, die `.env` ausdrücklich
**nicht**: Sie enthält Datenbankpasswort, `SECRET_KEY`, SMTP-Zugangsdaten und
den privaten VAPID-Schlüssel. Sie gehört in den Passwortmanager oder in eine
verschlüsselte Sicherung.
Warum die `.env` so wichtig ist: Ein verlorener `SECRET_KEY` ist verschmerzbar
(alle müssen sich neu anmelden), ein verlorener `VAPID_PRIVATE_KEY` auch (alle
müssen Push neu einschalten). Ein verlorenes `DB_PASSWORD` bei erhaltener
Datenbank ist dagegen ärgerlich dann kommt man an die eigenen Daten nur noch
über das Root-Passwort heran.
## Datenbanksicherung
```bash
cd /opt/einkaufsapp
# Konsistenter Auszug ohne Anhalten der Anwendung
docker compose exec -T db mariadb-dump \
-u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" \
--single-transaction --quick --routines --events \
--default-character-set=utf8mb4 \
einkaufsapp | gzip > "einkaufsapp-$(date +%F).sql.gz"
```
`--single-transaction` ist der entscheidende Schalter: Er nimmt einen
konsistenten Stand aus einem Zeitpunkt, ohne die Tabellen zu sperren. Ohne ihn
kann eine Sicherung mitten in einer Änderung entstehen zum Beispiel mit einem
Listeneintrag, dessen Artikel noch fehlt.
`--quick` verhindert, dass große Tabellen komplett in den Arbeitsspeicher
geladen werden.
### Als tägliche Aufgabe
```bash
sudo tee /etc/cron.daily/einkaufsapp-backup >/dev/null <<'EOF'
#!/bin/sh
set -eu
cd /opt/einkaufsapp
PASS=$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)
DEST=/var/backups/einkaufsapp
mkdir -p "$DEST"
docker compose exec -T db mariadb-dump -u root -p"$PASS" \
--single-transaction --quick --routines --events \
--default-character-set=utf8mb4 einkaufsapp \
| gzip > "$DEST/db-$(date +%F).sql.gz"
# Vierzehn Tage aufbewahren
find "$DEST" -name 'db-*.sql.gz' -mtime +14 -delete
EOF
sudo chmod +x /etc/cron.daily/einkaufsapp-backup
```
Der Auszug landet damit im Dateisystem und wird von deiner vorhandenen
Borgmatic-Sicherung mit erfasst. Das ist der bessere Weg, als das Volume direkt
zu sichern: Ein Dateisystemabbild einer laufenden Datenbank ist nicht
zuverlässig wiederherstellbar.
**Prüfen, dass die Sicherung etwas taugt:**
```bash
zcat /var/backups/einkaufsapp/db-$(date +%F).sql.gz | tail -5
```
Am Ende muss `-- Dump completed on …` stehen. Fehlt die Zeile, ist der Auszug
abgebrochen eine abgeschnittene Sicherung sieht sonst genauso aus wie eine
vollständige.
## Wiederherstellung
```bash
cd /opt/einkaufsapp
docker compose down
docker volume rm einkaufsapp_db_data # ACHTUNG: löscht den aktuellen Stand
docker compose up -d db
sleep 20 # MariaDB initialisiert sich
zcat einkaufsapp-2026-08-08.sql.gz | docker compose exec -T db \
mariadb -u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" einkaufsapp
docker compose up -d
docker compose logs --tail=20 api
```
Alembic bringt das Schema beim Start auf den neuesten Stand, falls die
Sicherung aus einer älteren Fassung stammt. Der umgekehrte Fall neuere
Sicherung, ältere Anwendung geht nicht; dann erst den Quelltext aktualisieren.
**Einmal im Jahr ausprobieren.** Eine Sicherung, die nie zurückgespielt wurde,
ist eine Vermutung, keine Sicherung.
## Aufräumen
Läuft von selbst: einmal 30 Sekunden nach dem Start des `api`-Containers,
danach alle `CLEANUP_INTERVAL_HOURS` (Voreinstellung 24).
| Was | Frist | Einstellung |
|---|---|---|
| Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | 30 Tage | `CLEANUP_DELETED_DAYS` |
| Outbox-Quittungen | 7 Tage | `CLEANUP_OPS_DAYS` |
| Abgelaufene Sitzungen | sofort | |
| Rate-Limit-Zähler | 1 Tag | |
| Verbrauchte Mail-Token | 7 Tage nach Ablauf | |
| Abgelaufene Einladungen und öffentliche Links | 30 Tage nach Ablauf | `CLEANUP_DELETED_DAYS` |
| Produktzwischenspeicher | mindestens 1 Jahr | |
Manuell auslösen (als Administrator angemeldet):
```bash
curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST https://einkauf.example.de/api/admin/cleanup
```
Im Log erscheint nach jedem Durchlauf eine Zeile wie
`Aufräumen: 12 gelöschte Einträge, 340 Outbox-Quittungen`.
**Warum die 30 Tage nicht kürzer sein sollten:** So lange kann ein Gerät
offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste
gelöscht wurde. Wird früher aufgeräumt, taucht die Liste auf dem Gerät weiter
auf, bis jemand sie von Hand entfernt.
## Aktualisieren
```bash
cd /opt/einkaufsapp
# Sicherung ZUERST - Migrationen lassen sich nicht immer zurücknehmen
/etc/cron.daily/einkaufsapp-backup
# Neuen Stand einspielen, dann:
./tools/check-env.sh .env
node tools/check-js.mjs
python3 tools/check-nginx.py web
docker compose up -d --build
docker compose logs --tail=30 api # Migrationen beobachten
```
Nach Änderungen an den Dateien unter `web/html/js/` muss `VERSION` in
`web/html/sw.js` hochgezählt werden sonst behalten bereits installierte
Clients die alte Fassung.
**`docker compose up -d`, nicht `restart`:** Ein Neustart übernimmt keine
geänderten Werte aus der `.env`. Das hat in diesem Projekt schon mehrfach für
Verwirrung gesorgt.
## Speicherplatz im Blick behalten
```bash
docker compose exec db mariadb -u root -p einkaufsapp -e "
SELECT table_name AS Tabelle,
ROUND(data_length/1024/1024, 1) AS 'Daten MB',
ROUND(index_length/1024/1024, 1) AS 'Index MB',
table_rows AS 'Zeilen (geschätzt)'
FROM information_schema.tables
WHERE table_schema = 'einkaufsapp'
ORDER BY data_length DESC;"
```
Erwartungsgemäß wachsen `price_point` (jeder erfasste Preis) und
`product_cache` (jeder gescannte Strichcode) am stärksten. Beide sind gewollt:
Die Preisdatenbank ist der Zweck, und der Zwischenspeicher verhindert, dass
jeder Scan nach draußen geht.
Wächst `applied_op` unerwartet, liegt ein Gerät mit einer festhängenden Outbox
vor dann lohnt ein Blick ins Log des `api`-Containers.

115
docs/mail-zustellbarkeit.md Normal file
View File

@@ -0,0 +1,115 @@
# Zustellbarkeit ausgehender Mails
Die Anwendung setzt alle Kopfzeilen korrekt, aber ob eine Mail im Posteingang
oder im Spam landet, entscheidet sich überwiegend im DNS und beim Relay nicht
im Anwendungscode. Diese Notiz trennt beides sauber.
## Was die Anwendung erledigt
| Kopfzeile | Warum |
|---|---|
| `Date` | Fehlt sie, werten praktisch alle Filter ab |
| `Message-ID` | Mit der Domain aus `SMTP_ENVELOPE_FROM`, damit sie zur DKIM-Signatur passt |
| `From` mit Anzeigename | RFC-konform kodiert über `email.headerregistry` |
| `Reply-To` | Optional, wenn Antworten woanders hin sollen |
| `Auto-Submitted: auto-generated` | RFC 3834 unterdrückt Abwesenheitsschleifen |
| `X-Auto-Response-Suppress: All` | Dasselbe für Exchange/Outlook |
| EHLO-Hostname | Aus `SMTP_HELO_HOSTNAME` statt der Container-ID |
| Envelope-From getrennt vom `From` | SPF wird gegen den Envelope geprüft |
Der Versand wiederholt sich bei temporären Fehlern mit wachsendem Abstand
(2 s, 4 s, 8 s). Bei permanenter Ablehnung Empfänger unbekannt, Absender
abgelehnt, Authentifizierung fehlgeschlagen bricht er sofort ab, statt das
Relay weiter zu belasten.
## Was du im DNS einrichten musst
Ohne diese drei Einträge landen Mails auch bei perfektem Code im Spam. Alle
beziehen sich auf die Domain aus `SMTP_ENVELOPE_FROM`.
**SPF** erlaubt deinem Relay, für die Domain zu senden:
```
example.de. IN TXT "v=spf1 mx a:mail.example.de -all"
```
`-all` (hard fail) ist strenger als `~all` und wird von Empfängern besser
bewertet. Setze es erst, wenn du sicher bist, dass alle legitimen Absender
erfasst sind.
**DKIM** signiert ausgehende Mails. Die Signatur erzeugt das Relay, nicht
diese Anwendung. In Postfix über OpenDKIM oder rspamd, in mailcow ist es
eingebaut. Der öffentliche Schlüssel gehört ins DNS:
```
selector._domainkey.example.de. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBg..."
```
Wichtig: Die signierende Domain (`d=` in der Signatur) muss zur Domain im
`From`-Header passen, sonst schlägt die DMARC-Ausrichtung fehl.
**DMARC** sagt Empfängern, was bei Fehlschlägen passieren soll:
```
_dmarc.example.de. IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.de; adkim=s; aspf=s"
```
Fang mit `p=none` an und wertet die Reports ein paar Wochen aus, bevor du auf
`quarantine` oder `reject` gehst.
**PTR (Reverse DNS)** die IP deines Relays muss auf einen Namen auflösen, der
vorwärts wieder auf dieselbe IP zeigt. Bei Hetzner setzt du das im
Cloud-Console-Interface bzw. im Robot. Fehlt der PTR, weisen einige große
Anbieter direkt beim `MAIL FROM` ab.
## Ausrichtung von From und Envelope-From
DMARC verlangt, dass mindestens eine der beiden Prüfungen *ausgerichtet* ist:
- **SPF-Ausrichtung:** Domain in `SMTP_ENVELOPE_FROM` = Domain in `SMTP_FROM`
- **DKIM-Ausrichtung:** signierende Domain = Domain in `SMTP_FROM`
Mit `adkim=s; aspf=s` im DMARC-Record verlangst du exakte Übereinstimmung
(nicht nur die Organisationsdomain). Das ist strenger und sicherer, bedeutet
aber: `bounces@example.de` und `einkaufsapp@example.de` sind ausgerichtet,
`bounces@bounce.example.de` wäre es nicht mehr.
## Testen
Vor dem ersten echten Versand:
```bash
# Verbindung zum Relay prüfen, ohne etwas zu senden
curl -s -b cookies.txt localhost:8000/api/admin/mail/check | jq
# Testnachricht auslösen
CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}')
curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \
-X POST localhost:8000/api/admin/mail/test \
-H 'Content-Type: application/json' \
-d '{"to":"dein-postfach@example.de"}'
docker compose logs --tail=20 api
```
Für eine unabhängige Bewertung eignet sich ein Dienst, der dir eine
Wegwerf-Adresse gibt und die eingehende Mail auf SPF, DKIM, DMARC, PTR und
Inhaltsmerkmale prüft etwa `mail-tester.com`. Alles unterhalb von 8/10 lohnt
das Nachbessern.
Achte in der Auswertung besonders auf:
- `SPF: pass` mit der Envelope-Domain
- `DKIM: pass` mit `d=` gleich der From-Domain
- `DMARC: pass`
- kein Eintrag auf Spamhaus/Barracuda für deine Relay-IP
## Häufige Ursachen für Spam-Einstufung
| Symptom | Ursache |
|---|---|
| Landet bei Gmail im Spam, sonst nirgends | Fehlender oder falscher PTR-Eintrag |
| DKIM `pass`, DMARC `fail` | Signierende Domain weicht von der From-Domain ab |
| SPF `softfail` | Relay-IP nicht im SPF-Record der Envelope-Domain |
| Alles `pass`, trotzdem Spam | Neue Domain ohne Sendereputation das gibt sich nach einigen Wochen regelmäßigen Versands |
| Outlook/Hotmail blockt | Microsoft verlangt oft eine Anmeldung beim SNDS-Programm |

130
docs/reverse-proxy.md Normal file
View File

@@ -0,0 +1,130 @@
# Betrieb hinter dem Reverse Proxy (OPNsense)
Aufbau: Browser → HTTPS → nginx auf der OPNsense → HTTP → Port 46600 auf dem
Docker-Host → nginx im `web`-Container → `api`.
## Drei Werte in der `.env`
```
PUBLIC_BASE_URL=https://einkauf.example.de
COOKIE_SECURE=true
FORWARDED_ALLOW_IPS=<IP des Docker-Hosts oder *>
```
Nach jeder Änderung `docker compose up -d`, nicht `restart` ein Neustart
übernimmt keine geänderten Umgebungswerte.
**`PUBLIC_BASE_URL`** landet in jeder Verifikations-, Einladungs- und
Passwort-Reset-Mail. Steht dort noch `http://…:46600`, führen die Links ins
Leere, sobald jemand von außen klickt.
**`COOKIE_SECURE=true`** ist ab jetzt Pflicht, aber auch erst ab jetzt möglich:
Ein `Secure`-Cookie über reines HTTP wird vom Browser verworfen, und die
Anmeldung scheitert dann ohne verwertbare Meldung. Beides muss gleichzeitig
umgestellt werden.
## Was die OPNsense weiterreichen muss
Im nginx-Plugin unter *Http(s) → Location* für den Upstream:
```
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
```
`X-Forwarded-Proto` ist der wichtigste Wert: Ohne ihn hält die Anwendung die
Verbindung für unverschlüsselt.
Zusätzlich für Server-Sent Events (ab Phase 4):
```
proxy_buffering off;
proxy_read_timeout 3600s;
```
Ohne `proxy_buffering off` sammelt nginx die Ereignisse und gibt sie erst
gebündelt weiter die Liste aktualisiert sich dann mit Verzögerung oder gar
nicht.
## Client-IP fürs Rate Limiting
Mit zwei Proxys davor sieht die Anwendung ohne Weiterreichung nur die IP des
`web`-Containers. Dann würde eine einzige IP das Limit für alle auslösen.
`X-Forwarded-For` wird durch beide Stufen durchgereicht; uvicorn wertet es
wegen `--proxy-headers` aus.
`FORWARDED_ALLOW_IPS=*` ist vertretbar, solange der `api`-Container keinen
veröffentlichten Port hat dann kann den Header nur der `web`-Container
setzen. Sobald du den API-Port für Debugging freigibst, gehört dort die
konkrete IP hinein.
## PWA-Installation
Erst über HTTPS bietet der Browser „Zur Startseite hinzufügen" an und lässt den
Service Worker zu. Bei iOS ist die Installation zusätzlich Voraussetzung dafür,
dass Web Push in Phase 8 überhaupt funktioniert im normalen Safari-Tab gibt
es keine Benachrichtigungen.
## Prüfen
```bash
curl -sI https://einkauf.example.de/ | grep -i strict-transport
curl -s https://einkauf.example.de/readyz
```
Und nach der Anmeldung im Browser unter *Entwicklerwerkzeuge → Anwendung →
Cookies*: `ea_session` muss `Secure` und `HttpOnly` tragen, `ea_csrf` nur
`Secure`.
## Kopfzeilen: was aus dem Protokoll hervorgeht
Eine Auswertung der tatsächlich ausgelieferten Antworten hat drei Punkte
ergeben, die auf der OPNsense-Seite liegen.
### `X-XSS-Protection: 1` entfernen
Die OPNsense setzt diese Kopfzeile. Sie ist überholt: Chrome hat den
zugehörigen Filter entfernt, und in den Browsern, die ihn noch kennen, kann er
in Einzelfällen selbst Lücken aufreißen, indem er Teile der Seite unterdrückt.
Der Wert `1` ohne `mode=block` ist dabei die ungünstigste Variante.
Empfehlung: im nginx-Plugin abschalten oder auf `0` setzen. Der Schutz kommt
von der Content-Security-Policy, nicht von dieser Kopfzeile.
### HSTS: `preload` passt nicht zur Laufzeit
```
strict-transport-security: max-age=15768000; includeSubDomains; preload
```
Das sind gut sechs Monate. Für die Aufnahme in die Preload-Liste verlangen die
Browserhersteller mindestens ein Jahr (`31536000`); mit dem jetzigen Wert wird
das `preload` schlicht ignoriert.
Entweder auf `31536000` erhöhen dann aber im Bewusstsein, dass ein Eintrag in
der Preload-Liste die Domain für alle Unterdomains dauerhaft auf HTTPS
festlegt und sich nur mit Monaten Vorlauf rückgängig machen lässt oder das
`preload` weglassen.
### Doppelte Kopfzeilen
`X-Content-Type-Options`, `X-Frame-Options` und `Permissions-Policy` kommen
jeweils zweimal an: einmal vom `web`-Container, einmal von der OPNsense. Das
ist unschädlich, aber unnötig.
Wichtiger ist, was daraus folgt: **Eine doppelte
`Content-Security-Policy` wäre nicht unschädlich.** Browser wenden dann beide
an und lassen nur zu, was in *beiden* erlaubt ist. Eine zusätzliche CSP auf der
OPNsense könnte die Anwendung also lahmlegen, ohne dass eine Fehlermeldung
darauf hinweist sichtbar wäre nur, dass Module oder Stile nicht laden.
Derzeit setzt die OPNsense keine, das sollte so bleiben.
Die OPNsense überschreibt außerdem `Referrer-Policy` mit `same-origin` statt
des strengeren `no-referrer` aus dem Container. Unkritisch, solange die
Anwendung nicht nach außen verlinkt für die öffentlichen Listenlinks unter
`/s/<token>` bedeutet es, dass der Token nur an die eigene Herkunft übertragen
würde. Wer es strenger mag, stellt die OPNsense auf `no-referrer` um.

263
docs/sicherheit.md Normal file
View File

@@ -0,0 +1,263 @@
# Sicherheit
Stand der Prüfung: nach Phase 9, Revision `0011`, 75 Endpunkte.
## Prüfungen, die sich wiederholen lassen
```bash
python3 tools/check-routes.py # Endpunkte ohne Berechtigungsprüfung
python3 tools/check-schema.py # schema.sql gegen die Modelle
./tools/check-env.sh .env # doppelte oder fehlerhafte Einträge
node tools/check-js.mjs # undefinierte Bezeichner
python3 tools/check-nginx.py web # Kopfzeilen, doppelte Direktiven
node tools/test-barcode.mjs # Strichcode-Decoder
cd backend && python3 -m compileall -q app alembic
```
## SQL-Injection
**Nicht möglich, soweit prüfbar.** Alle Abfragen laufen über
SQLAlchemy-Ausdrücke; Werte werden als gebundene Parameter übergeben, nie in
SQL-Text eingesetzt.
Zwei Stellen verdienen eine Erklärung:
**Rohes SQL** gibt es an genau zwei Stellen, beide ohne Nutzereingaben:
```python
db.execute(text("SELECT 1")) # Readiness
db.execute(text("DELETE FROM user_session WHERE expires_at <= :now"),
{"now": utcnow()}) # gebunden
```
**Der einzige f-String in einer Abfrage** steht in der Artikelsuche:
```python
needle = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(Article.name.like(f"%{needle}%", escape="\\"))
```
Der f-String baut den *Suchwert*, nicht das SQL `.like()` bindet ihn als
Parameter. Die Maskierung ist trotzdem nötig, sonst wäre die Eingabe `%` ein
Platzhalter und `_` ein Jokerzeichen. Kein Sicherheitsproblem, aber ein
Verhaltensproblem: Ohne sie fände die Suche nach „50%" den gesamten Bestand.
**Keine dynamischen Sortier- oder Filterspalten.** Alle `order_by`-Ausdrücke
verweisen auf feste Modellattribute. Ein `?sort=` mit Spaltennamen aus der
Anfrage gibt es nirgends das ist der übliche Weg, wie SQL-Injection trotz ORM
zurückkommt.
## Cross-Site-Scripting
**Im Browser:** Die Oberfläche setzt Text ausschließlich über `textContent`
(Funktion `el()` in `dom.js`). Es gibt kein `innerHTML`, kein
`insertAdjacentHTML`, kein `document.write`.
```bash
grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write" web/html/js/
```
**Serverseitig gerenderte Druckansicht:** Jinja2 mit `autoescape`. Geprüft mit
eingeschleusten Artikelnamen wie `<img src=x onerror=alert(1)>` sie erscheinen
als Text, nicht als Markup.
**Content-Security-Policy:** `default-src 'self'` ohne `unsafe-inline` und ohne
`unsafe-eval`. Das war der ausschlaggebende Grund gegen einen Browser-Babel und
für ES-Module ohne Bundler.
Die Kopfzeilen liegen in `web/security_headers.conf` und werden in **jede**
`location` eingebunden, die eigene `add_header` setzt. Das ist keine
Doppelmoppelei: nginx vererbt `add_header` nur, wenn die untergeordnete Ebene
gar keines setzt ein einzelnes `Cache-Control` in einer `location` lässt sonst
alle Kopfzeilen des `server`-Blocks verschwinden, ohne Fehlermeldung.
`tools/check-nginx.py` prüft das.
## Sitzungen und CSRF
- Sitzungstoken: 32 Byte aus `secrets.token_urlsafe`, in der Datenbank nur als
SHA-256-Hash. Der Klartext steht ausschließlich im `HttpOnly`-Cookie.
- CSRF: Double-Submit. Ein zweites, lesbares Cookie muss bei jeder
schreibenden Anfrage im Header `X-CSRF-Token` wiederkommen. Zusätzlich
`SameSite=Lax`.
- **Kein `SECRET_KEY`.** Er war bis zu dieser Prüfung als Pflichtfeld
konfiguriert und wurde nirgends verwendet Sitzungen und CSRF arbeiten mit
serverseitig gespeicherten Zufallswerten, nicht mit signierten. Entfernt:
Eine Einstellung, die Wichtigkeit vortäuscht, ist schlechter als keine.
- Passwortwechsel verwirft alle bestehenden Sitzungen und stellt eine neue aus.
## Berechtigungen
`tools/check-routes.py` listet alle Endpunkte mit ihren
Berechtigungsabhängigkeiten. Aktuell: 75 Endpunkte, keiner ohne Schutz.
Rollen: `viewer` liest, `editor` ändert, `owner` verwaltet Mitgliedschaften.
Dazu das unabhängige Zusatzrecht `may_share_public`.
**Wer kein Mitglied ist, bekommt `404` statt `403`.** Sonst ließe sich über die
Statuscodes herausfinden, welche Listen-IDs existieren.
**Objekte werden gegen die Liste geprüft.** `_check_belongs()` in `catalog.py`
stellt sicher, dass ein übergebener Markt, eine Warengruppe oder ein Artikel
wirklich zu der Liste gehört, um die es geht. Ohne das könnte ein Mitglied durch
Angabe einer fremden ID Rückschlüsse auf andere Listen ziehen.
Zwei Fälle prüfen im Rumpf statt über eine Abhängigkeit und sind deshalb im
Skript als Ausnahme vermerkt:
- `DELETE /api/lists/{id}/members/{uid}` der Eigentümer darf andere
entfernen, jedes Mitglied sich selbst
- `/api/public/…` geschützt durch den Token im Pfad
## Token in Adressen
Öffentliche Links, Einladungen und Passwort-Zurücksetzen tragen ihren Token in
der URL. Das ist üblich und unvermeidbar, hat aber eine Nebenwirkung, die leicht
übersehen wird: **nginx schreibt die vollständige URL ins Zugriffsprotokoll.**
Ein Protokoll wandert in Sicherungen, wird an Auswertungswerkzeuge gereicht und
lebt länger als der Link. Wer Leserecht darauf hat, käme an alle geteilten
Listen.
Deshalb ist `access_log off` gesetzt für:
```
/s/ öffentliche Listenlinks
/invite, /reset Links aus Einladungs- und Reset-Mails
/api/public/, /api/invites/
/api/auth/verify
```
`Referrer-Policy: no-referrer` verhindert zusätzlich, dass der Token beim
Weiterklicken an fremde Seiten gelangt.
## Passwörter
- Argon2id (`argon2-cffi`), Standardparameter der Bibliothek
- Mindestens 12 Zeichen, keine Zeichenklassenpflicht Länge schlägt
Sonderzeichen
- Beim Anmelden läuft die Prüfung auch bei unbekanntem Konto gegen einen
Dummy-Hash, damit die Antwortzeit keinen Rückschluss zulässt
- `check_needs_rehash` bei jeder Anmeldung: Werden die Parameter später
verschärft, wandern bestehende Konten von selbst mit
## Kontenaufzählung
Registrierung und Passwort-Zurücksetzen antworten immer gleich, unabhängig
davon, ob die Adresse existiert. Der Preis ist Diagnosekomfort: „Ich habe mich
registriert und bekomme keine Mail" kann auch heißen, dass das Konto längst da
ist.
Beim Einladen gibt es bewusst eine Ausnahme (`409` bei bereits vorhandenem
Zugriff) das darf nur der Eigentümer, und der sieht die Mitgliederliste
ohnehin.
## Missbrauchsbremsen
| Wo | Grenze | Warum |
|---|---|---|
| Anmeldung | 20/15 min je IP **und** 8/15 min je Konto | Nur je IP hilft nicht gegen verteilte Angriffe; nur je Konto macht das Aussperren fremder Nutzer trivial |
| Registrierung, Reset | 5/Stunde je IP | |
| Einladungen | 20/Stunde je Liste, 30/Stunde je IP | Jede Einladung erzeugt eine Mail an eine frei wählbare Adresse sonst wäre die App eine Versandhilfe für unerwünschte Nachrichten |
| Öffentliche Links | 120/15 min je IP | Begrenzt die Last, nicht das Raten (256 Bit sind nicht ratbar) |
| nginx | 10/min auf `/api/auth/` | Zweite Verteidigungslinie |
Zusätzlich: höchstens 100 Listen je Konto, 50 offene Einladungen je Liste,
10 aktive öffentliche Links je Liste, 20 Push-Geräte je Konto, 200 Operationen
je Stapel.
## Datensparsamkeit
- **Preisdaten ohne Nutzerbezug.** `price_point` führt Markt, Artikel, Betrag
und Zeitpunkt kein `user_id`. Aus „wer hat wann wo was gekauft" ließe sich
ein Bewegungs- und Konsumprofil bilden.
- **Öffentliche Ansicht anonymisiert serverseitig.** Wer welchen Artikel
eingetragen hat, wird nicht ausgeliefert nicht erst im Browser
ausgeblendet.
- **Push-Meldungen ohne Inhalt.** Listenname und wer geändert hat, keine
Artikelnamen. Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm.
- **Zugriffszähler ohne Personenbezug.** Bei öffentlichen Links sieht der
Eigentümer, *dass* der Link benutzt wird, nicht von wem.
- **Produktabfragen über den Server.** Open Food Facts erfährt die IP-Adressen
der Nutzer nicht.
- **Nur die E-Mail-Adresse ist Pflichtangabe.** Anzeigename freiwillig, auch
als Pseudonym.
- Zwischen Mitgliedern wird der Anzeigename ausgeliefert, nicht die
vollständige E-Mail-Adresse.
## Benutzerverwaltung
**Administratorkonten sind vor Deaktivierung und Löschung geschützt**, auch
gegenüber anderen Administratoren und dem eigenen Konto. Die Verwaltung soll
sich nicht aussperren können weder versehentlich noch durch jemanden, der
kurzzeitig Zugriff erlangt hat.
**Adressänderung bei Administratorkonten verlangt beide Bestätigungen.** Ohne
diese Regel wäre der Ablauf ein Übernahmewerkzeug: Wer kurz Zugriff auf ein
Administratorkonto hat, stellt die Adresse um und hat es dauerhaft der
rechtmäßige Inhaber kommt über „Passwort vergessen" nicht mehr hinein. Mit der
Zustimmung von der alten Adresse geht das nur, wenn auch das Postfach
übernommen wurde.
**Kein vom Administrator vergebenes Passwort.** Neue Konten haben zunächst
keinen gültigen Passwort-Hash; die Person setzt ihn über den Link aus der
Willkommensnachricht. Ein vergebenes Passwort wäre dem Administrator bekannt
und ginge im Klartext per Mail.
**Willkommensnachricht nur vor der Einrichtung erneut versendbar.** Sonst hätte
ein Administrator einen stillen Weg, sich Zugang zu fremden Konten zu
verschaffen: Link anfordern, Postfach ist unbeteiligt, Passwort neu setzen.
**Deaktivierungsmeldung erst nach erfolgreicher Passwortprüfung.** Vorher wäre
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.
## Bekannte Grenzen
Ehrlichkeitshalber, keine dieser Punkte ist ein Fehler aber jeder ist eine
bewusste Abwägung, die man kennen sollte.
**E-Mail-Adressen liegen im Klartext.** Verschlüsselung brächte wenig: Die
Anwendung muss Mails versenden, der Schlüssel läge also neben der Datenbank.
Der Schutz griffe nur gegen ein gestohlenes Backup ohne Serverzugriff.
**Ein öffentlicher Link ist die Berechtigung.** Wer ihn weitergibt, gibt den
Zugriff weiter. Abgefedert durch Pflicht-Ablaufdatum (höchstens 365 Tage),
jederzeitigen Widerruf und Speicherung nur als Hash.
**Preise sind nachträglich nicht zuordenbar.** Folge des fehlenden `user_id`
wer einen falschen Preis eingetragen hat, lässt sich nicht ermitteln.
**Administratoren sehen alle E-Mail-Adressen.** Für die Verwaltung
unumgänglich. Es gibt kein Protokoll darüber, wer wann in die Benutzerliste
gesehen hat bei der vorgesehenen Zahl von Administratoren wäre das mehr
Aufwand als Nutzen.
**Administratorrechte lassen sich nur in der Datenbank vergeben und
entziehen.** Bewusst: Ein Endpunkt dafür wäre der direkteste Weg zur
Rechteausweitung, wenn irgendwo anders ein Fehler steckt. Der Preis ist ein
umständlicher Vorgang für einen seltenen Fall.
**Kein Zwei-Faktor-Verfahren.** Für den vorgesehenen Nutzerkreis vertretbar;
bei mehr Konten wäre TOTP der nächste Schritt.
**Keine Ende-zu-Ende-Verschlüsselung der Listeninhalte.** Wer Zugriff auf die
Datenbank hat, liest die Listen. Ende-zu-Ende würde die serverseitige
Gruppierung, den Ausdruck und die öffentlichen Links unmöglich machen.
**Der `api`-Container bindet `./backend` als Volume ein.** Praktisch beim
Entwickeln, aber im Dauerbetrieb läuft damit Code vom Host statt aus dem
geprüften Abbild. Für den Produktivbetrieb: Zeile entfernen und `--reload` aus
`entrypoint.sh` nehmen.
**`FORWARDED_ALLOW_IPS=*`** ist vertretbar, solange der `api`-Container keinen
veröffentlichten Port hat dann kann nur der `web`-Container den Header
setzen. Sobald du den API-Port zum Debuggen freigibst, gehört dort die konkrete
IP hinein, sonst lässt sich das Rate Limiting durch einen gefälschten
`X-Forwarded-For` umgehen.
**Sicherheitskopfzeilen kommen doppelt** (Container und OPNsense). Unschädlich,
solange die OPNsense **keine eigene CSP** setzt: Browser wenden dann beide an
und erlauben nur die Schnittmenge die Anwendung würde stillschweigend
aufhören zu funktionieren. Siehe `docs/reverse-proxy.md`.

25
render_view.py Normal file
View File

@@ -0,0 +1,25 @@
import json
import sys
view = json.load(sys.stdin)
print(f'Liste: {view["list_name"]} (rev {view["rev"]})')
for market in view["markets"]:
print(f' [{market["market_name"]}] offen: {market["open_count"]} '
f'Summe: {market["total_cents"]} ct')
for cat in market["categories"]:
print(f' <{cat["category_name"]}>')
for item in cat["items"]:
menge = ""
if item.get("pack_size") or (item.get("count") or 1) > 1:
pack = (f'{item["pack_size"]} {item.get("pack_unit") or ""}'.strip()
if item.get("pack_size") else "")
count = item.get("count") or 1
menge = f' {count} × {pack}'.rstrip() if count > 1 and pack \
else (f' {pack}' if pack else f' {count} Stück')
preis = (f' [{item["price_cents"]} ct je Gebinde'
f', gesamt {item.get("total_cents")} ct]'
if item["price_cents"] else "")
eigenschaft = f' ({item.get("variant")})' if item.get("variant") else ""
print(f' - {item["article_name"]}{eigenschaft}{menge}{preis} '
f'[{item["status"]}]')
print(f' Gesamtsumme: {view["grand_total_cents"]} ct')

176
smoke-phase2.sh Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Durchlauf durch die Phase-2-Endpunkte. Legt eine Testliste an, füllt
# sie und prüft die gruppierte Ansicht.
#
# ./smoke-phase2.sh http://localhost:46600 admin@example.de
#
# Das Passwort wird abgefragt, nicht als Argument übergeben. Damit landet
# es nicht in der Shell-History, und Sonderzeichen wie ! machen keinen
# Ärger: die History-Expansion der Bash greift nur bei interaktiver
# Eingabe auf der Kommandozeile, nicht bei "read".
#
# Alternativ per Umgebungsvariable, z.B. aus einem Passwortmanager:
# EA_PASSWORD="$(pass show einkaufsapp)" ./smoke-phase2.sh URL MAIL
#
# Benötigt curl sowie jq oder python3.
set -euo pipefail
BASE="${1:-http://localhost:46600}"
EMAIL="${2:?E-Mail angeben}"
if [ -n "${EA_PASSWORD:-}" ]; then
PASSWORD="$EA_PASSWORD"
else
printf 'Passwort für %s: ' "$EMAIL" >&2
IFS= read -rs PASSWORD
printf '\n' >&2
fi
[ -n "$PASSWORD" ] || { echo "Kein Passwort angegeben." >&2; exit 1; }
# jq ist bequem, aber nicht zwingend. Ohne jq springt python3 ein -
# beides kann JSON korrekt erzeugen und lesen, was hier der Punkt ist:
# Passwoerter und Umlaute duerfen nicht per String-Bastelei in die
# Nutzlast wandern.
if command -v jq >/dev/null 2>&1; then
JSON_TOOL=jq
elif command -v python3 >/dev/null 2>&1; then
JSON_TOOL=python3
else
echo "Benötigt jq oder python3. Installieren mit: apt install jq" >&2
exit 1
fi
# json_obj key value [key value ...] -> JSON-Objekt auf stdout
json_obj() {
if [ "$JSON_TOOL" = jq ]; then
local args=() filter="{" first=1 i=1
while [ $# -gt 0 ]; do
args+=(--arg "k$i" "$1" --arg "v$i" "$2")
[ $first -eq 1 ] || filter="$filter,"
filter="$filter(\$k$i): \$v$i"
first=0; i=$((i+1)); shift 2
done
jq -n "${args[@]}" "$filter}"
else
python3 -c 'import json,sys; a=sys.argv[1:]; print(json.dumps(dict(zip(a[::2], a[1::2]))))' "$@"
fi
}
# json_get <schluessel> - liest einen Wert aus JSON auf stdin
json_get() {
if [ "$JSON_TOOL" = jq ]; then
jq -r ".$1 // empty"
else
python3 -c 'import json,sys
try:
d = json.load(sys.stdin)
except Exception:
sys.exit(0)
v = d.get(sys.argv[1]) if isinstance(d, dict) else None
print("" if v is None else v)' "$1"
fi
}
JAR="$(mktemp)"
chmod 600 "$JAR"
trap 'rm -f "$JAR"' EXIT
say() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
api() {
local method="$1" path="$2" body="${3:-}"
local csrf
csrf="$(awk '/ea_csrf/ {print $7}' "$JAR")"
if [ -n "$body" ]; then
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
-H 'Content-Type: application/json' -H "X-CSRF-Token: $csrf" \
--data-binary "$body"
else
curl -sS -b "$JAR" -c "$JAR" -X "$method" "$BASE$path" \
-H "X-CSRF-Token: $csrf"
fi
}
say "Anmelden"
# JSON strukturiert erzeugen, nicht per String-Interpolation: sonst
# zerbricht jedes " oder \ im Passwort die Nutzlast.
LOGIN_JSON="$(json_obj email "$EMAIL" password "$PASSWORD")"
curl -sS -c "$JAR" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
--data-binary "$LOGIN_JSON" > /tmp/ea_login.$$ 2>&1
WHO="$(json_get email < /tmp/ea_login.$$)"
if [ -z "$WHO" ]; then
echo " Anmeldung fehlgeschlagen: $(json_get detail < /tmp/ea_login.$$)" >&2
rm -f /tmp/ea_login.$$
exit 1
fi
echo " angemeldet als $WHO"
rm -f /tmp/ea_login.$$
unset PASSWORD LOGIN_JSON
say "CSRF-Schutz muss greifen (erwartet 403)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists" \
-H 'Content-Type: application/json' --data-binary '{"name":"Ohne CSRF"}')
[ "$code" = "403" ] && echo " ok: $code" || { echo " FEHLER: $code statt 403"; exit 1; }
say "Liste anlegen"
LIST=$(api POST /api/lists "$(json_obj name Wocheneinkauf)" | json_get id)
echo " list_id=$LIST"
say "Märkte anlegen"
EDEKA=$(api POST "/api/lists/$LIST/markets" '{"name":"Edeka","sort_order":1}' | json_get id)
DM=$(api POST "/api/lists/$LIST/markets" '{"name":"dm","sort_order":2}' | json_get id)
echo " Edeka=$EDEKA dm=$DM"
say "Doppelter Marktname muss abgelehnt werden (erwartet 409)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" -X POST "$BASE/api/lists/$LIST/markets" \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $(awk '/ea_csrf/ {print $7}' "$JAR")" \
--data-binary '{"name":"Edeka"}')
[ "$code" = "409" ] && echo " ok: $code" || { echo " FEHLER: $code statt 409"; exit 1; }
say "Warengruppen anlegen"
MOLK=$(api POST "/api/lists/$LIST/categories" '{"name":"Molkerei","sort_order":1}' | json_get id)
OBST=$(api POST "/api/lists/$LIST/categories" '{"name":"Obst und Gemüse","sort_order":2}' | json_get id)
say "Artikel mit Attributen anlegen"
api POST "/api/lists/$LIST/articles" "$(cat <<JSON
{"name":"Vollmilch","barcode":"4001234567890",
"default_market_id":"$EDEKA","default_category_id":"$MOLK",
"attributes":[{"name":"Verpackungseinheit","value":"1 Liter"},
{"name":"Fettgehalt","value":"3,5 %"}],
"available_market_ids":["$EDEKA"]}
JSON
)" | json_get name | sed 's/^/ Artikel angelegt: /'
say "Einträge anlegen (Artikel wird bei Bedarf erzeugt)"
for a in Butter Äpfel Bananen Zahnpasta; do
api POST "/api/lists/$LIST/items" "$(json_obj article_name "$a")" > /dev/null
done
MILCH=$(api POST "/api/lists/$LIST/items" \
'{"article_name":"Vollmilch","quantity":2,"unit":"l"}' | json_get id)
say "Zuordnungen setzen"
api PATCH "/api/lists/$LIST/items/$MILCH" \
"{\"market_id\":\"$EDEKA\",\"category_id\":\"$MOLK\",\"price_cents\":129}" \
| json_get article_name | sed 's/^/ Preis gesetzt für: /'
say "Barcode-Suche"
api GET "/api/lists/$LIST/articles/by-barcode/4001234567890" | json_get name | sed 's/^/ gefunden: /'
say "Gruppierte Ansicht"
api GET "/api/lists/$LIST/view" | python3 "$(dirname "$0")/render_view.py"
say "Abhaken und aufräumen"
api PATCH "/api/lists/$LIST/items/$MILCH" '{"status":"bought"}' | json_get status | sed 's/^/ neuer Status: /'
api POST "/api/lists/$LIST/items/clear-bought" | json_get removed | sed 's/^/ entfernte Einträge: /'
say "Fremde Liste darf nicht sichtbar sein (erwartet 404)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -b "$JAR" \
"$BASE/api/lists/00000000-0000-0000-0000-000000000000")
[ "$code" = "404" ] && echo " ok: $code" || { echo " FEHLER: $code statt 404"; exit 1; }
say "Fertig"
echo "Testliste $LIST bleibt bestehen. Löschen mit:"
echo " curl -b <cookiejar> -X DELETE $BASE/api/lists/$LIST -H 'X-CSRF-Token: ...'"

43
tools/check-all.sh Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Alle Prüfungen in einem Durchgang.
#
# bash tools/check-all.sh
#
# Falls das Ausführungsbit fehlt (etwa nach dem Entpacken eines Archivs):
# chmod +x tools/*.sh
#
# Vor jedem docker compose build sinnvoll. Bricht beim ersten Problem ab
# und gibt Rückgabewert 1 zurück - damit lässt es sich als Git-Hook oder
# in einer CI verwenden.
set -uo pipefail
cd "$(dirname "$0")/.."
failed=0
run() {
local label="$1"; shift
printf '\n\033[1m== %s\033[0m\n' "$label"
if "$@"; then
:
else
echo " -> fehlgeschlagen"
failed=$((failed + 1))
fi
}
[ -f .env ] && run "Konfiguration (.env)" bash tools/check-env.sh .env
run "Konfigurationsvorlage" bash tools/check-env.sh .env.example
run "Berechtigungen" python3 tools/check-routes.py
run "Datenbankschema" python3 tools/check-schema.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
run "Python-Syntax" sh -c 'cd backend && python3 -m compileall -q app alembic && echo "in Ordnung"'
printf '\n'
if [ "$failed" -eq 0 ]; then
echo "Alle Prüfungen bestanden."
else
echo "$failed Prüfung(en) fehlgeschlagen."
exit 1
fi

52
tools/check-env.sh Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Sucht doppelte Variablen in der .env.
#
# ./tools/check-env.sh [.env]
#
# Hintergrund: Docker Compose nimmt bei mehrfach definierten Variablen
# die LETZTE. Eine vergessene leere Vorlagenzeile unterhalb des
# eingefügten Werts schaltet die betroffene Funktion damit still wieder
# ab - ohne Fehlermeldung, nur mit einem Verhalten, das niemand erklären
# kann.
set -euo pipefail
FILE="${1:-.env}"
[ -f "$FILE" ] || { echo "$FILE nicht gefunden." >&2; exit 2; }
problems=0
# Doppelte Schlüssel
dupes=$(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$FILE" \
| cut -d= -f1 | sort | uniq -d || true)
if [ -n "$dupes" ]; then
echo "Mehrfach definiert (Docker Compose nimmt jeweils die letzte Zeile):"
while read -r key; do
[ -z "$key" ] && continue
echo " $key"
grep -n "^$key=" "$FILE" | sed 's/^/ Zeile /'
problems=$((problems + 1))
done <<< "$dupes"
fi
# Leerzeichen vor dem Gleichheitszeichen - solche Zeilen ignoriert Compose
spaced=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]* +=' "$FILE" || true)
if [ -n "$spaced" ]; then
echo "Leerzeichen vor dem '=' (wird von Docker Compose ignoriert):"
echo "$spaced" | sed 's/^/ Zeile /'
problems=$((problems + 1))
fi
# Anführungszeichen - die landen wörtlich im Wert
quoted=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]*="' "$FILE" || true)
if [ -n "$quoted" ]; then
echo "Anführungszeichen im Wert (Compose übernimmt sie wörtlich):"
echo "$quoted" | sed 's/^/ Zeile /'
problems=$((problems + 1))
fi
if [ "$problems" -eq 0 ]; then
echo "$FILE: keine Auffälligkeiten"
else
exit 1
fi

243
tools/check-js.mjs Normal file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env node
/**
* Sucht Bezeichner, die verwendet, aber nirgends deklariert oder
* importiert werden.
*
* node tools/check-js.mjs
*
* Hintergrund: Ohne Build-Schritt gibt es keinen Linter, und der Browser
* meldet "x is not defined" erst, wenn die betroffene Zeile tatsächlich
* ausgeführt wird. Eine Funktion, die nur im Menü eines Eintrags
* gebraucht wird, kann so lange fehlen, ohne dass es auffällt.
*
* Das ist bewusst eine grobe Prüfung ohne echten Parser: Sie kennt keine
* Blockgeltungsbereiche und meldet daher nichts, was irgendwo in der
* Datei deklariert ist. Für den Zweck - "ganz vergessen" statt "am
* falschen Ort" - reicht das.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const ROOT = process.argv[2] || "web/html/js";
/** Was der Browser mitbringt. Bewusst knapp gehalten - je kürzer, desto
* mehr findet die Prüfung. */
const GLOBALS = new Set([
// Sprache
"Array", "Boolean", "Date", "Error", "Infinity", "Intl", "JSON", "Map",
"Math", "NaN", "Number", "Object", "Promise", "RegExp", "Set", "String",
"Symbol", "TypeError", "Uint8Array", "Uint8ClampedArray", "WeakMap",
"globalThis", "isNaN", "parseFloat", "parseInt", "structuredClone",
"undefined", "queueMicrotask", "console",
// Browser
"AbortController", "Blob", "CustomEvent", "DOMException", "Event",
"EventSource", "FileReader", "FormData", "Headers", "IDBKeyRange",
"Image", "ImageData", "Node", "Request", "Response", "URL",
"URLSearchParams", "alert", "clearInterval", "clearTimeout", "confirm",
"crypto", "document", "fetch", "history", "indexedDB", "localStorage",
"location", "navigator", "performance", "prompt", "requestAnimationFrame",
"Notification", "PushManager", "ServiceWorkerRegistration",
"self", "sessionStorage", "setInterval", "setTimeout", "window",
"BarcodeDetector", "Element", "HTMLElement", "MediaStream", "TextDecoder",
"TextEncoder", "atob", "btoa", "decodeURIComponent", "encodeURIComponent",
// Service Worker
"caches", "clients", "registration", "skipWaiting",
// Schlüsselwörter, die als Wort auftauchen
"arguments", "as", "async", "await", "break", "case", "catch", "class", "const",
"continue", "default", "delete", "do", "else", "export", "extends",
"false", "finally", "for", "from", "function", "get", "if", "import",
"in", "instanceof", "let", "new", "null", "of", "return", "set", "static",
"super", "switch", "this", "throw", "true", "try", "typeof", "var", "void",
"while", "yield",
]);
/** Entfernt Kommentare und Zeichenkettenliterale. Der Inhalt von
* Template-Ausdrücken ${...} bleibt erhalten - dort steht echter Code. */
function stripNoise(source) {
let out = "";
let i = 0;
const n = source.length;
while (i < n) {
const two = source.slice(i, i + 2);
if (two === "//") {
while (i < n && source[i] !== "\n") i++;
continue;
}
if (two === "/*") {
i += 2;
while (i < n && source.slice(i, i + 2) !== "*/") i++;
i += 2;
continue;
}
if (source[i] === '"' || source[i] === "'") {
const quote = source[i++];
while (i < n && source[i] !== quote) {
if (source[i] === "\\") i++;
i++;
}
i++;
out += '""';
continue;
}
if (source[i] === "`") {
i++;
while (i < n && source[i] !== "`") {
if (source[i] === "\\") { i += 2; continue; }
if (source.slice(i, i + 2) === "${") {
// Ausdruck übernehmen, Klammern zählen - und rekursiv säubern,
// denn darin können wieder Zeichenketten stehen.
i += 2;
let depth = 1;
let inner = "";
while (i < n && depth > 0) {
if (source[i] === "{") depth++;
else if (source[i] === "}") depth--;
if (depth > 0) inner += source[i];
i++;
}
out += " " + stripNoise(inner) + " ";
continue;
}
i++;
}
i++;
out += '""';
continue;
}
// Regulärer Ausdruck? Ein / ist nur dann Beginn eines Literals, wenn
// davor ein Operator oder Klammeranfang steht - sonst ist es eine
// Division. Ohne diese Unterscheidung landen die Flags (g, i, s) und
// Zeichenklassen (\D, \w) in der Bezeichnerliste.
if (source[i] === "/") {
const head = out.replace(/\s+$/, "");
const before = head.slice(-1);
// Nach einem Schlüsselwort steht ebenfalls ein Literal, keine
// Division: `return /x/.test(s)` ist gültiges JavaScript.
const keyword = /(?:^|[^\w$])(return|typeof|instanceof|in|of|case|do|else|void|delete|await|yield|new|throw)$/.test(head);
if (before === "" || keyword || "(,=:[!&|?{};+-*%<>~^".includes(before)) {
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") inClass = true;
else if (source[i] === "]") inClass = false;
else if (source[i] === "/" && !inClass) break;
else if (source[i] === "\n") break;
i++;
}
i++;
while (i < n && /[a-z]/.test(source[i])) i++; // Flags
out += "0";
continue;
}
}
out += source[i++];
}
return out;
}
function declaredNames(code) {
const names = new Set();
const add = (raw) => {
// Destrukturierung entfernen, dann jeden Teil einzeln betrachten.
for (const part of raw.replace(/[{}[\]]/g, "").split(",")) {
// "a = 1" -> a, "a: b" -> b (der zweite Name ist die Bindung)
const pieces = part.split("=")[0].split(":");
const name = pieces[pieces.length - 1].trim().replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
}
};
// Importe
for (const m of code.matchAll(/import\s+\*\s+as\s+([\w$]+)/g)) names.add(m[1]);
for (const m of code.matchAll(/import\s+\{([^}]*)\}/g)) {
for (const part of m[1].split(",")) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) names.add(name);
}
}
for (const m of code.matchAll(/import\s+([\w$]+)\s+from/g)) names.add(m[1]);
// Funktionen und Klassen
for (const m of code.matchAll(/(?:function|class)\s+([\w$]+)/g)) names.add(m[1]);
// Variablen, auch destrukturiert
for (const m of code.matchAll(/(?:const|let|var)\s+(\{[^}]*\}|\[[^\]]*\]|[\w$]+)/g)) {
add(m[1]);
}
// Parameterlisten: alles zwischen ( ) vor einem => oder {
for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) add(m[1]);
// Einzelner Pfeilparameter ohne Klammern
for (const m of code.matchAll(/(?:^|[^\w$.])([\w$]+)\s*=>/gm)) names.add(m[1]);
// catch (e)
for (const m of code.matchAll(/catch\s*\(\s*([\w$]+)/g)) names.add(m[1]);
// for (const x of ...) ist oben abgedeckt; benannte Objektmethoden
for (const m of code.matchAll(/([\w$]+)\s*\([^()]*\)\s*\{/g)) names.add(m[1]);
return names;
}
function usedNames(code) {
const names = new Set();
// Kein Punkt davor (sonst wäre es ein Eigenschaftszugriff), kein
// Doppelpunkt danach (sonst wäre es ein Objektschlüssel oder Label).
for (const m of code.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)/g)) {
const name = m[1];
const after = code.slice(m.index + name.length, m.index + name.length + 40);
if (/^\s*:/.test(after)) continue;
names.add(name);
}
return names;
}
function walk(dir, files = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path, files);
else if (entry.name.endsWith(".js")) files.push(path);
}
return files;
}
let problems = 0;
const targets = walk(ROOT);
targets.push("web/html/sw.js");
for (const file of targets) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
continue;
}
const code = stripNoise(source);
const declared = declaredNames(code);
const used = usedNames(code);
const unknown = [...used].filter(
(name) => !declared.has(name) && !GLOBALS.has(name)
).sort();
if (unknown.length) {
console.log(`\n ${file}`);
for (const name of unknown) {
// Zeilennummer des ersten Vorkommens für die Fehlersuche
const line = source.split("\n").findIndex((l) =>
new RegExp(`(^|[^\\w$.])${name}\\b`).test(l)) + 1;
console.log(` Zeile ${line}: ${name} wird verwendet, aber nirgends deklariert`);
problems++;
}
}
}
console.log(
problems
? `\n${problems} möglicherweise undefinierte(r) Bezeichner`
: `${targets.length} Dateien geprüft, keine undefinierten Bezeichner`
);
process.exit(problems ? 1 : 0);

139
tools/check-nginx.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Statische Prüfung der nginx-Konfiguration im web-Container.
Findet drei Fehlerklassen, die uns bereits begegnet sind und die nginx
erst beim Start bemerkt - oder gar nicht:
1. Doppelte Direktiven im selben Kontext.
nginx bricht mit "directive is duplicate" ab. Passiert leicht, wenn
eine location proxy_common.conf einbindet und etwas wiederholt, das
dort schon steht.
2. Fehlende Sicherheitskopfzeilen.
nginx vererbt add_header NUR, wenn die untergeordnete Ebene gar kein
add_header setzt. Ein einziges Cache-Control in einer location lässt
alle Kopfzeilen des server-Blocks verschwinden - ohne Fehlermeldung.
3. Ein types-Block im server-Kontext.
Der ersetzt die geerbte MIME-Tabelle vollständig, statt sie zu
ergänzen. CSS und JavaScript kommen dann als
application/octet-stream an und der Browser lehnt ES-Module ab.
Aufruf:
python3 tools/check-nginx.py # prüft web/
python3 tools/check-nginx.py pfad/zu/web
"""
import re
import sys
from pathlib import Path
# add_header darf mehrfach auftreten - jedes weitere hängt an, statt zu
# ersetzen. Alle anderen hier geprüften Direktiven dürfen es nicht.
REPEATABLE = {"add_header", "proxy_set_header", "try_files", "include",
"limit_req", "gzip_types", "error_page", "set"}
def directive_names(text: str) -> list[str]:
out = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
out.append(line.split()[0])
return out
def check(web_dir: Path) -> int:
conf_path = web_dir / "nginx.conf"
if not conf_path.is_file():
print(f"nginx.conf nicht gefunden unter {conf_path}", file=sys.stderr)
return 2
conf = conf_path.read_text(encoding="utf-8")
problems: list[str] = []
includes = {}
for name in ("proxy_common.conf", "security_headers.conf"):
path = web_dir / name
if path.is_file():
includes[name] = set(directive_names(path.read_text(encoding="utf-8")))
else:
problems.append(f"{name} fehlt, wird aber eingebunden")
includes[name] = set()
# ---- 3. types-Block ----
if re.search(r"^\s*types\s*\{", conf, re.M):
problems.append(
"types-Block gefunden: ersetzt im server-Kontext die gesamte "
"MIME-Tabelle. Stattdessen default_type in der betroffenen "
"location setzen."
)
# ---- Klammernbilanz ----
if conf.count("{") != conf.count("}"):
problems.append(
f"Geschweifte Klammern unausgeglichen: "
f"{conf.count('{')} auf, {conf.count('}')} zu"
)
# ---- pro location ----
for match in re.finditer(r"(location[^{]*)\{(.*?\n )\}", conf, re.S):
header = match.group(1).strip()
body = match.group(2)
lines = [
line.strip()
for line in body.splitlines()
if line.strip() and not line.strip().startswith("#")
]
included = {name for name in includes if any(name in line for line in lines)}
own = [line.split()[0] for line in lines if not line.startswith("include")]
for name in sorted(set(own)):
if own.count(name) > 1 and name not in REPEATABLE:
problems.append(f"{header}: {name} steht {own.count(name)}× im Block")
for inc in included:
if name in includes[inc] and name not in REPEATABLE:
problems.append(
f"{header}: {name} steht auch in {inc} nginx bricht "
"mit \"directive is duplicate\" ab"
)
if "add_header" in own and "security_headers.conf" not in included:
problems.append(
f"{header}: setzt add_header, bindet aber security_headers.conf "
"nicht ein die Kopfzeilen des server-Blocks gehen hier verloren"
)
# ---- Ereigniskanal ----
sse = re.search(r"location[^{]*events[^{]*\{(.*?\n )\}", conf, re.S)
if sse:
body = sse.group(1)
if "gzip off" not in body:
problems.append(
"Ereigniskanal: gzip off fehlt Komprimierung sammelt die "
"Ereignisse, statt sie einzeln durchzureichen"
)
common = (web_dir / "proxy_common.conf")
if common.is_file() and "proxy_buffering off" not in common.read_text():
problems.append(
"proxy_common.conf: proxy_buffering off fehlt ohne das "
"puffert nginx die Server-Sent Events"
)
else:
problems.append("Kein location-Block für den Ereigniskanal gefunden")
if problems:
print(f"{len(problems)} Problem(e) in {conf_path}:\n")
for p in problems:
print(f" - {p}")
return 1
print(f"{conf_path}: keine Probleme gefunden")
return 0
if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("web")
raise SystemExit(check(target))

88
tools/check-routes.py Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Listet alle Endpunkte samt ihrer Berechtigungsabhängigkeiten.
python3 tools/check-routes.py
Findet Endpunkte, die weder eine Abhängigkeit mit Berechtigungsprüfung
haben noch ausdrücklich als offen eingetragen sind. Damit fällt auf, wenn
beim Hinzufügen einer Route die Absicherung vergessen wurde - was beim
Schreiben leicht passiert, weil FastAPI das nicht anmahnt.
Rein statisch: Zwei Fälle kann das Skript nicht sehen und sie sind unten
als Ausnahmen vermerkt - Endpunkte, die ihre Prüfung im Rumpf machen
(DELETE auf Mitgliedschaften: Eigentümer oder man selbst) und solche,
die über einen Token im Pfad geschützt sind.
"""
import ast, pathlib, re
GUARDS = {
"CurrentUser": "angemeldet",
"VerifiedUser": "bestätigt",
"AdminUser": "ADMIN",
"ReadableList": "Listen-Leser",
"EditableList": "Listen-Bearbeiter",
"OwnedList": "Listen-EIGENTÜMER",
"SharableList": "Teilen-Berechtigt",
}
# Endpunkte, die bewusst ohne Anmeldung erreichbar sind
INTENDED_PUBLIC = {
"/api/auth/register", "/api/auth/login", "/api/auth/verify",
"/api/auth/password/reset-request", "/api/auth/password/reset",
# Willkommensstrecke: Wer den Link oeffnet, HAT noch kein Passwort
# und kann sich deshalb nicht anmelden. Der Token ist der Nachweis.
"/api/auth/welcome/{token}", "/api/auth/welcome/complete",
# Bestaetigung einer Adressaenderung - ebenfalls per Token, weil der
# Klick aus dem Postfach kommt.
"/api/auth/email-change/{token}",
"/api/config", "/manifest.webmanifest", "/api/push/config",
"/healthz", "/readyz",
}
# Öffentlich, aber durch einen Token im Pfad geschützt
TOKEN_GUARDED = ("/api/public/",)
rows = []
for path in sorted(pathlib.Path("backend/app/routers").glob("*.py")):
src = path.read_text()
prefix = ""
m = re.search(r'APIRouter\((?:[^)]*?)prefix="([^"]*)"', src, re.S)
if m:
prefix = m.group(1)
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in node.decorator_list:
if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
continue
if dec.func.attr.upper() not in {"GET","POST","PUT","PATCH","DELETE"}:
continue
method = dec.func.attr.upper()
route = prefix + (dec.args[0].value if dec.args else "?")
guards = []
for arg in node.args.args + node.args.kwonlyargs:
ann = ast.unparse(arg.annotation) if arg.annotation else ""
for name, label in GUARDS.items():
if ann == name:
guards.append(label)
rows.append((route, method, guards, path.name))
print(f"{'Pfad':52} {'Methode':7} Schutz")
print("-" * 100)
problems = 0
for route, method, guards, file in sorted(rows):
if guards:
mark = ", ".join(guards)
elif route in INTENDED_PUBLIC:
mark = "offen (gewollt)"
elif route.startswith(TOKEN_GUARDED):
mark = "Token im Pfad"
else:
mark = ">>> KEIN SCHUTZ <<<"
problems += 1
print(f"{route:52} {method:7} {mark}")
print("-" * 100)
print(f"{len(rows)} Endpunkte, {problems} ohne erkennbaren Schutz")

134
tools/check-schema.py Normal file
View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Vergleicht db/schema.sql mit den SQLAlchemy-Modellen.
python3 tools/check-schema.py
Die SQL-Datei ist von Hand geschrieben und kann beim nächsten
Modell-Umbau vergessen werden. Dieser Vergleich findet fehlende oder
überzählige Tabellen und Spalten - bevor jemand die Datei zum Aufsetzen
einer neuen Instanz benutzt und sich wundert.
Arbeitet rein textlich, ohne SQLAlchemy zu laden: Damit läuft die
Prüfung auch außerhalb des Containers.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MODELS = ROOT / "backend" / "app" / "models.py"
SCHEMA = ROOT / "db" / "schema.sql"
# Tabellen, die nicht aus Modellen stammen
EXTRA_TABLES = {"alembic_version"}
def from_models() -> dict[str, set[str]]:
"""__tablename__ und mapped_column-Namen je Klasse einsammeln."""
source = MODELS.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
current: str | None = None
for line in source.splitlines():
table = re.match(r'\s*__tablename__\s*=\s*"([^"]+)"', line)
if table:
current = table.group(1)
tables[current] = set()
continue
if current is None:
continue
# Beziehungen sind keine Spalten
if "relationship(" in line:
continue
column = re.match(r"\s*(\w+):\s*Mapped\[", line)
if column:
tables[current].add(column.group(1))
return tables
def from_schema() -> dict[str, set[str]]:
"""CREATE TABLE ... ( ... ) auswerten."""
source = SCHEMA.read_text(encoding="utf-8")
tables: dict[str, set[str]] = {}
for match in re.finditer(
r"CREATE TABLE `(\w+)` \((.*?)\n\) ENGINE", source, re.S
):
name, body = match.group(1), match.group(2)
columns = set()
for line in body.splitlines():
line = line.strip()
if line.startswith("--") or not line:
continue
# Spaltendefinitionen beginnen mit `name` gefolgt von einem Typ
col = re.match(r"`(\w+)`\s+[A-Z]", line)
if col:
columns.add(col.group(1))
tables[name] = columns
return tables
def main() -> int:
models = from_models()
schema = from_schema()
problems = 0
missing = set(models) - set(schema)
surplus = set(schema) - set(models) - EXTRA_TABLES
for name in sorted(missing):
print(f" FEHLT in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(surplus):
print(f" ÜBERZÄHLIG in schema.sql: Tabelle {name}")
problems += 1
for name in sorted(set(models) & set(schema)):
only_model = models[name] - schema[name]
only_schema = schema[name] - models[name]
for column in sorted(only_model):
print(f" FEHLT in schema.sql: {name}.{column}")
problems += 1
for column in sorted(only_schema):
print(f" ÜBERZÄHLIG in schema.sql: {name}.{column}")
problems += 1
# Migrationsstand muss zur höchsten Revision passen
versions = ROOT / "backend" / "alembic" / "versions"
revisions = sorted(
m.group(1)
for f in versions.glob("*.py")
if (m := re.search(r'^revision:\s*str\s*=\s*"(\w+)"', f.read_text(), re.M))
)
head = revisions[-1] if revisions else None
stamped = re.search(
r"INSERT INTO `alembic_version`.*VALUES \('(\w+)'\)", SCHEMA.read_text()
)
if head and (not stamped or stamped.group(1) != head):
print(
f" Migrationsstand passt nicht: schema.sql trägt "
f"{stamped.group(1) if stamped else 'nichts'}, "
f"höchste Revision ist {head}"
)
problems += 1
if problems:
print(f"\n{problems} Abweichung(en) zwischen Modellen und schema.sql")
return 1
print(
f"{len(models)} Tabellen, "
f"{sum(len(c) for c in models.values())} Spalten "
f"schema.sql stimmt mit den Modellen überein (Revision {head})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

210
tools/test-barcode.mjs Normal file
View File

@@ -0,0 +1,210 @@
// Test des Strichcode-Decoders ohne Kamera.
//
// node tools/test-barcode.mjs
//
// Erzeugt synthetische EAN-13-, EAN-8- und UPC-A-Signale, verzerrt sie
// (Unschärfe, Rauschen, unterschiedliche Skalierung, umgedreht) und
// prüft, ob der Decoder sie zurückliest - und ob er auf leeren Flächen
// und Rauschen schweigt.
import {
checksumValid,
decodeImage,
decodeLine,
} from "../web/html/js/barcode.js";
// Deterministischer Zufallszahlengenerator (xorshift32).
//
// Math.random() hätte hier einen Test ergeben, der mal durchgeht und mal
// nicht - genau das ist beim ersten Gesamtdurchlauf passiert. Ein Test
// mit wechselndem Ergebnis ist schlimmer als keiner: Man gewöhnt sich an,
// ihn zu ignorieren.
let _seed = 0x2f6f4e01;
function rnd() {
_seed ^= _seed << 13; _seed >>>= 0;
_seed ^= _seed >> 17;
_seed ^= _seed << 5; _seed >>>= 0;
return _seed / 0x100000000;
}
function reseed(value) { _seed = value >>> 0 || 1; }
// --- Kodierer zum Gegentesten (nur im Test, nicht in der App) ---
const L = ["0001101","0011001","0010011","0111101","0100011",
"0110001","0101111","0111011","0110111","0001011"];
const R_ = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const G = R_.map((s) => [...s].reverse().join(""));
const R = L.map((s) => [...s].map((b) => (b === "0" ? "1" : "0")).join(""));
const PARITY = ["LLLLLL","LLGLGG","LLGGLG","LLGGGL","LGLLGG",
"LGGLLG","LGGGLL","LGLGLG","LGLGGL","LGGLGL"];
function checkDigit(body) {
const d = [...body].map(Number);
let sum = 0;
for (let i = d.length - 1, w = 3; i >= 0; i--, w = 4 - w) sum += d[i] * w;
return (10 - (sum % 10)) % 10;
}
function encodeEAN13(body12) {
const code = body12 + checkDigit(body12);
const first = Number(code[0]);
const parity = PARITY[first];
let bits = "101";
for (let i = 0; i < 6; i++) {
const d = Number(code[i + 1]);
bits += parity[i] === "L" ? L[d] : G[d];
}
bits += "01010";
for (let i = 0; i < 6; i++) bits += R[Number(code[i + 7])];
bits += "101";
return { code, bits };
}
function encodeEAN8(body7) {
const code = body7 + checkDigit(body7);
let bits = "101";
for (let i = 0; i < 4; i++) bits += L[Number(code[i])];
bits += "01010";
for (let i = 4; i < 8; i++) bits += R[Number(code[i])];
bits += "101";
return { code, bits };
}
/** Bitmuster -> Helligkeitszeile, mit Rand, Skalierung und Rauschen. */
function toLine(bits, { scale = 3, quiet = null, noise = 0, blur = 0 } = {}) {
// Ruhezone in MODULEN, nicht in Pixeln: Die Norm verlangt 7 bis 11
// Module. Ein fester Pixelwert wäre bei großer Skalierung zu schmal
// und würde einen Fehler vortäuschen, den es in der Wirklichkeit
// nicht gibt.
const margin = quiet ?? scale * 8;
const px = [];
for (let i = 0; i < margin; i++) px.push(255);
for (const b of bits) {
for (let s = 0; s < scale; s++) px.push(b === "1" ? 20 : 235);
}
for (let i = 0; i < margin; i++) px.push(255);
let out = px;
if (blur) {
out = px.map((_, i) => {
let sum = 0, n = 0;
for (let k = -blur; k <= blur; k++) {
if (px[i + k] !== undefined) { sum += px[i + k]; n++; }
}
return sum / n;
});
}
if (noise) {
out = out.map((v) => Math.max(0, Math.min(255, v + (rnd() - 0.5) * noise)));
}
return Uint8Array.from(out.map(Math.round));
}
function lineToImageData(line, height = 30) {
const width = line.length;
const data = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const p = (y * width + x) * 4;
data[p] = data[p + 1] = data[p + 2] = line[x];
data[p + 3] = 255;
}
}
return { width, height, data };
}
let pass = 0, fail = 0;
const check = (name, ok, extra = "") => {
if (ok) { pass++; console.log(` ok ${name}`); }
else { fail++; console.log(` FEHL ${name} ${extra}`); }
};
console.log("--- Prüfziffer ---");
check("EAN-13 gültig", checksumValid("4001234567890") === (checkDigit("400123456789") === 0));
check("bekannte EAN-13", checksumValid("4006381333931"));
check("verfälschte EAN-13 abgelehnt", !checksumValid("4006381333932"));
check("EAN-8", checksumValid("96385074"));
console.log("\n--- Sauberes Signal ---");
for (const body of ["400123456789", "978020137", "012345678", "590123412345".slice(0,12)]) {
const b12 = body.padEnd(12, "0").slice(0, 12);
const { code, bits } = encodeEAN13(b12);
check(`EAN-13 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
{
const { code, bits } = encodeEAN8("9638507");
check(`EAN-8 ${code}`, decodeLine(toLine(bits)) === code, `-> ${decodeLine(toLine(bits))}`);
}
console.log("\n--- Umgedreht gehalten ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits);
check(`rückwärts ${code}`, decodeLine(Uint8Array.from([...line].reverse())) === code);
}
console.log("\n--- Unscharf und verrauscht ---");
for (const [blur, noise, scale] of [[1, 20, 3], [2, 30, 4], [1, 40, 5], [3, 25, 6]]) {
const { code, bits } = encodeEAN13("426000123456");
const got = decodeLine(toLine(bits, { blur, noise, scale }));
check(`blur=${blur} noise=${noise} scale=${scale}`, got === code, `-> ${got}`);
}
console.log("\n--- Knappe Ruhezone ---");
{
const { code, bits } = encodeEAN13("426000123456");
// 6 Module Rand: unterschreitet die Norm, wird aber noch gelesen
const ok6 = decodeLine(toLine(bits, { scale: 4, quiet: 24 }));
check("6 Module Rand wird gelesen", ok6 === code, `-> ${ok6}`);
// 2 Module Rand: wird abgelehnt. Das ist der Preis dafür, dass
// Rauschen keine Fehltreffer erzeugt.
const ok2 = decodeLine(toLine(bits, { scale: 4, quiet: 8 }));
check("2 Module Rand wird abgelehnt", ok2 === null, `-> ${ok2}`);
}
console.log("\n--- Ganzes Bild ---");
{
const { code, bits } = encodeEAN13("400123456789");
const line = toLine(bits, { blur: 1, noise: 25, scale: 4 });
check("decodeImage", decodeImage(lineToImageData(line)) === code);
}
console.log("\n--- Kein Strichcode im Bild ---");
{
const flat = Uint8Array.from({ length: 400 }, () => 200);
check("gleichmäßige Fläche -> null", decodeLine(flat) === null);
// Fehltreffer statistisch messen statt einmal zu würfeln.
//
// Eine einzelne Zeile kann in reinem Rauschen zufällig eine gültige
// Kombination ergeben - Startzeichen, zwölf Ziffern, passende
// Prüfziffer. Die Ruhezonenprüfung drückt das stark, ganz ausschließen
// lässt es sich nicht.
//
// Entscheidend ist die Ebene darüber: decodeImage verlangt, dass ZWEI
// Abtastlinien dasselbe Ergebnis liefern. Genau das ist im Sucher der
// Anwendung im Einsatz.
let lineHits = 0;
const LINE_TRIES = 500;
for (let i = 0; i < LINE_TRIES; i++) {
reseed(0x1000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeLine(noise)) lineHits++;
}
console.log(` info Einzelzeile auf Rauschen: ${lineHits}/${LINE_TRIES} Fehltreffer`);
check(`Einzelzeile: Fehltrefferquote unter 2 %`, lineHits / LINE_TRIES < 0.02,
`(${lineHits}/${LINE_TRIES})`);
let imageHits = 0;
const IMAGE_TRIES = 200;
for (let i = 0; i < IMAGE_TRIES; i++) {
reseed(0x9000 + i);
const noise = Uint8Array.from({ length: 400 }, () => (rnd() * 255) | 0);
if (decodeImage(lineToImageData(noise))) imageHits++;
}
console.log(` info Ganzes Bild auf Rauschen: ${imageHits}/${IMAGE_TRIES} Fehltreffer`);
check("Ganzes Bild: keine Fehltreffer auf Rauschen", imageHits === 0,
`(${imageHits}/${IMAGE_TRIES})`);
}
console.log(`\n${pass} bestanden, ${fail} fehlgeschlagen`);
process.exit(fail ? 1 : 0);

71
tools/vapid-keys.py Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Erzeugt das VAPID-Schlüsselpaar für Web Push.
python3 tools/vapid-keys.py
VAPID ("Voluntary Application Server Identification") ist die Art, wie
sich dieser Server gegenüber den Push-Diensten der Browserhersteller
ausweist. Das Schlüsselpaar gehört dir - es ist kein Konto bei Google
oder Apple nötig und es fließen keine Daten an Dritte, außer der
verschlüsselten Nachricht selbst.
Der öffentliche Schlüssel geht an den Browser, der private bleibt auf dem
Server. Wird der private Schlüssel getauscht, verlieren alle bestehenden
Anmeldungen ihre Gültigkeit und müssen erneuert werden - deshalb einmal
erzeugen und dann in Ruhe lassen.
Läuft ohne Zusatzpakete: `cryptography` ist bereits eine Abhängigkeit
des Backends.
"""
import base64
import sys
try:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
except ImportError:
print(
"Das Paket 'cryptography' fehlt. Entweder im Container ausführen:\n"
" docker compose exec api python3 tools/vapid-keys.py\n"
"oder lokal installieren: pip install cryptography",
file=sys.stderr,
)
raise SystemExit(1)
def b64(data: bytes) -> str:
"""base64url ohne Auffüllzeichen - so verlangen es Web Push und JWT."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def main() -> int:
key = ec.generate_private_key(ec.SECP256R1())
# Privater Schlüssel: die 32 Byte des Skalars, nicht das PEM-Format.
# py_vapid nimmt diese Form direkt an.
private_value = key.private_numbers().private_value
private_bytes = private_value.to_bytes(32, "big")
# Öffentlicher Schlüssel: unkomprimierter Punkt, 65 Byte (0x04 + X + Y).
# Genau diese Form erwartet der Browser als applicationServerKey.
public_bytes = key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
print("# In die .env übernehmen. Vorhandene Zeilen ERSETZEN, nicht")
print("# ergänzen: Bei doppelten Einträgen nimmt Docker Compose den")
print("# letzten - und das ist oft die leere Vorlagenzeile.\n")
print(f"VAPID_PRIVATE_KEY={b64(private_bytes)}")
print(f"VAPID_PUBLIC_KEY={b64(public_bytes)}")
print("# Kontaktadresse für die Push-Dienste - bei Problemen melden sie sich dort.")
print("VAPID_SUBJECT=mailto:admin@example.de")
print()
print("# Der private Schlüssel gehört NICHT ins Versionsverwaltungssystem.")
print("# Ein Wechsel entwertet alle bestehenden Anmeldungen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

12
web/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM nginx:alpine
# Konfiguration ausserhalb des Web-Roots
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY proxy_common.conf /etc/nginx/proxy_common.conf
COPY security_headers.conf /etc/nginx/security_headers.conf
# Statische Dateien. Ab Phase 3 kommt hier das gebaute Vite-Ergebnis
# aus einer vorgelagerten Build-Stufe hinein.
COPY html/ /usr/share/nginx/html/
EXPOSE 8080

1245
web/html/app.css Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
web/html/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
web/html/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
web/html/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

13
web/html/icons/icon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Einkaufsliste">
<rect width="512" height="512" rx="96" fill="#2f6f4e"/>
<!-- Korb -->
<path d="M112 200h288l-30 200a28 28 0 0 1-28 24H170a28 28 0 0 1-28-24z"
fill="none" stroke="#ffffff" stroke-width="26" stroke-linejoin="round"/>
<!-- Henkel -->
<path d="M188 200v-24a68 68 0 0 1 136 0v24"
fill="none" stroke="#ffffff" stroke-width="26" stroke-linecap="round"/>
<!-- Haken -->
<path d="M198 300l38 40 82-88"
fill="none" stroke="#ffffff" stroke-width="30"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 643 B

33
web/html/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2f6f4e">
<title>Einkaufsliste</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/favicon.ico" sizes="any">
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css">
<!-- Ohne diese Zeile öffnet iOS die App vom Startbildschirm im Safari-Rahmen -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Wird beim Start aus /api/config überschrieben. -->
<meta name="apple-mobile-web-app-title" content="Einkaufsliste">
</head>
<body>
<main id="app">
<p class="loading">Wird geladen …</p>
</main>
<noscript>
<p class="error">Diese App benötigt JavaScript.</p>
</noscript>
<script type="module" src="/js/app.js"></script>
</body>
</html>

85
web/html/js/api.js Normal file
View File

@@ -0,0 +1,85 @@
// Kommunikation mit der API. Einziger Ort, an dem fetch() aufgerufen wird.
"use strict";
export class ApiError extends Error {
constructor(status, detail) {
super(detail);
this.name = "ApiError";
this.status = status;
}
}
export class OfflineError extends Error {
constructor() {
super("Keine Verbindung zum Server.");
this.name = "OfflineError";
}
}
/** Das CSRF-Token steht in einem lesbaren Cookie. Das Session-Cookie ist
* HttpOnly und für JavaScript unsichtbar - genau so soll es sein. */
function csrfToken() {
const hit = document.cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("ea_csrf="));
return hit ? decodeURIComponent(hit.slice("ea_csrf=".length)) : "";
}
/** Übersetzt die Fehlerform von FastAPI in einen Satz für Menschen.
* Bei Validierungsfehlern ist "detail" eine Liste, keine Zeichenkette. */
function describe(status, data) {
const detail = data && data.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail) && detail.length) {
const first = detail[0];
const field = Array.isArray(first.loc) ? first.loc[first.loc.length - 1] : "";
return field ? `Ungültige Eingabe bei "${field}": ${first.msg}` : first.msg;
}
if (status === 401) return "Nicht angemeldet.";
if (status === 429) return "Zu viele Versuche. Bitte einen Moment warten.";
if (status >= 500) return "Der Server hat einen Fehler gemeldet.";
return `Unerwarteter Fehler (HTTP ${status}).`;
}
export async function api(path, { method = "GET", body } = {}) {
const headers = {};
if (body !== undefined) headers["Content-Type"] = "application/json";
if (method !== "GET" && method !== "HEAD") headers["X-CSRF-Token"] = csrfToken();
let response;
try {
response = await fetch(path, {
method,
headers,
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
// Netzwerkfehler von HTTP-Fehlern trennen: In Phase 4 entscheidet
// genau das, ob eine Änderung in die Outbox wandert.
throw new OfflineError();
}
const text = await response.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
throw new ApiError(
response.status,
`Unerwartete Antwort vom Server (HTTP ${response.status}).`
);
}
}
if (!response.ok) throw new ApiError(response.status, describe(response.status, data));
return data;
}
export const get = (path) => api(path);
export const post = (path, body) => api(path, { method: "POST", body });
export const patch = (path, body) => api(path, { method: "PATCH", body });
export const put = (path, body) => api(path, { method: "PUT", body });
export const del = (path) => api(path, { method: "DELETE" });

285
web/html/js/app.js Normal file
View File

@@ -0,0 +1,285 @@
// Einstiegspunkt: Routing zwischen den Ansichten und Start des
// Service Workers.
"use strict";
import { ApiError, get } from "./api.js";
import { clear, el, mount } from "./dom.js";
import { set, state } from "./store.js";
import {
changePasswordView,
forgotView,
loginView,
logout,
registerView,
resetView,
} from "./views/auth.js";
import { listDetailView } from "./views/list-detail.js";
import { listsView } from "./views/lists.js";
import { articlesView } from "./views/articles.js";
import { pricesView } from "./views/prices.js";
import { adminView } from "./views/admin.js";
import { settingsView } from "./views/settings.js";
import { welcomeView } from "./views/welcome.js";
import { manageView } from "./views/manage.js";
import * as db from "./db.js";
import { installTriggers, stopWatching } from "./sync.js";
import { publicView } from "./views/public.js";
import { acceptInviteView, shareView } from "./views/share.js";
const root = document.getElementById("app");
// Routen als Zeichenkette im Fragment: "#/lists/<id>". Kein History-API-
// Routing, weil der Service Worker dann jede Tiefe abfangen müsste.
function currentRoute() {
const hash = location.hash.replace(/^#/, "");
const parts = hash.split("/").filter(Boolean);
if (parts[0] === "lists" && parts[1] && parts[2] === "manage") {
return { name: "manage", listId: parts[1] };
}
if (parts[0] === "lists" && parts[1] && parts[2] === "share") {
return { name: "share", listId: parts[1] };
}
if (parts[0] === "lists" && parts[1] && parts[2] === "articles") {
return { name: "articles", listId: parts[1] };
}
if (parts[0] === "lists" && parts[1] && parts[2] === "prices") {
return { name: "prices", listId: parts[1] };
}
if (parts[0] === "settings") {
return { name: "settings" };
}
if (parts[0] === "admin") {
return { name: "admin" };
}
if (parts[0] === "lists" && parts[1]) {
return { name: "list", listId: parts[1] };
}
return { name: "lists" };
}
/** Beim Verlassen der Detailansicht den Ereigniskanal schließen -
* sonst bleibt pro besuchter Liste eine offene Verbindung stehen. */
function navigate(hash) {
stopWatching();
if (location.hash === hash) {
route();
} else {
location.hash = hash;
}
}
let authView = "login";
function goto(view) {
authView = view;
route();
}
async function signOut() {
await logout();
authView = "login";
location.hash = "";
route();
}
function fail(err) {
mount(root,
el("section.card", {},
el("h1", {}, "Etwas ist schiefgelaufen"),
el("p.error", {}, err.message),
el("button.primary", { type: "button", onclick: () => route() }, "Erneut versuchen")
)
);
}
async function route() {
// Öffentlicher Link: /s/<token>
// Muss VOR jeder Anmeldeprüfung stehen - der Empfänger hat kein Konto.
if (location.pathname.startsWith("/s/")) {
document.body.classList.add("public-mode");
await publicView(root, {
token: decodeURIComponent(location.pathname.slice(3)),
});
return;
}
// Der Link aus der Reset-Mail zeigt auf /reset?token=...
if (location.pathname === "/reset") {
resetView(root, { goto: (v) => { history.replaceState(null, "", "/"); goto(v); } });
return;
}
// Willkommenslink aus der vom Administrator versendeten Einladung.
// Muss vor der Anmeldeprüfung stehen: Wer den Link öffnet, HAT noch
// kein Passwort und kann sich deshalb nicht anmelden.
if (location.pathname === "/willkommen") {
const token = new URLSearchParams(location.search).get("token");
if (token) {
await welcomeView(root, {
token,
toLogin: () => {
history.replaceState(null, "", "/");
route();
},
});
return;
}
}
// Einladungslink: /invite?token=...
// Wer nicht angemeldet ist, sieht zuerst das Anmeldeformular. Der Pfad
// bleibt dabei erhalten, sodass es nach der Anmeldung hier weitergeht.
const inviteToken =
location.pathname === "/invite"
? new URLSearchParams(location.search).get("token")
: null;
if (!state.user) {
const views = { login: loginView, register: registerView, forgot: forgotView };
views[authView](root, {
goto,
onSignedIn: (user) => {
set({ user });
route();
},
});
return;
}
if (state.user.must_change_password) {
changePasswordView(root, { onDone: () => route(), onLogout: signOut });
return;
}
if (inviteToken) {
await acceptInviteView(root, {
token: inviteToken,
onAccepted: () => {
history.replaceState(null, "", "/");
route();
},
toLists: () => {
history.replaceState(null, "", "/");
route();
},
});
return;
}
const r = currentRoute();
if (r.name !== "list") stopWatching();
try {
if (r.name === "list") {
await listDetailView(root, {
listId: r.listId,
back: () => navigate(""),
manage: (id) => navigate(`#/lists/${id}/manage`),
share: (id) => navigate(`#/lists/${id}/share`),
articles: (id) => navigate(`#/lists/${id}/articles`),
prices: (id) => navigate(`#/lists/${id}/prices`),
});
} else if (r.name === "share") {
await shareView(root, {
listId: r.listId,
back: () => navigate(`#/lists/${r.listId}`),
});
} else if (r.name === "settings") {
await settingsView(root, {
back: () => navigate(""),
admin: () => navigate("#/admin"),
});
} else if (r.name === "admin") {
await adminView(root, { back: () => navigate("#/settings") });
} else if (r.name === "prices") {
await pricesView(root, {
listId: r.listId,
back: () => navigate(`#/lists/${r.listId}`),
});
} else if (r.name === "articles") {
await articlesView(root, {
listId: r.listId,
back: () => navigate(`#/lists/${r.listId}`),
});
} else if (r.name === "manage") {
await manageView(root, {
listId: r.listId,
back: () => navigate(`#/lists/${r.listId}`),
});
} else {
await listsView(root, {
openList: (id) => navigate(`#/lists/${id}`),
onLogout: signOut,
settings: () => navigate("#/settings"),
});
}
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
set({ user: null });
route();
return;
}
fail(err);
}
}
window.addEventListener("hashchange", route);
/** Anwendungsname aus der API holen und überall anwenden.
*
* Warum nicht beim Bauen einsetzen: Dann müsste der web-Container bei
* jeder Umbenennung neu gebaut werden. So genügt eine Änderung in der
* .env und ein Neustart des api-Containers.
*
* Der zuletzt bekannte Name liegt lokal, damit die App auch ohne
* Verbindung nicht namenlos startet. */
async function loadAppName() {
let config = null;
try {
config = await get("/api/config");
await db.cacheSet("config", config);
} catch {
config = await db.cacheGet("config").catch(() => null);
}
if (config?.app_name) set({ appName: config.app_name });
applyAppName(state.appName);
}
function applyAppName(name) {
document.title = name;
for (const node of document.querySelectorAll("[data-app-name]")) {
node.textContent = name;
}
}
async function start() {
await loadAppName();
try {
set({ user: await get("/api/auth/me") });
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 401) {
// Offline oder Serverfehler: trotzdem das Anmeldeformular zeigen,
// damit die App nicht auf einem Ladehinweis stehenbleibt.
console.warn("Anmeldestatus nicht abrufbar:", err.message);
}
}
// Wartende Änderungen senden, sobald die Verbindung zurück ist, die
// App wieder im Vordergrund steht - oder ersatzweise alle 30 Sekunden.
installTriggers(
() => (currentRoute().name === "list" ? currentRoute().listId : null),
() => route()
);
await route();
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (err) {
console.warn("Service Worker nicht registriert:", err.message);
}
}
}
start();

265
web/html/js/barcode.js Normal file
View File

@@ -0,0 +1,265 @@
// Decoder für EAN-13, EAN-8 und UPC-A. Ohne Fremdbibliothek.
//
// Warum selbst geschrieben: Die native BarcodeDetector-API gibt es nur in
// Chrome und auf Android. Die übliche Alternative wäre ZXing - eine
// 250-KB-Datei aus dem npm-Ökosystem. Für drei Strichcode-Formate, die
// alle nach demselben simplen Schema arbeiten, ist das unverhältnismäßig.
//
// Verfahren: Aus einem Kamerabild werden mehrere waagerechte Linien
// abgetastet, in Hell-Dunkel-Folgen zerlegt und gegen die Zifferntabellen
// gehalten. Ein Ergebnis gilt erst als sicher, wenn es zweimal
// unabhängig herauskommt und die Prüfziffer stimmt.
"use strict";
// Jede Ziffer besteht aus 7 Modulen in 4 Streifen. Die Tabelle enthält
// die Streifenbreiten der L-Kodierung; daraus lassen sich die beiden
// anderen ableiten:
// L Streifenbreiten, beginnend mit hell (linke Hälfte)
// G dieselben Breiten rückwärts, hell (linke Hälfte)
// R dieselben Breiten, beginnend mit dunkel (rechte Hälfte)
const L_WIDTHS = [
[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2],
[1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2],
];
// Welche Abfolge von L und G in der linken Hälfte steht, verrät die
// erste Ziffer - sie ist selbst nicht als Streifen kodiert.
const PARITY = [
"LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG",
"LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL",
];
const MAX_DEVIATION = 0.42; // erlaubte Abweichung je Streifen, in Modulen
/** Vergleicht vier gemessene Streifen mit einem Sollmuster.
* Verglichen werden Verhältnisse, keine absoluten Breiten - dadurch
* spielt es keine Rolle, wie weit die Kamera vom Code entfernt ist. */
function patternDistance(counters, pattern) {
const total = counters[0] + counters[1] + counters[2] + counters[3];
if (total <= 0) return Infinity;
const unit = total / 7;
// Zu schmale Streifen deuten auf Rauschen statt auf einen Strichcode.
if (unit < 0.6) return Infinity;
let sum = 0;
for (let i = 0; i < 4; i++) {
const deviation = Math.abs(counters[i] / unit - pattern[i]);
if (deviation > MAX_DEVIATION * 2) return Infinity;
sum += deviation;
}
return sum;
}
/** @returns {{digit: number, code: "L"|"G"|"R"}|null} */
function matchDigit(counters, half) {
let best = null;
let bestDistance = MAX_DEVIATION * 4;
for (let digit = 0; digit < 10; digit++) {
if (half === "left") {
const dL = patternDistance(counters, L_WIDTHS[digit]);
if (dL < bestDistance) { bestDistance = dL; best = { digit, code: "L" }; }
const reversed = [...L_WIDTHS[digit]].reverse();
const dG = patternDistance(counters, reversed);
if (dG < bestDistance) { bestDistance = dG; best = { digit, code: "G" }; }
} else {
const dR = patternDistance(counters, L_WIDTHS[digit]);
if (dR < bestDistance) { bestDistance = dR; best = { digit, code: "R" }; }
}
}
return best;
}
/** Prüfziffer nach dem Modulo-10-Verfahren. Gilt für EAN-13, EAN-8
* und UPC-A gleichermaßen - nur die Gewichtung beginnt je nach Länge
* bei 1 oder 3. */
export function checksumValid(code) {
const digits = [...code].map(Number);
if (digits.some(Number.isNaN)) return false;
const check = digits.pop();
let sum = 0;
// Von rechts nach links abwechselnd ×3 und ×1.
for (let i = digits.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
sum += digits[i] * weight;
}
return (10 - (sum % 10)) % 10 === check;
}
/** Zerlegt eine Helligkeitszeile in Streifenlängen.
* Der Schwellwert wird je Zeile aus deren eigenem Hell-Dunkel-Umfang
* gebildet - so stört ein Schatten über dem halben Bild nicht. */
function toRuns(line) {
let min = 255;
let max = 0;
for (const value of line) {
if (value < min) min = value;
if (value > max) max = value;
}
// Zu wenig Kontrast: da ist kein Strichcode.
if (max - min < 40) return null;
const threshold = (min + max) / 2;
const runs = [];
let dark = line[0] < threshold;
let length = 0;
for (const value of line) {
const isDark = value < threshold;
if (isDark === dark) {
length++;
} else {
runs.push({ dark, length });
dark = isDark;
length = 1;
}
}
runs.push({ dark, length });
return runs;
}
// Vor dem Startzeichen verlangt die Norm eine helle Ruhezone von elf
// Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer:
// In zufälligem Rauschen gibt es keine so breiten hellen Flächen, in
// einem echten Kamerabild dagegen immer. Konservativ angesetzt, weil
// der Rand bei knapper Bildausschnittwahl auch mal kürzer ausfällt.
const MIN_QUIET_ZONE = 3.5;
/** Versucht ab einem dunklen Streifen einen vollständigen Code zu lesen. */
function decodeAt(runs, start, digitCount) {
// digitCount: 13 -> je 6 Ziffern pro Hälfte, 8 -> je 4
const perHalf = digitCount === 13 ? 6 : 4;
// Startzeichen: drei Streifen von je einem Modul.
const guard = [runs[start], runs[start + 1], runs[start + 2]];
if (guard.some((r) => r === undefined)) return null;
const unit = (guard[0].length + guard[1].length + guard[2].length) / 3;
if (unit < 0.7) return null;
for (const r of guard) {
if (Math.abs(r.length / unit - 1) > MAX_DEVIATION) return null;
}
// Ruhezone davor
const before = runs[start - 1];
if (start > 0 && (before.dark || before.length < unit * MIN_QUIET_ZONE)) {
return null;
}
let index = start + 3;
const digits = [];
let parity = "";
for (let i = 0; i < perHalf; i++) {
const counters = [
runs[index]?.length, runs[index + 1]?.length,
runs[index + 2]?.length, runs[index + 3]?.length,
];
if (counters.some((c) => c === undefined)) return null;
const match = matchDigit(counters, "left");
if (!match) return null;
digits.push(match.digit);
parity += match.code === "R" ? "L" : match.code;
index += 4;
}
// Trennzeichen in der Mitte: fünf Streifen von je einem Modul.
for (let i = 0; i < 5; i++) {
const run = runs[index + i];
if (!run) return null;
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
}
index += 5;
for (let i = 0; i < perHalf; i++) {
const counters = [
runs[index]?.length, runs[index + 1]?.length,
runs[index + 2]?.length, runs[index + 3]?.length,
];
if (counters.some((c) => c === undefined)) return null;
const match = matchDigit(counters, "right");
if (!match) return null;
digits.push(match.digit);
index += 4;
}
// Schlusszeichen
for (let i = 0; i < 3; i++) {
const run = runs[index + i];
if (!run) return null;
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
}
// Ruhezone dahinter - oder das Ende der abgetasteten Zeile.
const after = runs[index + 3];
if (after && (after.dark || after.length < unit * MIN_QUIET_ZONE)) return null;
let code;
if (digitCount === 13) {
const first = PARITY.indexOf(parity);
if (first < 0) return null;
code = String(first) + digits.join("");
} else {
code = digits.join("");
}
return checksumValid(code) ? code : null;
}
/** Liest eine einzelne Helligkeitszeile. */
export function decodeLine(line) {
const runs = toRuns(line);
if (!runs || runs.length < 30) return null;
for (const direction of ["forward", "backward"]) {
// Ein auf dem Kopf stehender Code liest sich rückwärts genauso.
const sequence = direction === "forward" ? runs : [...runs].reverse();
for (let i = 0; i < sequence.length - 20; i++) {
if (!sequence[i].dark) continue;
for (const digitCount of [13, 8]) {
const code = decodeAt(sequence, i, digitCount);
if (code) return code;
}
}
}
return null;
}
/**
* Sucht in einem Bild nach einem Strichcode.
*
* @param {ImageData} imageData
* @param {number} lines Anzahl waagerechter Abtastlinien
* @returns {string|null}
*/
export function decodeImage(imageData, lines = 21) {
const { width, height, data } = imageData;
const results = new Map();
for (let n = 0; n < lines; n++) {
// Linien über die mittleren zwei Drittel verteilen: Dort liegt der
// Code, wenn der Nutzer ihn im Sucherrahmen hält.
const y = Math.round(height * (1 / 6 + (2 / 3) * (n / (lines - 1 || 1))));
const row = new Uint8Array(width);
const offset = y * width * 4;
for (let x = 0; x < width; x++) {
const p = offset + x * 4;
// Grauwert nach Wahrnehmungsgewichtung.
row[x] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
}
const code = decodeLine(row);
if (!code) continue;
const count = (results.get(code) || 0) + 1;
// Erst wenn zwei Linien dasselbe ergeben, gilt es als sicher. Ein
// Einzeltreffer kann von Rauschen kommen.
if (count >= 2) return code;
results.set(code, count);
}
return null;
}

151
web/html/js/db.js Normal file
View File

@@ -0,0 +1,151 @@
// Lokaler Speicher auf Basis von IndexedDB.
//
// Warum nicht localStorage: Der ist synchron (blockiert die Oberfläche),
// auf wenige Megabyte begrenzt und kennt keine Transaktionen. Für eine
// Warteschlange, die auch einen Absturz überleben muss, ist er ungeeignet.
"use strict";
const DB_NAME = "einkaufsapp";
const DB_VERSION = 1;
const STORE_CACHE = "cache"; // Schlüssel -> zuletzt gesehener Serverstand
const STORE_OUTBOX = "outbox"; // noch nicht bestätigte Operationen
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_CACHE)) {
db.createObjectStore(STORE_CACHE);
}
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
const store = db.createObjectStore(STORE_OUTBOX, { keyPath: "op_id" });
// Reihenfolge je Liste: Operationen müssen in der Folge ankommen,
// in der sie entstanden sind.
store.createIndex("by_list_seq", ["list_id", "seq"]);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () =>
reject(new Error("Datenbank blockiert bitte andere Tabs schließen."));
});
return dbPromise;
}
function run(storeName, mode, fn) {
return open().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let result;
try {
result = fn(store);
} catch (err) {
reject(err);
return;
}
tx.oncomplete = () => resolve(result && result.__req ? result.__req.result : result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error("Transaktion abgebrochen"));
}));
}
function wrap(request) {
return { __req: request };
}
// ---------------------------------------------------------------------------
// Zwischenspeicher
// ---------------------------------------------------------------------------
export function cacheGet(key) {
return run(STORE_CACHE, "readonly", (store) => wrap(store.get(key)));
}
export function cacheSet(key, value) {
return run(STORE_CACHE, "readwrite", (store) => wrap(store.put(value, key)));
}
export function cacheDelete(key) {
return run(STORE_CACHE, "readwrite", (store) => wrap(store.delete(key)));
}
// ---------------------------------------------------------------------------
// Outbox
// ---------------------------------------------------------------------------
/** Zufällige, praktisch eindeutige Kennung. crypto.randomUUID gibt es
* nur in sicheren Kontexten (HTTPS oder localhost) - deshalb der
* Rückfallweg. */
export function newOpId() {
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
const bytes = new Uint8Array(16);
(globalThis.crypto || {}).getRandomValues?.(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
let seqCounter = Date.now();
export function enqueue(listId, kind, payload) {
const op = {
op_id: newOpId(),
list_id: listId,
kind,
payload,
seq: seqCounter++,
created_at: new Date().toISOString(),
attempts: 0,
};
return run(STORE_OUTBOX, "readwrite", (store) => {
store.add(op);
return op;
});
}
export function pending(listId) {
return run(STORE_OUTBOX, "readonly", (store) => {
const index = store.index("by_list_seq");
const range = IDBKeyRange.bound([listId, -Infinity], [listId, Infinity]);
return wrap(index.getAll(range));
});
}
export function pendingAll() {
return run(STORE_OUTBOX, "readonly", (store) => wrap(store.getAll()));
}
export function remove(opIds) {
return run(STORE_OUTBOX, "readwrite", (store) => {
for (const id of opIds) store.delete(id);
});
}
export function bumpAttempts(opIds) {
return run(STORE_OUTBOX, "readwrite", (store) => {
for (const id of opIds) {
const request = store.get(id);
request.onsuccess = () => {
const op = request.result;
if (op) {
op.attempts = (op.attempts || 0) + 1;
op.last_error_at = new Date().toISOString();
store.put(op);
}
};
}
});
}
export async function clearList(listId) {
const ops = await pending(listId);
await remove(ops.map((o) => o.op_id));
}

104
web/html/js/dom.js Normal file
View File

@@ -0,0 +1,104 @@
// Kleine DOM-Hilfen. Ersetzt kein Framework, spart nur Wiederholung.
"use strict";
/** el("button.primary", { onclick: fn }, "Text")
* Setzt Text ausschließlich über textContent - dadurch kann kein
* Benutzereingabewert als HTML interpretiert werden. */
export function el(spec, props = {}, ...children) {
const [tag, ...classes] = String(spec).split(".");
const node = document.createElement(tag || "div");
if (classes.length) node.className = classes.join(" ");
for (const [key, value] of Object.entries(props)) {
if (value === null || value === undefined || value === false) continue;
if (key.startsWith("on") && typeof value === "function") {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key === "dataset") {
Object.assign(node.dataset, value);
} else if (key in node && key !== "list") {
node[key] = value;
} else {
node.setAttribute(key, value === true ? "" : value);
}
}
append(node, children);
return node;
}
function append(node, children) {
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
}
export function clear(node) {
node.replaceChildren();
return node;
}
/** Ersetzt den Inhalt eines Knotens.
*
* Nicht clear(root).append(...) verwenden: Node.append() macht aus einem
* null die Zeichenkette "null" und zeigt sie an. Genau dafür filtert
* el() seine Kinder - mount() zieht denselben Schutz auf die Wurzel. */
export function mount(node, ...children) {
node.replaceChildren();
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/** Preis in Cent -> "1,29 €". Rechnen immer in Cent, formatieren erst hier. */
export function euro(cents) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format((cents || 0) / 100);
}
/** "1,29" oder "1.29" -> 129. Gibt null zurück, wenn nichts Sinnvolles drinsteht. */
export function parseCents(text) {
const cleaned = String(text).trim().replace(",", ".");
if (!cleaned) return null;
const value = Number(cleaned);
if (!Number.isFinite(value) || value < 0) return null;
return Math.round(value * 100);
}
export function parseQuantity(text) {
const cleaned = String(text).trim().replace(",", ".");
if (!cleaned) return null;
const value = Number(cleaned);
return Number.isFinite(value) && value >= 0 ? value : null;
}
/** Stückzahl: ganze Zahl ab 1. Leer oder unsinnig ergibt 1 - ein Eintrag
* ohne Stückzahl wäre unvollständig. */
export function parseCount(text) {
const value = Number(String(text).trim().replace(",", "."));
if (!Number.isFinite(value) || value < 1) return 1;
return Math.min(999, Math.round(value));
}
/** Nur das Gebinde, ohne Stückzahl.
*
* Wird in der Listenzeile gebraucht, weil die Stückzahl dort als
* eigenes Feld links neben dem Namen steht - beim Einkaufen soll sie
* ins Auge springen und nicht im Kleingedruckten stehen. */
export function formatPack(packSize, packUnit) {
if (!packSize) return "";
return `${formatQuantity(packSize)}${packUnit ? " " + packUnit : ""}`;
}
/** Menge aus der API kommt als Zeichenkette ("2.000"), damit unterwegs
* keine Nachkommastellen verloren gehen. Für die Anzeige aufräumen. */
export function formatQuantity(value) {
if (value === null || value === undefined) return "";
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString("de-DE", { maximumFractionDigits: 3 });
}

165
web/html/js/push.js Normal file
View File

@@ -0,0 +1,165 @@
// An- und Abmeldung für Push-Benachrichtigungen.
//
// Ablauf: Der Browser holt sich beim Push-Dienst seines Herstellers einen
// Endpunkt, den er zusammen mit Schlüsselmaterial an unseren Server
// meldet. Der Server kann darüber verschlüsselte Nachrichten schicken -
// lesen kann sie nur dieses Gerät, auch der Push-Dienst nicht.
"use strict";
import { get, post } from "./api.js";
/** Der öffentliche VAPID-Schlüssel kommt als base64url ohne
* Auffüllzeichen; die Browser-API will ein Uint8Array. */
function decodeKey(base64url) {
const padded = base64url.replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
/** Kurze Gerätebezeichnung, damit man mehrere Anmeldungen unterscheiden
* kann. Bewusst grob - der vollständige User-Agent wäre ein
* Wiedererkennungsmerkmal und wird nicht gebraucht. */
function deviceLabel() {
const ua = navigator.userAgent;
const system =
/Android/i.test(ua) ? "Android"
: /iPhone|iPad|iPod/i.test(ua) ? "iPhone/iPad"
: /Macintosh/i.test(ua) ? "Mac"
: /Windows/i.test(ua) ? "Windows"
: /Linux/i.test(ua) ? "Linux"
: "Gerät";
const browser =
/Firefox\//i.test(ua) ? "Firefox"
: /Edg\//i.test(ua) ? "Edge"
: /Chrome\//i.test(ua) ? "Chrome"
: /Safari\//i.test(ua) ? "Safari"
: "Browser";
return `${browser} auf ${system}`.slice(0, 80);
}
/** Läuft die App als installierte PWA? Auf iOS ist das die
* Voraussetzung dafür, dass Push überhaupt funktioniert. */
export function isInstalled() {
return (
window.matchMedia?.("(display-mode: standalone)")?.matches === true ||
navigator.standalone === true
);
}
export function isApple() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
}
/** Was der Browser hier grundsätzlich kann. */
export function supported() {
return (
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window &&
window.isSecureContext
);
}
/**
* Aktueller Zustand.
* @returns {Promise<{
* supported: boolean, serverEnabled: boolean, permission: string,
* subscribed: boolean, needsInstall: boolean, throttleHours: number
* }>}
*/
export async function status() {
const base = {
supported: supported(),
serverEnabled: false,
permission: "Notification" in window ? Notification.permission : "unsupported",
subscribed: false,
// iOS lässt Push nur in der installierten App zu.
needsInstall: isApple() && !isInstalled(),
throttleHours: 2,
};
try {
const config = await get("/api/push/config");
base.serverEnabled = config.enabled;
base.throttleHours = config.throttle_hours;
} catch {
return base;
}
if (!base.supported) return base;
try {
const registration = await navigator.serviceWorker.ready;
base.subscribed = Boolean(await registration.pushManager.getSubscription());
} catch {
// Kein Service Worker bereit - dann eben nicht angemeldet.
}
return base;
}
/** Fragt die Erlaubnis ab und meldet das Gerät an.
* @returns {Promise<{ok: boolean, reason?: string}>} */
export async function subscribe() {
if (!supported()) {
return { ok: false, reason: "Dieser Browser unterstützt keine Benachrichtigungen." };
}
const config = await get("/api/push/config");
if (!config.enabled || !config.public_key) {
return { ok: false, reason: "Auf diesem Server sind Benachrichtigungen nicht eingerichtet." };
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
return {
ok: false,
reason: permission === "denied"
? "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt sich nur in den Browsereinstellungen wieder ändern."
: "Keine Erlaubnis erteilt.",
};
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
// Pflicht bei allen aktuellen Browsern: Nachrichten ohne
// sichtbare Meldung sind nicht erlaubt.
userVisibleOnly: true,
applicationServerKey: decodeKey(config.public_key),
});
}
const json = subscription.toJSON();
await post("/api/push/subscribe", {
endpoint: json.endpoint,
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
label: deviceLabel(),
});
return { ok: true };
}
/** Meldet dieses Gerät ab - lokal und auf dem Server. */
export async function unsubscribe() {
if (!supported()) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
const endpoint = subscription.endpoint;
// Erst lokal abmelden: Schlägt der Server fehl, sollen trotzdem keine
// Meldungen mehr ankommen.
await subscription.unsubscribe().catch(() => {});
await post("/api/push/unsubscribe", { endpoint }).catch(() => {});
}
export function sendTest() {
return post("/api/push/test");
}
export function listDevices() {
return get("/api/push/subscriptions");
}

130
web/html/js/sortable.js Normal file
View File

@@ -0,0 +1,130 @@
// Zeilen per Anfasser umsortieren, ohne Fremdbibliothek.
//
// Umgesetzt mit Pointer Events statt der HTML5-Drag-and-Drop-API: Letztere
// funktioniert auf Touchgeräten praktisch nicht, und die App wird
// überwiegend am Telefon bedient.
//
// Verfahren: Das gezogene Element folgt dem Finger per transform. Sobald
// es den Mittelpunkt eines Nachbarn überschreitet, wird es im DOM davor
// oder dahinter einsortiert und der Bezugspunkt zurückgesetzt - dadurch
// springt nichts.
"use strict";
const DRAG_THRESHOLD = 4; // Pixel, bevor aus einem Tippen ein Ziehen wird
/**
* @param {HTMLElement} container Elternknoten der sortierbaren Zeilen
* @param {object} options
* @param {string} options.handleSelector Auswahl für den Anfasser
* @param {string} options.itemSelector Auswahl für eine Zeile
* @param {(ids: string[]) => void} options.onReorder neue Reihenfolge
*/
export function makeSortable(container, { handleSelector, itemSelector, onReorder }) {
let dragging = null;
let startY = 0;
let offset = 0;
let moved = false;
let originalOrder = [];
const rows = () => [...container.querySelectorAll(itemSelector)];
const idsOf = () => rows().map((r) => r.dataset.id);
function begin(ev) {
const handle = ev.target.closest(handleSelector);
if (!handle || !container.contains(handle)) return;
if (ev.button !== undefined && ev.button !== 0) return;
dragging = handle.closest(itemSelector);
if (!dragging) return;
startY = ev.clientY;
offset = 0;
moved = false;
originalOrder = idsOf();
handle.setPointerCapture(ev.pointerId);
ev.preventDefault();
}
function move(ev) {
if (!dragging) return;
offset = ev.clientY - startY;
if (!moved && Math.abs(offset) < DRAG_THRESHOLD) return;
if (!moved) {
moved = true;
dragging.classList.add("dragging");
container.classList.add("sorting");
}
dragging.style.transform = `translateY(${offset}px)`;
const box = dragging.getBoundingClientRect();
const middle = box.top + box.height / 2;
const previous = dragging.previousElementSibling;
const next = dragging.nextElementSibling;
if (previous && middle < previous.getBoundingClientRect().top + previous.offsetHeight / 2) {
container.insertBefore(dragging, previous);
reanchor(ev);
} else if (next && middle > next.getBoundingClientRect().top + next.offsetHeight / 2) {
container.insertBefore(next, dragging);
reanchor(ev);
}
}
/** Nach einem Umsortieren sitzt das Element an neuer Stelle. Ohne das
* Zurücksetzen des Bezugspunkts würde es um seine eigene Höhe springen. */
function reanchor(ev) {
startY = ev.clientY;
offset = 0;
dragging.style.transform = "";
}
function end() {
if (!dragging) return;
dragging.style.transform = "";
dragging.classList.remove("dragging");
container.classList.remove("sorting");
const wasMoved = moved;
dragging = null;
moved = false;
if (!wasMoved) return;
const order = idsOf();
if (order.join() !== originalOrder.join()) onReorder(order);
}
container.addEventListener("pointerdown", begin);
container.addEventListener("pointermove", move);
container.addEventListener("pointerup", end);
container.addEventListener("pointercancel", end);
// Bedienbar auch ohne Zeigegerät: Anfasser fokussieren, dann Pfeiltasten.
container.addEventListener("keydown", (ev) => {
const handle = ev.target.closest(handleSelector);
if (!handle) return;
if (ev.key !== "ArrowUp" && ev.key !== "ArrowDown") return;
const row = handle.closest(itemSelector);
const before = idsOf();
if (ev.key === "ArrowUp" && row.previousElementSibling) {
container.insertBefore(row, row.previousElementSibling);
} else if (ev.key === "ArrowDown" && row.nextElementSibling) {
container.insertBefore(row.nextElementSibling, row);
} else {
return;
}
ev.preventDefault();
handle.focus();
const after = idsOf();
if (after.join() !== before.join()) onReorder(after);
});
}

51
web/html/js/store.js Normal file
View File

@@ -0,0 +1,51 @@
// Ein einziger Zustandsspeicher. Ansichten lesen daraus und zeichnen sich
// neu, wenn er sich ändert - niemand fasst fremde DOM-Knoten an.
//
// Das ist im Kern das, was ein Framework auch tut. Der Unterschied ist,
// dass hier nichts passiert, was nicht in dieser Datei steht.
"use strict";
const listeners = new Set();
export const state = {
/** Aus /api/config, mit Rückfallwert für den ersten Start ohne Netz. */
appName: "Einkaufsliste",
user: null,
lists: [],
/** Ansicht der geöffneten Liste, so wie /view sie liefert. */
view: null,
markets: [],
categories: [],
online: navigator.onLine,
/** "ok" | "offline" | "error" */
syncState: navigator.onLine ? "ok" : "offline",
/** Einmalige Meldung aus der Synchronisation, wird von der Ansicht
* abgeholt und zurückgesetzt. */
syncProblem: null,
lastSync: null,
};
export function subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Nach jeder Zustandsänderung genau einmal aufrufen. Mehrere Aufrufe im
* selben Tick werden zusammengefasst, damit nicht mehrfach gezeichnet wird. */
let scheduled = false;
export function notify() {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
for (const fn of listeners) fn(state);
});
}
export function set(patch) {
Object.assign(state, patch);
notify();
}
window.addEventListener("online", () => set({ online: true }));
window.addEventListener("offline", () => set({ online: false }));

349
web/html/js/sync.js Normal file
View File

@@ -0,0 +1,349 @@
// Synchronisation: Outbox senden, Ansicht holen, Ereigniskanal halten.
//
// Grundgedanke: Der Server ist die Quelle der Wahrheit. Lokal liegt sein
// zuletzt gesehener Stand plus die noch nicht bestätigten eigenen
// Operationen. Angezeigt wird beides übereinandergelegt.
"use strict";
import { OfflineError, get, post } from "./api.js";
import * as db from "./db.js";
import { set, state } from "./store.js";
const MAX_ATTEMPTS = 8;
// ---------------------------------------------------------------------------
// Operationen lokal auf die zwischengespeicherte Ansicht anwenden
// ---------------------------------------------------------------------------
function allItems(view) {
return view.markets.flatMap((m) => m.categories.flatMap((c) => c.items));
}
/** Baut die Gruppierung neu auf, nachdem sich Zuordnungen geändert haben.
* Dieselbe Reihenfolge wie serverseitig: Märkte und Warengruppen nach
* sort_order, dann Name; Artikel alphabetisch. */
function regroup(view, markets, categories) {
const items = allItems(view);
const marketById = new Map(markets.map((m) => [m.id, m]));
const categoryById = new Map(categories.map((c) => [c.id, c]));
const buckets = new Map();
for (const item of items) {
const mid = marketById.has(item.market_id) ? item.market_id : null;
const cid = categoryById.has(item.category_id) ? item.category_id : null;
if (!buckets.has(mid)) buckets.set(mid, new Map());
const inner = buckets.get(mid);
if (!inner.has(cid)) inner.set(cid, []);
inner.get(cid).push(item);
}
const rank = (id, byId) => {
if (id === null) return [1, 0, ""];
const entry = byId.get(id);
return [0, entry.sort_order, entry.name.toLocaleLowerCase("de")];
};
const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || a[2].localeCompare(b[2], "de");
const marketIds = [...buckets.keys()].sort(
(a, b) => cmp(rank(a, marketById), rank(b, marketById))
);
let grand = 0;
view.markets = marketIds.map((mid) => {
const inner = buckets.get(mid);
const categoryIds = [...inner.keys()].sort(
(a, b) => cmp(rank(a, categoryById), rank(b, categoryById))
);
let total = 0;
let open = 0;
const cats = categoryIds.map((cid) => {
const group = inner.get(cid).sort((a, b) =>
a.article_name.localeCompare(b.article_name, "de"));
for (const item of group) {
if (item.status === "open") open += 1;
if (item.price_cents) {
// Preis je Gebinde mal Stückzahl - nicht mal Packungsgröße.
total += item.price_cents * (item.count || 1);
}
}
return {
category_id: cid,
category_name: cid ? categoryById.get(cid).name : "Ohne Warengruppe",
items: group,
};
});
grand += total;
return {
market_id: mid,
market_name: mid ? marketById.get(mid).name : "Ohne Markt",
categories: cats,
open_count: open,
total_cents: total,
};
});
view.grand_total_cents = grand;
return view;
}
/** Wendet eine noch nicht bestätigte Operation auf die Ansicht an, damit
* offline getätigte Änderungen sofort sichtbar sind. */
export function applyOp(view, op, markets, categories) {
const items = allItems(view);
const find = (id) => items.find((i) => i.id === id);
let needsRegroup = false;
if (op.kind === "item.create") {
const p = op.payload;
// Vorläufiger Eintrag: Die endgültige ID vergibt der Server.
items.push({
id: op.op_id,
pending: true,
article_id: null,
article_name: p.article_name,
created_by: null,
created_by_name: null,
market_id: p.market_id ?? null,
category_id: p.category_id ?? null,
count: p.count ?? 1,
pack_size: p.pack_size ?? null,
pack_unit: p.pack_unit ?? null,
variant: p.variant ?? null,
note: p.note ?? null,
status: "open",
price_cents: null,
total_cents: null,
row_rev: 0,
updated_at: op.created_at,
});
// items ist eine Kopie - deshalb über regroup zurückschreiben.
view.markets = [{ market_id: null, market_name: "", categories: [
{ category_id: null, category_name: "", items },
], open_count: 0, total_cents: 0 }];
needsRegroup = true;
} else if (op.kind === "item.update") {
const item = find(op.payload.item_id);
if (item) {
const p = op.payload;
if (p.clear_market) { item.market_id = null; needsRegroup = true; }
else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; }
if (p.clear_category) { item.category_id = null; needsRegroup = true; }
else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; }
for (const field of ["count", "pack_size", "pack_unit", "variant",
"note", "status", "price_cents"]) {
if (field in p) item[field] = p[field];
}
// Zeilensumme lokal nachziehen, damit die Anzeige sofort stimmt.
item.total_cents = item.price_cents
? item.price_cents * (item.count || 1) : null;
item.pending = true;
needsRegroup = true;
}
} else if (op.kind === "item.delete") {
const keep = items.filter((i) => i.id !== op.payload.item_id);
view.markets = [{ market_id: null, market_name: "", categories: [
{ category_id: null, category_name: "", items: keep },
], open_count: 0, total_cents: 0 }];
needsRegroup = true;
} else if (op.kind === "items.clear_bought") {
const keep = items.filter((i) => i.status !== "bought");
view.markets = [{ market_id: null, market_name: "", categories: [
{ category_id: null, category_name: "", items: keep },
], open_count: 0, total_cents: 0 }];
needsRegroup = true;
}
return needsRegroup ? regroup(view, markets, categories) : view;
}
/** Serverstand plus alle offenen Operationen. */
export async function composeView(listId) {
const cached = await db.cacheGet(`view:${listId}`);
if (!cached) return null;
const markets = (await db.cacheGet(`markets:${listId}`)) || [];
const categories = (await db.cacheGet(`categories:${listId}`)) || [];
// Tiefe Kopie, damit der zwischengespeicherte Serverstand unberührt bleibt.
let view = structuredClone(cached);
const ops = await db.pending(listId);
for (const op of ops.sort((a, b) => a.seq - b.seq)) {
view = applyOp(view, op, markets, categories);
}
view.pending_ops = ops.length;
return view;
}
// ---------------------------------------------------------------------------
// Senden und Holen
// ---------------------------------------------------------------------------
let flushing = false;
/** Schickt die Warteschlange. Gibt zurück, ob etwas gesendet wurde. */
export async function flush(listId) {
if (flushing) return false;
const ops = (await db.pending(listId)).sort((a, b) => a.seq - b.seq);
if (!ops.length) return false;
// Operationen, die zu oft gescheitert sind, blockieren sonst dauerhaft
// alles Nachfolgende - sie werden verworfen und gemeldet.
const stuck = ops.filter((o) => o.attempts >= MAX_ATTEMPTS);
if (stuck.length) {
await db.remove(stuck.map((o) => o.op_id));
set({ syncProblem:
`${stuck.length} Änderung(en) konnten nicht gespeichert werden und wurden verworfen.` });
}
const sending = ops.filter((o) => o.attempts < MAX_ATTEMPTS).slice(0, 200);
if (!sending.length) return false;
flushing = true;
try {
const result = await post(`/api/lists/${listId}/ops`, {
ops: sending.map((o) => ({ op_id: o.op_id, kind: o.kind, payload: o.payload })),
});
const done = [];
const rejected = [];
for (const r of result.results) {
if (r.status === "rejected") rejected.push(r);
done.push(r.op_id);
}
await db.remove(done);
if (rejected.length) {
set({ syncProblem: rejected[0].error
? `Eine Änderung wurde abgelehnt: ${rejected[0].error}`
: "Eine Änderung wurde abgelehnt." });
}
return true;
} catch (err) {
if (err instanceof OfflineError) {
// Kein Fehler im eigentlichen Sinn - beim nächsten Versuch erneut.
return false;
}
await db.bumpAttempts(sending.map((o) => o.op_id));
set({ syncProblem: err.message });
return false;
} finally {
flushing = false;
}
}
/** Holt den Serverstand und legt ihn in den Zwischenspeicher.
*
* Ein einziger Aufruf statt vier: /snapshot liefert Liste, Ansicht,
* Märkte und Warengruppen zusammen. Bei einer Synchronisation nach
* jedem Abhaken sparen die drei eingesparten Rundreisen spürbar Zeit,
* besonders im Mobilfunknetz. */
export async function pull(listId) {
const snapshot = await get(`/api/lists/${listId}/snapshot`);
const { view, markets, categories } = snapshot;
const meta = snapshot.list;
await Promise.all([
db.cacheSet(`view:${listId}`, view),
db.cacheSet(`markets:${listId}`, markets),
db.cacheSet(`categories:${listId}`, categories),
db.cacheSet(`meta:${listId}`, meta),
]);
return { view, markets, categories, meta };
}
/** Der übliche Ablauf: erst senden, dann holen. Umgekehrt würde der
* frisch geholte Stand die eigenen offenen Änderungen überdecken. */
export async function syncNow(listId) {
try {
await flush(listId);
await pull(listId);
set({ lastSync: new Date().toISOString(), syncState: "ok" });
return true;
} catch (err) {
set({ syncState: err instanceof OfflineError ? "offline" : "error" });
return false;
}
}
// ---------------------------------------------------------------------------
// Ereigniskanal
// ---------------------------------------------------------------------------
let source = null;
let sourceListId = null;
export function watch(listId, onChange) {
stopWatching();
if (!("EventSource" in globalThis)) return;
sourceListId = listId;
source = new EventSource(`/api/lists/${listId}/events`);
source.addEventListener("rev", async (ev) => {
let rev = null;
try {
rev = JSON.parse(ev.data).rev;
} catch {
return;
}
const cached = await db.cacheGet(`view:${listId}`);
// Nur holen, wenn sich wirklich etwas geändert hat.
if (!cached || cached.rev !== rev) {
await syncNow(listId);
onChange();
}
});
source.addEventListener("gone", () => {
stopWatching();
onChange();
});
// Wiederverbindung übernimmt der Browser selbst; nur der Zustand wird
// gemeldet, damit die Oberfläche es anzeigen kann.
source.onerror = () => set({ syncState: "offline" });
source.onopen = () => set({ syncState: "ok" });
}
export function stopWatching() {
if (source) {
source.close();
source = null;
sourceListId = null;
}
}
export function watchedList() {
return sourceListId;
}
// ---------------------------------------------------------------------------
// Auslöser für den Versand
// ---------------------------------------------------------------------------
export function installTriggers(getListId, onChange) {
const attempt = async () => {
const listId = getListId();
if (!listId) return;
// Nur senden. Das anschließende Holen übernimmt die Ansicht, die
// onChange auslöst - sonst liefe pull() zweimal hintereinander.
if (await flush(listId)) onChange();
};
// Verbindung zurück: sofort versuchen.
window.addEventListener("online", () => {
set({ online: true, syncState: "ok" });
attempt();
});
window.addEventListener("offline", () => set({ online: false, syncState: "offline" }));
// App wieder im Vordergrund - typischer Moment nach dem Einkauf.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") attempt();
});
// Rückfallebene, falls beide Ereignisse ausbleiben.
setInterval(attempt, 30000);
}

347
web/html/js/views/admin.js Normal file
View File

@@ -0,0 +1,347 @@
// Benutzerverwaltung für Administratoren.
"use strict";
import { del, get, post, put } from "../api.js";
import { el, mount } from "../dom.js";
import { state } from "../store.js";
let openId = null;
function formatDate(iso) {
if (!iso) return null;
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
/** Wie lange ist das her, grob. Für "seit wann nicht mehr gesehen" ist
* der Monat die passende Auflösung - der Tag wäre Scheingenauigkeit. */
function ago(iso) {
if (!iso) return "nie";
const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
if (days < 1) return "heute";
if (days === 1) return "gestern";
if (days < 31) return `vor ${days} Tagen`;
const months = Math.round(days / 30);
if (months < 24) return `vor ${months} Monaten`;
return `vor ${Math.round(months / 12)} Jahren`;
}
export async function adminView(root, { back }) {
let users = [];
let stats = null;
let config = null;
let banner = null;
async function reload() {
[users, stats, config] = await Promise.all([
get("/api/admin/users"),
get("/api/admin/stats"),
get("/api/admin/settings"),
]);
}
function say(message, kind = "notice") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 10000);
}
async function guarded(fn) {
try {
const result = await fn();
await reload();
render();
if (result && result.detail) say(result.detail);
} catch (err) {
say(err.message, "error");
}
}
// -------------------------------------------------------------------
// Kennzahlen
// -------------------------------------------------------------------
function statsCard() {
const tile = (label, value, warn = false) =>
el("div", { className: `stat${warn && value ? " warn" : ""}` },
el("span.value", {}, String(value)),
el("span.label", {}, label));
return el("section.card", {},
el("h1", {}, "Benutzer"),
el("div.stats", {},
tile("gesamt", stats.total),
tile("aktiv", stats.active),
tile("deaktiviert", stats.inactive),
tile("nicht eingerichtet", stats.unverified),
tile("Administratoren", stats.admins)),
stats.due_deactivation || stats.due_deletion
? el("p.lead.warn", {},
"Beim nächsten nächtlichen Durchlauf: ",
stats.due_deactivation
? `${stats.due_deactivation} Konto/Konten werden deaktiviert`
: "",
stats.due_deactivation && stats.due_deletion ? ", " : "",
stats.due_deletion
? `${stats.due_deletion} werden gelöscht`
: "",
".")
: null
);
}
// -------------------------------------------------------------------
// Neues Konto
// -------------------------------------------------------------------
function createCard() {
const email = el("input", { type: "email", placeholder: "person@example.de" });
const name = el("input", { type: "text", maxLength: 80, placeholder: "freiwillig" });
const asAdmin = el("input", { type: "checkbox" });
return el("section.card", {},
el("h2", {}, "Konto anlegen"),
el("p.lead", {},
"Die Person bekommt eine Willkommensnachricht mit einem Link, über " +
"den sie ihr Passwort selbst festlegt. Ein vom Administrator " +
"vergebenes Passwort wäre ihm bekannt und ginge im Klartext per Mail."),
el("label", {}, "E-Mail-Adresse"), email,
el("label", {}, "Anzeigename"), name,
el("label.checkline", {}, asAdmin,
el("span", {}, "Administratorrechte erteilen")),
el("button.primary", {
type: "button",
onclick: () => {
const value = email.value.trim();
if (!value) return;
guarded(async () => {
await post("/api/admin/users", {
email: value,
display_name: name.value.trim() || null,
is_admin: asAdmin.checked,
});
email.value = "";
name.value = "";
asAdmin.checked = false;
say(`Konto angelegt, Willkommensnachricht an ${value} versendet.`);
});
},
}, "Anlegen und einladen")
);
}
// -------------------------------------------------------------------
// Ein Konto
// -------------------------------------------------------------------
function userActions(user) {
const self = user.id === state.user.id;
const newEmail = el("input", { type: "email", placeholder: "neue Adresse" });
const blocks = [
el("label", {}, "E-Mail-Adresse ändern"),
el("p.sub", {},
user.is_admin
? "Administratorkonto: Die Änderung wird erst wirksam, wenn BEIDE " +
"Adressen bestätigt haben die neue und die bisherige."
: "Die neue Adresse muss bestätigen; die bisherige bekommt einen " +
"Hinweis, damit eine untergeschobene Änderung auffällt."),
el("div.row", {}, newEmail,
el("button.secondary", {
type: "button",
onclick: () => {
const value = newEmail.value.trim();
if (!value) return;
guarded(async () => {
const result = await post(`/api/admin/users/${user.id}/email`,
{ new_email: value });
newEmail.value = "";
return result;
});
},
}, "Ändern")),
];
if (user.pending_email) {
blocks.push(el("p.lead.warn", {},
`Adresswechsel zu ${user.pending_email} läuft. `,
el("button.linklike", {
type: "button",
onclick: () => guarded(() => del(`/api/admin/users/${user.id}/email`)),
}, "Zurückziehen")));
}
if (!user.verified) {
blocks.push(el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/welcome`)),
}, "Willkommensnachricht erneut senden")));
}
// Administratorkonten sind vor Deaktivierung und Löschung geschützt.
if (user.is_admin) {
blocks.push(el("p.sub", {},
"Administratorkonten lassen sich nicht deaktivieren oder löschen. " +
"Sonst könnte sich die Verwaltung selbst aussperren."));
return el("div.item-menu", {}, blocks);
}
if (self) {
blocks.push(el("p.sub", {}, "Das eigene Konto lässt sich hier nicht ändern."));
return el("div.item-menu", {}, blocks);
}
blocks.push(el("div.menu-actions", {},
user.is_active
? el("button", {
type: "button",
onclick: () => {
if (!confirm(
`Konto ${user.email} deaktivieren?\n\n` +
"Die Person kann sich nicht mehr anmelden. Listen und " +
"Mitgliedschaften bleiben erhalten."
)) return;
guarded(() => post(`/api/admin/users/${user.id}/deactivate`));
},
}, "Deaktivieren")
: el("button", {
type: "button",
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/activate`)),
}, "Reaktivieren")));
blocks.push(el("hr.thin", {}));
blocks.push(el("label", {}, "Konto löschen"));
blocks.push(el("p.sub", {},
user.owned_lists
? `Diesem Konto gehören ${user.owned_lists} Liste(n). Geteilte Listen ` +
"gehen an das dienstälteste andere Mitglied über, Listen ohne " +
"weitere Mitglieder werden gelöscht."
: "Diesem Konto gehören keine Listen."));
blocks.push(el("button.danger.wide", {
type: "button",
onclick: () => {
const typed = prompt(
`Konto ${user.email} endgültig löschen?\n\n` +
"Zur Bestätigung die E-Mail-Adresse eingeben:");
if (!typed) return;
guarded(() => post(`/api/admin/users/${user.id}/delete`, {
lists: "handover",
confirm_email: typed.trim(),
}));
},
}, "Endgültig löschen"));
return el("div.item-menu", {}, blocks);
}
function userRow(user) {
const facts = [
user.is_admin ? "Administrator" : null,
!user.verified ? "noch nicht eingerichtet" : null,
!user.is_active ? `deaktiviert seit ${formatDate(user.deactivated_at)}` : null,
user.is_active && user.verified ? `zuletzt ${ago(user.last_seen_at)}` : null,
user.owned_lists ? `${user.owned_lists} eigene Liste(n)` : null,
user.memberships ? `${user.memberships} Mitgliedschaft(en)` : null,
].filter(Boolean);
return el("li", {
className: `admin-user${user.is_active ? "" : " inactive"}`,
},
el("button.user-open", {
type: "button",
onclick: () => { openId = openId === user.id ? null : user.id; render(); },
},
el("span.name", {}, user.display_name || user.email),
el("span.sub", {},
user.display_name ? `${user.email} · ` : "",
facts.join(" · "))),
openId === user.id ? userActions(user) : null
);
}
// -------------------------------------------------------------------
// 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 selfReg = el("input", {
type: "checkbox",
checked: config.allow_self_registration,
disabled: config.locked_by_env,
});
return el("section.card", {},
el("h2", {}, "Automatische Bereinigung"),
el("p.lead", {},
"Läuft einmal täglich. ",
el("strong", {}, "0 bedeutet abgeschaltet"),
", nicht „sofort“. Administratorkonten sind ausgenommen."),
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
deactivate,
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
remove,
el("label.checkline", {}, selfReg,
el("span", {}, "Selbstregistrierung erlauben")),
config.locked_by_env
? el("p.sub", {},
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
"und hier nicht änderbar.")
: null,
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,
...(config.locked_by_env
? {}
: { allow_self_registration: selfReg.checked }),
});
say("Einstellungen gespeichert.");
}),
}, "Speichern"),
el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => guarded(() => post("/api/admin/cleanup")),
}, "Jetzt aufräumen"))
);
}
// -------------------------------------------------------------------
function render() {
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back }, " Zurück")),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
statsCard(),
el("section.card", {},
el("h2", {}, "Konten"),
el("ul.admin-users", {}, users.map(userRow))),
createCard(),
automationCard()
);
}
await reload();
render();
return render;
}

View File

@@ -0,0 +1,296 @@
// Artikelstamm einer Liste: Namen, Strichcodes, Vorgaben für Markt und
// Warengruppe, Verfügbarkeit und frei definierbare Eigenschaften.
"use strict";
import { del, get, patch, post } from "../api.js";
import { el, mount } from "../dom.js";
import { set, state } from "../store.js";
import { cameraAvailable, scanBarcode } from "./scanner.js";
let editingId = null;
let query = "";
export async function articlesView(root, { listId, back }) {
let articles = [];
let banner = null;
async function reload() {
const [list, markets, categories] = await Promise.all([
get(`/api/lists/${listId}/articles${query ? `?q=${encodeURIComponent(query)}` : ""}`),
get(`/api/lists/${listId}/markets`),
get(`/api/lists/${listId}/categories`),
]);
articles = list;
set({ markets, categories });
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
async function guarded(fn) {
try {
await fn();
await reload();
render();
} catch (err) {
say(err.message);
}
}
// -------------------------------------------------------------------
// Bearbeitungsformular
// -------------------------------------------------------------------
function editor(article) {
const name = el("input", { type: "text", value: article.name, maxLength: 200 });
const barcode = el("input", {
type: "text",
inputMode: "numeric",
value: article.barcode || "",
maxLength: 64,
placeholder: "keiner hinterlegt",
});
const note = el("input", {
type: "text", value: article.note || "", maxLength: 500,
placeholder: "z. B. Lieblingsmarke",
});
const marketSelect = el("select", {},
el("option", { value: "" }, "— keine Vorgabe —"),
state.markets.map((m) => el("option", {
value: m.id, selected: m.id === article.default_market_id,
}, m.name)));
const categorySelect = el("select", {},
el("option", { value: "" }, "— keine Vorgabe —"),
state.categories.map((c) => el("option", {
value: c.id, selected: c.id === article.default_category_id,
}, c.name)));
// Verfügbarkeit: mehrere Märkte ankreuzbar
const availability = state.markets.map((m) => {
const box = el("input", {
type: "checkbox",
checked: article.available_market_ids.includes(m.id),
dataset: { marketId: m.id },
});
return el("label.checkline", {}, box, el("span", {}, m.name));
});
// Freie Eigenschaften
const attrRows = el("div.attr-rows", {});
function addAttrRow(attr = { name: "", value: "" }) {
const key = el("input.attr-key", {
type: "text", value: attr.name, maxLength: 80, placeholder: "Eigenschaft",
});
const value = el("input.attr-value", {
type: "text", value: attr.value, maxLength: 300, placeholder: "Wert",
});
const row = el("div.attr-row", {}, key, value,
el("button.danger.remove", {
type: "button", title: "Zeile entfernen",
onclick: () => row.remove(),
}, "×"));
attrRows.append(row);
}
for (const attr of article.attributes) addAttrRow(attr);
if (!article.attributes.length) addAttrRow();
async function scan() {
const code = await scanBarcode();
if (code) barcode.value = code;
}
/** Angaben zum eingetragenen Code aus der Produktdatenbank holen.
* Überschreibt nur leere Felder - Gepflegtes bleibt stehen. */
async function fetchProductData() {
const code = barcode.value.replace(/\s/g, "");
if (!code) {
say("Erst einen Strichcode eintragen oder scannen.");
return;
}
try {
const hit = await get(
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
if (!hit.found || hit.source !== "openfoodfacts") {
say("Zu diesem Code liegen keine Angaben vor.", "notice");
return;
}
if (hit.name && !name.value.trim()) name.value = hit.name;
if (hit.package && !note.value.trim()) note.value = hit.package;
const rows = [...attrRows.querySelectorAll(".attr-row")];
const empty = rows.find(
(r) => !r.querySelector(".attr-key").value.trim());
if (hit.brand && empty) {
empty.querySelector(".attr-key").value = "Marke";
empty.querySelector(".attr-value").value = hit.brand;
}
say(`Angaben zu „${hit.name}“ übernommen. Noch nicht gespeichert.`,
"notice");
} catch (err) {
say(err.message);
}
}
async function save() {
const attributes = [...attrRows.querySelectorAll(".attr-row")]
.map((row) => ({
name: row.querySelector(".attr-key").value.trim(),
value: row.querySelector(".attr-value").value.trim(),
}))
.filter((a) => a.name);
const available = [...availability]
.map((label) => label.querySelector("input"))
.filter((box) => box.checked)
.map((box) => box.dataset.marketId);
editingId = null;
await guarded(() => patch(`/api/lists/${listId}/articles/${article.id}`, {
name: name.value.trim() || article.name,
barcode: barcode.value.replace(/\s/g, "") || null,
note: note.value.trim() || null,
default_market_id: marketSelect.value || null,
default_category_id: categorySelect.value || null,
attributes,
available_market_ids: available,
}));
}
return el("div.article-editor", {},
el("label", {}, "Name"), name,
el("label", {}, "Strichcode"),
el("div.row", {}, barcode,
cameraAvailable()
? el("button.secondary", { type: "button", onclick: scan }, "Scannen")
: null,
el("button.secondary", {
type: "button",
title: "Bezeichnung und Packungsgröße aus der Produktdatenbank holen",
onclick: fetchProductData,
}, "Abrufen")),
el("label", {}, "Notiz"), note,
el("label", {}, "Vorgabe Markt"), marketSelect,
el("label", {}, "Vorgabe Warengruppe"), categorySelect,
state.markets.length
? el("div.availability", {},
el("label", {}, "Erhältlich bei"), availability)
: null,
el("label", {}, "Eigenschaften",
el("span.hint", {}, " (Verpackungseinheit, Farbe, Größe …)")),
attrRows,
el("button.linklike.add-attr", {
type: "button", onclick: () => addAttrRow(),
}, "+ weitere Eigenschaft"),
el("div.menu-actions", {},
el("button.primary", { type: "button", onclick: save }, "Speichern"),
el("button", {
type: "button",
onclick: () => { editingId = null; render(); },
}, "Abbrechen"),
el("button.danger", {
type: "button",
onclick: () => {
if (!confirm(
`Artikel „${article.name}“ löschen?\n\n` +
"Offene Einträge dieses Artikels verschwinden mit."
)) return;
editingId = null;
guarded(() => del(`/api/lists/${listId}/articles/${article.id}`));
},
}, "Löschen"))
);
}
// -------------------------------------------------------------------
// Zeile
// -------------------------------------------------------------------
function row(article) {
if (editingId === article.id) {
return el("li.article-row.editing", {},
el("span.name", {}, article.name), editor(article));
}
const marketName = state.markets.find((m) => m.id === article.default_market_id)?.name;
const categoryName = state.categories.find(
(c) => c.id === article.default_category_id)?.name;
const facts = [
article.barcode ? `Code ${article.barcode}` : null,
marketName,
categoryName,
article.attributes.length
? article.attributes.map((a) => `${a.name}: ${a.value}`).join(", ")
: null,
].filter(Boolean);
return el("li.article-row", {},
el("button.article-open", {
type: "button",
onclick: () => { editingId = article.id; render(); },
},
el("span.name", {}, article.name),
facts.length ? el("span.sub", {}, facts.join(" · ")) : null)
);
}
// -------------------------------------------------------------------
function render() {
const search = el("input", {
type: "search",
value: query,
placeholder: "Artikel suchen",
oninput: (ev) => {
query = ev.target.value;
clearTimeout(render._timer);
render._timer = setTimeout(() => guarded(async () => {}), 250);
},
});
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste")),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
el("section.card", {},
el("h1", {}, "Artikel"),
el("p.lead", {},
"Vorgaben für Markt und Warengruppe gelten für neue Einträge. " +
"Ein hinterlegter Strichcode lässt den Artikel beim Scannen sofort " +
"finden."),
search,
articles.length
? el("ul.articles", {}, articles.map(row))
: el("p.empty", {},
query ? "Kein Artikel gefunden." : "Noch kein Artikel angelegt.")),
el("p.footnote", {},
"Artikel entstehen automatisch, sobald du sie auf die Liste setzt.")
);
if (query) {
const field = root.querySelector('input[type="search"]');
field.focus();
field.setSelectionRange(field.value.length, field.value.length);
}
}
await reload();
render();
return render;
}

233
web/html/js/views/auth.js Normal file
View File

@@ -0,0 +1,233 @@
// Anmeldung, Registrierung, Passwort zurücksetzen und Startpasswortwechsel.
"use strict";
import { ApiError, get, post } from "../api.js";
import { clear, el, mount } from "../dom.js";
import { set, state } from "../store.js";
/** Baut ein Formular ohne <form>-Element: dessen Standardverhalten
* (Seite neu laden) stört hier nur. Enter löst trotzdem aus. */
function form({ title, lead, leadWarn, fields, submitLabel, onSubmit, links = [] }) {
const inputs = {};
const error = el("p.error", { hidden: true });
const notice = el("p.notice", { hidden: true });
const button = el("button.primary", { type: "button" }, submitLabel);
const showError = (message) => {
error.textContent = message || "";
error.hidden = !message;
};
const showNotice = (message) => {
notice.textContent = message || "";
notice.hidden = !message;
};
const run = async () => {
showError("");
showNotice("");
button.disabled = true;
try {
const values = Object.fromEntries(
Object.entries(inputs).map(([k, i]) => [k, i.value])
);
await onSubmit(values, { showNotice, showError });
} catch (err) {
showError(err.message);
} finally {
button.disabled = false;
}
};
button.addEventListener("click", run);
const rows = fields.map((f) => {
const input = el("input", {
id: `f-${f.name}`,
type: f.type || "text",
autocomplete: f.autocomplete || "off",
maxLength: f.maxLength || 256,
onkeydown: (ev) => {
if (ev.key === "Enter") run();
},
});
inputs[f.name] = input;
return [
el("label", { htmlFor: `f-${f.name}` },
f.label,
f.hint ? el("span.hint", {}, ` ${f.hint}`) : null),
input,
];
});
return el("section.card", {},
el("h1", {}, title),
lead ? el(leadWarn ? "p.lead.warn" : "p.lead", {}, lead) : null,
rows,
error,
notice,
button,
links.length
? el("p.switch", {}, links.flatMap((l, i) => [
i > 0 ? " · " : null,
el("a", { href: "#", onclick: (ev) => { ev.preventDefault(); l.action(); } },
l.label),
]))
: null
);
}
export function loginView(root, { goto, onSignedIn }) {
const params = new URLSearchParams(location.search);
const verified = params.get("verified");
const emailChange = params.get("adresswechsel");
const node = form({
title: state.appName,
lead: "Bitte anmelden.",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
{ name: "password", label: "Passwort", type: "password", autocomplete: "current-password" },
],
submitLabel: "Anmelden",
onSubmit: async ({ email, password }) => {
const user = await post("/api/auth/login", { email: email.trim(), password });
onSignedIn(user);
},
links: [
{ label: "Konto anlegen", action: () => goto("register") },
{ label: "Passwort vergessen", action: () => goto("forgot") },
],
});
mount(root, node);
if (verified === "ok") {
const notice = node.querySelector(".notice");
notice.textContent = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden.";
notice.hidden = false;
history.replaceState(null, "", "/");
} else if (verified === "invalid") {
const error = node.querySelector(".error");
error.textContent = "Der Bestätigungslink ist ungültig oder abgelaufen.";
error.hidden = false;
history.replaceState(null, "", "/");
}
if (emailChange) {
const messages = {
fertig: ["notice",
"E-Mail-Adresse geändert. Bitte melde dich mit der neuen Adresse an."],
teilweise: ["notice",
"Bestätigung angekommen. Bei Administratorkonten muss auch die " +
"zweite Adresse zustimmen erst danach wird die Änderung wirksam."],
abgelaufen: ["error", "Der Bestätigungslink ist abgelaufen."],
erledigt: ["notice", "Dieser Adresswechsel ist bereits abgeschlossen."],
unbekannt: ["error", "Dieser Bestätigungslink ist unbekannt."],
};
const entry = messages[emailChange];
if (entry) {
const target = node.querySelector(entry[0] === "error" ? ".error" : ".notice");
target.textContent = entry[1];
target.hidden = false;
history.replaceState(null, "", "/");
}
}
node.querySelector("input").focus();
}
export function registerView(root, { goto }) {
mount(root, form({
title: "Konto anlegen",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
{ name: "display_name", label: "Anzeigename", hint: "(freiwillig)", maxLength: 80 },
{ name: "password", label: "Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
],
submitLabel: "Registrieren",
onSubmit: async (v, { showNotice }) => {
const result = await post("/api/auth/register", {
email: v.email.trim(),
password: v.password,
display_name: v.display_name.trim() || null,
});
showNotice(result.detail);
},
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
}));
}
export function forgotView(root, { goto }) {
mount(root, form({
title: "Passwort zurücksetzen",
lead: "Wir schicken dir einen Link, sofern die Adresse bei uns registriert ist.",
fields: [
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
],
submitLabel: "Link anfordern",
onSubmit: async ({ email }, { showNotice }) => {
const result = await post("/api/auth/password/reset-request", { email: email.trim() });
showNotice(result.detail);
},
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
}));
}
export function resetView(root, { goto }) {
const token = new URLSearchParams(location.search).get("token") || "";
mount(root, form({
title: "Neues Passwort setzen",
fields: [
{ name: "password", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
],
submitLabel: "Passwort setzen",
onSubmit: async ({ password, repeat }, { showNotice }) => {
if (password !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
const result = await post("/api/auth/password/reset", { token, password });
showNotice(result.detail);
history.replaceState(null, "", "/");
setTimeout(() => goto("login"), 1500);
},
}));
}
export function changePasswordView(root, { onDone, onLogout }) {
mount(root, form({
title: "Passwort ändern",
lead: "Dieses Konto wurde mit einem Startpasswort aus der Konfiguration angelegt. " +
"Bevor du weiterarbeiten kannst, musst du ein eigenes Passwort setzen.",
leadWarn: true,
fields: [
{ name: "current", label: "Bisheriges Passwort", type: "password",
autocomplete: "current-password" },
{ name: "next", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
type: "password", autocomplete: "new-password" },
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
],
submitLabel: "Passwort ändern",
onSubmit: async ({ current, next, repeat }) => {
if (next !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
await post("/api/auth/password/change", {
current_password: current,
new_password: next,
});
const user = await get("/api/auth/me");
set({ user });
onDone(user);
},
links: [{ label: "Abmelden", action: onLogout }],
}));
}
export async function logout() {
try {
await post("/api/auth/logout");
} catch (err) {
// Auch wenn der Server nicht erreichbar ist: lokal abmelden.
if (!(err instanceof ApiError)) { /* offline - egal */ }
}
set({ user: null, lists: [], view: null });
}

View File

@@ -0,0 +1,600 @@
// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel
// alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen
// den Server - dadurch funktioniert die Ansicht auch ohne Verbindung.
//
// Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt.
// Der Versand läuft danach; scheitert er, bleibt die Operation liegen und
// wird beim nächsten Anlauf erneut versucht.
"use strict";
// get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine
// Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht.
import { ApiError, get } from "../api.js";
import * as db from "../db.js";
import {
clear, el, euro, formatPack, formatQuantity, mount,
parseCents, parseCount, parseQuantity,
} from "../dom.js";
import { set, state } from "../store.js";
import { composeView, flush, pull, syncNow, watch } from "../sync.js";
import { confirmScan } from "./scan-result.js";
import { cameraAvailable, scanBarcode } from "./scanner.js";
let openMenuId = null;
export async function listDetailView(
root, { listId, back, manage, share, articles, prices }
) {
let view = null;
let meta = null;
let markets = [];
let categories = [];
let banner = null;
/** article_id -> { best_market_ids, best_cents, spread_cents, prices } */
let priceHints = new Map();
// -------------------------------------------------------------------
// Laden
// -------------------------------------------------------------------
/** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet
* die Liste ohne sie weiter. Deshalb hier kein throw, sondern der
* Rückgriff auf den zuletzt bekannten Stand. */
async function loadHints() {
if (!state.online) {
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
return;
}
try {
const rows = await get(`/api/lists/${listId}/price-hints`);
priceHints = new Map(rows.map((r) => [r.article_id, r]));
await db.cacheSet(`hints:${listId}`, rows);
} catch {
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
}
}
async function readLocal() {
view = await composeView(listId);
markets = (await db.cacheGet(`markets:${listId}`)) || [];
categories = (await db.cacheGet(`categories:${listId}`)) || [];
meta = await db.cacheGet(`meta:${listId}`);
}
async function refresh({ fromServer = false } = {}) {
if (fromServer) {
try {
await pull(listId);
set({ syncState: "ok" });
} catch {
set({ syncState: navigator.onLine ? "error" : "offline" });
}
}
await readLocal();
render();
}
function say(message) {
banner = message;
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
/** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */
async function change(kind, payload) {
await db.enqueue(listId, kind, payload);
await readLocal();
render();
const sent = await flush(listId);
if (sent) {
await pull(listId).catch(() => {});
await readLocal();
}
if (state.syncProblem) {
const problem = state.syncProblem;
set({ syncProblem: null });
say(problem);
} else {
render();
}
}
// -------------------------------------------------------------------
// Bausteine
// -------------------------------------------------------------------
function itemRow(item) {
const bought = item.status === "bought";
const deferred = item.status === "deferred";
// Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig
// keine ID - Folgeänderungen daran ließen sich nicht zuordnen.
const provisional = Boolean(item.pending && item.row_rev === 0);
const check = el("button.check", {
type: "button",
"aria-pressed": bought ? "true" : "false",
title: bought ? "Als offen markieren" : "Als gekauft markieren",
onclick: () => change("item.update", {
item_id: item.id,
status: bought ? "open" : "bought",
}),
disabled: provisional,
}, bought ? "✓" : "");
const priceField = el("input.price", {
type: "text",
inputMode: "decimal",
placeholder: "€",
value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "",
title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen",
disabled: provisional,
onchange: (ev) => {
const cents = parseCents(ev.target.value);
if (ev.target.value.trim() && cents === null) {
ev.target.value = "";
say("Preis konnte nicht gelesen werden.");
return;
}
change("item.update", { item_id: item.id, price_cents: cents });
},
});
const menuButton = el("button.menu-toggle", {
type: "button",
title: "Weitere Aktionen",
disabled: provisional,
onclick: () => {
openMenuId = openMenuId === item.id ? null : item.id;
render();
},
}, "⋯");
const pack = formatPack(item.pack_size, item.pack_unit);
const shared = meta && meta.member_count > 1;
const by = shared && item.created_by_name ? `von ${item.created_by_name}` : "";
const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · ");
return el("li", {
className: [
"item",
bought ? "bought" : "",
deferred ? "deferred" : "",
item.pending ? "pending" : "",
].filter(Boolean).join(" "),
},
el("div.item-main", {},
check,
// Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele"
// die wichtigste Angabe und darf nicht im Untertitel untergehen.
el("span", {
className: `item-count${item.count > 1 ? " many" : ""}`,
title: item.count === 1 ? "1 Stück" : `${item.count} Stück`,
}, `${item.count || 1}\u00d7`),
el("div.item-text", {},
el("span.name", {}, item.article_name),
sub ? el("span.sub", {}, sub) : null),
el("div.price-cell", {},
priceField,
// Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde
// gilt und was zusammen daraus wird.
item.price_cents && item.count > 1
? el("span.line-total", {}, `= ${euro(item.total_cents)}`)
: null),
menuButton),
openMenuId === item.id && !provisional ? itemMenu(item) : null
);
}
function itemMenu(item) {
const marketSelect = el("select", {
onchange: (ev) => {
const value = ev.target.value;
openMenuId = null;
change("item.update", value
? { item_id: item.id, market_id: value }
: { item_id: item.id, clear_market: true });
},
},
el("option", { value: "" }, "— ohne Markt —"),
markets.map((m) =>
el("option", { value: m.id, selected: m.id === item.market_id }, m.name))
);
const categorySelect = el("select", {
onchange: (ev) => {
const value = ev.target.value;
openMenuId = null;
change("item.update", value
? { item_id: item.id, category_id: value }
: { item_id: item.id, clear_category: true });
},
},
el("option", { value: "" }, "— ohne Warengruppe —"),
categories.map((c) =>
el("option", { value: c.id, selected: c.id === item.category_id }, c.name))
);
const deferred = item.status === "deferred";
const variantField = el("input", {
type: "text",
value: item.variant || "",
maxLength: 200,
placeholder: "z. B. bunt, ganz",
onchange: (ev) => change("item.update", {
item_id: item.id,
variant: ev.target.value.trim() || null,
}),
});
const noteField = el("input", {
type: "text",
value: item.note || "",
maxLength: 500,
placeholder: "Bemerkung für den Einkauf",
onchange: (ev) => change("item.update", {
item_id: item.id,
note: ev.target.value.trim() || null,
}),
});
const hint = priceHints.get(item.article_id);
const hintBlock = hint ? (() => {
const bestNames = hint.best_market_ids
.map((id) => markets.find((m) => m.id === id)?.name)
.filter(Boolean)
.join(", ");
const hereCents = item.market_id ? hint.prices[item.market_id] : null;
if (!bestNames) return null;
const alreadyBest = item.market_id
&& hint.best_market_ids.includes(item.market_id);
return el("p.price-tip", {},
alreadyBest
? `Hier am günstigsten: ${euro(hint.best_cents)}.`
: `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`,
!alreadyBest && hereCents
? ` hier zuletzt ${euro(hereCents)}.`
: !alreadyBest ? "." : "");
})() : null;
const countEdit = el("input", {
type: "text",
inputMode: "numeric",
value: String(item.count || 1),
title: "Stückzahl",
onchange: (ev) => change("item.update", {
item_id: item.id, count: parseCount(ev.target.value),
}),
});
const packSizeEdit = el("input.qty", {
type: "text",
inputMode: "decimal",
value: item.pack_size ? formatQuantity(item.pack_size) : "",
placeholder: "Gebinde",
onchange: (ev) => change("item.update", {
item_id: item.id, pack_size: parseQuantity(ev.target.value),
}),
});
const packUnitEdit = el("input.unit", {
type: "text",
maxLength: 32,
value: item.pack_unit || "",
placeholder: "Einheit",
onchange: (ev) => change("item.update", {
item_id: item.id, pack_unit: ev.target.value.trim() || null,
}),
});
return el("div.item-menu", {},
hintBlock,
el("label", {}, "Anzahl und Gebinde",
el("span.hint", {}, " (Preis gilt je Gebinde)")),
el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit),
el("label", {}, "Eigenschaft"), variantField,
el("label", {}, "Notiz"), noteField,
el("label", {}, "Markt"), marketSelect,
el("label", {}, "Warengruppe"), categorySelect,
el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => {
openMenuId = null;
change("item.update", {
item_id: item.id,
status: deferred ? "open" : "deferred",
});
},
}, deferred ? "Wieder aufnehmen" : "Zurückstellen"),
el("button.danger", {
type: "button",
onclick: () => {
openMenuId = null;
change("item.delete", { item_id: item.id });
},
}, "Löschen"))
);
}
function marketSection(market) {
return el("section.market", {},
el("h2", {},
el("span", {}, market.market_name),
el("span.market-meta", {},
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
market.categories.map((cat) =>
el("div.category", {},
el("h3", {}, cat.category_name),
el("ul.items", {}, cat.items.map(itemRow))))
);
}
function addRow() {
const nameField = el("input", {
id: "add-name",
type: "text",
maxLength: 200,
placeholder: "Artikel hinzufügen",
autocomplete: "off",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die
// Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben
// 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
const countField = el("input.count", {
type: "text", inputMode: "numeric", placeholder: "Anz.", value: "",
title: "Stückzahl wie viele Packungen",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
const qtyField = el("input.qty", {
type: "text", inputMode: "decimal", placeholder: "Gebinde",
title: "Packungsgröße, z. B. 500",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
const unitField = el("input.unit", {
type: "text", maxLength: 32, placeholder: "Einheit",
title: "z. B. ml, g, Stk",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss
// "bunt, ganz" nur einmal getippt werden.
const suggestions = new Set();
for (const market of view?.markets || []) {
for (const cat of market.categories) {
for (const item of cat.items) {
if (item.variant) suggestions.add(item.variant);
}
}
}
const variantList = el("datalist", { id: "variant-suggestions" },
[...suggestions].sort((a, b) => a.localeCompare(b, "de"))
.map((v) => el("option", { value: v })));
const variantField = el("input.variant", {
type: "text",
maxLength: 200,
placeholder: "Eigenschaft",
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
autocomplete: "off",
list: "variant-suggestions",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt
// gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht.
variantField.setAttribute("list", "variant-suggestions");
async function addItem() {
const name = nameField.value.trim();
if (!name) return;
const payload = {
article_name: name,
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
};
nameField.value = "";
countField.value = "";
qtyField.value = "";
unitField.value = "";
variantField.value = "";
await change("item.create", payload);
document.getElementById("add-name")?.focus();
}
/** Strichcode scannen, auflösen, bestätigen, hinzufügen.
*
* Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die
* Produktdatenbank liegen auf dem Server. Offline bliebe eine
* Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen,
* statt etwas Unbrauchbares in die Warteschlange zu legen. */
async function scanAndAdd() {
const code = await scanBarcode();
if (!code) return;
if (!state.online) {
say("Zum Scannen wird eine Verbindung gebraucht die Zuordnung " +
"des Codes steht auf dem Server.");
return;
}
let lookup;
try {
lookup = await get(
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
} catch (err) {
say(err.message);
return;
}
// Bereits im eigenen Bestand: ohne Rückfrage auf die Liste.
if (lookup.source === "catalog") {
await change("item.create", {
article_id: lookup.article_id,
barcode: code,
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
});
countField.value = "";
qtyField.value = "";
unitField.value = "";
variantField.value = "";
say(`${lookup.name}“ hinzugefügt.`);
return;
}
// Sonst bestätigen lassen - auch bei einem Treffer in der
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
// eigenen Artikelstamm wandern.
const confirmed = await confirmScan(lookup);
if (!confirmed) return;
await change("item.create", {
article_name: confirmed.name,
barcode: code,
count: parseCount(confirmed.count),
pack_size: parseQuantity(confirmed.packSize),
pack_unit: confirmed.packUnit || null,
variant: confirmed.variant || null,
});
}
// Aufbau: Name über die volle Breite, darunter die Detailfelder,
// darunter die Schaltflächen. Vorher standen Felder und
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
// letzte Knopf aus der Box.
return el("div.add-row", {},
nameField,
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
el("div.add-actions", {},
cameraAvailable()
? el("button.secondary", {
type: "button",
title: "Strichcode scannen",
onclick: scanAndAdd,
}, "Scannen")
: null,
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")),
variantList
);
}
function statusLine() {
const pendingCount = view?.pending_ops || 0;
if (!state.online) {
return el("p.sync-hint.offline", {},
"Offline Änderungen werden gespeichert und später übertragen",
pendingCount ? ` (${pendingCount} wartend).` : ".");
}
if (pendingCount) {
return el("p.sync-hint.pending", {},
pendingCount === 1
? "1 Änderung wird übertragen …"
: `${pendingCount} Änderungen werden übertragen …`);
}
if (state.syncState === "error") {
return el("p.sync-hint.problem", {},
"Der Server ist gerade nicht erreichbar. ",
el("button.linklike", {
type: "button",
onclick: () => refresh({ fromServer: true }),
}, "Erneut versuchen"));
}
return null;
}
// -------------------------------------------------------------------
// Gesamtansicht
// -------------------------------------------------------------------
function render() {
if (!view) {
mount(root, el("p.loading", {}, "Wird geladen …"));
return;
}
const boughtCount = view.markets.reduce(
(sum, m) => sum + m.categories.reduce(
(n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0);
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back }, " Listen"),
el("span.bar-actions", {},
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
// geöffnete App gedruckt werden kann.
el("a.linklike", {
href: `/api/lists/${listId}/print`,
target: "_blank",
rel: "noopener",
}, "Drucken"),
el("button.linklike", { type: "button", onclick: () => prices(listId) },
"Preise"),
el("button.linklike", { type: "button", onclick: () => articles(listId) },
"Artikel"),
el("button.linklike", { type: "button", onclick: () => manage(listId) },
"Märkte & Gruppen"),
meta && meta.may_share_public
? el("button.linklike", { type: "button", onclick: () => share(listId) },
meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen")
: null)),
statusLine(),
banner ? el("p.error", {}, banner) : null,
el("section.card", {},
el("h1", {}, view.list_name),
addRow()),
view.markets.length
? view.markets.map(marketSection)
: el("p.empty", {}, "Die Liste ist leer."),
el("footer.summary", {},
el("div.total", {},
el("span", {}, "Gesamt"),
el("strong", {}, euro(view.grand_total_cents))),
boughtCount
? el("button.secondary", {
type: "button",
onclick: () => change("items.clear_bought", {}),
}, boughtCount === 1
? "1 gekauften Eintrag entfernen"
: `${boughtCount} gekaufte Einträge entfernen`)
: null)
);
}
// -------------------------------------------------------------------
// Start
// -------------------------------------------------------------------
// Erst den lokalen Stand zeigen - die App startet dadurch sofort,
// auch ohne Verbindung.
await readLocal();
render();
// Dann im Hintergrund abgleichen.
await syncNow(listId);
await readLocal();
await loadHints();
render();
// Änderungen anderer Geräte.
watch(listId, async () => {
await readLocal();
render();
});
return () => { readLocal().then(render); };
}

232
web/html/js/views/lists.js Normal file
View File

@@ -0,0 +1,232 @@
// Übersicht aller Listen des angemeldeten Nutzers.
"use strict";
import { del, get, patch, post } from "../api.js";
import * as db from "../db.js";
import { clear, el, mount } from "../dom.js";
import { set, state } from "../store.js";
// Welche Zeile hat gerade ihr Menü offen
let openMenuId = null;
// Welche Zeile wird gerade umbenannt
let renamingId = null;
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
export async function listsView(root, { openList, onLogout, settings }) {
let banner = null;
async function reload() {
set({ lists: await get("/api/lists") });
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
async function guarded(fn) {
try {
await fn();
await reload();
render();
} catch (err) {
say(err.message);
}
}
// -------------------------------------------------------------------
// Eine Zeile
// -------------------------------------------------------------------
function row(entry) {
const isOwner = entry.role === "owner";
if (renamingId === entry.id) {
const field = el("input", {
type: "text",
value: entry.name,
maxLength: 120,
onkeydown: (ev) => {
if (ev.key === "Enter") commit();
if (ev.key === "Escape") { renamingId = null; render(); }
},
});
function commit() {
const name = field.value.trim();
if (!name) {
say("Der Name darf nicht leer sein.");
return;
}
renamingId = null;
if (name === entry.name) {
render();
return;
}
guarded(() => patch(`/api/lists/${entry.id}`, { name }));
}
const node = el("li.list-row.renaming", {},
el("div.row", {}, field,
el("button.primary", { type: "button", onclick: commit }, "Speichern"),
el("button", {
type: "button",
className: "cancel",
onclick: () => { renamingId = null; render(); },
}, "Abbrechen"))
);
queueMicrotask(() => { field.focus(); field.select(); });
return node;
}
const people = entry.member_count === 1
? "nur ich"
: `${entry.member_count} Personen`;
return el("li.list-row", {},
el("div.list-main", {},
el("button.list-open", { type: "button", onclick: () => openList(entry.id) },
el("span.name", {}, entry.name),
el("span.meta", {}, `${people} · ${ROLE_LABEL[entry.role] || entry.role}`)),
el("button.menu-toggle", {
type: "button",
title: "Weitere Aktionen",
"aria-label": `Aktionen für ${entry.name}`,
onclick: () => {
openMenuId = openMenuId === entry.id ? null : entry.id;
render();
},
}, "⋯")),
openMenuId === entry.id
? el("div.list-menu", {},
isOwner
? el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => {
openMenuId = null;
renamingId = entry.id;
render();
},
}, "Umbenennen"),
el("button.danger", {
type: "button",
onclick: () => remove(entry),
}, "Löschen"))
: el("div.menu-actions", {},
el("button.danger", {
type: "button",
onclick: () => leave(entry),
}, "Liste verlassen")))
: null
);
}
function remove(entry) {
const extra = entry.member_count > 1
? `\n\n${entry.member_count - 1} weitere Person(en) verlieren damit den Zugriff.`
: "";
if (!confirm(`Liste „${entry.name}“ löschen?${extra}`)) return;
openMenuId = null;
guarded(async () => {
await del(`/api/lists/${entry.id}`);
// Zwischenspeicher der gelöschten Liste mit aufräumen, sonst
// bleibt ein verwaister Stand in IndexedDB liegen.
await forgetLocally(entry.id);
});
}
function leave(entry) {
if (!confirm(
`Liste „${entry.name}“ verlassen?\n\n` +
"Du siehst sie danach nicht mehr. Der Eigentümer kann dich erneut einladen."
)) return;
openMenuId = null;
guarded(async () => {
await post(`/api/lists/${entry.id}/leave`);
await forgetLocally(entry.id);
});
}
async function forgetLocally(listId) {
await Promise.allSettled([
db.cacheDelete(`view:${listId}`),
db.cacheDelete(`markets:${listId}`),
db.cacheDelete(`categories:${listId}`),
db.cacheDelete(`meta:${listId}`),
db.clearList(listId),
]);
}
// -------------------------------------------------------------------
// Neue Liste
// -------------------------------------------------------------------
function createRow() {
const field = el("input", {
id: "new-list",
type: "text",
maxLength: 120,
placeholder: "z. B. Wocheneinkauf",
onkeydown: (ev) => { if (ev.key === "Enter") create(); },
});
const button = el("button.primary", { type: "button", onclick: () => create() },
"Anlegen");
async function create() {
const name = field.value.trim();
if (!name) {
say("Bitte einen Namen eingeben.");
return;
}
button.disabled = true;
try {
await post("/api/lists", { name });
field.value = "";
await reload();
// Ohne diesen Aufruf blieb die neue Liste unsichtbar, bis die
// Seite neu geladen wurde: Der Zustandsspeicher war aktuell,
// die Ansicht hatte sich aber nie dafür angemeldet.
render();
document.getElementById("new-list")?.focus();
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
}
return el("div.row", {}, field, button);
}
// -------------------------------------------------------------------
// Gesamtansicht
// -------------------------------------------------------------------
function render() {
mount(root,
el("header.bar", {},
el("span.who", {}, state.user.display_name || state.user.email),
el("span.bar-actions", {},
el("button.linklike", { type: "button", onclick: settings }, "Einstellungen"),
el("button.linklike", { type: "button", onclick: onLogout }, "Abmelden"))),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
el("section.card", {},
el("h1", {}, "Meine Listen"),
state.lists.length
? el("ul.lists", {}, state.lists.map(row))
: el("p.empty", {}, "Noch keine Liste vorhanden."),
el("label", { htmlFor: "new-list" }, "Neue Liste"),
createRow())
);
}
await reload();
render();
return render;
}

186
web/html/js/views/manage.js Normal file
View File

@@ -0,0 +1,186 @@
// Märkte und Warengruppen pflegen.
//
// Die Reihenfolge wird gezogen, nicht getippt: `sort_order` verwaltet die
// App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück. Die
// Abstände lassen Platz, falls später einmal einzeln eingefügt werden soll.
"use strict";
import { del, get, post, put } from "../api.js";
import { clear, el, mount } from "../dom.js";
import { makeSortable } from "../sortable.js";
import { set, state } from "../store.js";
const STEP = 10;
export async function manageView(root, { listId, back }) {
let banner = null;
let listName = "";
async function reload() {
const [markets, categories, meta] = await Promise.all([
get(`/api/lists/${listId}/markets`),
get(`/api/lists/${listId}/categories`),
get(`/api/lists/${listId}`),
]);
listName = meta.name;
set({ markets, categories });
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
async function guarded(fn, { silent = false } = {}) {
try {
await fn();
await reload();
render();
} catch (err) {
if (!silent) say(err.message);
await reload().catch(() => {});
render();
}
}
/** Schreibt die neue Reihenfolge zurück - nur die Einträge, deren Rang
* sich tatsächlich geändert hat. */
async function persistOrder(entries, endpoint, orderedIds) {
const byId = new Map(entries.map((e) => [e.id, e]));
const updates = [];
orderedIds.forEach((id, index) => {
const entry = byId.get(id);
const next = (index + 1) * STEP;
if (entry && entry.sort_order !== next) {
updates.push(put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
name: entry.name,
sort_order: next,
}));
}
});
if (!updates.length) return;
await guarded(() => Promise.all(updates));
}
function section({ title, entries, endpoint, hint, emptyText }) {
const nameField = el("input", {
type: "text",
maxLength: 120,
placeholder: "Name",
onkeydown: (ev) => { if (ev.key === "Enter") add(); },
});
function add() {
const name = nameField.value.trim();
if (!name) return;
guarded(async () => {
// Neues ans Ende: ein Schritt hinter dem letzten Rang.
const last = entries.length ? Math.max(...entries.map((e) => e.sort_order)) : 0;
await post(`/api/lists/${listId}/${endpoint}`, {
name,
sort_order: last + STEP,
});
nameField.value = "";
});
}
const list = el("ul.sortable", {});
for (const entry of entries) {
const handle = el("button.grip", {
type: "button",
title: "Zum Verschieben ziehen oder mit den Pfeiltasten bewegen",
"aria-label": `${entry.name} verschieben`,
}, "⠿");
const name = el("input.entry-name", {
type: "text",
value: entry.name,
maxLength: 120,
onchange: (ev) => {
const value = ev.target.value.trim();
if (!value) {
ev.target.value = entry.name;
return;
}
guarded(() => put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
name: value,
sort_order: entry.sort_order,
}));
},
});
const remove = el("button.danger.remove", {
type: "button",
title: `${entry.name} löschen`,
"aria-label": `${entry.name} löschen`,
onclick: () => {
if (!confirm(`${entry.name}“ löschen?`)) return;
guarded(() => del(`/api/lists/${listId}/${endpoint}/${entry.id}`));
},
}, "×");
list.append(el("li.sortable-row", { dataset: { id: entry.id } },
handle, name, remove));
}
if (entries.length > 1) {
makeSortable(list, {
handleSelector: ".grip",
itemSelector: ".sortable-row",
onReorder: (ids) => persistOrder(entries, endpoint, ids),
});
}
return el("section.card", {},
el("h2", {}, title),
hint ? el("p.lead", {}, hint) : null,
entries.length ? list : el("p.empty", {}, emptyText),
el("label", { htmlFor: `add-${endpoint}` }, "Hinzufügen"),
el("div.row", {},
Object.assign(nameField, { id: `add-${endpoint}` }),
el("button.primary", { type: "button", onclick: add }, "Anlegen"))
);
}
function render() {
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste"),
listName ? el("span.who", {}, listName) : null),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
section({
title: "Märkte",
entries: state.markets,
endpoint: "markets",
hint: "Die Reihenfolge bestimmt, in welcher Folge die Märkte in der " +
"Liste und im Ausdruck erscheinen. Am Anfasser ziehen, um sie " +
"zu ändern.",
emptyText: "Noch kein Markt angelegt.",
}),
section({
title: "Warengruppen",
entries: state.categories,
endpoint: "categories",
hint: "Innerhalb eines Marktes wird nach diesen Gruppen gegliedert; " +
"die Artikel darin stehen alphabetisch.",
emptyText: "Noch keine Warengruppe angelegt.",
}),
el("p.footnote", {},
"Ein gelöschter Markt nimmt keine Einträge mit sie landen in der " +
"Gruppe „Ohne Markt“.")
);
}
await reload();
render();
return render;
}

164
web/html/js/views/prices.js Normal file
View File

@@ -0,0 +1,164 @@
// Preisvergleich zwischen Märkten.
//
// Bewusst keine Matrix Artikel × Markt: Bei vier Märkten und dreißig
// Artikeln wird die auf einem Telefon unlesbar. Stattdessen je Artikel
// eine Karte mit den Märkten nach Preis sortiert - man sucht ohnehin
// "wo ist X am günstigsten", nicht "wie sieht die ganze Tabelle aus".
"use strict";
import { get } from "../api.js";
import { el, euro, formatQuantity, mount } from "../dom.js";
let expandedId = null;
function formatDate(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "2-digit",
});
}
/** Preis je Einheit, geliefert in Zehntelcent. */
function unitPrice(deci, unit) {
if (!deci || !unit) return null;
return `${euro(deci / 10)} je ${unit}`;
}
export async function pricesView(root, { listId, back }) {
let data = null;
let banner = null;
let detail = null;
async function reload() {
data = await get(`/api/lists/${listId}/prices`);
}
function say(message) {
banner = message;
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
async function toggle(articleId) {
if (expandedId === articleId) {
expandedId = null;
detail = null;
render();
return;
}
expandedId = articleId;
detail = null;
render();
try {
detail = await get(`/api/lists/${listId}/articles/${articleId}/prices`);
render();
} catch (err) {
say(err.message);
}
}
function marketRow(row, marketId, cents, isBest) {
return el("div", { className: `price-row${isBest ? " best" : ""}` },
el("span.market", {}, data.market_names[marketId] || "?"),
el("span.amount", {}, euro(cents)),
isBest && row.spread_cents > 0
? el("span.badge", {}, "günstigster")
: row.spread_cents > 0
? el("span.diff", {}, `+${euro(cents - row.best_cents)}`)
: null
);
}
function detailBlock() {
if (!detail) return el("p.loading", {}, "Wird geladen …");
return el("div.price-detail", {},
el("h4", {}, "Beobachtungen je Markt"),
el("ul.price-markets", {}, detail.markets.map((m) =>
el("li", {},
el("span.market", {}, m.market_name),
el("span.sub", {},
m.latest_pack_size
? `${formatQuantity(m.latest_pack_size)} ${m.latest_pack_unit || ""} · `
: "",
unitPrice(m.unit_price_deci, m.latest_pack_unit)
? `${unitPrice(m.unit_price_deci, m.latest_pack_unit)} · `
: "",
m.observations === 1
? "einmal erfasst"
: `${m.observations}× erfasst, ${euro(m.min_cents)}${euro(m.max_cents)}`,
` · zuletzt ${formatDate(m.latest_at)}`),
el("span.amount", {}, euro(m.latest_cents))))),
detail.history.length > 1
? el("details.price-history", {},
el("summary", {}, `Verlauf (${detail.history.length} Einträge)`),
el("ul", {}, detail.history.map((h) =>
el("li", {},
`${formatDate(h.recorded_at)} · ${h.market_name} · ${euro(h.price_cents)}`,
h.pack_size
? ` (${formatQuantity(h.pack_size)} ${h.pack_unit || ""})` : ""))))
: null
);
}
function articleCard(row) {
const entries = Object.entries(row.prices).sort((a, b) => a[1] - b[1]);
return el("section.card.price-card", {},
el("button.price-head", {
type: "button",
onclick: () => toggle(row.article_id),
"aria-expanded": expandedId === row.article_id ? "true" : "false",
},
el("span.name", {}, row.article_name),
row.spread_cents > 0
? el("span.spread", {}, `bis ${euro(row.spread_cents)} Unterschied`)
: el("span.spread", {}, "überall gleich")),
el("div.price-rows", {}, entries.map(([marketId, cents]) =>
marketRow(row, marketId, cents, row.best_market_ids.includes(marketId)))),
expandedId === row.article_id ? detailBlock() : null
);
}
function render() {
const withSpread = data.rows.filter((r) => r.spread_cents > 0);
const savings = withSpread.reduce((sum, r) => sum + r.spread_cents, 0);
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste")),
banner ? el("p.error", {}, banner) : null,
el("section.card", {},
el("h1", {}, "Preise"),
data.rows.length
? el("p.lead", {},
`${data.rows.length} Artikel mit erfassten Preisen. `,
withSpread.length
? `Bei ${withSpread.length} davon unterscheiden sich die Märkte ` +
`zusammen ${euro(savings)} Unterschied, wenn jeder Artikel ` +
`im günstigsten Markt gekauft würde.`
: "Bislang gibt es keine Preisunterschiede zwischen den Märkten.")
: el("p.empty", {},
"Noch keine Preise erfasst. Trag beim Einkaufen Preise an den " +
"Artikeln ein sobald ein Eintrag einem Markt zugeordnet ist, " +
"wird der Preis hier gesammelt.")),
data.rows.map(articleCard),
data.rows.length
? el("p.footnote", {},
"Erfasst werden Markt, Artikel, Betrag und Zeitpunkt ohne " +
"Angabe, wer den Preis eingetragen hat.")
: null
);
}
await reload();
render();
return render;
}

143
web/html/js/views/public.js Normal file
View File

@@ -0,0 +1,143 @@
// Öffentliche Ansicht einer Liste über /s/<token>. Kein Konto nötig.
// Nur ansehen und - sofern erlaubt - abhaken. Auf Druck ausgelegt.
"use strict";
import { get, post } from "../api.js";
import { clear, el, euro, formatPack, mount } from "../dom.js";
function formatDate(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export async function publicView(root, { token }) {
let view = null;
let banner = null;
async function load() {
view = await get(`/api/public/${encodeURIComponent(token)}`);
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
function itemRow(item) {
const bought = item.status === "bought";
const check = view.allow_check
? el("button.check", {
type: "button",
"aria-pressed": bought ? "true" : "false",
title: bought ? "Als offen markieren" : "Als gekauft markieren",
onclick: async () => {
const next = bought ? "open" : "bought";
item.status = next; // sofort anzeigen
render();
try {
await post(`/api/public/${encodeURIComponent(token)}/items/${item.id}`,
{ status: next });
await load();
} catch (err) {
await load().catch(() => {});
say(err.message);
}
render();
},
}, bought ? "✓" : "")
: el("span.check.readonly", { "aria-hidden": "true" }, bought ? "✓" : "");
const pack = formatPack(item.pack_size, item.pack_unit);
return el("li", { className: `item${bought ? " bought" : ""}` },
el("div.item-main", {},
check,
el("span", {
className: `item-count${item.count > 1 ? " many" : ""}`,
}, `${item.count || 1}\u00d7`),
el("div.item-text", {},
el("span.name", {}, item.article_name),
pack || item.variant || item.note
? el("span.sub", {},
[pack, item.variant, item.note].filter(Boolean).join(" · "))
: null),
item.price_cents
? el("span.price-static", {},
euro(item.total_cents ?? item.price_cents))
: null
)
);
}
function render() {
mount(root,
el("header.public-head", {},
el("h1", {}, view.list_name),
el("p.sub", {},
"Geteilte Ansicht",
view.allow_check ? " du kannst Artikel abhaken." : " nur zum Ansehen.",
` Gültig bis ${formatDate(view.expires_at)}.`)
),
banner ? el("p.error", {}, banner.message) : null,
view.markets.length
? view.markets.map((market) =>
el("section.market", {},
el("h2", {},
el("span", {}, market.market_name),
el("span.market-meta", {},
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
market.categories.map((cat) =>
el("div.category", {},
el("h3", {}, cat.category_name),
el("ul.items", {}, cat.items.map(itemRow))))))
: el("p.empty", {}, "Die Liste ist leer."),
el("footer.summary", {},
el("div.total", {},
el("span", {}, "Gesamt"),
el("strong", {}, euro(view.grand_total_cents)))),
el("p.footnote.no-print", {},
// Eigene Druckseite statt window.print(): Sie ist auf A4
// ausgelegt und funktioniert auch, wenn der Empfänger die
// Ansicht nur weiterleiten will.
el("a.linklike", {
href: `/api/public/${encodeURIComponent(token)}/print`,
target: "_blank",
rel: "noopener",
}, "Drucken"),
" · ",
el("button.linklike", {
type: "button",
onclick: async () => {
try {
await load();
render();
} catch (err) {
say(err.message);
}
},
}, "Aktualisieren"))
);
}
try {
await load();
} catch (err) {
mount(root,
el("section.card", {},
el("h1", {}, "Liste nicht verfügbar"),
el("p.error", {}, err.message),
el("p.lead", {},
"Bitte die Person, die den Link geteilt hat, um einen neuen."))
);
return;
}
render();
}

View File

@@ -0,0 +1,110 @@
// Bestätigungsdialog nach einem Scan.
//
// Ersetzt das frühere prompt(): Dort ließ sich nur ein Name eingeben,
// und die Herkunft der Angaben war nicht erkennbar. Wenn Daten aus einer
// fremden Quelle vorbelegt werden, soll man sehen, woher sie stammen -
// und sie ändern können, bevor sie im eigenen Bestand landen.
"use strict";
import { el } from "../dom.js";
/**
* @param {object} lookup Antwort von /api/lists/{id}/barcode/{code}
* @returns {Promise<{name: string, quantity: string, unit: string, variant: string}|null>}
*/
export function confirmScan(lookup) {
return new Promise((resolve) => {
const known = lookup.found && lookup.source === "openfoodfacts";
const nameField = el("input", {
type: "text",
maxLength: 200,
value: lookup.name || "",
placeholder: "Wie heißt der Artikel?",
onkeydown: (ev) => { if (ev.key === "Enter") accept(); },
});
const countField = el("input.count", {
type: "text",
inputMode: "numeric",
value: lookup.count != null ? String(lookup.count) : "",
placeholder: "Anz.",
title: "Stückzahl",
});
const qtyField = el("input.qty", {
type: "text",
inputMode: "decimal",
value: lookup.pack_size != null
? String(lookup.pack_size).replace(".", ",") : "",
placeholder: "Gebinde",
title: "Packungsgröße",
});
const unitField = el("input.unit", {
type: "text",
maxLength: 32,
value: lookup.pack_unit || "",
placeholder: "Einheit",
});
const variantField = el("input.variant", {
type: "text",
maxLength: 200,
value: lookup.brand || "",
placeholder: "Eigenschaft",
});
function accept() {
const name = nameField.value.trim();
if (!name) {
nameField.focus();
return;
}
close({
name,
count: countField.value,
packSize: qtyField.value,
packUnit: unitField.value.trim(),
variant: variantField.value.trim(),
});
}
const dialog = el("div.modal", {},
el("div.modal-card", {},
el("h2", {}, known ? "Produkt gefunden" : "Neuer Artikel"),
el("p.sub", {}, `Strichcode ${lookup.barcode}`),
known
? el("p.source-note", {},
"Vorgeschlagen aus Open Food Facts",
lookup.package ? ` Packung: ${lookup.package}` : "",
". Bitte prüfen und bei Bedarf anpassen; übernommen wird, " +
"was hier steht.")
: el("p.lead", {},
"Zu diesem Code liegen keine Angaben vor. Trag den Namen ein " +
"beim nächsten Scan wird der Artikel dann sofort gefunden."),
el("label", {}, "Name"), nameField,
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
el("div.menu-actions", {},
el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),
el("button", { type: "button", onclick: () => close(null) }, "Abbrechen"))
)
);
function close(result) {
dialog.remove();
window.removeEventListener("keydown", onKey);
document.body.classList.remove("modal-open");
resolve(result);
}
function onKey(ev) {
if (ev.key === "Escape") close(null);
}
window.addEventListener("keydown", onKey);
document.body.classList.add("modal-open");
document.body.append(dialog);
nameField.focus();
nameField.select();
});
}

Some files were not shown because too many files have changed in this diff Show More