diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4de427 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 77575d5..8806507 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 67f8c65..0000000 --- a/LICENSE +++ /dev/null @@ -1,232 +0,0 @@ -GNU GENERAL PUBLIC LICENSE -Version 3, 29 June 2007 - -Copyright © 2007 Free Software Foundation, Inc. - -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 . - -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 . - -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 . diff --git a/README.md b/README.md index 3548433..9bcfb0d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,1156 @@ -# einkaufsapp +# Einkaufsapp – Phase 0 & 1 -Plattformunabhängige Einkaufsapp \ No newline at end of file +Backend-Grundgerüst mit Registrierung, Mailverifikation, Login und +Administrationsschalter, dazu nginx als einziger Eintrittspunkt. + +## Ports + +Nach außen ist genau **ein** Port offen: `HTTP_PORT` aus der `.env`, +voreingestellt `46600`. Dahinter liegt nginx und verteilt: + +| Pfad | Ziel | +|---|---| +| `/` | statische Dateien (ab Phase 3 die gebaute PWA) | +| `/api/…` | FastAPI | +| `/docs`, `/openapi.json` | FastAPI-Dokumentation | +| `/healthz`, `/readyz` | Statusprüfungen | + +Der `api`-Container hat keinen veröffentlichten Port. Wenn du ihn zum +Debuggen doch direkt erreichen willst, leg eine +`docker-compose.override.yml` an: + +```yaml +services: + api: + ports: + - "127.0.0.1:8000:8000" +``` + +Die Bindung an `127.0.0.1` ist wichtig – sonst hängt eine ungeschützte API +am öffentlichen Interface. + +## Start + +```bash +cp .env.example .env +# DB_PASSWORD, DB_ROOT_PASSWORD und ADMIN_EMAIL setzen + +docker compose --profile dev up -d --build +docker compose ps +docker compose logs -f api web +``` + +Erwartete Log-Ausgabe: + +``` +[entrypoint] Warte auf Datenbank db:3306 ... +[wait_for_db] Datenbank erreichbar. +[entrypoint] Migrationen einspielen ... +INFO [alembic.runtime.migration] Running upgrade -> 0001 +[entrypoint] Starte API. +INFO: Application startup complete. +``` + +### Ersteinrichtung im Browser + +1. `ADMIN_INITIAL_PASSWORD` in der `.env` setzen +2. `docker compose up -d` – **nicht** `restart`: ein Neustart übernimmt keine + geänderten Werte aus der `.env`, der Container muss neu angelegt werden +3. `http://:46600/` aufrufen, mit `ADMIN_EMAIL` und dem Startpasswort + anmelden +4. Die Oberfläche verlangt sofort ein neues Passwort – erst danach ist alles + andere erreichbar +5. `ADMIN_INITIAL_PASSWORD` wieder aus der `.env` entfernen + +Ohne Startpasswort führt derselbe Weg über „Konto anlegen"; dann brauchst du +für die Bestätigungsmail allerdings einen funktionierenden SMTP-Relay oder +Mailpit. + +Oberfläche: `http://:46600/` +Interaktive API-Dokumentation: `http://:46600/docs` +Abgefangene Mails (nur mit `--profile dev`): `http://localhost:8025` + +## Durchklicken + +```bash +curl -s localhost:46600/readyz + +# 1. Registrieren +curl -s -X POST localhost:46600/api/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}' + +# 2. Link aus Mailpit (http://localhost:8025) im Browser öffnen +# oder: curl -s "localhost:46600/api/auth/verify?token=" + +# 3. Anmelden – Cookies in eine Datei schreiben +curl -s -c cookies.txt -X POST localhost:46600/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@example.com","password":"ein-langes-testpasswort"}' + +# 4. Eigenes Profil lesen (nur Session-Cookie nötig) +curl -s -b cookies.txt localhost:46600/api/auth/me + +# 5. Schreibender Request braucht zusätzlich den CSRF-Header +CSRF=$(grep ea_csrf cookies.txt | awk '{print $7}') +curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \ + -X PUT localhost:46600/api/admin/settings \ + -H 'Content-Type: application/json' \ + -d '{"allow_self_registration": false}' +``` + +Schritt 5 ohne den Header muss mit `403 CSRF-Token fehlt oder ungültig` +abgewiesen werden – das ist der Test, ob der Schutz greift. + +### Mailversand prüfen + +```bash +# Verbindung zum Relay, ohne etwas zu senden +curl -s -b cookies.txt localhost:46600/api/admin/mail/check + +# Testnachricht +curl -s -b cookies.txt -H "X-CSRF-Token: $CSRF" \ + -X POST localhost:46600/api/admin/mail/test \ + -H 'Content-Type: application/json' \ + -d '{"to":"dein-postfach@example.de"}' + +docker compose logs --tail=20 api +``` + +## Was hier absichtlich so ist + +**Erstes Administratorkonto – zwei Wege.** + +*Ohne Startpasswort* (`ADMIN_INITIAL_PASSWORD` leer): Du registrierst dich +regulär über `POST /api/auth/register`. Stimmt die Adresse mit `ADMIN_EMAIL` +überein, wird das Konto zum Administrator. Kein Passwort liegt jemals in der +Umgebung – dafür brauchst du für die Bestätigung entweder Mailpit oder ein +`UPDATE` in der Datenbank. + +*Mit Startpasswort*: Setz `ADMIN_INITIAL_PASSWORD` in der `.env`. Beim ersten +Start legt die Anwendung das Konto an, bestätigt es sofort und markiert es mit +`must_change_password`. Bis das Passwort geändert ist, antworten alle Routen +außer `GET /api/auth/me` und `POST /api/auth/password/change` mit `403`. + +Die Absicherungen sind bewusst eng: Das Konto entsteht nur, wenn die Datenbank +noch **gar keinen** Nutzer enthält. Ein vergessener Eintrag in der `.env` kann +damit weder ein bestehendes Konto überschreiben noch ein zweites Admin-Konto +nachschieben – die Anwendung protokolliert stattdessen eine Warnung. + +Trotzdem: Nach der ersten Anmeldung gehört der Wert aus der `.env` entfernt. +Er steht dort im Klartext, liegt in jedem Backup und ist über +`docker inspect einkaufsapp_api` für jeden lesbar, der Zugriff auf den Host +hat. Für Docker Secrets gibt es `ADMIN_INITIAL_PASSWORD_FILE`. + +**Registrierung antwortet immer gleich.** Ob eine Adresse schon existiert, +verrät die API nicht – weder über den Statuscode noch über die Laufzeit. Beim +Login läuft die Passwortprüfung auch bei unbekanntem Konto gegen einen +Dummy-Hash, damit die Antwortzeit konstant bleibt. + +**Rate Limiting sowohl pro IP als auch pro Konto.** Nur pro IP zu begrenzen +hilft nicht gegen verteilte Angriffe auf ein einzelnes Konto; nur pro Konto zu +begrenzen macht das Aussperren fremder Nutzer trivial. + +**`restart: "no"` beim API-Container.** Während der Entwicklung soll ein +Startfehler zu einem stehenden Container führen, nicht zu einer Endlosschleife, +die das Log flutet. Für den Produktivbetrieb auf `unless-stopped` ändern. + +**Selbstregistrierung: drei Zustände statt zwei.** `ALLOW_SELF_REGISTRATION` +kennt `true`, `false` und `admin`. Bei `true`/`false` ist der Wert fest +verdrahtet, die Admin-API antwortet auf Änderungsversuche mit `409` und nennt +den Grund. Bei `admin` entscheidet die Datenbankeinstellung und der +Administrator darf zur Laufzeit umschalten. `SELF_REGISTRATION_DEFAULT` ist +dann der Startwert beim allerersten Start. + +Die Alternative wäre gewesen, die Umgebungsvariable bei jedem Neustart über die +Admin-Einstellung schreiben zu lassen – dann wäre eine Abschaltung nach dem +nächsten `docker compose up` still wieder verschwunden. + +**SMTP-Relay.** `SMTP_SECURITY` ersetzt die vorherigen Flags `SMTP_STARTTLS` +und `SMTP_SSL`; erlaubt sind `none`, `starttls` und `ssl`. Beim Start prüft die +Anwendung, dass keine Zugangsdaten über eine unverschlüsselte Verbindung gehen, +und bricht sonst mit einer klaren Meldung ab. Details zu SPF, DKIM, DMARC und +PTR stehen in `docs/mail-zustellbarkeit.md`. + +**Ein Port statt zwei.** Oberfläche und API teilen sich dieselbe Origin. +Damit entfällt CORS vollständig, Cookies verhalten sich vorhersagbar, und der +Service Worker in Phase 4 kann API-Antworten ohne Sonderregeln cachen. Zwei +Ports hätten drei zusätzliche Fehlerquellen eingeführt, ohne etwas zu gewinnen. + +**`COOKIE_SECURE` muss zum Zugriffsweg passen.** Bei direktem HTTP-Zugriff auf +Port 46600 gehört `false` in die `.env`. Steht ein Reverse Proxy mit TLS davor +– bei dir vermutlich OPNsense –, dann `true` und `PUBLIC_BASE_URL` auf +`https://…`. Ein `Secure`-Cookie über reines HTTP wird vom Browser verworfen, +und die Anmeldung scheitert dann ohne verständliche Fehlermeldung. + +**CSP ohne `unsafe-inline`.** Die Platzhalterseite lagert deshalb CSS und +JavaScript in eigene Dateien aus. Wenn du dort etwas ergänzt: Inline-` + + +""") + + +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, + ) diff --git a/backend/app/product_lookup.py b/backend/app/product_lookup.py new file mode 100644 index 0000000..32c40ee --- /dev/null +++ b/backend/app/product_lookup.py @@ -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\d+)\s*[x×*]\s*(?P\d+(?:[.,]\d+)?)\s*(?P{_UNITS})\b", + re.IGNORECASE, +) + +# "500 g", "1,5 l", "250ml" +_QUANTITY = re.compile( + rf"(?P\d+(?:[.,]\d+)?)\s*(?P{_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 diff --git a/backend/app/push.py b/backend/app/push.py new file mode 100644 index 0000000..fd3b7d1 --- /dev/null +++ b/backend/app/push.py @@ -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() diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..c9daa52 --- /dev/null +++ b/backend/app/routers/admin.py @@ -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." + ) diff --git a/backend/app/routers/appinfo.py b/backend/app/routers/appinfo.py new file mode 100644 index 0000000..6389372 --- /dev/null +++ b/backend/app/routers/appinfo.py @@ -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"}, + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..9faecd9 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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.") diff --git a/backend/app/routers/catalog.py b/backend/app/routers/catalog.py new file mode 100644 index 0000000..3a7e6d8 --- /dev/null +++ b/backend/app/routers/catalog.py @@ -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." + ) diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py new file mode 100644 index 0000000..2bf80d3 --- /dev/null +++ b/backend/app/routers/items.py @@ -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 + ) diff --git a/backend/app/routers/lists.py b/backend/app/routers/lists.py new file mode 100644 index 0000000..202388f --- /dev/null +++ b/backend/app/routers/lists.py @@ -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.") diff --git a/backend/app/routers/prices.py b/backend/app/routers/prices.py new file mode 100644 index 0000000..a365835 --- /dev/null +++ b/backend/app/routers/prices.py @@ -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) diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py new file mode 100644 index 0000000..50a12c4 --- /dev/null +++ b/backend/app/routers/public.py @@ -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.") diff --git a/backend/app/routers/push.py b/backend/app/routers/push.py new file mode 100644 index 0000000..14f9cae --- /dev/null +++ b/backend/app/routers/push.py @@ -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." + ) diff --git a/backend/app/routers/sharing.py b/backend/app/routers/sharing.py new file mode 100644 index 0000000..5283f03 --- /dev/null +++ b/backend/app/routers/sharing.py @@ -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.") diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py new file mode 100644 index 0000000..cbb8d22 --- /dev/null +++ b/backend/app/routers/sync.py @@ -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", + }, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..16faa5c --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/schemas_admin.py b/backend/app/schemas_admin.py new file mode 100644 index 0000000..595853d --- /dev/null +++ b/backend/app/schemas_admin.py @@ -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 diff --git a/backend/app/schemas_push.py b/backend/app/schemas_push.py new file mode 100644 index 0000000..9ccfb21 --- /dev/null +++ b/backend/app/schemas_push.py @@ -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 diff --git a/backend/app/schemas_shopping.py b/backend/app/schemas_shopping.py new file mode 100644 index 0000000..333be3d --- /dev/null +++ b/backend/app/schemas_shopping.py @@ -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] diff --git a/backend/app/schemas_sync.py b/backend/app/schemas_sync.py new file mode 100644 index 0000000..f9b3f0f --- /dev/null +++ b/backend/app/schemas_sync.py @@ -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] diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..0e3bdae --- /dev/null +++ b/backend/app/security.py @@ -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) diff --git a/backend/app/users.py b/backend/app/users.py new file mode 100644 index 0000000..3e62b8e --- /dev/null +++ b/backend/app/users.py @@ -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} diff --git a/backend/app/wait_for_db.py b/backend/app/wait_for_db.py new file mode 100644 index 0000000..3d49f88 --- /dev/null +++ b/backend/app/wait_for_db.py @@ -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()) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..2799423 --- /dev/null +++ b/backend/entrypoint.sh @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2735791 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..fd7045b --- /dev/null +++ b/db/schema.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f389c1d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/betrieb.md b/docs/betrieb.md new file mode 100644 index 0000000..492ac52 --- /dev/null +++ b/docs/betrieb.md @@ -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. diff --git a/docs/mail-zustellbarkeit.md b/docs/mail-zustellbarkeit.md new file mode 100644 index 0000000..c0e95f9 --- /dev/null +++ b/docs/mail-zustellbarkeit.md @@ -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 | diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..1bccc6d --- /dev/null +++ b/docs/reverse-proxy.md @@ -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= +``` + +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/` bedeutet es, dass der Token nur an die eigene Herkunft übertragen +würde. Wer es strenger mag, stellt die OPNsense auf `no-referrer` um. diff --git a/docs/sicherheit.md b/docs/sicherheit.md new file mode 100644 index 0000000..fdc7c2f --- /dev/null +++ b/docs/sicherheit.md @@ -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 `` – 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`. diff --git a/render_view.py b/render_view.py new file mode 100644 index 0000000..3ad4f06 --- /dev/null +++ b/render_view.py @@ -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') diff --git a/smoke-phase2.sh b/smoke-phase2.sh new file mode 100644 index 0000000..3c13be6 --- /dev/null +++ b/smoke-phase2.sh @@ -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 - 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 < /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 -X DELETE $BASE/api/lists/$LIST -H 'X-CSRF-Token: ...'" diff --git a/tools/check-all.sh b/tools/check-all.sh new file mode 100644 index 0000000..d581458 --- /dev/null +++ b/tools/check-all.sh @@ -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 diff --git a/tools/check-env.sh b/tools/check-env.sh new file mode 100644 index 0000000..6af133e --- /dev/null +++ b/tools/check-env.sh @@ -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 diff --git a/tools/check-js.mjs b/tools/check-js.mjs new file mode 100644 index 0000000..873ea40 --- /dev/null +++ b/tools/check-js.mjs @@ -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(/(? !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); diff --git a/tools/check-nginx.py b/tools/check-nginx.py new file mode 100644 index 0000000..0831be0 --- /dev/null +++ b/tools/check-nginx.py @@ -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)) diff --git a/tools/check-routes.py b/tools/check-routes.py new file mode 100644 index 0000000..664236e --- /dev/null +++ b/tools/check-routes.py @@ -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") diff --git a/tools/check-schema.py b/tools/check-schema.py new file mode 100644 index 0000000..75a8911 --- /dev/null +++ b/tools/check-schema.py @@ -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()) diff --git a/tools/test-barcode.mjs b/tools/test-barcode.mjs new file mode 100644 index 0000000..85cc444 --- /dev/null +++ b/tools/test-barcode.mjs @@ -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); diff --git a/tools/vapid-keys.py b/tools/vapid-keys.py new file mode 100644 index 0000000..e9179c2 --- /dev/null +++ b/tools/vapid-keys.py @@ -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()) diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..049ad62 --- /dev/null +++ b/web/Dockerfile @@ -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 diff --git a/web/html/app.css b/web/html/app.css new file mode 100644 index 0000000..8548534 --- /dev/null +++ b/web/html/app.css @@ -0,0 +1,1245 @@ +:root { + --bg: #f6f6f4; + --fg: #1c1c1a; + --muted: #6b6b66; + --card: #ffffff; + --line: #dedcd6; + --accent: #2f6f4e; + --accent-fg: #ffffff; + --error: #a32a1f; + --warn-bg: #fdf3e0; + --warn-line: #e4c98a; + --radius: 10px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #16161a; + --fg: #e8e8e4; + --muted: #9a9a94; + --card: #202027; + --line: #34343c; + --accent: #58a37a; + --accent-fg: #10130f; + --error: #e88a80; + --warn-bg: #2e2718; + --warn-line: #6b5a2e; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.55; + font-size: 16px; +} + +#app { + max-width: 30rem; + margin: 0 auto; + padding: 1.5rem 1rem calc(2rem + env(safe-area-inset-bottom)); +} + +.loading { color: var(--muted); text-align: center; padding: 3rem 0; } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.5rem 1.25rem; + margin-bottom: 1rem; +} + +h1 { font-size: 1.35rem; margin: 0 0 .5rem; } + +.lead { color: var(--muted); margin: 0 0 1.25rem; } + +.lead.warn { + color: var(--fg); + background: var(--warn-bg); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: .75rem .9rem; +} + +label { + display: block; + font-weight: 600; + font-size: .9rem; + margin: 1rem 0 .3rem; +} + +.hint { font-weight: 400; color: var(--muted); } + +input[type="email"], +input[type="password"], +input[type="text"] { + width: 100%; + padding: .7rem .8rem; + font-size: 1rem; + font-family: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +button { + font: inherit; + cursor: pointer; + border-radius: var(--radius); + border: 1px solid transparent; + padding: .7rem 1.1rem; +} + +button.primary { + width: 100%; + margin-top: 1.25rem; + background: var(--accent); + color: var(--accent-fg); + font-weight: 600; +} + +button.primary:disabled { opacity: .55; cursor: progress; } + +.row { display: flex; gap: .5rem; } +.row input { flex: 1; } +.row button.primary { width: auto; margin-top: 0; flex-shrink: 0; } + +.error, .notice { + margin: .9rem 0 0; + padding: .6rem .8rem; + border-radius: var(--radius); + font-size: .92rem; +} + +.error { color: var(--error); border: 1px solid currentColor; } +.notice { color: var(--accent); border: 1px solid currentColor; } + +.switch { margin: 1rem 0 0; text-align: center; font-size: .92rem; } +a { color: var(--accent); } + +.bar { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; + margin-bottom: 1rem; + font-size: .92rem; +} + +.who { color: var(--muted); overflow-wrap: anywhere; } + +.lists { list-style: none; margin: 0 0 1rem; padding: 0; } + +.lists li { + display: flex; + justify-content: space-between; + gap: .75rem; + padding: .65rem 0; + border-bottom: 1px solid var(--line); +} + +.lists li:last-child { border-bottom: none; } +.lists .meta { color: var(--muted); font-size: .85rem; white-space: nowrap; } +.empty { color: var(--muted); } + +.footnote { + color: var(--muted); + font-size: .85rem; + text-align: center; + margin-top: 1.5rem; +} + +/* =================================================================== + Phase 3: Listenansicht + =================================================================== */ + +a.linklike, +button.linklike { + background: none; + border: none; + padding: 0; + color: var(--accent); + font: inherit; + text-align: left; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +button.secondary { + background: var(--card); + border: 1px solid var(--line); + color: var(--fg); + font-weight: 600; +} + +button.danger { + background: none; + border: 1px solid var(--line); + color: var(--error); + padding: .4rem .7rem; +} + +.offline-hint { + background: var(--warn-bg); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: .6rem .8rem; + margin: 0 0 1rem; + font-size: .9rem; +} + +/* --- Artikel hinzufügen --- */ +.add-row { margin-top: .5rem; } +.add-row > input { margin-bottom: .5rem; } +.add-row .qty { flex: 0 0 5.5rem; } +.add-row .unit { flex: 0 0 6rem; } + +/* --- Markt- und Gruppenabschnitte --- */ +.market { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + margin-bottom: 1rem; + overflow: hidden; +} + +.market > h2 { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: .75rem; + margin: 0; + padding: .8rem 1rem; + font-size: 1.05rem; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-bottom: 1px solid var(--line); +} + +.market-meta { + font-size: .82rem; + font-weight: 400; + color: var(--muted); + white-space: nowrap; +} + +.category { padding: 0 0 .25rem; } + +.category > h3 { + margin: .75rem 1rem .25rem; + font-size: .8rem; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} + +ul.items { list-style: none; margin: 0; padding: 0; } + +li.item { border-top: 1px solid var(--line); } +.category > ul.items > li.item:first-child { border-top: none; } + +.item-main { + display: flex; + align-items: center; + gap: .6rem; + padding: .55rem 1rem; +} + +/* Große Trefferfläche: die App wird einhändig im Laden bedient. + padding: 0 ist entscheidend - die allgemeine button-Regel setzt + 1,1rem waagerecht, und das ist mehr als die vorgesehenen 2rem + Breite. Der Knopf wurde dadurch auseinandergezogen und wirkte oval. + aspect-ratio hält ihn auch dann kreisrund, wenn eine Schrift die + Zeilenhöhe verändert. */ +button.check { + flex: 0 0 2.1rem; + width: 2.1rem; + height: 2.1rem; + aspect-ratio: 1; + padding: 0; + border: 2px solid var(--line); + border-radius: 50%; + background: var(--bg); + color: var(--accent-fg); + font-size: 1.05rem; + font-weight: 700; + line-height: 1; + display: grid; + place-items: center; +} + +button.check[aria-pressed="true"] { + background: var(--accent); + border-color: var(--accent); +} + +.item-text { flex: 1; min-width: 0; } +.item-text .name { display: block; overflow-wrap: anywhere; } +.item-text .sub { display: block; font-size: .82rem; color: var(--muted); } + +li.item.bought .item-text .name { + text-decoration: line-through; + color: var(--muted); +} + +li.item.deferred { opacity: .6; } +li.item.deferred .item-text .name { font-style: italic; } + +.price-cell { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: .1rem; +} + +input.price { + width: 4.5rem; + padding: .4rem .5rem; + text-align: right; + font-size: .9rem; +} + +/* Zeilensumme bei mehreren Stück: macht sichtbar, dass der eingetragene + Preis je Gebinde gilt. */ +.line-total { + font-size: .75rem; + color: var(--muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +button.menu-toggle { + flex: 0 0 auto; + background: none; + border: none; + color: var(--muted); + font-size: 1.2rem; + line-height: 1; + padding: .3rem .4rem; +} + +.item-menu { + padding: .25rem 1rem 1rem 3.6rem; + background: color-mix(in srgb, var(--fg) 4%, transparent); +} + +.item-menu label { + margin: .6rem 0 .2rem; + font-size: .8rem; +} + +.item-menu select { + width: 100%; + padding: .5rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +.menu-actions { + display: flex; + gap: .5rem; + margin-top: .9rem; +} + +.menu-actions button { + flex: 1; + background: var(--card); + border: 1px solid var(--line); + color: var(--fg); + font-size: .9rem; +} + +.menu-actions button.danger { color: var(--error); } + +/* --- Fußzeile mit Summe --- */ +.summary { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1rem; +} + +.summary .total { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 1.1rem; +} + +.summary button { width: 100%; margin-top: .9rem; } + +/* --- Verwaltung von Märkten und Warengruppen --- */ + +ul.sortable { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; +} + +li.sortable-row { + display: flex; + align-items: center; + gap: .4rem; + padding: .3rem 0; + background: var(--card); + /* Beim Ziehen wird transform gesetzt - ohne relative Positionierung + würde die Zeile hinter ihren Nachbarn verschwinden. */ + position: relative; + border-radius: var(--radius); +} + +/* Anfasser: feste Breite, der Rest teilt sich den Platz. */ +button.grip { + flex: 0 0 2rem; + height: 2.4rem; + padding: 0; + background: none; + border: 1px solid transparent; + color: var(--muted); + font-size: 1.1rem; + line-height: 1; + cursor: grab; + /* Ohne das scrollt die Seite, statt die Zeile zu ziehen. */ + touch-action: none; + user-select: none; +} + +button.grip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-radius: var(--radius); +} + +li.sortable-row input.entry-name { + flex: 1 1 auto; + min-width: 0; +} + +button.remove { + flex: 0 0 2.2rem; + padding: .45rem 0; + text-align: center; + font-size: 1.1rem; + line-height: 1; +} + +/* Während des Ziehens */ +ul.sortable.sorting { cursor: grabbing; } +ul.sortable.sorting button.grip { cursor: grabbing; } +ul.sortable.sorting li.sortable-row:not(.dragging) { transition: transform .12s ease; } + +li.sortable-row.dragging { + z-index: 2; + box-shadow: 0 4px 14px rgba(0, 0, 0, .18); + opacity: .95; +} + +li.sortable-row.dragging button.grip { color: var(--accent); } + +/* --- Druck: A4, gruppiert, mit Kästchen zum Abhaken --- */ +@media print { + @page { size: A4; margin: 15mm; } + + body { background: #fff; color: #000; font-size: 11pt; } + #app { max-width: none; padding: 0; } + + .bar, .add-row, .summary button, button.menu-toggle, + .item-menu, .offline-hint, .footnote, label, .row { display: none !important; } + + .card, .market, .summary { + border: none; + background: none; + padding: 0; + margin: 0 0 6mm; + } + + .market { break-inside: avoid; } + .market > h2 { background: none; border-bottom: 1pt solid #000; padding: 0 0 1mm; } + .category > h3 { margin: 3mm 0 1mm; color: #000; } + + li.item { border-top: none; } + .item-main { padding: .5mm 0; } + + /* Leeres Kästchen statt der Schaltfläche */ + button.check { + width: 4mm; height: 4mm; + border: .5pt solid #000; border-radius: 0; + background: none; color: #000; font-size: 8pt; + } + + input.price { + border: none; background: none; width: 18mm; + border-bottom: .5pt dotted #666; + } +} + +/* =================================================================== + Phase 5: Teilen + =================================================================== */ + +.bar-actions { display: flex; gap: 1rem; } + +select { + width: 100%; + padding: .6rem .5rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +ul.share-list { list-style: none; margin: 0; padding: 0; } + +ul.share-list > li { + padding: .8rem 0; + border-bottom: 1px solid var(--line); +} + +ul.share-list > li:last-child { border-bottom: none; } + +.who-block .name { display: block; font-weight: 600; overflow-wrap: anywhere; } +.who-block .sub { display: block; font-size: .82rem; color: var(--muted); } + +select.role { margin-top: .5rem; } + +.member-actions { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-top: .6rem; +} + +.member-actions button { + flex: 1 1 auto; + background: var(--card); + border: 1px solid var(--line); + color: var(--fg); + font-size: .85rem; + padding: .45rem .7rem; +} + +.member-actions button.danger { color: var(--error); } + +button.danger.wide { + width: 100%; + margin-top: .5rem; + padding: .7rem; + font-weight: 600; +} + +li.invite.revoked .who-block .name, +li.invite.expired .who-block .name { + color: var(--muted); + text-decoration: line-through; +} + +li.invite.accepted .who-block .name { color: var(--muted); } + +@media print { + .bar-actions, .member-actions, .share-list { display: none !important; } +} + +/* =================================================================== + Öffentliche Ansicht (/s/) + =================================================================== */ + +.public-head { margin-bottom: 1rem; } +.public-head h1 { margin-bottom: .2rem; } +.public-head .sub { margin: 0; color: var(--muted); font-size: .88rem; } + +span.check.readonly { + flex: 0 0 2.1rem; + width: 2.1rem; + height: 2.1rem; + aspect-ratio: 1; + border: 2px solid var(--line); + border-radius: 50%; + display: grid; + place-items: center; + color: var(--accent); + font-size: 1.05rem; + font-weight: 700; +} + +.price-static { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + color: var(--muted); + font-size: .9rem; +} + +label.checkline { + display: flex; + align-items: center; + gap: .5rem; + font-weight: 400; + margin: .7rem 0 0; +} + +label.checkline input[type="checkbox"] { + width: 1.1rem; + height: 1.1rem; + accent-color: var(--accent); + flex: 0 0 auto; +} + +.fresh-link { + background: var(--warn-bg); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: .9rem; + margin-bottom: 1rem; +} + +.fresh-link p { margin: 0 0 .5rem; } +.fresh-link .sub { font-size: .82rem; color: var(--muted); margin: .7rem 0 0; } + +input.linkfield { + width: 100%; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .82rem; + padding: .55rem; +} + +input[type="date"] { + width: 100%; + padding: .65rem .5rem; + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +@media print { + .public-head .sub, .no-print { display: none !important; } + span.check.readonly { + width: 4mm; height: 4mm; + border: .5pt solid #000; border-radius: 0; + font-size: 8pt; + } + .price-static { color: #000; } +} + +/* =================================================================== + Phase 4: Synchronisationszustand + =================================================================== */ + +.sync-hint { + margin: 0 0 1rem; + padding: .55rem .8rem; + border-radius: var(--radius); + font-size: .88rem; + border: 1px solid; +} + +.sync-hint.offline { + background: var(--warn-bg); + border-color: var(--warn-line); + color: var(--fg); +} + +.sync-hint.pending { + border-color: var(--line); + color: var(--muted); +} + +.sync-hint.problem { + border-color: var(--error); + color: var(--error); +} + +/* Noch nicht bestätigte Änderungen kennzeichnen - ohne sie zu verstecken. + Der Nutzer soll sehen, dass die Änderung da ist, aber auch, dass sie + noch unterwegs ist. */ +li.item.pending .item-text .name::after { + content: " ·"; + color: var(--accent); + font-weight: 700; +} + +li.item.pending { background: color-mix(in srgb, var(--accent) 5%, transparent); } + +button.check:disabled { opacity: .4; cursor: default; } + +@media print { + .sync-hint { display: none !important; } + li.item.pending { background: none; } + li.item.pending .item-text .name::after { content: ""; } +} + +/* =================================================================== + Listenübersicht mit Aktionen + =================================================================== */ + +ul.lists > li.list-row { + display: block; + padding: 0; + border-bottom: 1px solid var(--line); +} + +ul.lists > li.list-row:last-child { border-bottom: none; } + +.list-main { + display: flex; + align-items: center; + gap: .5rem; +} + +button.list-open { + flex: 1; + min-width: 0; + display: block; + text-align: left; + background: none; + border: none; + padding: .7rem 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +button.list-open .name { + display: block; + font-weight: 600; + color: var(--accent); + overflow-wrap: anywhere; +} + +button.list-open .meta { + display: block; + font-size: .82rem; + color: var(--muted); + white-space: normal; +} + +.list-menu { padding: 0 0 .8rem; } + +li.list-row.renaming { padding: .6rem 0; } +li.list-row.renaming .row { gap: .4rem; } +li.list-row.renaming input { flex: 1; min-width: 0; } + +button.cancel { + background: var(--card); + border: 1px solid var(--line); + color: var(--fg); + flex-shrink: 0; +} + +/* =================================================================== + Phase 6: Strichcode-Sucher und Artikelstamm + =================================================================== */ + +body.scanning { overflow: hidden; } + +.scan-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: #101012; + color: #f2f2f0; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem calc(1rem + env(safe-area-inset-right)) + calc(1rem + env(safe-area-inset-bottom)) + calc(1rem + env(safe-area-inset-left)); + overflow-y: auto; +} + +.scan-stage { + position: relative; + flex: 1 1 auto; + min-height: 40vh; + border-radius: var(--radius); + overflow: hidden; + background: #000; +} + +.scan-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Sucherrahmen über dem Bereich, den der Decoder auswertet. */ +.scan-frame { + position: absolute; + left: 6%; + right: 6%; + top: 32.5%; + height: 35%; + border: 2px solid rgba(255, 255, 255, .85); + border-radius: 6px; + box-shadow: 0 0 0 100vmax rgba(0, 0, 0, .45); + pointer-events: none; +} + +.scan-hint { + margin: 0; + text-align: center; + font-size: .92rem; + color: #cfcfc9; +} + +.scan-manual label { + color: #cfcfc9; + font-size: .85rem; + margin-bottom: .3rem; +} + +.scan-manual input { + background: #1c1c20; + border-color: #34343c; + color: #f2f2f0; +} + +.scan-overlay .scan-close { + background: #1c1c20; + border-color: #34343c; + color: #f2f2f0; + width: 100%; +} + +button.secondary.scan { flex-shrink: 0; } + +/* --- Artikelstamm --- */ + +ul.articles { list-style: none; margin: 1rem 0 0; padding: 0; } + +li.article-row { + border-bottom: 1px solid var(--line); +} + +li.article-row:last-child { border-bottom: none; } + +button.article-open { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: .7rem 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +button.article-open .name { + display: block; + font-weight: 600; + color: var(--accent); + overflow-wrap: anywhere; +} + +button.article-open .sub { + display: block; + font-size: .82rem; + color: var(--muted); +} + +li.article-row.editing > .name { + display: block; + font-weight: 600; + padding: .7rem 0 0; +} + +.article-editor { padding-bottom: 1rem; } + +.availability { margin-top: 1rem; } + +.attr-rows { margin-top: .3rem; } + +.attr-row { + display: flex; + gap: .4rem; + margin-bottom: .4rem; +} + +.attr-row .attr-key { flex: 0 0 40%; min-width: 0; } +.attr-row .attr-value { flex: 1; min-width: 0; } + +button.linklike.add-attr { margin-top: .2rem; font-size: .9rem; } + +input[type="search"] { + width: 100%; + padding: .7rem .8rem; + font-size: 1rem; + font-family: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +@media print { + .scan-overlay { display: none !important; } +} + +/* --- Eingabezeile: Name, Detailfelder, Schaltflächen untereinander --- */ + +.add-row > input#add-name { margin-bottom: .5rem; } + +.add-fields { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-bottom: .5rem; +} + +/* Menge schmal, Einheit schmal, Eigenschaft nimmt den Rest. Bei sehr + schmalen Geräten bricht die Eigenschaft in die nächste Zeile. */ +.add-fields .count { flex: 0 1 4rem; min-width: 3.2rem; text-align: center; } +.add-fields .qty { flex: 0 1 5rem; min-width: 4rem; } +.add-fields .unit { flex: 0 1 5.5rem; min-width: 4rem; } +.add-fields .variant { flex: 1 1 8rem; min-width: 0; } + +.add-actions { + display: flex; + gap: .5rem; +} + +/* Vorher liefen die Schaltflächen aus der Box, weil sie sich die Zeile + mit den Eingabefeldern teilten. Jetzt eigene Zeile, gleichmäßig + verteilt. */ +.add-actions button { + flex: 1; + margin-top: 0; +} + +/* =================================================================== + Bestätigungsdialog nach dem Scan + =================================================================== */ + +body.modal-open { overflow: hidden; } + +.modal { + position: fixed; + inset: 0; + z-index: 90; + background: rgba(0, 0, 0, .5); + display: flex; + align-items: flex-end; + justify-content: center; + padding: 1rem; + padding-bottom: calc(1rem + env(safe-area-inset-bottom)); + overflow-y: auto; +} + +@media (min-width: 30rem) { + .modal { align-items: center; } +} + +.modal-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.25rem; + width: 100%; + max-width: 28rem; +} + +.modal-card h2 { margin: 0 0 .2rem; font-size: 1.15rem; } +.modal-card .sub { margin: 0 0 1rem; color: var(--muted); font-size: .85rem; } + +/* Herkunftshinweis: Wenn Angaben aus einer fremden Quelle vorbelegt + werden, soll das sichtbar sein - nicht als Kleingedrucktes. */ +.source-note { + background: var(--warn-bg); + border: 1px solid var(--warn-line); + border-radius: var(--radius); + padding: .6rem .8rem; + margin: 0 0 1rem; + font-size: .88rem; +} + +.modal-card .add-fields { margin-top: .6rem; } +.modal-card .menu-actions { margin-top: 1.2rem; } +.modal-card .menu-actions button.primary { margin-top: 0; } + +/* =================================================================== + Phase 7: Preisvergleich + =================================================================== */ + +.price-card { padding: 0; overflow: hidden; } + +button.price-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: .75rem; + width: 100%; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: none; + border-bottom: 1px solid var(--line); + padding: .75rem 1rem; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; +} + +button.price-head .name { font-weight: 600; overflow-wrap: anywhere; } + +button.price-head .spread { + font-size: .8rem; + color: var(--muted); + white-space: nowrap; +} + +.price-rows { padding: .25rem 1rem .75rem; } + +.price-row { + display: flex; + align-items: baseline; + gap: .6rem; + padding: .4rem 0; + border-bottom: 1px solid var(--line); +} + +.price-row:last-child { border-bottom: none; } +.price-row .market { flex: 1; min-width: 0; overflow-wrap: anywhere; } + +.price-row .amount { + font-variant-numeric: tabular-nums; + font-weight: 600; + white-space: nowrap; +} + +.price-row.best .amount { color: var(--accent); } + +.price-row .badge { + flex: 0 0 auto; + font-size: .72rem; + font-weight: 700; + letter-spacing: .03em; + text-transform: uppercase; + color: var(--accent); + border: 1px solid currentColor; + border-radius: 999px; + padding: .05rem .45rem; +} + +.price-row .diff { + flex: 0 0 auto; + font-size: .78rem; + color: var(--muted); + font-variant-numeric: tabular-nums; + min-width: 3.5rem; + text-align: right; +} + +.price-detail { + padding: .5rem 1rem 1rem; + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--fg) 3%, transparent); +} + +.price-detail h4 { + margin: .6rem 0 .4rem; + font-size: .8rem; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} + +ul.price-markets { list-style: none; margin: 0; padding: 0; } + +ul.price-markets > li { + display: grid; + grid-template-columns: 1fr auto; + gap: 0 .6rem; + padding: .45rem 0; + border-bottom: 1px solid var(--line); +} + +ul.price-markets > li:last-child { border-bottom: none; } +ul.price-markets .market { font-weight: 600; } + +ul.price-markets .sub { + grid-column: 1 / 2; + font-size: .78rem; + color: var(--muted); +} + +ul.price-markets .amount { + grid-row: 1 / 3; + grid-column: 2 / 3; + align-self: center; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.price-history { margin-top: .8rem; font-size: .85rem; } +.price-history summary { cursor: pointer; color: var(--accent); } +.price-history ul { list-style: none; margin: .5rem 0 0; padding: 0; } +.price-history li { padding: .2rem 0; color: var(--muted); } + +/* Hinweis im Eintragsmenü */ +.price-tip { + margin: 0 0 .3rem; + padding: .5rem .7rem; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-radius: var(--radius); + font-size: .85rem; +} + +@media print { + .price-tip, .price-detail { display: none !important; } +} + +/* --- Stückzahl direkt am Artikel --- */ + +.item-count { + flex: 0 0 auto; + min-width: 1.9rem; + text-align: right; + font-variant-numeric: tabular-nums; + font-size: .95rem; + color: var(--muted); + white-space: nowrap; +} + +/* Mehr als ein Stück wird hervorgehoben: Beim Einkaufen ist das die + Angabe, die man leicht übersieht und dann zu wenig einpackt. */ +.item-count.many { + font-weight: 700; + color: var(--fg); +} + +li.item.bought .item-count { color: var(--muted); font-weight: 400; } + +@media print { + .item-count { color: #000; } + .item-count.many { font-weight: 700; } +} + +/* --- Einstellungen --- */ + +h3.device-heading { + margin: 1.5rem 0 .3rem; + font-size: .8rem; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--muted); +} + +.card > .menu-actions { margin-top: 1rem; } + +/* =================================================================== + Benutzerverwaltung + =================================================================== */ + +.stats { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin: 1rem 0; +} + +.stat { + flex: 1 1 5rem; + min-width: 4.5rem; + padding: .6rem .5rem; + border: 1px solid var(--line); + border-radius: var(--radius); + text-align: center; +} + +.stat .value { + display: block; + font-size: 1.35rem; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat .label { display: block; font-size: .75rem; color: var(--muted); } + +.stat.warn { + background: var(--warn-bg); + border-color: var(--warn-line); +} + +ul.admin-users { list-style: none; margin: 0; padding: 0; } + +li.admin-user { border-bottom: 1px solid var(--line); } +li.admin-user:last-child { border-bottom: none; } + +/* Deaktivierte Konten erkennbar, aber nicht versteckt: Man muss sie + finden, um sie zu reaktivieren. */ +li.admin-user.inactive .user-open .name { + color: var(--muted); + text-decoration: line-through; +} + +button.user-open { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: .7rem 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +button.user-open .name { + display: block; + font-weight: 600; + color: var(--accent); + overflow-wrap: anywhere; +} + +button.user-open .sub { + display: block; + font-size: .82rem; + color: var(--muted); +} + +li.admin-user .item-menu { padding-left: 0; padding-right: 0; } + +.item-menu .sub { + display: block; + font-size: .82rem; + color: var(--muted); + margin: .2rem 0 0; +} + +hr.thin { + border: none; + border-top: 1px solid var(--line); + margin: 1.2rem 0 .8rem; +} + +input.months { + width: 6rem; + padding: .6rem .5rem; + font: inherit; + text-align: right; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); +} + +@media print { .stats, ul.admin-users { display: none !important; } } diff --git a/web/html/icons/apple-touch-icon.png b/web/html/icons/apple-touch-icon.png new file mode 100644 index 0000000..7e1a7b5 Binary files /dev/null and b/web/html/icons/apple-touch-icon.png differ diff --git a/web/html/icons/favicon.ico b/web/html/icons/favicon.ico new file mode 100644 index 0000000..3c712c8 Binary files /dev/null and b/web/html/icons/favicon.ico differ diff --git a/web/html/icons/icon-192.png b/web/html/icons/icon-192.png new file mode 100644 index 0000000..107edaa Binary files /dev/null and b/web/html/icons/icon-192.png differ diff --git a/web/html/icons/icon-512.png b/web/html/icons/icon-512.png new file mode 100644 index 0000000..9814fe5 Binary files /dev/null and b/web/html/icons/icon-512.png differ diff --git a/web/html/icons/icon-maskable-512.png b/web/html/icons/icon-maskable-512.png new file mode 100644 index 0000000..7de8441 Binary files /dev/null and b/web/html/icons/icon-maskable-512.png differ diff --git a/web/html/icons/icon.svg b/web/html/icons/icon.svg new file mode 100644 index 0000000..a8e85e0 --- /dev/null +++ b/web/html/icons/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/web/html/index.html b/web/html/index.html new file mode 100644 index 0000000..4390997 --- /dev/null +++ b/web/html/index.html @@ -0,0 +1,33 @@ + + + + + + + +Einkaufsliste + + + + + + + + + + + + + + +
+

Wird geladen …

+
+ + + + + + diff --git a/web/html/js/api.js b/web/html/js/api.js new file mode 100644 index 0000000..f6f75ca --- /dev/null +++ b/web/html/js/api.js @@ -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" }); diff --git a/web/html/js/app.js b/web/html/js/app.js new file mode 100644 index 0000000..a412907 --- /dev/null +++ b/web/html/js/app.js @@ -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/". 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/ + // 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(); diff --git a/web/html/js/barcode.js b/web/html/js/barcode.js new file mode 100644 index 0000000..a1fc5a6 --- /dev/null +++ b/web/html/js/barcode.js @@ -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; +} diff --git a/web/html/js/db.js b/web/html/js/db.js new file mode 100644 index 0000000..2ad6140 --- /dev/null +++ b/web/html/js/db.js @@ -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)); +} diff --git a/web/html/js/dom.js b/web/html/js/dom.js new file mode 100644 index 0000000..2b57d5a --- /dev/null +++ b/web/html/js/dom.js @@ -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 }); +} diff --git a/web/html/js/push.js b/web/html/js/push.js new file mode 100644 index 0000000..07f7623 --- /dev/null +++ b/web/html/js/push.js @@ -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"); +} diff --git a/web/html/js/sortable.js b/web/html/js/sortable.js new file mode 100644 index 0000000..cc948a9 --- /dev/null +++ b/web/html/js/sortable.js @@ -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); + }); +} diff --git a/web/html/js/store.js b/web/html/js/store.js new file mode 100644 index 0000000..11d8861 --- /dev/null +++ b/web/html/js/store.js @@ -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 })); diff --git a/web/html/js/sync.js b/web/html/js/sync.js new file mode 100644 index 0000000..846225a --- /dev/null +++ b/web/html/js/sync.js @@ -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); +} diff --git a/web/html/js/views/admin.js b/web/html/js/views/admin.js new file mode 100644 index 0000000..3e0b710 --- /dev/null +++ b/web/html/js/views/admin.js @@ -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; +} diff --git a/web/html/js/views/articles.js b/web/html/js/views/articles.js new file mode 100644 index 0000000..d56c584 --- /dev/null +++ b/web/html/js/views/articles.js @@ -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; +} diff --git a/web/html/js/views/auth.js b/web/html/js/views/auth.js new file mode 100644 index 0000000..5e594c9 --- /dev/null +++ b/web/html/js/views/auth.js @@ -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
-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 }); +} diff --git a/web/html/js/views/list-detail.js b/web/html/js/views/list-detail.js new file mode 100644 index 0000000..04fc537 --- /dev/null +++ b/web/html/js/views/list-detail.js @@ -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); }; +} diff --git a/web/html/js/views/lists.js b/web/html/js/views/lists.js new file mode 100644 index 0000000..4203317 --- /dev/null +++ b/web/html/js/views/lists.js @@ -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; +} diff --git a/web/html/js/views/manage.js b/web/html/js/views/manage.js new file mode 100644 index 0000000..d2a2253 --- /dev/null +++ b/web/html/js/views/manage.js @@ -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; +} diff --git a/web/html/js/views/prices.js b/web/html/js/views/prices.js new file mode 100644 index 0000000..295258e --- /dev/null +++ b/web/html/js/views/prices.js @@ -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; +} diff --git a/web/html/js/views/public.js b/web/html/js/views/public.js new file mode 100644 index 0000000..55833e9 --- /dev/null +++ b/web/html/js/views/public.js @@ -0,0 +1,143 @@ +// Öffentliche Ansicht einer Liste über /s/. 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(); +} diff --git a/web/html/js/views/scan-result.js b/web/html/js/views/scan-result.js new file mode 100644 index 0000000..7b9cf75 --- /dev/null +++ b/web/html/js/views/scan-result.js @@ -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(); + }); +} diff --git a/web/html/js/views/scanner.js b/web/html/js/views/scanner.js new file mode 100644 index 0000000..df069a4 --- /dev/null +++ b/web/html/js/views/scanner.js @@ -0,0 +1,171 @@ +// Kamerasucher für Strichcodes. +// +// Zwei Wege, in dieser Reihenfolge: +// 1. BarcodeDetector - nativ in Chrome und auf Android, erkennt auch +// QR- und Datamatrix-Codes, läuft außerhalb des Hauptthreads. +// 2. Eigener Decoder aus barcode.js - überall sonst, insbesondere +// Safari auf iPhone und iPad. +// +// Wenn beides ausfällt (keine Kamera, Zugriff verweigert), bleibt die +// Eingabe von Hand. Die Ziffernfolge steht unter jedem Strichcode. +"use strict"; + +import { decodeImage } from "../barcode.js"; +import { clear, el } from "../dom.js"; + +const SCAN_INTERVAL_MS = 120; + +async function nativeDetector() { + if (!("BarcodeDetector" in window)) return null; + try { + const formats = await window.BarcodeDetector.getSupportedFormats(); + const wanted = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "qr_code"] + .filter((f) => formats.includes(f)); + if (!wanted.length) return null; + return new window.BarcodeDetector({ formats: wanted }); + } catch { + return null; + } +} + +/** + * Öffnet den Sucher als Überlagerung. + * @returns {Promise} erkannte Ziffernfolge oder null bei Abbruch + */ +export function scanBarcode() { + return new Promise((resolve) => { + let stream = null; + let timer = null; + let closed = false; + + const video = el("video", { + playsInline: true, + muted: true, + autoplay: true, + className: "scan-video", + }); + const canvas = document.createElement("canvas"); + const hint = el("p.scan-hint", {}, "Kamera wird geöffnet …"); + + const manualField = el("input", { + type: "text", + inputMode: "numeric", + placeholder: "Ziffern unter dem Strichcode", + maxLength: 32, + onkeydown: (ev) => { if (ev.key === "Enter") useManual(); }, + }); + + function useManual() { + const value = manualField.value.replace(/\D/g, ""); + if (value.length < 6) { + hint.textContent = "Bitte mindestens sechs Ziffern eingeben."; + return; + } + finish(value); + } + + const overlay = el("div.scan-overlay", {}, + el("div.scan-stage", {}, video, el("div.scan-frame", {})), + hint, + el("div.scan-manual", {}, + el("label", {}, "Oder von Hand eingeben"), + el("div.row", {}, manualField, + el("button.primary", { type: "button", onclick: useManual }, "Übernehmen"))), + el("button.secondary.scan-close", { type: "button", onclick: () => finish(null) }, + "Abbrechen") + ); + + function finish(code) { + if (closed) return; + closed = true; + if (timer) clearInterval(timer); + // Kamera zuverlässig freigeben - sonst leuchtet die Anzeigeleuchte + // weiter und der Akku leidet. + if (stream) for (const track of stream.getTracks()) track.stop(); + overlay.remove(); + document.body.classList.remove("scanning"); + window.removeEventListener("keydown", onKey); + resolve(code); + } + + function onKey(ev) { + if (ev.key === "Escape") finish(null); + } + + window.addEventListener("keydown", onKey); + document.body.classList.add("scanning"); + document.body.append(overlay); + + (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: { ideal: "environment" }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + audio: false, + }); + } catch (err) { + hint.textContent = + err.name === "NotAllowedError" + ? "Kein Zugriff auf die Kamera. Bitte in den Browsereinstellungen erlauben – oder die Ziffern von Hand eingeben." + : "Keine Kamera verfügbar. Bitte die Ziffern von Hand eingeben."; + manualField.focus(); + return; + } + + video.srcObject = stream; + try { + await video.play(); + } catch { + // Manche Browser verlangen eine Nutzeraktion; das Bild erscheint + // dann trotzdem, sobald die Wiedergabe anläuft. + } + + const detector = await nativeDetector(); + hint.textContent = detector + ? "Strichcode in den Rahmen halten." + : "Strichcode in den Rahmen halten. Für eine gute Erkennung waagerecht und gut ausgeleuchtet."; + + timer = setInterval(async () => { + if (closed || video.readyState < 2) return; + + const width = video.videoWidth; + const height = video.videoHeight; + if (!width || !height) return; + + if (detector) { + try { + const found = await detector.detect(video); + if (found.length) { + const value = String(found[0].rawValue || "").trim(); + if (value) return finish(value); + } + } catch { + // Weiter mit dem eigenen Decoder. + } + } + + // Nur den mittleren Streifen auswerten: Dort liegt der Code, + // und es spart die meiste Rechenzeit. + const bandHeight = Math.round(height * 0.35); + const y = Math.round((height - bandHeight) / 2); + canvas.width = width; + canvas.height = bandHeight; + + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(video, 0, y, width, bandHeight, 0, 0, width, bandHeight); + + const code = decodeImage(context.getImageData(0, 0, width, bandHeight)); + if (code) finish(code); + }, SCAN_INTERVAL_MS); + })(); + }); +} + +/** Ob ein Sucher überhaupt sinnvoll ist. Über reines HTTP verweigert der + * Browser den Kamerazugriff - dann direkt zur Eingabe von Hand. */ +export function cameraAvailable() { + return Boolean(navigator.mediaDevices?.getUserMedia) && window.isSecureContext; +} diff --git a/web/html/js/views/settings.js b/web/html/js/views/settings.js new file mode 100644 index 0000000..751abe9 --- /dev/null +++ b/web/html/js/views/settings.js @@ -0,0 +1,233 @@ +// Einstellungen des angemeldeten Kontos: Anzeigename, Passwort, +// Benachrichtigungen. +"use strict"; + +import { del, patch, post } from "../api.js"; +import { el, mount } from "../dom.js"; +import * as push from "../push.js"; +import { set, state } from "../store.js"; + +function formatDate(iso) { + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", + }); +} + +export async function settingsView(root, { back, admin }) { + let banner = null; + let pushStatus = null; + let devices = []; + + async function reload() { + pushStatus = await push.status(); + devices = pushStatus.serverEnabled + ? await push.listDevices().catch(() => []) + : []; + } + + function say(message, kind = "notice") { + banner = { message, kind }; + render(); + setTimeout(() => { banner = null; render(); }, 8000); + } + + async function guarded(fn) { + try { + await fn(); + await reload(); + render(); + } catch (err) { + say(err.message, "error"); + } + } + + // ------------------------------------------------------------------- + // Benachrichtigungen + // ------------------------------------------------------------------- + + function pushSection() { + if (!pushStatus.serverEnabled) { + return el("section.card", {}, + el("h2", {}, "Benachrichtigungen"), + el("p.lead", {}, + "Auf diesem Server sind Push-Benachrichtigungen nicht eingerichtet. " + + "Dafür wird ein VAPID-Schlüsselpaar in der Konfiguration benötigt.")); + } + + // Auf iOS gibt es Push nur in der installierten App - das ist eine + // Einschränkung von Apple, kein Fehler der Anwendung. + if (pushStatus.needsInstall) { + return el("section.card", {}, + el("h2", {}, "Benachrichtigungen"), + el("p.lead.warn", {}, + "Auf iPhone und iPad funktionieren Benachrichtigungen nur, wenn die " + + "App über „Teilen → Zum Home-Bildschirm“ installiert wurde. " + + "Öffne sie danach über das Symbol auf dem Startbildschirm und " + + "komm hierher zurück.")); + } + + if (!pushStatus.supported) { + return el("section.card", {}, + el("h2", {}, "Benachrichtigungen"), + el("p.lead", {}, + "Dieser Browser unterstützt keine Push-Benachrichtigungen. " + + "Über eine unverschlüsselte Verbindung sind sie ebenfalls nicht " + + "möglich – dann hilft der Zugriff über HTTPS.")); + } + + const blocked = pushStatus.permission === "denied"; + + return el("section.card", {}, + el("h2", {}, "Benachrichtigungen"), + el("p.lead", {}, + "Wenn jemand eine geteilte Liste ändert, bekommst du eine Meldung – ", + el("strong", {}, `höchstens eine je Liste alle ${pushStatus.throttleHours} Stunden`), + ". Wer im Laden steht und abhakt, erzeugt sonst im Minutentakt " + + "Benachrichtigungen."), + + blocked + ? el("p.lead.warn", {}, + "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt " + + "sich nur in den Einstellungen des Browsers wieder ändern – " + + "meist über das Schloss- oder Info-Symbol in der Adresszeile.") + : null, + + pushStatus.subscribed + ? el("div.menu-actions", {}, + el("button", { + type: "button", + onclick: () => guarded(async () => { + await push.sendTest(); + say("Testnachricht unterwegs. Kommt sie nicht an, steht der " + + "Grund im Log des api-Containers."); + }), + }, "Testnachricht senden"), + el("button.danger", { + type: "button", + onclick: () => guarded(async () => { + await push.unsubscribe(); + say("Dieses Gerät bekommt keine Benachrichtigungen mehr."); + }), + }, "Auf diesem Gerät abschalten")) + : el("button.primary", { + type: "button", + disabled: blocked, + onclick: () => guarded(async () => { + const result = await push.subscribe(); + if (!result.ok) { + say(result.reason, "error"); + return; + } + say("Benachrichtigungen für dieses Gerät eingeschaltet."); + }), + }, "Auf diesem Gerät einschalten"), + + devices.length + ? el("div", {}, + el("h3.device-heading", {}, "Angemeldete Geräte"), + el("ul.share-list", {}, devices.map((device) => + el("li", {}, + el("div.who-block", {}, + el("span.name", {}, device.label || "Unbenanntes Gerät"), + el("span.sub", {}, + `seit ${formatDate(device.created_at)}`, + device.last_success_at + ? ` · zuletzt erreicht ${formatDate(device.last_success_at)}` + : " · noch nichts zugestellt")), + el("div.member-actions", {}, + el("button.danger", { + type: "button", + onclick: () => guarded(() => + del(`/api/push/subscriptions/${device.id}`)), + }, "Entfernen")))))) + : null + ); + } + + // ------------------------------------------------------------------- + // Profil und Passwort + // ------------------------------------------------------------------- + + function profileSection() { + const nameField = el("input", { + type: "text", + value: state.user.display_name || "", + maxLength: 80, + placeholder: "wird anderen Mitgliedern angezeigt", + }); + + return el("section.card", {}, + el("h2", {}, "Profil"), + el("p.lead", {}, state.user.email), + el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")), + el("div.row", {}, nameField, + el("button.primary", { + type: "button", + onclick: () => guarded(async () => { + const user = await patch("/api/auth/me", { + display_name: nameField.value.trim() || null, + }); + set({ user }); + say("Gespeichert."); + }), + }, "Speichern")) + ); + } + + function passwordSection() { + const current = el("input", { type: "password", autocomplete: "current-password" }); + const next = el("input", { type: "password", autocomplete: "new-password" }); + const repeat = el("input", { type: "password", autocomplete: "new-password" }); + + return el("section.card", {}, + el("h2", {}, "Passwort ändern"), + el("p.lead", {}, + "Nach der Änderung werden alle anderen Geräte abgemeldet."), + el("label", {}, "Bisheriges Passwort"), current, + el("label", {}, "Neues Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), next, + el("label", {}, "Wiederholen"), repeat, + el("button.primary", { + type: "button", + onclick: () => guarded(async () => { + if (next.value !== repeat.value) { + throw new Error("Die beiden Eingaben stimmen nicht überein."); + } + const result = await post("/api/auth/password/change", { + current_password: current.value, + new_password: next.value, + }); + current.value = next.value = repeat.value = ""; + say(result.detail); + }), + }, "Passwort ändern") + ); + } + + function render() { + mount(root, + el("header.bar", {}, + el("button.linklike", { type: "button", onclick: back }, "‹ Zurück"), + el("span.who", {}, state.user.display_name || state.user.email)), + + banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null, + + el("section.card", {}, + el("h1", {}, "Einstellungen"), + // Nur für Administratoren sichtbar - der Endpunkt prüft + // zusätzlich, die Oberfläche allein wäre kein Schutz. + state.user.is_admin + ? el("div", {}, + el("p.lead", {}, "Du hast Administratorrechte."), + el("button.secondary", { type: "button", onclick: admin }, + "Benutzerverwaltung")) + : null), + pushSection(), + profileSection(), + passwordSection() + ); + } + + await reload(); + render(); + return render; +} diff --git a/web/html/js/views/share.js b/web/html/js/views/share.js new file mode 100644 index 0000000..b2289f5 --- /dev/null +++ b/web/html/js/views/share.js @@ -0,0 +1,468 @@ +// Teilen einer Liste: Einladen, erneut senden, widerrufen, Zugriff +// entziehen, Eigentum übertragen. Nur für den Eigentümer erreichbar. +"use strict"; + +import { del, get, post, put } from "../api.js"; +import { clear, el, mount } from "../dom.js"; + +const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" }; + +const STATUS_LABEL = { + pending: "offen", + accepted: "angenommen", + revoked: "widerrufen", + expired: "abgelaufen", +}; + +function formatDate(iso) { + return new Date(iso).toLocaleDateString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", + }); +} + +export async function shareView(root, { listId, back }) { + let list = null; + let members = []; + let invites = []; + let banner = null; + + let publicLinks = []; + // Der Klartext eines neu erzeugten Links - nur bis zum nächsten Neuzeichnen + // im Speicher, danach nicht mehr rekonstruierbar. + let freshLink = null; + + async function reload() { + [list, members, invites, publicLinks] = await Promise.all([ + get(`/api/lists/${listId}`), + get(`/api/lists/${listId}/members`), + get(`/api/lists/${listId}/invites`), + get(`/api/lists/${listId}/public-links`), + ]); + } + + function say(message, kind = "notice") { + banner = { message, kind }; + render(); + setTimeout(() => { banner = null; render(); }, 8000); + } + + 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"); + } + } + + // ---------------- Einladen ---------------- + + function inviteForm() { + const email = el("input", { + type: "email", + autocomplete: "off", + placeholder: "person@example.de", + onkeydown: (ev) => { if (ev.key === "Enter") submit(); }, + }); + const role = el("select", {}, + el("option", { value: "editor" }, "Bearbeiter – darf ändern"), + el("option", { value: "viewer" }, "Nur lesen") + ); + const button = el("button.primary", { type: "button", onclick: () => submit() }, + "Einladen"); + + async function submit() { + const value = email.value.trim(); + if (!value) return; + button.disabled = true; + try { + await post(`/api/lists/${listId}/invites`, { email: value, role: role.value }); + email.value = ""; + await reload(); + render(); + say(`Einladung an ${value} versendet.`); + } catch (err) { + say(err.message, "error"); + } finally { + button.disabled = false; + } + } + + return el("section.card", {}, + el("h2", {}, "Person einladen"), + el("p.lead", {}, + "Die Einladung geht per E-Mail an die angegebene Adresse und gilt nur " + + "für diese. Ein Konto braucht die Person noch nicht – sie kann sich " + + "beim Öffnen des Links eines anlegen."), + el("label", {}, "E-Mail-Adresse"), email, + el("label", {}, "Berechtigung"), role, + button + ); + } + + // ---------------- Mitglieder ---------------- + + function memberRow(member) { + const isOwner = member.role === "owner"; + const name = member.display_name || member.email; + + const roleSelect = isOwner ? null : el("select.role", { + onchange: (ev) => guarded(() => + put(`/api/lists/${listId}/members/${member.user_id}`, { + role: ev.target.value, + may_share_public: member.may_share_public, + })), + }, + el("option", { value: "editor", selected: member.role === "editor" }, + "Bearbeiter"), + el("option", { value: "viewer", selected: member.role === "viewer" }, + "Nur lesen") + ); + + // Zusatzrecht, unabhängig von der Rolle. + const shareRight = isOwner ? null : el("label.checkline", {}, + el("input", { + type: "checkbox", + checked: member.may_share_public, + onchange: (ev) => guarded(() => + put(`/api/lists/${listId}/members/${member.user_id}`, { + role: member.role, + may_share_public: ev.target.checked, + })), + }), + el("span", {}, "darf öffentliche Links erzeugen") + ); + + return el("li", {}, + el("div.who-block", {}, + el("span.name", {}, name), + el("span.sub", {}, + member.display_name ? `${member.email} · ` : "", + isOwner ? ROLE_LABEL.owner : `dabei seit ${formatDate(member.joined_at)}`) + ), + roleSelect, + shareRight, + isOwner ? null : el("div.member-actions", {}, + el("button", { + type: "button", + title: "Eigentum übertragen", + onclick: () => { + if (!confirm( + `Eigentum an „${list.name}“ auf ${name} übertragen?\n\n` + + "Du wirst dabei zum Bearbeiter und kannst die Freigabe " + + "danach nicht mehr verwalten." + )) return; + guarded(() => post(`/api/lists/${listId}/transfer`, + { user_id: member.user_id })); + }, + }, "Eigentum übertragen"), + el("button.danger", { + type: "button", + onclick: () => { + if (!confirm(`${name} den Zugriff auf „${list.name}“ entziehen?`)) return; + guarded(() => del(`/api/lists/${listId}/members/${member.user_id}`)); + }, + }, "Zugriff entziehen") + ) + ); + } + + // ---------------- Einladungen ---------------- + + function inviteRow(invite) { + const open = invite.status === "pending"; + const done = invite.status === "accepted"; + + return el("li", { className: `invite ${invite.status}` }, + el("div.who-block", {}, + el("span.name", {}, invite.email), + el("span.sub", {}, + `${ROLE_LABEL[invite.role]} · ${STATUS_LABEL[invite.status]}`, + open ? ` bis ${formatDate(invite.expires_at)}` : "", + invite.send_count > 1 ? ` · ${invite.send_count}× gesendet` : "") + ), + done ? null : el("div.member-actions", {}, + el("button", { + type: "button", + onclick: () => guarded(() => + post(`/api/lists/${listId}/invites/${invite.id}/resend`)), + }, invite.status === "revoked" || invite.status === "expired" + ? "Neu senden" + : "Erneut senden"), + open ? el("button.danger", { + type: "button", + onclick: () => { + if (!confirm( + `Einladung an ${invite.email} widerrufen?\n\n` + + "Der bereits versendete Link funktioniert danach nicht mehr." + )) return; + guarded(() => del(`/api/lists/${listId}/invites/${invite.id}`)); + }, + }, "Widerrufen") : null + ) + ); + } + + // ---------------- Öffentliche Links ---------------- + + function defaultExpiry(days) { + const d = new Date(); + d.setDate(d.getDate() + days); + d.setHours(23, 59, 0, 0); + return d; + } + + function toDateInput(date) { + const pad = (n) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + } + + function publicSection() { + const label = el("input", { + type: "text", + maxLength: 120, + placeholder: "z. B. „für Oma“ – nur zur Unterscheidung", + }); + const until = el("input", { + type: "date", + value: toDateInput(defaultExpiry(14)), + min: toDateInput(defaultExpiry(1)), + max: toDateInput(defaultExpiry(365)), + }); + const allowCheck = el("input", { type: "checkbox", checked: true }); + const button = el("button.primary", { type: "button", onclick: () => create() }, + "Link erzeugen"); + + async function create() { + if (!until.value) { + say("Bitte ein Ablaufdatum angeben.", "error"); + return; + } + const expires = new Date(until.value); + expires.setHours(23, 59, 0, 0); + button.disabled = true; + try { + const result = await post(`/api/lists/${listId}/public-links`, { + label: label.value.trim() || null, + expires_at: expires.toISOString(), + allow_check: allowCheck.checked, + }); + freshLink = result.url; + await reload(); + render(); + } catch (err) { + say(err.message, "error"); + } finally { + button.disabled = false; + } + } + + const active = publicLinks.filter((l) => l.status === "active"); + + return el("section.card", {}, + el("h2", {}, "Öffentlicher Link"), + el("p.lead", {}, + "Wer den Link hat, kann die Liste ansehen – ohne Konto. Namen der " + + "Personen, die Artikel eingetragen haben, werden dabei nicht " + + "angezeigt. Behandle den Link wie einen Schlüssel: Weitergabe " + + "bedeutet Zugriff."), + + freshLink ? el("div.fresh-link", {}, + el("p", {}, el("strong", {}, "Der Link – jetzt kopieren:")), + el("input.linkfield", { + type: "text", + value: freshLink, + readOnly: true, + onclick: (ev) => ev.target.select(), + }), + el("div.member-actions", {}, + el("button", { + type: "button", + onclick: async (ev) => { + try { + await navigator.clipboard.writeText(freshLink); + ev.target.textContent = "Kopiert"; + } catch { + say("Kopieren nicht möglich – bitte von Hand markieren.", "error"); + } + }, + }, "In die Zwischenablage"), + el("button", { type: "button", onclick: () => { freshLink = null; render(); } }, + "Ausblenden")), + el("p.sub", {}, + "Aus Sicherheitsgründen steht in der Datenbank nur ein Prüfwert. " + + "Später lässt sich die Adresse nicht mehr anzeigen – dann bleibt " + + "nur, einen neuen Link zu erzeugen.") + ) : null, + + publicLinks.length + ? el("ul.share-list", {}, publicLinks.map(publicLinkRow)) + : el("p.empty", {}, "Noch kein Link erzeugt."), + + el("label", {}, "Bezeichnung ", el("span.hint", {}, "(freiwillig)")), label, + el("label", {}, "Gültig bis"), until, + el("label.checkline", {}, allowCheck, + el("span", {}, "Abhaken erlauben (sonst nur ansehen)")), + button, + + active.length > 1 + ? el("button.danger.wide", { + type: "button", + onclick: () => { + if (!confirm(`Alle ${active.length} aktiven Links widerrufen?`)) return; + guarded(() => post(`/api/lists/${listId}/public-links/revoke-all`)); + }, + }, "Alle Links widerrufen") + : null + ); + } + + function publicLinkRow(link) { + const STATE = { active: "aktiv", expired: "abgelaufen", revoked: "widerrufen" }; + return el("li", { className: `invite ${link.status === "active" ? "" : "revoked"}` }, + el("div.who-block", {}, + el("span.name", {}, link.label || "Ohne Bezeichnung"), + el("span.sub", {}, + `${STATE[link.status]} · gültig bis ${formatDate(link.expires_at)}`, + link.allow_check ? " · Abhaken erlaubt" : " · nur ansehen", + link.access_count + ? ` · ${link.access_count}× geöffnet, zuletzt ${formatDate(link.last_access_at)}` + : " · noch nicht geöffnet") + ), + link.status === "revoked" ? null : el("div.member-actions", {}, + el("button.danger", { + type: "button", + onclick: () => { + if (!confirm( + "Diesen Link widerrufen?\n\n" + + "Er funktioniert danach für alle nicht mehr, die ihn haben." + )) return; + guarded(() => del(`/api/lists/${listId}/public-links/${link.id}`)); + }, + }, "Widerrufen")) + ); + } + + // ---------------- Gesamtansicht ---------------- + + function render() { + const shared = members.filter((m) => m.role !== "owner"); + const openInvites = invites.filter((i) => i.status === "pending"); + + 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", {}, "Teilen"), + el("p.lead", {}, + `„${list.name}“ ist derzeit `, + shared.length === 0 && openInvites.length === 0 + ? "mit niemandem geteilt." + : `mit ${shared.length} Person(en) geteilt` + + (openInvites.length + ? `, ${openInvites.length} Einladung(en) offen.` + : ".")) + ), + + inviteForm(), + + list.may_share_public ? publicSection() : null, + + el("section.card", {}, + el("h2", {}, "Zugriff"), + el("ul.share-list", {}, members.map(memberRow)) + ), + + invites.length + ? el("section.card", {}, + el("h2", {}, "Einladungen"), + el("ul.share-list", {}, invites.map(inviteRow))) + : null, + + shared.length || openInvites.length + ? el("section.card", {}, + el("h2", {}, "Freigabe aufheben"), + el("p.lead", {}, + "Entzieht allen Personen den Zugriff und widerruft offene " + + "Einladungen. Die Liste selbst bleibt bestehen."), + el("button.danger.wide", { + type: "button", + onclick: () => { + if (!confirm( + `Freigabe von „${list.name}“ vollständig aufheben?\n\n` + + `${shared.length} Zugriff(e) werden entzogen, ` + + `${openInvites.length} Einladung(en) widerrufen.` + )) return; + guarded(() => post(`/api/lists/${listId}/unshare`)); + }, + }, "Freigabe vollständig aufheben")) + : null + ); + } + + await reload(); + render(); + return render; +} + +// --------------------------------------------------------------------------- +// Empfängerseite: Einladung annehmen +// --------------------------------------------------------------------------- + +export async function acceptInviteView(root, { token, onAccepted, toLists }) { + let preview; + try { + preview = await get(`/api/invites/${encodeURIComponent(token)}`); + } catch (err) { + mount(root, + el("section.card", {}, + el("h1", {}, "Einladung"), + el("p.error", {}, err.message), + el("button.primary", { type: "button", onclick: toLists }, "Zu meinen Listen") + ) + ); + return; + } + + const button = el("button.primary", { type: "button" }, "Einladung annehmen"); + const error = el("p.error", { hidden: true }); + + button.addEventListener("click", async () => { + button.disabled = true; + error.hidden = true; + try { + await post(`/api/invites/${encodeURIComponent(token)}/accept`); + onAccepted(); + } catch (err) { + error.textContent = err.message; + error.hidden = false; + button.disabled = false; + } + }); + + mount(root, + el("section.card", {}, + el("h1", {}, "Einladung"), + el("p.lead", {}, + preview.invited_by_name + ? `${preview.invited_by_name} teilt die Liste „${preview.list_name}“ mit dir.` + : `Die Liste „${preview.list_name}“ wurde mit dir geteilt.`), + el("p", {}, `Berechtigung: ${ROLE_LABEL[preview.role]}`), + preview.matches_current_user + ? null + : el("p.lead.warn", {}, + `Diese Einladung ist an ${preview.email} gerichtet, du bist aber mit ` + + "einem anderen Konto angemeldet. Melde dich mit der eingeladenen " + + "Adresse an oder bitte um eine neue Einladung."), + error, + preview.matches_current_user ? button : null, + el("p.switch", {}, + el("button.linklike", { type: "button", onclick: toLists }, "Später")) + ) + ); +} diff --git a/web/html/js/views/welcome.js b/web/html/js/views/welcome.js new file mode 100644 index 0000000..97c0a10 --- /dev/null +++ b/web/html/js/views/welcome.js @@ -0,0 +1,81 @@ +// Willkommensstrecke: Passwort setzen über den Link aus der +// Einladungsnachricht. Läuft ohne Anmeldung. +"use strict"; + +import { get, post } from "../api.js"; +import { el, mount } from "../dom.js"; + +export async function welcomeView(root, { token, toLogin }) { + let preview; + try { + preview = await get(`/api/auth/welcome/${encodeURIComponent(token)}`); + } catch (err) { + mount(root, + el("section.card", {}, + el("h1", {}, "Einladung"), + el("p.error", {}, err.message), + el("button.primary", { type: "button", onclick: toLogin }, "Zur Anmeldung"))); + return; + } + + const nameField = el("input", { + type: "text", + maxLength: 80, + value: preview.display_name || "", + placeholder: "wird anderen Mitgliedern angezeigt", + }); + const passField = el("input", { + type: "password", + autocomplete: "new-password", + onkeydown: (ev) => { if (ev.key === "Enter") submit(); }, + }); + const repeatField = el("input", { + type: "password", + autocomplete: "new-password", + onkeydown: (ev) => { if (ev.key === "Enter") submit(); }, + }); + const error = el("p.error", { hidden: true }); + const notice = el("p.notice", { hidden: true }); + const button = el("button.primary", { type: "button" }, "Zugang einrichten"); + + async function submit() { + error.hidden = true; + if (passField.value !== repeatField.value) { + error.textContent = "Die beiden Eingaben stimmen nicht überein."; + error.hidden = false; + return; + } + button.disabled = true; + try { + const result = await post("/api/auth/welcome/complete", { + token, + password: passField.value, + display_name: nameField.value.trim() || null, + }); + notice.textContent = result.detail; + notice.hidden = false; + setTimeout(toLogin, 1500); + } catch (err) { + error.textContent = err.message; + error.hidden = false; + button.disabled = false; + } + } + + button.addEventListener("click", submit); + + mount(root, + el("section.card", {}, + el("h1", {}, "Willkommen"), + el("p.lead", {}, + `Für ${preview.email} wurde ein Zugang eingerichtet. `, + "Leg jetzt dein Passwort fest – danach kannst du dich anmelden."), + el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")), nameField, + el("label", {}, "Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), passField, + el("label", {}, "Wiederholen"), repeatField, + error, + notice, + button)); + + passField.focus(); +} diff --git a/web/html/print.css b/web/html/print.css new file mode 100644 index 0000000..7757169 --- /dev/null +++ b/web/html/print.css @@ -0,0 +1,128 @@ +/* Stil der serverseitigen Druckansicht. + Eigene Datei statt