commit 904d14b64c50c51b0937aea84fb8fc2eae85e49e Author: Alexander Klein Date: Sat Sep 12 22:22:17 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5021857 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.env* +.envrc +config.mk +/bin +/dist +/remote +/request +*.key +*.pem +*.p12 +*.pfx +*.log +*.out +*.prof +coverage.* +**/__debug_bin* +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4380346 --- /dev/null +++ b/.env.example @@ -0,0 +1,70 @@ +# Copy this file to .env before starting the Compose stack. Do not commit .env. + +# PostgreSQL database and role name. Simple lowercase identifiers are recommended. +POSTGRES_DB=gardomatic +POSTGRES_USER=gardomatic +# Required, non-empty password. If it contains URI-reserved characters, use the +# percent-encoded form of the same password in GARDOMATIC_DB_DSN below. +POSTGRES_PASSWORD=change-me +# Host port published by Compose. Allowed: integer from 1 to 65535. +POSTGRES_PORT=5432 + +# Host ports published for API and web. Allowed: integers from 1 to 65535. +API_PORT=4000 +WEB_PORT=4040 + +# Runtime mode. Allowed: development, test, production. +GARDOMATIC_ENV=development +# Required PostgreSQL connection string. Supported form: +# postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=MODE +GARDOMATIC_DB_DSN=postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable +# Connection-pool limits. Open must be at least 1; idle must be between 0 and open. +GARDOMATIC_DB_MAX_OPEN_CONNS=25 +GARDOMATIC_DB_MAX_IDLE_CONNS=25 +# Go duration, for example 30s, 15m or 1h30m. +GARDOMATIC_DB_MAX_IDLE_TIME=15m + +# Listener ports. Allowed: integers from 1 to 65535. +GARDOMATIC_API_PORT=4000 +GARDOMATIC_WEB_PORT=4040 +# Listener hosts. Empty means all available interfaces; otherwise use an IP address +# or resolvable hostname such as 127.0.0.1 or localhost. +GARDOMATIC_API_HOST= +GARDOMATIC_WEB_HOST= +# Absolute HTTP(S) URLs including scheme and host. These are addresses used by the +# applications, not listener addresses. +GARDOMATIC_API_BASE_URL=http://localhost:4000 +GARDOMATIC_WEB_BASE_URL=http://localhost:4040 + +# Non-empty cookie name shared by API and web. +GARDOMATIC_SESSION_COOKIE_NAME=gardomatic_session +# Go durations, for example 30m, 12h or 168h. Use positive values. +GARDOMATIC_SESSION_LIFETIME=12h +GARDOMATIC_SESSION_IDLE_TIMEOUT=30m +# Boolean. Accepted true values: 1, t, T, TRUE, true, True. Accepted false +# values: 0, f, F, FALSE, false, False. Must be true in production. +GARDOMATIC_COOKIE_SECURE=false + +# Boolean with the same accepted forms as GARDOMATIC_COOKIE_SECURE. +GARDOMATIC_RATE_LIMIT_ENABLED=true +# Positive requests per second; decimal values such as 0.5 are accepted. +GARDOMATIC_RATE_LIMIT_RPS=10 +# Positive integer defining the permitted request burst. +GARDOMATIC_RATE_LIMIT_BURST=40 +# Comma-separated absolute URLs; every entry requires a scheme and host. Empty +# disables cross-origin browser access. Use origins without paths, for example: +# https://garden.example.com,https://admin.example.com +GARDOMATIC_CORS_TRUSTED_ORIGINS=http://localhost:4040 + +# Delivery mode. Allowed: file or smtp. +GARDOMATIC_SMTP_MODE=file +# SMTP hostname and TCP port. Used only in smtp mode. +GARDOMATIC_SMTP_HOST= +GARDOMATIC_SMTP_PORT=25 +# SMTP credentials. Used only in smtp mode; keep the password in .env only. +GARDOMATIC_SMTP_USERNAME= +GARDOMATIC_SMTP_PASSWORD= +# Sender mailbox, for example gardomatic@example.com. +GARDOMATIC_SMTP_SENDER=gardomatic@localhost +# Writable absolute file path. Required only in file mode. +GARDOMATIC_SMTP_FILE_PATH=/tmp/gardomatic-mails.log diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 0000000..ea6b2a5 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,7 @@ +# Copy this file to .envrc. Make targets source it as a shell file; direnv can +# load it as well. Secrets may therefore be quoted normally for Bash. + +# Local development database. +export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' +# Required only for integration tests. Always use a disposable test database. +export GARDOMATIC_TEST_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic_test?sslmode=disable' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..003fc71 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Änderung + + + +## Prüfung + + + +- [ ] Betroffene Tests wurden ergänzt oder der Verzicht wurde begründet. +- [ ] `go test ./...` wurde ausgeführt oder ein Fehlschlag wurde dokumentiert. +- [ ] Dokumentation und Konfigurationsbeispiele wurden bei Bedarf aktualisiert. +- [ ] Der Beitrag enthält kein undeklariertes Fremdmaterial und keine Geheimnisse. + +## Contributor License Agreement + +- [ ] **I have read and agree to the Gardomatic Contributor License Agreement, + Version 1.0 (`CLA.md`), and I have the authority to grant the rights stated + in it for this contribution.** + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8fd29c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: gardomatic + POSTGRES_USER: gardomatic + POSTGRES_PASSWORD: gardomatic + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U gardomatic" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + env: + GARDOMATIC_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable + GARDOMATIC_TEST_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install migrate + run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest + - name: Migrate database + run: migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_DB_DSN" up + - name: Verify module and source + run: | + go mod tidy -diff + go vet ./... + - name: Test with race detector + run: go test -race -count=1 ./... + - name: Build all binaries + run: make build/all diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..bfd889a --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,22 @@ +name: CLA + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + acceptance: + name: Verify CLA acceptance + runs-on: ubuntu-latest + steps: + - name: Check pull request declaration + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + declaration='^- \[[xX]\] \*\*I have read and agree to the Gardomatic Contributor License Agreement,' + if ! grep -Eq "$declaration" <<< "$PR_BODY"; then + echo "The pull request author must accept CLA.md using the checkbox in the pull request template." + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97b5c5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Environment files and secrets +.env +.env.* +!.env.example +!.env.*.example +.envrc +/config.mk +*.key +*.pem +*.p12 +*.pfx +remote/production/gardomatic.env + +# Local HTTP requests which may contain credentials +request/*.local.http +request/*.private.http + +# Build and test artifacts +/bin/ +/dist/ +*.test +*.out +coverage.* +*.prof +**/__debug_bin* + +# Runtime files +*.log +/tmp/ + +# Local editor and operating-system files +.idea/ +.vscode/* +!.vscode/launch.json +.DS_Store +Thumbs.db diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b5d8a54 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Gardomatic: API", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/api", + "envFile": "${workspaceFolder}/.env", + "env": { + "GARDOMATIC_API_HOST": "127.0.0.1" + } + }, + { + "name": "Gardomatic: Web", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/web", + "envFile": "${workspaceFolder}/.env", + "env": { + "GARDOMATIC_WEB_HOST": "0.0.0.0" + } + } + ], + "compounds": [ + { + "name": "Gardomatic: API + Web", + "configurations": [ + "Gardomatic: API", + "Gardomatic: Web" + ], + "stopAll": true + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf11b62 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# AGENTS.md + +Diese Anweisungen gelten für das gesamte Repository. Benutzeranweisungen haben +Vorrang. Spezifischere `AGENTS.md`- oder `AGENTS.override.md`-Dateien in +Unterverzeichnissen dürfen diese Regeln für ihren Bereich ergänzen oder +überschreiben. + +## Projektüberblick + +Gardomatic ist eine mobile, mehrbenutzerfähige Gartenverwaltung in Go. Das +Repository enthält drei Programme: + +- `cmd/api`: JSON-API; besitzt Authentifizierung, Autorisierung und Fachlogik. +- `cmd/web`: serverseitig gerenderte Webanwendung; greift ausschließlich über + `lib/client` auf die API zu. +- `cmd/cli`: Administration über direkten PostgreSQL-Zugriff. + +PostgreSQL ist die einzige unterstützte Datenbank. Das Web-Frontend soll in den +wesentlichen Abläufen ohne JavaScript funktionieren; htmx ergänzt diese Abläufe. + +## Arbeitsweise + +- Vor Änderungen zuerst den betroffenen Ablauf über alle Schichten verfolgen. + Relevante Implementierungen, Helfer und Tests mit `rg` suchen. +- Änderungen klein und auf den Auftrag begrenzt halten. Keine beiläufigen + Umbenennungen, Formatierungen oder Refactorings außerhalb des betroffenen + Bereichs durchführen. +- Bestehende, nicht zum Auftrag gehörende Änderungen im Arbeitsverzeichnis + erhalten und nicht überschreiben. +- Fachliche Änderungen als vollständigen Vertical Slice umsetzen, soweit + betroffen: Migration, Storage-Vertrag, PostgreSQL-Adapter, API, Client, Web und + Tests. +- Öffentliche Schnittstellen und persistierte Daten nur bewusst und + rückwärtskompatibel ändern. Unvermeidbare Brüche klar dokumentieren. + +## Struktur und Schichtengrenzen + +- `cmd/*` bleibt schlank und enthält nur Konfiguration, Verdrahtung und Startcode. +- Datenbankzugriffe gehören hinter die Verträge in `internal/storage`; konkrete + PostgreSQL-Implementierungen liegen in `internal/storage/postgres`. +- HTTP- und Berechtigungslogik der JSON-API gehört in `internal/api`. +- Wiederverwendbare API-Aufrufe des Web-Clients gehören in `lib/client`. +- `internal/web` greift nicht direkt auf PostgreSQL oder Storage-Modelle zu. +- Browserpfade werden zentral in `internal/web/paths.go` gepflegt; keine + verstreuten Pfad-Literale einführen. +- Templates, statische Dateien und Mailvorlagen in ihren bestehenden Verzeichnissen + ablegen und die vorhandenen Einbettungsmechanismen beibehalten. +- Neue Pakete nur bilden, wenn sie eine klare Verantwortung besitzen. Keine + Sammelpakete wie `utils`, `common` oder `helpers` neu einführen. + +## Idiomatischer Go-Code + +- Die in `go.mod` festgelegte Go-Version und Standardbibliothek bevorzugen. +- Geänderte Go-Dateien mit `gofmt` formatieren. Code muss `go vet` und + `staticcheck` bestehen. +- Kleine, fokussierte Funktionen, frühe Rückgaben und verständliche Namen + bevorzugen. Kommentare erklären das Warum, nicht den offensichtlichen Ablauf. +- Fehler mit hilfreichem Kontext und `%w` weiterreichen, wenn Aufrufer die Ursache + noch untersuchen sollen. Erwartbare Domänenfehler mit `errors.Is`/`errors.As` + behandeln. +- `context.Context` entlang bestehender Request- und Storage-Grenzen weitergeben; + keine neuen Hintergrundkontexte mitten in einem Request-Ablauf erzeugen. +- Abhängigkeiten explizit verdrahten. Globale veränderliche Zustände vermeiden. +- Neue Produktionsabhängigkeiten nur bei klarem Mehrwert einführen. Vorhandene + Standardbibliotheks- oder Projektlösungen bevorzugen und neue Abhängigkeiten in + der Übergabe begründen. + +## Keine Codeduplizierung + +- Vor dem Anlegen neuer Typen, Validatoren, Abfragen, Handler-Helfer, Clientmethoden + oder Template-Teile nach gleichartigem Code suchen. +- Gemeinsame fachliche Regeln an einer Stelle implementieren und von den + aufrufenden Schichten wiederverwenden. API und Web dürfen dieselbe Fachregel + nicht unabhängig voneinander nachbilden. +- Wiederholte SQL-Fragmente, Filter- und Paginglogik über die bereits vorhandenen + Storage-Helfer konsolidieren, sofern Semantik und Sicherheitsgrenzen identisch + sind. +- Ähnliche, aber fachlich unterschiedliche Abläufe nicht vorschnell abstrahieren. + Eine Abstraktion muss Namen, Verantwortung und Fehlerverhalten klarer machen. +- Beim Entfernen einer Duplikation alle Aufrufer migrieren und das alte Konstrukt + löschen, sobald es nicht mehr benötigt wird. + +## Fachliche und sicherheitsrelevante Regeln + +- Ein Garten ist eine abgeschlossene Daten- und Berechtigungsgrenze. Jede + gartenbezogene Abfrage muss die Garten-ID berücksichtigen. +- Fremde oder nicht sichtbare Garten-IDs liefern `404`; verbotene Aktionen in einem + sichtbaren Garten liefern `403`. +- Authentifizierung und Autorisierung verbleiben in der API. Das Web leitet das + Session-Cookie über einen request-spezifischen API-Client weiter. +- Rollen und Berechtigungen nicht durch reine UI-Prüfungen absichern. +- Artenstammdaten und konkrete Pflanzen getrennt halten; Pflanzen dürfen ohne Art + existieren. +- Automatisch erzeugte Aufgaben müssen idempotent bleiben. Fälligkeitsfenster und + Zeiträume über Jahresgrenzen hinweg korrekt behandeln. +- Nutzereingaben an der zuständigen Systemgrenze validieren. HTML-Ausgabe über + `html/template` escapen und bestehende CSRF-, Cookie- und Security-Header- + Mechanismen nicht umgehen. +- Keine Zugangsdaten, Tokens, echte personenbezogene Daten oder lokale `.env`- und + `.envrc`-Inhalte committen oder in Logs und Tests ausgeben. + +## Datenbank und Migrationen + +- Schemaänderungen ausschließlich als neues, fortlaufend nummeriertes Paar aus + `.up.sql` und `.down.sql` in `internal/storage/postgres/migrations` hinzufügen. +- Bereits eingecheckte Migrationen nicht nachträglich ändern, außer der Benutzer + fordert dies ausdrücklich und die Migration wurde nachweislich noch nirgends + angewendet. +- Migrationen müssen auf einer leeren Datenbank vorwärts laufen. Die Down-Migration + muss die Änderung soweit sinnvoll und sicher rückgängig machen. +- SQL parametrieren, Transaktionsgrenzen bewusst wählen und konkurrierende + Zugriffe berücksichtigen. +- Neue oder geänderte Abfragen durch PostgreSQL-Integrationstests abdecken, wenn + Verhalten nicht sinnvoll mit einem Unit-Test geprüft werden kann. + +## Tests + +- Jede Verhaltensänderung erhält passende Tests, sofern technisch möglich. Fehler- + und Berechtigungspfade gehören ebenso dazu wie der Erfolgsfall. +- Fehlerbehebungen möglichst zuerst mit einem Regressionstest reproduzieren. +- Tests nahe am getesteten Paket ablegen und vorhandene Testhelfer wiederverwenden. + Tabellengetriebene Tests nutzen, wenn mehrere gleichartige Fälle dadurch klarer + werden. +- Tests müssen deterministisch und voneinander unabhängig sein. Keine echten + Netzwerkdienste, Uhrzeit oder Zufallswerte unkontrolliert voraussetzen. +- Bestehende Tests nicht löschen, abschwächen oder überspringen, nur um einen Build + grün zu bekommen. +- Zuerst die direkt betroffenen Pakete testen, anschließend standardmäßig: + + ```sh + go test ./... + ``` + +- Vor Abschluss einer größeren oder sicherheitsrelevanten Änderung zusätzlich + ausführen: + + ```sh + make audit + ``` + +- PostgreSQL-Integrationstests nur mit einer ausdrücklich dafür vorgesehenen + Datenbank ausführen: + + ```sh + GARDOMATIC_TEST_DB_DSN="$TEST_DATABASE_URL" go test -count=1 ./internal/storage/postgres + ``` + + `make test/integration` migriert die in `GARDOMATIC_DB_DSN` konfigurierte + Datenbank. Niemals versehentlich gegen Produktion ausführen. + +## Dokumentation und Abschluss + +- README, Konfigurationsbeispiele und weiterführende Dokumentation aktualisieren, + wenn sich Setup, Befehle, Umgebungsvariablen oder Nutzerverhalten ändern. +- Für Beiträge Dritter gelten `CONTRIBUTING.md` und `CLA.md`. Die CLA-Checkbox oder + Zustimmung niemals stellvertretend für eine beitragende Person setzen. +- Den CLA-Workflow nicht umgehen, abschwächen oder auf einen grünen Status setzen, + wenn die nachweisbare Zustimmung der beitragenden Person fehlt. +- Kein Fremdmaterial übernehmen, dessen Herkunft, Lizenz oder Vereinbarkeit mit + der Projektlizenz und der CLA unklar ist. +- Rechtstexte (`LICENSE` und `CLA.md`) nur auf ausdrücklichen Auftrag ändern. +- Vor der Übergabe den Diff auf unnötige Änderungen, Duplikationen, Geheimnisse und + fehlende Tests prüfen. +- In der Übergabe geänderte Bereiche, ausgeführte Prüfungen und verbleibende Risiken + knapp nennen. Übersprungene Prüfungen mit Grund aufführen. diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..93f0faf --- /dev/null +++ b/CLA.md @@ -0,0 +1,182 @@ +# Gardomatic Contributor License Agreement + +Version 1.0, 10 September 2026 + +Thank you for your interest in contributing to Gardomatic. This Contributor +License Agreement (the "Agreement") documents the rights granted by contributors +to Alexander Klein ("We", "Us", or the "Project Owner"). It is based on the +Harmony Contributor License Agreement Template, Version 1.0. + +This is a legally binding agreement. Please read it carefully before submitting a +Contribution. + +## How to accept this Agreement + +For an individual Contribution, You accept this Agreement by submitting a pull +request and checking the CLA acceptance box included in the pull request template. +The pull request and its history provide the electronic record of acceptance. + +If You contribute on behalf of a Legal Entity, or if Your employer owns or may own +rights in the Contribution, an authorized representative must contact +alex@kleiax.de before the Contribution can be accepted. We may request a separately +signed entity agreement. + +Do not include a home address, identification document, or other unnecessary +personal information in a public pull request. + +## 1. Definitions + +"You" means the individual who Submits a Contribution to Us. When a Contribution +is made on behalf of a Legal Entity, "You" also means that Legal Entity and its +Affiliates where the context requires it. + +"Legal Entity" means an entity that is not a natural person. "Affiliates" means +other Legal Entities that control, are controlled by, or are under common control +with that Legal Entity. "Control" means (i) the direct or indirect power to direct +the management of an entity, whether by contract or otherwise, (ii) ownership of +fifty percent or more of the securities entitled to elect its management, or (iii) +beneficial ownership of the entity. + +"Contribution" means any work of authorship that is Submitted by You to Us and in +which You own or assert ownership of the Copyright. + +"Copyright" means all rights protecting works of authorship owned or controlled by +You or Your Affiliates, including copyright, moral, and neighboring rights, for +their full term and any extensions. + +"Material" means Gardomatic and its associated source code, documentation, media, +and other materials made available by Us to third parties. After You Submit a +Contribution, it may be included in the Material. + +"Submit" means any electronic, verbal, or written communication sent to Us or our +representatives through systems managed by or on behalf of Us for discussing or +improving the Material. This includes pull requests, commits, patches, issue +trackers, and code review comments. Communications conspicuously marked "Not a +Contribution" are excluded. + +"Submission Date" means the date on which You Submit a Contribution to Us. + +"Effective Date" means the date You accept this Agreement or the date You first +Submit a Contribution to Us, whichever is earlier. + +## 2. Grant of Rights + +### 2.1 Copyright License + +You retain ownership of the Copyright in Your Contribution and keep the same rights +to use or license the Contribution that You would have had without entering into +this Agreement. + +To the maximum extent permitted by applicable law, You grant to Us a perpetual, +worldwide, non-exclusive, transferable, royalty-free, and irrevocable license under +the Copyright covering the Contribution. This license includes the right to +sublicense through multiple tiers of sublicensees and to reproduce, modify, +prepare derivative works of, display, perform, distribute, and otherwise use the +Contribution as part of the Material, subject to Section 2.3. + +### 2.2 Patent License + +For patent claims, including method, process, and apparatus claims, that You or Your +Affiliates own, control, or have the right to grant now or in the future, You grant +to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, and +irrevocable patent license, with the right to sublicense through multiple tiers of +sublicensees, to make, have made, use, sell, offer for sale, import, and otherwise +transfer the Contribution and the Contribution in combination with the Material. + +This patent license is granted only to the extent that exercising the rights in the +Contribution would infringe those patent claims and is subject to Section 2.3. + +### 2.3 Outbound License + +If We include Your Contribution in the Material, We may license the Contribution +under any license, including copyleft, permissive, commercial, or proprietary +licenses. + +As a condition of exercising this right, We will also make the Contribution +available under the license or licenses that apply to the Material on the +Submission Date. At the time this Agreement was published, that license is the +PolyForm Noncommercial License 1.0.0. + +### 2.4 Moral Rights + +If moral rights apply to the Contribution, to the maximum extent permitted by law, +You waive and agree not to assert those rights against Us, our successors in +interest, or our direct or indirect licensees in connection with exercising the +rights granted by this Agreement. + +### 2.5 Our Rights + +We are not obligated to use, merge, publish, or continue to distribute any +Contribution. + +### 2.6 Reservation of Rights + +Any rights not expressly licensed under this Section 2 are reserved by You. + +## 3. Your Confirmations + +You confirm that: + +1. You have the legal authority to enter into this Agreement. +2. You or Your Affiliates own the Copyright and patent claims covering the + Contribution that are required to grant the rights in Section 2. +3. The rights granted under Section 2 do not violate rights previously granted to + third parties, including Your employer. +4. If You are an employee and Your employer owns or may own rights in the + Contribution, You have obtained its written approval or an authorized + representative has entered into an entity agreement with Us. +5. You have clearly identified any part of the Contribution that You did not author + and have provided its source and applicable license. You will not Submit + third-party material unless its license permits the Contribution and the rights + granted by this Agreement. +6. If You are under eighteen years old, Your parent or legal guardian has approved + Your acceptance of this Agreement. + +## 4. Disclaimer + +EXCEPT FOR THE EXPRESS CONFIRMATIONS IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS +IS". TO THE MAXIMUM EXTENT PERMITTED BY LAW, YOU DISCLAIM ALL EXPRESS OR IMPLIED +WARRANTIES, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, AND NON-INFRINGEMENT. IF A WARRANTY CANNOT BE DISCLAIMED, IT IS LIMITED TO +THE MINIMUM SCOPE AND DURATION PERMITTED BY LAW. + +## 5. Consequential Damage Waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NEITHER YOU NOR WE WILL BE LIABLE +TO THE OTHER UNDER THIS AGREEMENT FOR LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, +LOSS OF DATA, OR INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR EXEMPLARY +DAMAGES, REGARDLESS OF THE LEGAL THEORY ON WHICH THE CLAIM IS BASED. + +## 6. Miscellaneous + +1. This Agreement is governed by the laws of the Federal Republic of Germany, + excluding its conflict-of-law rules and the United Nations Convention on + Contracts for the International Sale of Goods. +2. This Agreement is the entire agreement between You and Us concerning Your + Contributions and replaces prior agreements or understandings about those + Contributions. +3. If You or We transfer rights or obligations received under this Agreement to a + third party, that third party must agree in writing to comply with the relevant + rights and obligations of this Agreement. +4. A failure to require performance of a provision once does not waive the right to + require it later. +5. If a provision is found invalid or unenforceable, it will be replaced to the + extent possible by an enforceable provision that most closely reflects its + purpose. The remaining provisions continue in effect. +6. We may publish a new version of this Agreement for future Contributions. A new + version does not retroactively change the terms governing Contributions already + Submitted under an earlier version. + +## Template attribution + +This Agreement is adapted from the Harmony Contributor Agreement Template, +Version 1.0, published by Harmony Agreements under the Creative Commons Attribution +3.0 Unported License: + + + +The template has been customized for Gardomatic by selecting a contributor license +(not a copyright assignment), selecting Harmony outbound license Option Five, +naming the parties and project, defining electronic acceptance, choosing German +law, and removing unused template alternatives and placeholders. Harmony +Agreements does not endorse this adaptation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2bd6762 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Zu Gardomatic beitragen + +Vielen Dank für dein Interesse an Gardomatic. Beiträge sollen klein, +nachvollziehbar und mit der bestehenden Architektur vereinbar sein. + +## Vor dem ersten Beitrag + +1. Lies die Projektbeschreibung und den Entwicklungsablauf in der + [`README.md`](README.md). +2. Beachte die verbindlichen technischen Regeln in [`AGENTS.md`](AGENTS.md). +3. Lies die [`Contributor License Agreement`](CLA.md). Sie ist eine rechtlich + bindende Vereinbarung. + +Mit der CLA behältst du das Copyright an deinem Beitrag. Du erlaubst Alexander +Klein zugleich, den Beitrag innerhalb von Gardomatic unter der öffentlichen +PolyForm-Lizenz sowie unter separaten kommerziellen oder proprietären Lizenzen zu +verwenden. Das ermöglicht das in diesem Projekt vorgesehene Dual-Licensing-Modell. + +### CLA annehmen + +Einzelpersonen nehmen Version 1.0 der CLA an, indem sie einen Pull Request mit der +unveränderten CLA-Erklärung aus der Pull-Request-Vorlage einreichen und die +zugehörige Checkbox selbst markieren. Die Annahme gilt auch für spätere Beiträge +unter derselben CLA-Version. + +Der Workflow `.github/workflows/cla.yml` prüft diese Erklärung bei jedem Pull +Request. Ein fehlgeschlagener CLA-Check darf nicht umgangen oder administrativ als +Ersatz für die Zustimmung der beitragenden Person bestätigt werden. + +Beiträge im Namen eines Unternehmens oder einer anderen Organisation müssen vorab +mit `alex@kleiax.de` abgestimmt werden. Dasselbe gilt, wenn dein Arbeitgeber Rechte +an deinem Beitrag besitzen könnte. Eine Projektperson darf die CLA-Erklärung nicht +stellvertretend für Beitragende markieren. + +## Entwicklungsablauf + +1. Erstelle einen fokussierten Branch und beschreibe das zu lösende Problem. +2. Suche vor der Implementierung nach vorhandenen Helfern und ähnlichen Abläufen. +3. Halte die Schichtengrenzen zwischen API, Storage, Client und Web ein. +4. Ergänze Tests für neues oder korrigiertes Verhalten, soweit technisch möglich. +5. Formatiere geänderten Go-Code mit `gofmt`. +6. Führe zunächst die betroffenen Tests und anschließend `go test ./...` aus. +7. Führe bei größeren Änderungen zusätzlich `make audit` aus. +8. Erläutere im Pull Request Verhalten, Motivation, Tests und mögliche Risiken. + +Für Datenbankänderungen ist ein neues Up-/Down-Migrationspaar erforderlich. +Bereits veröffentlichte Migrationen dürfen nicht nachträglich verändert werden. + +## Pull Requests + +Ein Pull Request sollte: + +- genau ein zusammenhängendes Problem lösen, +- keine unbeabsichtigten oder fachfremden Änderungen enthalten, +- vorhandene Tests nicht abschwächen, +- geändertes Nutzerverhalten und Konfiguration dokumentieren, +- alle verwendeten Quellen und Fremdmaterialien mit ihrer Lizenz offenlegen und +- die CLA-Erklärung enthalten. + +Ein Beitrag kann abgelehnt oder bis zur Klärung zurückgestellt werden. Das gilt +insbesondere bei fehlender CLA-Annahme, unklaren Rechten, Sicherheitsproblemen, +fehlenden Tests oder Änderungen außerhalb des vereinbarten Umfangs. + +## Fremdmaterial und KI-gestützte Beiträge + +Reiche nur Material ein, an dem du die erforderlichen Rechte besitzt. Kopiere +keinen Code, keine Texte, Bilder oder anderen Inhalte aus inkompatibel lizenzierten +Quellen. Kennzeichne Fremdmaterial und nenne Quelle sowie Lizenz. + +Für KI-gestützte Beiträge bleibst du selbst verantwortlich. Prüfe den erzeugten +Inhalt auf Korrektheit, Sicherheit, Herkunft und mögliche Lizenzkonflikte, bevor du +ihn einreichst. + +## Sicherheitsprobleme + +Noch nicht veröffentlichte Sicherheitslücken sollten nicht als öffentlicher Issue +oder Pull Request eingereicht werden. Melde sie zunächst vertraulich an +`alex@kleiax.de` und füge keine echten Zugangsdaten oder personenbezogenen Daten +bei. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9398062 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM docker.io/library/golang:1.26-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +FROM build AS api-build +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +FROM build AS web-build +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/web ./cmd/web + +FROM docker.io/library/alpine:3.22 AS runtime + +RUN apk add --no-cache ca-certificates \ + && addgroup -S gardomatic \ + && adduser -S -G gardomatic gardomatic + +WORKDIR /app +USER gardomatic + +FROM runtime AS api +COPY --from=api-build /out/api /app/api +EXPOSE 4000 +ENTRYPOINT ["/app/api"] + +FROM runtime AS web +COPY --from=web-build /out/web /app/web +EXPOSE 4040 +ENTRYPOINT ["/app/web"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8e6626 --- /dev/null +++ b/LICENSE @@ -0,0 +1,136 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. + +Required Notice: Copyright © 2026 Alexander Klein. + +For permission to use Gardomatic commercially, contact +Alexander Klein at alex@kleiax.de to obtain a separate commercial license. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e95c09 --- /dev/null +++ b/Makefile @@ -0,0 +1,197 @@ +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: + +# Non-secret deployment settings. Runtime secrets remain in the shell-compatible +# .envrc and remote/setup/.env files instead of being parsed by Make. +-include config.mk + +define load_envrc +set -a; [[ ! -f ./.envrc ]] || source ./.envrc; set +a; +endef + +# ==================================================================================== # +# HELPERS +# ==================================================================================== # + +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' $(firstword $(MAKEFILE_LIST)) | column -t -s ':' | sed -e 's/^/ /' + +.PHONY: confirm +confirm: + @read -r -p "Are you sure? [y/N] " answer && [[ "$${answer:-N}" == y ]] + +## config/init: create missing local configuration files from their examples +.PHONY: config/init +config/init: + @if [[ -e ./.env ]]; then echo 'keep .env'; else install -m 0600 ./.env.example ./.env; echo 'create .env'; fi + @if [[ -e ./.envrc ]]; then echo 'keep .envrc'; else install -m 0600 ./.envrc.example ./.envrc; echo 'create .envrc'; fi + @if [[ -e ./config.mk ]]; then echo 'keep config.mk'; else install -m 0600 ./config.mk.example ./config.mk; echo 'create config.mk'; fi + @if [[ -e ./remote/setup/.env ]]; then echo 'keep remote/setup/.env'; else install -m 0600 ./remote/setup/.env.example ./remote/setup/.env; echo 'create remote/setup/.env'; fi + +# ==================================================================================== # +# DEVELOPMENT +# ==================================================================================== # + +## run/api: run the cmd/api application +.PHONY: run/api +run/api: + @${load_envrc} go run ./cmd/api + +## run/web: run the cmd/web application +.PHONY: run/web +run/web: + @${load_envrc} go run ./cmd/web + +## run/cli: run the cmd/cli administration tool (use ARGS='...') +.PHONY: run/cli +run/cli: + @${load_envrc} go run ./cmd/cli ${ARGS} + +## db/psql: connect to the database using psql +.PHONY: db/psql +db/psql: + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; psql "$$GARDOMATIC_DB_DSN" + +## db/migrations/new name=$1: create a new database migration +.PHONY: db/migrations/new +db/migrations/new: + @[[ "${name}" =~ ^[a-z0-9_]+$$ ]] || { echo 'name must contain lowercase letters, digits, or underscores' >&2; exit 1; } + migrate create -seq -ext=.sql -dir=./internal/storage/postgres/migrations "${name}" + +## db/migrations/up: apply all up database migrations +.PHONY: db/migrations/up +db/migrations/up: + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" up + +## db/migrations/down: apply all down database migrations +.PHONY: db/migrations/down +db/migrations/down: confirm + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" down + +# ==================================================================================== # +# QUALITY CONTROL +# ==================================================================================== # + +## tidy: tidy module dependencies and format all Go files +.PHONY: tidy +tidy: + go mod tidy + go mod verify + go fmt ./... + +## audit: run quality control checks +.PHONY: audit +audit: + go mod tidy -diff + go mod verify + go vet ./... + go tool staticcheck ./... + go test -race -vet=off ./... + +## test/integration: migrate the configured PostgreSQL database and run integration tests +.PHONY: test/integration +test/integration: + @${load_envrc} test -n "$${GARDOMATIC_TEST_DB_DSN:-}" || { echo 'GARDOMATIC_TEST_DB_DSN is required and must point to a disposable test database' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_TEST_DB_DSN" up; go test -count=1 ./internal/storage/postgres + +# ==================================================================================== # +# BUILD +# ==================================================================================== # + +.PHONY: build/dirs +build/dirs: + mkdir -p ./bin ${PRODUCTION_BUILD_DIR} + +## build/api: build the cmd/api application +.PHONY: build/api +build/api: build/dirs + go build -ldflags='-s' -o=./bin/api ./cmd/api + +## build/web: build the cmd/web application +.PHONY: build/web +build/web: build/dirs + go build -ldflags='-s' -o=./bin/web ./cmd/web + +## build/cli: build the cmd/cli administration tool +.PHONY: build/cli +build/cli: build/dirs + go build -ldflags='-s' -o=./bin/cli ./cmd/cli + +## build/production: cross-compile all production binaries +.PHONY: build/production +build/production: build/dirs + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/api ./cmd/api + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/web ./cmd/web + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/cli ./cmd/cli + +## code/stats: print source lines and their estimated number of book pages +.PHONY: code/stats +code/stats: + @lines=$$(git ls-files -z -- '*.go' '*.tmpl' '*.html' '*.css' '*.js' '*.sql' '*.sh' \ + ':(exclude)vendor/**' ':(exclude)**/*.min.css' ':(exclude)**/*.min.js' \ + | xargs -0 awk 'END { print NR }'); \ + pages=$$((($$lines + 49) / 50)); \ + printf 'Codeumfang: %s Zeilen\nBuchumfang: ca. %s Seiten (bei 50 Codezeilen pro Seite)\n' "$$lines" "$$pages" + +## build/all: build API, web, and administration CLI, then print code statistics +.PHONY: build/all +build/all: build/api build/web build/cli + @$(MAKE) --no-print-directory code/stats + +# ==================================================================================== # +# PRODUCTION +# ==================================================================================== # + +PRODUCTION_HOST ?= +PRODUCTION_SSH_USER ?= root +PRODUCTION_SSH_PORT ?= 22 +PRODUCTION_SSH_IDENTITY_FILE ?= +PRODUCTION_GOOS ?= linux +PRODUCTION_GOARCH ?= amd64 +PRODUCTION_BUILD_DIR = ./bin/${PRODUCTION_GOOS}_${PRODUCTION_GOARCH} + +production_target = ${PRODUCTION_SSH_USER}@${PRODUCTION_HOST} +production_ssh_options = -p ${PRODUCTION_SSH_PORT} +ifneq ($(strip ${PRODUCTION_SSH_IDENTITY_FILE}),) +production_ssh_options += -i ${PRODUCTION_SSH_IDENTITY_FILE} +endif + +.PHONY: production/check-config +production/check-config: + @test -n "${PRODUCTION_HOST}" || { echo 'PRODUCTION_HOST is required; configure it in config.mk' >&2; exit 1; } + @test -n "${PRODUCTION_SSH_USER}" || { echo 'PRODUCTION_SSH_USER is required' >&2; exit 1; } + @[[ "${PRODUCTION_SSH_PORT}" =~ ^[0-9]+$$ ]] && (( ${PRODUCTION_SSH_PORT} >= 1 && ${PRODUCTION_SSH_PORT} <= 65535 )) || { echo 'PRODUCTION_SSH_PORT must be between 1 and 65535' >&2; exit 1; } + @[[ "${PRODUCTION_GOOS}" == linux ]] || { echo 'PRODUCTION_GOOS must be linux' >&2; exit 1; } + @[[ "${PRODUCTION_GOARCH}" == amd64 || "${PRODUCTION_GOARCH}" == arm64 ]] || { echo 'PRODUCTION_GOARCH must be amd64 or arm64' >&2; exit 1; } + @if [[ -n "${PRODUCTION_SSH_IDENTITY_FILE}" && ! -f "${PRODUCTION_SSH_IDENTITY_FILE}" ]]; then echo 'PRODUCTION_SSH_IDENTITY_FILE does not exist' >&2; exit 1; fi + +## production/connect: connect to the production server +.PHONY: production/connect +production/connect: production/check-config + ssh ${production_ssh_options} ${production_target} + +## production/provision: provision a fresh Ubuntu server (root or passwordless sudo required) +.PHONY: production/provision +production/provision: production/check-config + @test -f ./remote/setup/.env || { echo 'Copy remote/setup/.env.example to remote/setup/.env and configure it first' >&2; exit 1; } + @permissions=$$(stat -c '%a' ./remote/setup/.env); (( (8#$$permissions & 077) == 0 )) || { echo 'remote/setup/.env must have mode 0600' >&2; exit 1; } + rsync -P -e "ssh ${production_ssh_options}" ./remote/setup/provision-server.sh ${production_target}:/tmp/gardomatic-provision-server.sh + ssh ${production_ssh_options} ${production_target} 'status=0; chmod 700 /tmp/gardomatic-provision-server.sh && /tmp/gardomatic-provision-server.sh --env-file - || status=$$?; rm -f /tmp/gardomatic-provision-server.sh; exit $$status' < ./remote/setup/.env + +## production/deploy: deploy API and web to production +.PHONY: production/deploy +production/deploy: production/check-config build/production + ssh ${production_ssh_options} ${production_target} 'mkdir -p "$$HOME/gardomatic-deploy/migrations"' + rsync -P -e "ssh ${production_ssh_options}" ${PRODUCTION_BUILD_DIR}/api ${PRODUCTION_BUILD_DIR}/web ${PRODUCTION_BUILD_DIR}/cli ${production_target}:gardomatic-deploy/ + rsync -rP --delete -e "ssh ${production_ssh_options}" ./internal/storage/postgres/migrations/ ${production_target}:gardomatic-deploy/migrations/ + rsync -P -e "ssh ${production_ssh_options}" ./remote/production/api.service ./remote/production/web.service ./remote/production/deploy-server.sh ./remote/setup/create-admin.sh ${production_target}:gardomatic-deploy/ + ssh -t ${production_ssh_options} ${production_target} 'chmod 700 "$$HOME/gardomatic-deploy/deploy-server.sh" && "$$HOME/gardomatic-deploy/deploy-server.sh"' + +## production/create-admin: interactively create an active application administrator +.PHONY: production/create-admin +production/create-admin: production/check-config + ssh -t ${production_ssh_options} ${production_target} /usr/local/sbin/gardomatic-create-admin diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aedef0 --- /dev/null +++ b/README.md @@ -0,0 +1,456 @@ +# Gardomatic + +Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur gemeinsamen +Organisation von Gärten. Sie bündelt Pflanzenwissen, Pflanzorte, anstehende +Arbeiten, Bilder und ein Gartentagebuch in einer Anwendung. + +Das Projekt besteht aus einer JSON-API und einer serverseitig gerenderten +Webanwendung in Go. PostgreSQL speichert alle Fachdaten. Das Frontend ist +mobile-first, bleibt in den wesentlichen Abläufen ohne JavaScript nutzbar und +verwendet htmx für komfortable Teilaktualisierungen. Eine installierbare PWA stellt +eine Offline-App-Shell bereit; die eigentlichen Gartendaten bleiben serverseitig. + +> **Projektstatus:** Gardomatic wird aktiv entwickelt. Datenmodell, API und +> Bedienoberfläche können sich noch verändern. + +## Funktionen + +- mehrere voneinander isolierte Gärten pro Benutzer +- gemeinsame Gartenpflege mit Einladungen, Rollen und Berechtigungen +- Verwaltung von Arten, Sorten, konkreten Pflanzen und Pflanzorten +- Pflegehinweise, Kategorien, Tags und Bilder +- Aufgaben mit Fälligkeitsfenstern, Prioritäten und Wiederholungen +- Aufgabenvorlagen pro Art und automatische, idempotente Aufgabenerzeugung +- Aufgabenansicht und Kalenderdarstellung +- Gartentagebuch, Pinnwand und Bildbibliothek +- Benutzerkonto, Aktivierung, Sitzungsverwaltung und Passwortänderung +- administrative Einladung, Anonymisierung und Verwaltung von Benutzern, Rollen und Anwendungseinstellungen inklusive maskierter Laufzeitkonfiguration und Testmailversand +- Kommandozeilenwerkzeug zur Benutzer- und Datenbankadministration + +## Architektur + +```text +Browser + │ + ▼ +Webanwendung :4040 ──► typisierter API-Client + │ + ▼ + JSON-API :4000 + │ + ▼ + Storage-Schnittstellen + │ + ▼ + PostgreSQL 16 +``` + +Die API ist die maßgebliche Sicherheits- und Fachgrenze. Sie verwaltet +Authentifizierung, Sitzungen, Autorisierung, Validierung und Datenzugriff. Die +Webanwendung greift nicht direkt auf die Datenbank zu, sondern leitet das +Session-Cookie eines Requests über `lib/client` an die API weiter. + +Gartenbezogene Browserrouten beginnen mit `/g/{gardenID}/`, die entsprechenden +API-Ressourcen mit `/v1/gardens/{gardenID}/`. Ein Garten bildet eine abgeschlossene +Daten- und Berechtigungsgrenze. + +### Programme + +| Programm | Aufgabe | Standardadresse | +| --- | --- | --- | +| `cmd/api` | JSON-API und Fachlogik | `http://localhost:4000` | +| `cmd/web` | serverseitig gerenderte Weboberfläche | `http://localhost:4040` | +| `cmd/cli` | Administration direkt über PostgreSQL | keine | + +## Voraussetzungen + +Für den vollständigen lokalen Betrieb werden benötigt: + +- Go gemäß der Version in `go.mod` (aktuell Go 1.26 oder neuer) +- PostgreSQL 16 +- optional Docker oder Podman mit Compose-Unterstützung +- [`golang-migrate`](https://github.com/golang-migrate/migrate) für lokale + Migrationen und Integrationstests +- `make` für die bereitgestellten Entwicklungsbefehle + +Die Compose-Variante bringt PostgreSQL und `golang-migrate` bereits als Container +mit. Go wird dort nur benötigt, wenn das Administrations-CLI auf dem Host verwendet +werden soll. + +## Schnellstart mit Compose + +1. Konfiguration anlegen und mindestens das Datenbankpasswort ändern: + + ```sh + cp .env.example .env + ``` + + `POSTGRES_PASSWORD` und das Passwort in `GARDOMATIC_DB_DSN` sollten + übereinstimmen, damit auch lokale CLI- und Testbefehle dieselbe Datenbank + erreichen können. `.env` ist von Git ausgeschlossen. + +2. Datenbank, Migrationen, API und Webanwendung starten: + + ```sh + docker compose up --build + ``` + + Bei Podman kann je nach Installation stattdessen `podman compose` oder + `podman-compose` verwendet werden. + +3. Einen ersten aktiven Benutzer anlegen. Dazu in einem zweiten Terminal die DSN + aus der lokalen `.env` setzen und das CLI starten: + + ```sh + export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' + go run ./cmd/cli users create \ + --name 'Admin' \ + --email 'admin@example.com' \ + --role application:admin \ + --active \ + --generate-password + ``` + + Das generierte Passwort wird einmalig ausgegeben. + +4. [http://localhost:4040](http://localhost:4040) öffnen und anmelden. + +Die API-Gesundheitsprüfung ist unter +[http://localhost:4000/v1/healthcheck](http://localhost:4000/v1/healthcheck), die +Web-Gesundheitsprüfung unter [http://localhost:4040/ping](http://localhost:4040/ping) +erreichbar. Eine lesbare Statusseite mit API- und Serverinformationen steht unter +[http://localhost:4040/healtcheck](http://localhost:4040/healtcheck) bereit. + +Den Stack beendet `docker compose down`. Die PostgreSQL-Daten liegen im benannten +Volume `gardomatic-postgres-data` und bleiben dabei erhalten. `docker compose down +-v` löscht dieses Volume und damit die lokale Datenbank dauerhaft. + +## Lokale Entwicklung ohne Anwendungscontainer + +PostgreSQL und die Migrationen können weiterhin über Compose laufen: + +```sh +cp .env.example .env +docker compose up -d postgres migrate +``` + +Der `Makefile` bindet eine lokale, nicht versionierte `.envrc` ein. Mindestens die +Datenbankverbindung muss darin für API-, CLI- und Datenbankbefehle exportiert sein: + +```sh +export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' +``` + +Danach API und Webanwendung in getrennten Terminals starten: + +```sh +make run/api +``` + +```sh +make run/web +``` + +Alternativ können beide Programme direkt mit `go run ./cmd/api` und +`go run ./cmd/web` gestartet werden, sofern die benötigten Umgebungsvariablen in +der Shell gesetzt sind. Die API benötigt zwingend `GARDOMATIC_DB_DSN`; alle +anderen Entwicklungswerte besitzen sinnvolle Standardwerte. + +## Konfiguration + +`.env.example` dokumentiert eine vollständige lokale Konfiguration. Geheimnisse +gehören ausschließlich in `.env`, `.envrc`, einen Secret Store oder die +Produktionsumgebung und dürfen nicht eingecheckt werden. + +### Compose-Variablen + +| Variable | Standard/Beispiel | Beschreibung | +| --- | --- | --- | +| `POSTGRES_DB` | `gardomatic` | Datenbankname des PostgreSQL-Containers | +| `POSTGRES_USER` | `gardomatic` | Datenbankbenutzer des Containers | +| `POSTGRES_PASSWORD` | `change-me` | Datenbankpasswort; lokal unbedingt ändern | +| `POSTGRES_PORT` | `5432` | auf dem Host veröffentlichter PostgreSQL-Port | +| `API_PORT` | `4000` | auf dem Host veröffentlichter API-Port | +| `WEB_PORT` | `4040` | auf dem Host veröffentlichter Web-Port | + +### Laufzeitvariablen + +| Variable | Standard | Verwendung | +| --- | --- | --- | +| `GARDOMATIC_ENV` | `development` | `development`, `test` oder `production` | +| `GARDOMATIC_DB_DSN` | erforderlich | PostgreSQL-Verbindungszeichenfolge für API und CLI | +| `GARDOMATIC_DB_MAX_OPEN_CONNS` | `25` | maximale offene DB-Verbindungen | +| `GARDOMATIC_DB_MAX_IDLE_CONNS` | `25` | maximale ungenutzte DB-Verbindungen | +| `GARDOMATIC_DB_MAX_IDLE_TIME` | `15m` | maximale Leerlaufzeit einer DB-Verbindung | +| `GARDOMATIC_API_HOST` | leer | Bind-Adresse der API | +| `GARDOMATIC_API_PORT` | `4000` | Listener-Port der API | +| `GARDOMATIC_WEB_HOST` | leer | Bind-Adresse der Webanwendung | +| `GARDOMATIC_WEB_PORT` | `4040` | Listener-Port der Webanwendung | +| `GARDOMATIC_API_BASE_URL` | `http://localhost:4000` | API-Adresse für den Web-Client | +| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | öffentliche Basis-URL für Links aus API und CLI | +| `GARDOMATIC_SESSION_COOKIE_NAME` | `gardomatic_session` | gemeinsamer Name des Session-Cookies | +| `GARDOMATIC_SESSION_LIFETIME` | `12h` | absolute Lebensdauer einer Sitzung | +| `GARDOMATIC_SESSION_IDLE_TIMEOUT` | `30m` | Ablaufzeit bei Inaktivität | +| `GARDOMATIC_COOKIE_SECURE` | `false` | nur HTTPS-Cookies; in Produktion zwingend `true` | +| `GARDOMATIC_RATE_LIMIT_ENABLED` | `true` | API-Ratenbegrenzung aktivieren | +| `GARDOMATIC_RATE_LIMIT_RPS` | `10` | erlaubte Requests pro Sekunde | +| `GARDOMATIC_RATE_LIMIT_BURST` | `40` | kurzfristig erlaubte Request-Spitze | +| `GARDOMATIC_CORS_TRUSTED_ORIGINS` | leer/lokal gesetzt | kommaseparierte erlaubte Origins | + +### E-Mail-Versand + +| Variable | Standard | Beschreibung | +| --- | --- | --- | +| `GARDOMATIC_SMTP_MODE` | `file` | `file` für Entwicklung oder `smtp` | +| `GARDOMATIC_SMTP_HOST` | leer | SMTP-Server | +| `GARDOMATIC_SMTP_PORT` | `25` | SMTP-Port | +| `GARDOMATIC_SMTP_USERNAME` | leer | SMTP-Benutzername | +| `GARDOMATIC_SMTP_PASSWORD` | leer | SMTP-Passwort | +| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Absenderadresse | +| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Ausgabe im `file`-Modus | + +Im Entwicklungsmodus schreibt der voreingestellte `file`-Mailer E-Mails in die +angegebene Datei. In der Compose-API liegt diese Datei innerhalb des Containers; +sie kann beispielsweise mit `docker compose exec api cat +/tmp/gardomatic-mails.log` gelesen werden. + +## Datenbankmigrationen + +Migrationen liegen als fortlaufend nummerierte Up-/Down-Paare in +`internal/storage/postgres/migrations`. + +Eine neue Migration anlegen: + +```sh +make db/migrations/new name=describe_change +``` + +Alle ausstehenden Migrationen anwenden: + +```sh +make db/migrations/up +``` + +Alle angewendeten Migrationen zurücknehmen: + +```sh +make db/migrations/down +``` + +`migrate down` ohne Schrittzahl setzt das gesamte Schema zurück und kann sämtliche +Anwendungsdaten löschen. Die Make-Ziele verwenden `GARDOMATIC_DB_DSN`; vor Up- und +besonders Down-Befehlen daher immer prüfen, auf welche Datenbank die Variable zeigt. +Bereits veröffentlichte Migrationen sollten nicht verändert werden; +Schemaänderungen erhalten eine neue Migration. + +## Tests und Qualitätsprüfungen + +Alle Unit- und Handler-Tests ausführen: + +```sh +go test ./... +``` + +Die PostgreSQL-Integrationstests werden ohne `GARDOMATIC_TEST_DB_DSN` automatisch +übersprungen. Für einen vollständigen Integrationslauf eine separate Testdatenbank +konfigurieren und zuerst migrieren: + +```sh +export GARDOMATIC_TEST_DB_DSN='postgres://user:password@localhost/gardomatic_test?sslmode=disable' +migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_TEST_DB_DSN" up +go test -count=1 ./internal/storage/postgres +``` + +Alternativ migriert folgendes Ziel die in `GARDOMATIC_DB_DSN` konfigurierte +Datenbank und führt die Integrationstests dagegen aus: + +```sh +make test/integration +``` + +Dieses Ziel ausschließlich mit einer entbehrlichen Testdatenbank verwenden. + +Der vollständige lokale Qualitätslauf umfasst Modulprüfung, `go vet`, +`staticcheck` und Tests mit Race Detector: + +```sh +make audit +``` + +Quelltext modernisieren und formatieren: + +```sh +make tidy +``` + +`make tidy` kann `go.mod`, `go.sum`, den Vendor-Bestand und Go-Quelltext verändern. +Den resultierenden Diff deshalb immer prüfen. + +Die CI unter `.github/workflows/ci.yml` ist dafür konfiguriert, PostgreSQL zu +migrieren, Module und Quelltext zu prüfen, alle Tests mit Race Detector auszuführen +und alle drei Programme zu bauen. + +## Administration mit dem CLI + +Das CLI benötigt für Datenbankbefehle `GARDOMATIC_DB_DSN`. Globale Optionen stehen +vor dem Unterbefehl. Mit `--json` liefert es maschinenlesbare Ausgabe; `--yes` +bestätigt bewusst konfigurierte Produktionsoperationen. + +Häufige Befehle: + +```sh +go run ./cmd/cli users list +go run ./cmd/cli users show --email alice@example.com +go run ./cmd/cli users invite --email alice@example.com --send-email +go run ./cmd/cli users activate --email alice@example.com +go run ./cmd/cli users deactivate --email alice@example.com +go run ./cmd/cli users reset-password --email alice@example.com --generate-password +go run ./cmd/cli db ping +``` + +Passwörter werden absichtlich nicht als Kommandozeilenargument angenommen. Sie +werden sicher abgefragt, generiert oder mit `--password-stdin` von der +Standardeingabe gelesen. Eine ausführliche Referenz mit Beispielen enthält +[`doc/cli.md`](doc/cli.md). + +## Build + +Einzelne Programme für das lokale System und Linux/AMD64 bauen: + +```sh +make build/api +make build/web +make build/cli +``` + +Alle Programme bauen: + +```sh +make build/all +``` + +Artefakte landen unter `bin/` und werden nicht versioniert. Der `Dockerfile` +enthält getrennte, minimale Laufzeit-Targets für API und Web. + +## Projektstruktur + +```text +cmd/ + api/ Start und Konfiguration der JSON-API + web/ Start und Konfiguration der Webanwendung + cli/ Administrations-CLI +internal/ + api/ API-Handler, Middleware und Fachabläufe + auth/ Passwort- und Tokenfunktionen + mailer/ Mailversand und Vorlagen + platform/ kleine technische Basispakete + storage/ Modelle und Storage-Schnittstellen + postgres/ PostgreSQL-Implementierungen und Migrationen + web/ Handler, Templates und statische Assets +lib/ + client/ typisierter Go-Client für die JSON-API +doc/ Planung und weiterführende Dokumentation +remote/ Produktionsbeispiele für systemd und Caddy +request/ manuelle HTTP-Beispielanfragen +``` + +## Entwicklungskonventionen + +Bei Änderungen sind insbesondere folgende Grundsätze verbindlich: + +- idiomatischer, mit `gofmt` formatierter Go-Code +- klare Verantwortlichkeiten und bestehende Schichtengrenzen +- Wiederverwendung vorhandener Helfer und Fachlogik statt Codeduplizierung +- Tests für neues oder korrigiertes Verhalten, soweit technisch möglich +- neue Migrationen statt Änderungen an bereits veröffentlichten Migrationen +- keine Zugangsdaten oder lokalen Umgebungsdateien im Repository + +Ausführliche Arbeitsregeln für Coding Agents und Beitragende stehen in +[`AGENTS.md`](AGENTS.md). + +## Beiträge + +Beiträge sind willkommen. Der vollständige Ablauf, Qualitätsanforderungen und der +Umgang mit Fremdmaterial sind in [`CONTRIBUTING.md`](CONTRIBUTING.md) beschrieben. + +Vor dem ersten Pull Request müssen Beitragende die +[`Contributor License Agreement`](CLA.md) lesen und im Pull Request selbst +akzeptieren. Beitragende behalten ihr Copyright, räumen dem Projektinhaber jedoch +die notwendigen Rechte ein, den Beitrag sowohl unter der öffentlichen +Projektlizenz als auch unter separaten kommerziellen oder proprietären Lizenzen zu +verwenden. Beiträge im Namen eines Unternehmens müssen vorab abgestimmt werden. + +## Deployment + +Der `Dockerfile` kann Images für API und Web erzeugen. Unter `remote/production` +liegen außerdem Beispielkonfigurationen für systemd und Caddy. Die +`production/*`-Ziele im `Makefile` sind auf die vorhandene Gardomatic-Infrastruktur +zugeschnitten, enthalten einen fest konfigurierten Zielhost und führen Migrationen +sowie Dienstneustarts aus. Sie sind keine allgemeine Deployment-Anleitung und +sollten vor jeder Verwendung geprüft werden. + +Die lokale Produktionsverbindung wird in `config.mk` konfiguriert. Eine kommentierte +Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel und +Zielarchitektur steht in `config.mk.example`. Der SSH-Admin ist der vom Hoster oder +bei der LXC-Erstellung bereitgestellte Benutzer (`root`, `ubuntu` oder ähnlich) +und benötigt Root-Rechte oder passwortloses `sudo`. Private SSH-Schlüssel bleiben +ausschließlich auf dem lokalen Rechner; auf dem Server muss vorab nur der +zugehörige öffentliche Schlüssel für diesen Admin hinterlegt sein. + +Das Server-Setup unter `remote/setup/provision-server.sh` liest seine Konfiguration +aus `remote/setup/.env`. Als Ausgangspunkt dient `remote/setup/.env.example`; die +echte Datei muss auf Modus `0600` gesetzt werden und bleibt von Git ausgeschlossen. +`make production/provision` überträgt das Skript und streamt die Konfiguration über +SSH, ohne die Quelldatei dauerhaft auf dem Server abzulegen. Das Provisioning legt +den gesperrten Servicebenutzer `gardomatic` ohne Login, SSH-Schlüssel oder +sudo-Rechte an und installiert die Laufzeitwerte als +`/etc/gardomatic/gardomatic.env`. + +Ein vollständiger Erstbetrieb besteht aus: + +```sh +make config/init +# Die benötigten lokalen Konfigurationsdateien bearbeiten, dann: +make production/provision +make production/deploy +make production/create-admin +``` + +`production/deploy` überträgt Binärdateien, Migrationen, systemd-Units und die +Admin-Hilfe über denselben SSH-Admin, wendet Migrationen an und startet die Dienste. +`production/create-admin` fragt interaktiv nach Name und E-Mail und erstellt den +Benutzer aktiviert und mit der Rolle `application:admin`; das sichere generierte +Passwort wird einmalig ausgegeben. + +Für Produktion gelten mindestens folgende Anforderungen: + +- `GARDOMATIC_ENV=production` +- HTTPS am Reverse Proxy +- `GARDOMATIC_COOKIE_SECURE=true` +- starke, extern verwaltete Zugangsdaten +- eingeschränkter Datenbankzugriff und regelmäßige Backups +- korrekte öffentliche Web-URL und vertrauenswürdige CORS-Origins +- SMTP statt dateibasiertem Mailversand, sofern E-Mails zugestellt werden sollen + +## Weiterführende Dokumentation + +- [`doc/planung.md`](doc/planung.md) – Produktbeschreibung, Leitplanken und Fahrplan +- [`doc/cli.md`](doc/cli.md) – vollständige Bedienung des Administrations-CLI +- [`doc/issues.md`](doc/issues.md) – bekannte Themen und Arbeitsnotizen +- [`doc/ideen.md`](doc/ideen.md) – mögliche spätere Erweiterungen + +## Lizenz + +Gardomatic steht unter der +[PolyForm Noncommercial License 1.0.0](LICENSE). Sie erlaubt Nutzung, Veränderung +und Weitergabe für nichtkommerzielle Zwecke unter den Bedingungen der Lizenz. + +Kommerzielle Nutzung ist von dieser Lizenz nicht abgedeckt und erfordert eine +separate, kostenpflichtige Lizenzvereinbarung. Anfragen können an +[alex@kleiax.de](mailto:alex@kleiax.de) gerichtet werden. + +Für Beiträge Dritter gilt zusätzlich die +[`Gardomatic Contributor License Agreement`](CLA.md). + +Wegen des Ausschlusses kommerzieller Nutzung ist Gardomatic „source-available“, +aber keine Open-Source-Software nach der Definition der Open Source Initiative. diff --git a/cmd/api/config.go b/cmd/api/config.go new file mode 100644 index 0000000..906c74e --- /dev/null +++ b/cmd/api/config.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "gardomatic.kleiax.de/internal/api" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/platform/environment" +) + +func configFromEnvironment() (api.Config, error) { + var errs []error + required := func(name string) string { + value, err := environment.Required(name) + if err != nil { + errs = append(errs, err) + } + return value + } + integer := func(name string, fallback int) int { + value, err := environment.Int(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + boolean := func(name string, fallback bool) bool { + value, err := environment.Bool(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + duration := func(name string, fallback time.Duration) time.Duration { + value, err := environment.Duration(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + floating := func(name string, fallback float64) float64 { + value, err := environment.Float(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + + cfg := api.Config{ + Host: environment.String("GARDOMATIC_API_HOST", ""), + Port: integer("GARDOMATIC_API_PORT", 4000), + Env: environment.String("GARDOMATIC_ENV", "development"), + WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"), + DB: api.DatabaseConfig{ + Dsn: required("GARDOMATIC_DB_DSN"), MaxOpenConns: integer("GARDOMATIC_DB_MAX_OPEN_CONNS", 25), + MaxIdleConns: integer("GARDOMATIC_DB_MAX_IDLE_CONNS", 25), MaxIdleTime: duration("GARDOMATIC_DB_MAX_IDLE_TIME", 15*time.Minute), + }, + Limiter: api.LimiterConfig{Enabled: boolean("GARDOMATIC_RATE_LIMIT_ENABLED", true), Rps: floating("GARDOMATIC_RATE_LIMIT_RPS", 10), Burst: integer("GARDOMATIC_RATE_LIMIT_BURST", 40)}, + Session: api.SessionConfig{Lifetime: duration("GARDOMATIC_SESSION_LIFETIME", 12*time.Hour), IdleTimeout: duration("GARDOMATIC_SESSION_IDLE_TIMEOUT", 30*time.Minute), CookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: boolean("GARDOMATIC_COOKIE_SECURE", false)}, + Mail: mailer.Config{Mode: mailer.Mode(environment.String("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), Host: environment.String("GARDOMATIC_SMTP_HOST", ""), Port: integer("GARDOMATIC_SMTP_PORT", 25), Username: environment.String("GARDOMATIC_SMTP_USERNAME", ""), Password: environment.String("GARDOMATIC_SMTP_PASSWORD", ""), Sender: environment.String("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), FilePath: environment.String("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log")}, + Cors: api.CORSConfig{TrustedOrigins: environment.CSV("GARDOMATIC_CORS_TRUSTED_ORIGINS")}, + } + + if cfg.Port < 1 || cfg.Port > 65535 { + errs = append(errs, errors.New("GARDOMATIC_API_PORT must be between 1 and 65535")) + } + if cfg.Env != "development" && cfg.Env != "test" && cfg.Env != "production" { + errs = append(errs, fmt.Errorf("GARDOMATIC_ENV has unsupported value %q", cfg.Env)) + } + if cfg.DB.MaxOpenConns < 1 || cfg.DB.MaxIdleConns < 0 || cfg.DB.MaxIdleConns > cfg.DB.MaxOpenConns { + errs = append(errs, errors.New("database pool limits are invalid")) + } + if cfg.Limiter.Rps <= 0 || cfg.Limiter.Burst < 1 { + errs = append(errs, errors.New("rate limit values must be positive")) + } + if strings.TrimSpace(cfg.Session.CookieName) == "" { + errs = append(errs, errors.New("GARDOMATIC_SESSION_COOKIE_NAME must not be empty")) + } + for _, origin := range cfg.Cors.TrustedOrigins { + parsed, err := url.ParseRequestURI(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, fmt.Errorf("invalid trusted origin %q", origin)) + } + } + if parsed, err := url.ParseRequestURI(cfg.WebBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, errors.New("GARDOMATIC_WEB_BASE_URL must be an absolute URL")) + } + if cfg.Env == "production" && !cfg.Session.CookieSecure { + errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production")) + } + if _, err := mailer.New(cfg.Mail); err != nil { + errs = append(errs, fmt.Errorf("mail configuration: %w", err)) + } + return cfg, errors.Join(errs...) +} diff --git a/cmd/api/config_test.go b/cmd/api/config_test.go new file mode 100644 index 0000000..ac60c5d --- /dev/null +++ b/cmd/api/config_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestConfigFromEnvironment(t *testing.T) { + t.Setenv("GARDOMATIC_DB_DSN", "postgres://example") + t.Setenv("GARDOMATIC_API_HOST", "127.0.0.1") + t.Setenv("GARDOMATIC_API_PORT", "4100") + t.Setenv("GARDOMATIC_CORS_TRUSTED_ORIGINS", "https://example.com, https://app.example.com") + cfg, err := configFromEnvironment() + if err != nil { + t.Fatal(err) + } + if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 { + t.Fatalf("unexpected config: %#v", cfg) + } +} + +func TestConfigRequiresDSN(t *testing.T) { + t.Setenv("GARDOMATIC_DB_DSN", "") + if _, err := configFromEnvironment(); err == nil { + t.Fatal("expected missing DSN error") + } +} diff --git a/cmd/api/doc.go b/cmd/api/doc.go new file mode 100644 index 0000000..0d824e6 --- /dev/null +++ b/cmd/api/doc.go @@ -0,0 +1,2 @@ +// Package main starts the Gardomatic JSON API service. +package main diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..f167571 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + + "gardomatic.kleiax.de/internal/api" +) + +func main() { + cfg, err := configFromEnvironment() + if err != nil { + log.Fatal(err) + } + server := api.New(cfg) + server.Run() +} diff --git a/cmd/cli/application.go b/cmd/cli/application.go new file mode 100644 index 0000000..305012b --- /dev/null +++ b/cmd/cli/application.go @@ -0,0 +1,663 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "slices" + "strings" + "text/tabwriter" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "gardomatic.kleiax.de/internal/vcs" + "golang.org/x/term" +) + +const activationTokenTTL = 3 * 24 * time.Hour + +type application struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + openStore func(string) (adminStore, error) + newMailer func(mailer.Config) (*mailer.Mailer, error) +} + +func newApplication(stdin io.Reader, stdout, stderr io.Writer) *application { + return &application{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + openStore: openPostgresStore, + newMailer: mailer.New, + } +} + +func (app *application) run(ctx context.Context, args []string) error { + cfg, err := configFromEnvironment() + if err != nil { + return err + } + + global := flag.NewFlagSet("gardomatic", flag.ContinueOnError) + global.SetOutput(app.stderr) + global.StringVar(&cfg.dsn, "dsn", cfg.dsn, "PostgreSQL DSN (default: GARDOMATIC_DB_DSN)") + global.StringVar(&cfg.environment, "env", cfg.environment, "environment name") + global.StringVar(&cfg.webBaseURL, "web-base-url", cfg.webBaseURL, "public web base URL") + global.BoolVar(&cfg.json, "json", false, "write JSON output") + global.BoolVar(&cfg.yes, "yes", false, "skip production confirmation") + global.Usage = func() { app.printUsage(global) } + if err = global.Parse(args); err != nil { + return err + } + + remaining := global.Args() + if len(remaining) == 0 { + global.Usage() + return nil + } + if remaining[0] == "help" || remaining[0] == "--help" || remaining[0] == "-h" { + global.Usage() + return nil + } + if slices.Contains(remaining[1:], "--help") || slices.Contains(remaining[1:], "-h") { + global.Usage() + return nil + } + if remaining[0] == "version" { + return app.writeValue(cfg, map[string]string{"version": vcs.Version()}, "Version: %s\n", vcs.Version()) + } + + store, err := app.openStore(cfg.dsn) + if err != nil { + return err + } + defer store.Close() + + switch remaining[0] { + case "users": + return app.runUsers(ctx, cfg, store, remaining[1:]) + case "gardens": + return app.runGardens(ctx, cfg, store, remaining[1:]) + case "db": + return app.runDB(ctx, cfg, store, remaining[1:]) + default: + return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0]) + } +} + +func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) == 0 { + return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role") + } + switch args[0] { + case "create": + return app.createUser(ctx, cfg, store, args[1:]) + case "list": + return app.listUsers(ctx, cfg, store, args[1:]) + case "show": + return app.showUser(ctx, cfg, store, args[1:]) + case "activate": + return app.setUserActivated(ctx, cfg, store, args[1:], true) + case "deactivate": + return app.setUserActivated(ctx, cfg, store, args[1:], false) + case "invite": + return app.inviteUser(ctx, cfg, store, args[1:]) + case "reset-password": + return app.resetPassword(ctx, cfg, store, args[1:]) + case "set-role": + return app.setUserRole(ctx, cfg, store, args[1:]) + default: + return fmt.Errorf("unknown users command %q", args[0]) + } +} + +func (app *application) runGardens(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) == 0 { + return errors.New("missing gardens command: add-user") + } + if args[0] != "add-user" { + return fmt.Errorf("unknown gardens command %q", args[0]) + } + return app.addGardenMember(ctx, cfg, store, args[1:]) +} + +func (app *application) setUserRole(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users set-role", app.stderr) + var email, role string + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", "", "application role (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + email, role = strings.TrimSpace(email), strings.TrimSpace(role) + if err := validateEmail(email); err != nil { + return err + } + if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { + return errors.New("role must be application:user or application:admin") + } + user, err := store.SetUserRole(ctx, email, role) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) addGardenMember(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("gardens add-user", app.stderr) + var gardenID int + var email, role string + fs.IntVar(&gardenID, "garden-id", 0, "garden ID (required)") + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", string(storage.GardenRoleMember), "garden role") + if err := parseFlags(fs, args); err != nil { + return err + } + email, role = strings.TrimSpace(email), strings.TrimSpace(role) + if gardenID < 1 { + return errors.New("garden-id must be a positive integer") + } + if err := validateEmail(email); err != nil { + return err + } + validRole := role == string(storage.GardenRoleOwner) || role == string(storage.GardenRoleAdmin) || role == string(storage.GardenRoleMember) || role == string(storage.GardenRoleViewer) || role == string(storage.GardenRoleWorker) + if !validRole { + return errors.New("role must be owner, admin, member, viewer, or worker") + } + member, err := store.AddGardenMember(ctx, gardenID, email, role) + if err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + return fmt.Errorf("garden %d, user %s, or role %s was not found", gardenID, email, role) + } + return err + } + if cfg.json { + return writeJSON(app.stdout, member) + } + _, err = fmt.Fprintf(app.stdout, "User: %s\nGarden: %d\nRole: %s\n", member.Email, member.GardenID, member.Role) + return err +} + +func (app *application) createUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users create", app.stderr) + var name, email, role string + var active, invite, generatePassword, passwordStdin, sendEmail bool + fs.StringVar(&name, "name", "", "display name (required)") + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", string(storage.ApplicationRoleUser), "application role") + fs.BoolVar(&active, "active", false, "create an activated account") + fs.BoolVar(&invite, "invite", false, "create an activation token") + fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") + fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") + fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") + if err := parseFlags(fs, args); err != nil { + return err + } + name = strings.TrimSpace(name) + email = strings.TrimSpace(email) + role = strings.TrimSpace(role) + if active == invite { + return errors.New("exactly one of --active or --invite is required") + } + if sendEmail && !invite { + return errors.New("--send-email requires --invite") + } + if generatePassword && passwordStdin { + return errors.New("--generate-password and --password-stdin are mutually exclusive") + } + if err := validateIdentity(name, email); err != nil { + return err + } + if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { + return errors.New("role must be application:user or application:admin") + } + password, generated, err := app.obtainPassword(generatePassword, passwordStdin) + if err != nil { + return err + } + passwordHash, err := hashPassword(password) + if err != nil { + return err + } + + result, err := store.CreateUser(ctx, createUserInput{ + Name: name, + Email: email, + PasswordHash: passwordHash, + Activated: active, + Role: role, + Invite: invite, + TokenTTL: activationTokenTTL, + }) + if err != nil { + if errors.Is(err, storage.ErrDuplicateEmail) { + return fmt.Errorf("a user with email %s already exists", email) + } + return err + } + + output := userMutationOutput{User: result.User} + if generated { + output.GeneratedPassword = password + } + if result.Token != nil { + output.ActivationToken = result.Token.Plaintext + output.ActivationURL, err = activationURL(cfg.webBaseURL, result.Token.Plaintext) + if err != nil { + return err + } + output.TokenExpiry = &result.Token.Expiry + if sendEmail { + if err = app.sendInvitation(cfg, result.User, *result.Token, output.ActivationURL, true); err != nil { + return fmt.Errorf("user was created, but sending invitation failed: %w", err) + } + output.EmailSent = true + } + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) listUsers(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users list", app.stderr) + if err := parseFlags(fs, args); err != nil { + return err + } + users, err := store.ListUsers(ctx) + if err != nil { + return err + } + if cfg.json { + return writeJSON(app.stdout, users) + } + tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tNAME\tEMAIL\tACTIVE\tROLE\tCREATED") + for _, user := range users { + fmt.Fprintf(tw, "%d\t%s\t%s\t%t\t%s\t%s\n", user.ID, user.Name, user.Email, user.Activated, user.Role, user.CreatedAt.Format(time.RFC3339)) + } + return tw.Flush() +} + +func (app *application) showUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users show", app.stderr) + var email string + fs.StringVar(&email, "email", "", "email address (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + if err := validateEmail(email); err != nil { + return err + } + user, err := store.GetUser(ctx, strings.TrimSpace(email)) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) setUserActivated(ctx context.Context, cfg config, store adminStore, args []string, active bool) error { + command := "activate" + if !active { + command = "deactivate" + } + fs := newFlagSet("users "+command, app.stderr) + var email string + fs.StringVar(&email, "email", "", "email address (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + if !active { + if err := app.confirmProduction(cfg, "deactivate user "+email); err != nil { + return err + } + } + user, err := store.SetActivated(ctx, email, active) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) inviteUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users invite", app.stderr) + var email string + var sendEmail bool + fs.StringVar(&email, "email", "", "email address (required)") + fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + user, token, err := store.IssueInvitation(ctx, email, activationTokenTTL) + if err != nil { + return userError(email, err) + } + link, err := activationURL(cfg.webBaseURL, token.Plaintext) + if err != nil { + return err + } + output := userMutationOutput{ + User: user, + ActivationToken: token.Plaintext, + ActivationURL: link, + TokenExpiry: &token.Expiry, + } + if sendEmail { + if err = app.sendInvitation(cfg, user, token, link, false); err != nil { + return fmt.Errorf("invitation token was created, but sending email failed: %w", err) + } + output.EmailSent = true + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) resetPassword(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users reset-password", app.stderr) + var email string + var generatePassword, passwordStdin bool + fs.StringVar(&email, "email", "", "email address (required)") + fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") + fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + if generatePassword && passwordStdin { + return errors.New("--generate-password and --password-stdin are mutually exclusive") + } + if err := app.confirmProduction(cfg, "reset password for "+email); err != nil { + return err + } + password, generated, err := app.obtainPassword(generatePassword, passwordStdin) + if err != nil { + return err + } + hash, err := hashPassword(password) + if err != nil { + return err + } + user, err := store.ResetPassword(ctx, email, hash) + if err != nil { + return userError(email, err) + } + output := userMutationOutput{User: user} + if generated { + output.GeneratedPassword = password + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) runDB(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) != 1 || args[0] != "ping" { + return errors.New("usage: gardomatic db ping") + } + if err := store.Ping(ctx); err != nil { + return err + } + return app.writeValue(cfg, map[string]string{"status": "ok"}, "database: ok\n") +} + +type userMutationOutput struct { + User userView `json:"user"` + ActivationToken string `json:"activation_token,omitempty"` + ActivationURL string `json:"activation_url,omitempty"` + TokenExpiry *time.Time `json:"token_expiry,omitempty"` + GeneratedPassword string `json:"generated_password,omitempty"` + EmailSent bool `json:"email_sent,omitempty"` +} + +func (app *application) writeUserMutation(cfg config, output userMutationOutput) error { + if cfg.json { + return writeJSON(app.stdout, output) + } + if err := app.writeUser(cfg, output.User); err != nil { + return err + } + if output.GeneratedPassword != "" { + fmt.Fprintf(app.stdout, "Generated password: %s\n", output.GeneratedPassword) + } + if output.ActivationToken != "" { + fmt.Fprintf(app.stdout, "Activation token: %s\n", output.ActivationToken) + fmt.Fprintf(app.stdout, "Activation URL: %s\n", output.ActivationURL) + fmt.Fprintf(app.stdout, "Token expires: %s\n", output.TokenExpiry.Format(time.RFC3339)) + } + if output.EmailSent { + fmt.Fprintln(app.stdout, "Invitation email sent.") + } + return nil +} + +func (app *application) writeUser(cfg config, user userView) error { + if cfg.json { + return writeJSON(app.stdout, user) + } + tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "ID:\t%d\n", user.ID) + fmt.Fprintf(tw, "Name:\t%s\n", user.Name) + fmt.Fprintf(tw, "Email:\t%s\n", user.Email) + fmt.Fprintf(tw, "Active:\t%t\n", user.Activated) + fmt.Fprintf(tw, "Role:\t%s\n", user.Role) + return tw.Flush() +} + +func (app *application) writeValue(cfg config, value any, format string, args ...any) error { + if cfg.json { + return writeJSON(app.stdout, value) + } + _, err := fmt.Fprintf(app.stdout, format, args...) + return err +} + +func (app *application) obtainPassword(generate, fromStdin bool) (string, bool, error) { + if generate { + return rand.Text(), true, nil + } + if fromStdin { + password, err := bufio.NewReader(app.stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", false, err + } + password = strings.TrimRight(password, "\r\n") + if err = validatePassword(password); err != nil { + return "", false, err + } + return password, false, nil + } + + input, ok := app.stdin.(*os.File) + if !ok || !term.IsTerminal(int(input.Fd())) { + return "", false, errors.New("standard input is not a terminal; use --password-stdin or --generate-password") + } + fmt.Fprint(app.stderr, "Password: ") + first, err := term.ReadPassword(int(input.Fd())) + fmt.Fprintln(app.stderr) + if err != nil { + return "", false, err + } + fmt.Fprint(app.stderr, "Repeat password: ") + second, err := term.ReadPassword(int(input.Fd())) + fmt.Fprintln(app.stderr) + if err != nil { + return "", false, err + } + if string(first) != string(second) { + return "", false, errors.New("passwords do not match") + } + if err = validatePassword(string(first)); err != nil { + return "", false, err + } + return string(first), false, nil +} + +func (app *application) confirmProduction(cfg config, action string) error { + if !strings.EqualFold(cfg.environment, "production") || cfg.yes { + return nil + } + fmt.Fprintf(app.stderr, "Production: %s. Continue? [y/N] ", action) + answer, err := bufio.NewReader(app.stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + return errors.New("operation cancelled") + } + return nil +} + +func (app *application) sendInvitation(cfg config, user userView, token auth.Token, link string, welcome bool) error { + m, err := app.newMailer(cfg.mail) + if err != nil { + return err + } + template := "token_activation.tmpl" + if welcome { + template = "user_welcome.tmpl" + } + return m.Send(user.Email, template, map[string]any{ + "activationToken": token.Plaintext, + "activationURL": link, + "userID": user.ID, + }) +} + +func (app *application) printUsage(fs *flag.FlagSet) { + fmt.Fprintln(app.stderr, `Gardomatic administration CLI + +Usage: + gardomatic [global options] users create --name NAME --email EMAIL (--active|--invite) [--role ROLE] [options] + gardomatic [global options] users list + gardomatic [global options] users show --email EMAIL + gardomatic [global options] users activate|deactivate --email EMAIL + gardomatic [global options] users invite --email EMAIL [--send-email] + gardomatic [global options] users reset-password --email EMAIL [options] + gardomatic [global options] users set-role --email EMAIL --role ROLE + gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE] + gardomatic [global options] db ping + gardomatic [global options] version + +Global options:`) + fs.PrintDefaults() +} + +func activationURL(baseURL, token string) (string, error) { + u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/activate") + if err != nil { + return "", fmt.Errorf("invalid web base URL: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return "", errors.New("web base URL must include scheme and host") + } + query := u.Query() + query.Set("token", token) + u.RawQuery = query.Encode() + return u.String(), nil +} + +func validateIdentity(name, email string) error { + password := auth.NewPassword([]byte("placeholder")) + user := storage.User{Name: name, Email: email, Password: *password} + v := validate.New() + storage.ValidateUser(v, user) + delete(v.Errors, "password") + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func validateEmail(email string) error { + v := validate.New() + storage.ValidateEmail(v, strings.TrimSpace(email)) + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func validatePassword(password string) error { + v := validate.New() + auth.ValidatePasswordPlaintext(v, password) + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func hashPassword(plaintext string) ([]byte, error) { + if err := validatePassword(plaintext); err != nil { + return nil, err + } + var password auth.Password + if err := password.Set(plaintext); err != nil { + return nil, err + } + return password.Get(), nil +} + +func validationError(fields map[string]string) error { + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+" "+fields[key]) + } + return errors.New(strings.Join(parts, "; ")) +} + +func userError(email string, err error) error { + switch { + case errors.Is(err, errUserNotFound): + return fmt.Errorf("no user found with email %s", email) + case errors.Is(err, errUserAlreadyActive): + return fmt.Errorf("user %s is already active", email) + default: + return err + } +} + +func newFlagSet(name string, output io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(output) + return fs +} + +func parseFlags(fs *flag.FlagSet, args []string) error { + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return nil +} + +func writeJSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + return encoder.Encode(value) +} diff --git a/cmd/cli/application_test.go b/cmd/cli/application_test.go new file mode 100644 index 0000000..c360c3c --- /dev/null +++ b/cmd/cli/application_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "golang.org/x/crypto/bcrypt" +) + +type fakeAdminStore struct { + createInput createUserInput + createCalls int + setActivatedCalls int + inviteCalls int + setRoleCalls int + addMemberCalls int + lastRole string +} + +func (s *fakeAdminStore) Ping(context.Context) error { return nil } +func (s *fakeAdminStore) Close() error { return nil } + +func (s *fakeAdminStore) CreateUser(_ context.Context, input createUserInput) (createUserResult, error) { + s.createCalls++ + s.createInput = input + result := createUserResult{User: userView{ + ID: 42, + Name: input.Name, + Email: input.Email, + Activated: input.Activated, + Role: input.Role, + CreatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC), + }} + if input.Invite { + token := auth.NewToken(42, input.TokenTTL, auth.ScopeActivation) + result.Token = &token + } + return result, nil +} + +func (s *fakeAdminStore) ListUsers(context.Context) ([]userView, error) { return nil, nil } +func (s *fakeAdminStore) GetUser(context.Context, string) (userView, error) { + return userView{}, errUserNotFound +} +func (s *fakeAdminStore) SetActivated(_ context.Context, email string, active bool) (userView, error) { + s.setActivatedCalls++ + return userView{ID: 42, Email: email, Activated: active}, nil +} +func (s *fakeAdminStore) IssueInvitation(_ context.Context, email string, ttl time.Duration) (userView, auth.Token, error) { + s.inviteCalls++ + return userView{ID: 42, Name: "Alice", Email: email}, auth.NewToken(42, ttl, auth.ScopeActivation), nil +} +func (s *fakeAdminStore) ResetPassword(context.Context, string, []byte) (userView, error) { + return userView{}, nil +} +func (s *fakeAdminStore) SetUserRole(_ context.Context, email, role string) (userView, error) { + s.setRoleCalls++ + s.lastRole = role + return userView{ID: 42, Email: email, Role: role}, nil +} +func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email, role string) (gardenMemberView, error) { + s.addMemberCalls++ + s.lastRole = role + return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil +} + +func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + t.Setenv("GARDOMATIC_DB_DSN", "postgres://unused") + t.Setenv("GARDOMATIC_ENV", "development") + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + app := newApplication(strings.NewReader(stdin), stdout, stderr) + app.openStore = func(string) (adminStore, error) { return store, nil } + return app, stdout, stderr +} + +func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "--json", + "--web-base-url", "https://gardomatic.example/app", + "users", "create", + "--name", " Alice ", + "--email", "alice@example.com", + "--invite", + "--generate-password", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + if store.createCalls != 1 { + t.Fatalf("CreateUser calls = %d, want 1", store.createCalls) + } + if store.createInput.Name != "Alice" || !store.createInput.Invite || store.createInput.Activated { + t.Errorf("CreateUser input = %+v", store.createInput) + } + + var output userMutationOutput + if err = json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decoding output: %v; output: %s", err, stdout.String()) + } + if output.GeneratedPassword == "" { + t.Fatal("generated password is missing") + } + if err = bcrypt.CompareHashAndPassword(store.createInput.PasswordHash, []byte(output.GeneratedPassword)); err != nil { + t.Errorf("stored password hash does not match generated password: %v", err) + } + if !strings.HasPrefix(output.ActivationURL, "https://gardomatic.example/app/activate?token=") { + t.Errorf("activation URL = %q", output.ActivationURL) + } + if output.ActivationToken == "" || !strings.Contains(output.ActivationURL, output.ActivationToken) { + t.Errorf("activation token and URL do not match: %+v", output) + } +} + +func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Alice", + "--email", "alice@example.com", + "--generate-password", + }) + if err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("run() error = %v, want account mode error", err) + } + if store.createCalls != 0 { + t.Errorf("CreateUser calls = %d, want 0", store.createCalls) + } +} + +func TestCreateUserCanAtomicallyCreateApplicationAdmin(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Initial Admin", + "--email", "admin@example.com", + "--role", "application:admin", + "--active", + "--generate-password", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + if store.createInput.Role != "application:admin" { + t.Fatalf("created role = %q, want application:admin", store.createInput.Role) + } + if !strings.Contains(stdout.String(), "application:admin") { + t.Fatalf("output does not contain admin role: %s", stdout.String()) + } +} + +func TestCreateUserRejectsUnknownApplicationRole(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Alice", + "--email", "alice@example.com", + "--role", "superadmin", + "--active", + "--generate-password", + }) + if err == nil || !strings.Contains(err.Error(), "role must be") { + t.Fatalf("run() error = %v, want role validation error", err) + } + if store.createCalls != 0 { + t.Fatalf("CreateUser calls = %d, want 0", store.createCalls) + } +} + +func TestProductionDeactivationRequiresConfirmation(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "n\n", store) + + err := app.run(context.Background(), []string{ + "--env", "production", + "users", "deactivate", "--email", "alice@example.com", + }) + if err == nil || err.Error() != "operation cancelled" { + t.Fatalf("run() error = %v, want operation cancelled", err) + } + if store.setActivatedCalls != 0 { + t.Errorf("SetActivated calls = %d, want 0", store.setActivatedCalls) + } +} + +func TestInviteCanWriteEmailWithActivationLink(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + mailPath := filepath.Join(t.TempDir(), "mail.log") + t.Setenv("GARDOMATIC_SMTP_MODE", "file") + t.Setenv("GARDOMATIC_SMTP_FILE_PATH", mailPath) + + err := app.run(context.Background(), []string{ + "--web-base-url", "https://gardomatic.example", + "users", "invite", "--email", "alice@example.com", "--send-email", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + content, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "https://gardomatic.example/activate?token=") { + t.Errorf("mail does not contain activation URL: %s", content) + } +} + +func TestActivationURLRejectsRelativeBase(t *testing.T) { + _, err := activationURL("localhost:4040", "token") + if err == nil { + t.Fatal("activationURL() returned no error") + } +} + +func TestSetUserRoleCanMakeApplicationAdmin(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{"users", "set-role", "--email", "alice@example.com", "--role", "application:admin"}) + if err != nil { + t.Fatal(err) + } + if store.setRoleCalls != 1 || store.lastRole != "application:admin" || !strings.Contains(stdout.String(), "application:admin") { + t.Fatalf("role update missing: calls=%d role=%q output=%q", store.setRoleCalls, store.lastRole, stdout.String()) + } +} + +func TestAddUserToGardenWithAdminRole(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "admin"}) + if err != nil { + t.Fatal(err) + } + if store.addMemberCalls != 1 || store.lastRole != "admin" || !strings.Contains(stdout.String(), "Garden: 7") { + t.Fatalf("garden membership missing: calls=%d role=%q output=%q", store.addMemberCalls, store.lastRole, stdout.String()) + } +} + +func TestRoleCommandsRejectUnknownRoles(t *testing.T) { + tests := [][]string{ + {"users", "set-role", "--email", "alice@example.com", "--role", "superadmin"}, + {"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "superadmin"}, + } + for _, args := range tests { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + if err := app.run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "role must be") { + t.Errorf("run(%v) error = %v, want role validation error", args, err) + } + if store.setRoleCalls != 0 || store.addMemberCalls != 0 { + t.Errorf("run(%v) reached store", args) + } + } +} diff --git a/cmd/cli/config.go b/cmd/cli/config.go new file mode 100644 index 0000000..7c4349b --- /dev/null +++ b/cmd/cli/config.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/mailer" +) + +type config struct { + dsn string + environment string + webBaseURL string + json bool + yes bool + mail mailer.Config +} + +func configFromEnvironment() (config, error) { + port, err := envInt("GARDOMATIC_SMTP_PORT", 25) + if err != nil { + return config{}, err + } + + return config{ + dsn: os.Getenv("GARDOMATIC_DB_DSN"), + environment: envString("GARDOMATIC_ENV", "development"), + webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"), + mail: mailer.Config{ + Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), + Host: os.Getenv("GARDOMATIC_SMTP_HOST"), + Port: port, + Username: os.Getenv("GARDOMATIC_SMTP_USERNAME"), + Password: os.Getenv("GARDOMATIC_SMTP_PASSWORD"), + Sender: envString("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), + FilePath: envString("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log"), + }, + }, nil +} + +func envString(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envInt(name string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + + n, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", name, err) + } + return n, nil +} diff --git a/cmd/cli/doc.go b/cmd/cli/doc.go new file mode 100644 index 0000000..03a6af3 --- /dev/null +++ b/cmd/cli/doc.go @@ -0,0 +1,2 @@ +// Package main provides the Gardomatic administration command-line tool. +package main diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..f0be839 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "context" + "fmt" + "os" +) + +func main() { + app := newApplication(os.Stdin, os.Stdout, os.Stderr) + if err := app.run(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/cli/store.go b/cmd/cli/store.go new file mode 100644 index 0000000..318c8a4 --- /dev/null +++ b/cmd/cli/store.go @@ -0,0 +1,336 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +var ( + errUserNotFound = errors.New("user not found") + errUserAlreadyActive = errors.New("user is already active") +) + +type userView struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Activated bool `json:"activated"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type gardenMemberView struct { + GardenID int `json:"garden_id"` + UserID int `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} + +type createUserInput struct { + Name string + Email string + PasswordHash []byte + Activated bool + Role string + Invite bool + TokenTTL time.Duration +} + +type createUserResult struct { + User userView + Token *auth.Token +} + +type adminStore interface { + Ping(context.Context) error + CreateUser(context.Context, createUserInput) (createUserResult, error) + ListUsers(context.Context) ([]userView, error) + GetUser(context.Context, string) (userView, error) + SetActivated(context.Context, string, bool) (userView, error) + IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error) + ResetPassword(context.Context, string, []byte) (userView, error) + SetUserRole(context.Context, string, string) (userView, error) + AddGardenMember(context.Context, int, string, string) (gardenMemberView, error) + Close() error +} + +type postgresStore struct { + db *sql.DB +} + +func openPostgresStore(dsn string) (adminStore, error) { + if dsn == "" { + return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(5) + db.SetMaxIdleConns(2) + db.SetConnMaxIdleTime(5 * time.Minute) + return &postgresStore{db: db}, nil +} + +// Close releases the CLI database pool. +func (s *postgresStore) Close() error { return s.db.Close() } + +// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout. +func (s *postgresStore) Ping(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return s.db.PingContext(ctx) +} + +// CreateUser creates an account and optionally an invitation token atomically. +func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return createUserResult{}, err + } + defer tx.Rollback() + + var result createUserResult + err = tx.QueryRowContext(ctx, ` + INSERT INTO users (name, email, password_hash, activated, application_role) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, email, activated, application_role, created_at, updated_at`, + input.Name, input.Email, input.PasswordHash, input.Activated, input.Role, + ).Scan( + &result.User.ID, + &result.User.Name, + &result.User.Email, + &result.User.Activated, + &result.User.Role, + &result.User.CreatedAt, + &result.User.UpdatedAt, + ) + if err != nil { + return createUserResult{}, mapPostgresError(err) + } + + if input.Invite { + token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation) + if _, err = tx.ExecContext(ctx, ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil { + return createUserResult{}, err + } + result.Token = &token + } + + if err = tx.Commit(); err != nil { + return createUserResult{}, err + } + return result, nil +} + +// ListUsers returns all non-deleted accounts for CLI administration. +func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, email, activated, application_role, created_at, updated_at + FROM users + WHERE deleted_at IS NULL + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]userView, 0) + for rows.Next() { + var user userView + if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +// GetUser returns a non-deleted account by email. +func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return getUser(ctx, s.db, email) +} + +// SetActivated changes whether an account may authenticate. +func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, err + } + defer tx.Rollback() + + var user userView + err = tx.QueryRowContext(ctx, ` + UPDATE users + SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE email = $1 AND deleted_at IS NULL + RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + if activated { + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil { + return userView{}, err + } + } + if err = tx.Commit(); err != nil { + return userView{}, err + } + return user, nil +} + +// IssueInvitation replaces an account's activation token. +func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, auth.Token{}, err + } + defer tx.Rollback() + + user, err := getUser(ctx, tx, email) + if err != nil { + return userView{}, auth.Token{}, err + } + if user.Activated { + return userView{}, auth.Token{}, errUserAlreadyActive + } + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil { + return userView{}, auth.Token{}, err + } + token := auth.NewToken(user.ID, ttl, auth.ScopeActivation) + if _, err = tx.ExecContext(ctx, ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil { + return userView{}, auth.Token{}, err + } + if err = tx.Commit(); err != nil { + return userView{}, auth.Token{}, err + } + return user, token, nil +} + +// ResetPassword replaces an account password hash and revokes authentication tokens. +func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, err + } + defer tx.Rollback() + + var user userView + err = tx.QueryRowContext(ctx, ` + UPDATE users + SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE email = $1 AND deleted_at IS NULL + RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + if _, err = tx.ExecContext(ctx, ` + DELETE FROM tokens + WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil { + return userView{}, err + } + if err = tx.Commit(); err != nil { + return userView{}, err + } + return user, nil +} + +// SetUserRole changes an account's application role. +func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var user userView + err := s.db.QueryRowContext(ctx, ` + UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1 + WHERE email=$1 AND deleted_at IS NULL + RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + return user, err +} + +// AddGardenMember adds or updates a user's membership in a garden. +func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var member gardenMemberView + err := s.db.QueryRowContext(ctx, ` + INSERT INTO garden_members(garden_id,user_id,role) + SELECT $1,u.id,$3 FROM users u + WHERE u.email=$2 AND u.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM gardens WHERE id=$1) + AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role, + ).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt) + if errors.Is(err, sql.ErrNoRows) { + return gardenMemberView{}, storage.ErrRecordNotFound + } + return member, mapPostgresError(err) +} + +type dbQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) { + var user userView + err := db.QueryRowContext(ctx, ` + SELECT id, name, email, activated, application_role, created_at, updated_at + FROM users + WHERE email = $1 AND deleted_at IS NULL`, email, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + return user, nil +} + +func mapPostgresError(err error) error { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" { + return storage.ErrDuplicateEmail + } + return err +} diff --git a/cmd/web/config.go b/cmd/web/config.go new file mode 100644 index 0000000..fa4cf47 --- /dev/null +++ b/cmd/web/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + + "gardomatic.kleiax.de/internal/platform/environment" + "gardomatic.kleiax.de/internal/web" +) + +func configFromEnvironment() (web.Config, error) { + port, err := environment.Int("GARDOMATIC_WEB_PORT", 4040) + if err != nil { + return web.Config{}, err + } + cookieSecure, err := environment.Bool("GARDOMATIC_COOKIE_SECURE", false) + if err != nil { + return web.Config{}, err + } + cfg := web.Config{Host: environment.String("GARDOMATIC_WEB_HOST", ""), Port: port, Env: environment.String("GARDOMATIC_ENV", "development"), APIBaseURL: environment.String("GARDOMATIC_API_BASE_URL", "http://localhost:4000"), SessionCookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: cookieSecure} + var errs []error + if cfg.Port < 1 || cfg.Port > 65535 { + errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 65535")) + } + parsed, parseErr := url.ParseRequestURI(cfg.APIBaseURL) + if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, fmt.Errorf("GARDOMATIC_API_BASE_URL must be an absolute HTTP URL")) + } + if cfg.Env == "production" && !cfg.CookieSecure { + errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production")) + } + return cfg, errors.Join(errs...) +} diff --git a/cmd/web/config_test.go b/cmd/web/config_test.go new file mode 100644 index 0000000..8f804ab --- /dev/null +++ b/cmd/web/config_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestConfigFromEnvironment(t *testing.T) { + t.Setenv("GARDOMATIC_WEB_HOST", "0.0.0.0") + t.Setenv("GARDOMATIC_WEB_PORT", "4444") + t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com") + cfg, err := configFromEnvironment() + if err != nil { + t.Fatal(err) + } + if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" { + t.Fatalf("unexpected config: %#v", cfg) + } +} diff --git a/cmd/web/doc.go b/cmd/web/doc.go new file mode 100644 index 0000000..31596ae --- /dev/null +++ b/cmd/web/doc.go @@ -0,0 +1,2 @@ +// Package main starts the Gardomatic server-rendered web application. +package main diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..99504f0 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log" + + "gardomatic.kleiax.de/internal/web" +) + +func main() { + cfg, err := configFromEnvironment() + if err != nil { + log.Fatal(err) + } + + server, err := web.New(cfg) + if err != nil { + log.Fatal(err) + } + server.Run() +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4a25191 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,94 @@ +services: + postgres: + image: docker.io/library/postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 15 + volumes: + - gardomatic-postgres-data:/var/lib/postgresql/data + + migrate: + image: docker.io/migrate/migrate:v4.19.1 + command: + - -path=/migrations + - -database=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + - up + depends_on: + postgres: + condition: service_healthy + volumes: + - ./internal/storage/postgres/migrations:/migrations:ro + restart: "no" + + api: + build: + context: . + target: api + environment: + GARDOMATIC_API_PORT: 4000 + GARDOMATIC_DB_DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + GARDOMATIC_DB_MAX_OPEN_CONNS: ${GARDOMATIC_DB_MAX_OPEN_CONNS:-25} + GARDOMATIC_DB_MAX_IDLE_CONNS: ${GARDOMATIC_DB_MAX_IDLE_CONNS:-25} + GARDOMATIC_DB_MAX_IDLE_TIME: ${GARDOMATIC_DB_MAX_IDLE_TIME:-15m} + GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development} + GARDOMATIC_WEB_BASE_URL: ${GARDOMATIC_WEB_BASE_URL:-http://localhost:4040} + GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session} + GARDOMATIC_SESSION_LIFETIME: ${GARDOMATIC_SESSION_LIFETIME:-12h} + GARDOMATIC_SESSION_IDLE_TIMEOUT: ${GARDOMATIC_SESSION_IDLE_TIMEOUT:-30m} + GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false} + GARDOMATIC_RATE_LIMIT_ENABLED: ${GARDOMATIC_RATE_LIMIT_ENABLED:-true} + GARDOMATIC_RATE_LIMIT_RPS: ${GARDOMATIC_RATE_LIMIT_RPS:-10} + GARDOMATIC_RATE_LIMIT_BURST: ${GARDOMATIC_RATE_LIMIT_BURST:-40} + GARDOMATIC_CORS_TRUSTED_ORIGINS: ${GARDOMATIC_CORS_TRUSTED_ORIGINS:-http://localhost:4040} + GARDOMATIC_SMTP_MODE: ${GARDOMATIC_SMTP_MODE:-file} + GARDOMATIC_SMTP_HOST: ${GARDOMATIC_SMTP_HOST:-} + GARDOMATIC_SMTP_PORT: ${GARDOMATIC_SMTP_PORT:-25} + GARDOMATIC_SMTP_USERNAME: ${GARDOMATIC_SMTP_USERNAME:-} + GARDOMATIC_SMTP_PASSWORD: ${GARDOMATIC_SMTP_PASSWORD:-} + GARDOMATIC_SMTP_SENDER: ${GARDOMATIC_SMTP_SENDER:-gardomatic@localhost} + GARDOMATIC_SMTP_FILE_PATH: ${GARDOMATIC_SMTP_FILE_PATH:-/tmp/gardomatic-mails.log} + ports: + - "${API_PORT:-4000}:4000" + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4000/v1/healthcheck"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + web: + build: + context: . + target: web + environment: + GARDOMATIC_API_BASE_URL: http://api:4000 + GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development} + GARDOMATIC_WEB_PORT: 4040 + GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session} + GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false} + ports: + - "${WEB_PORT:-4040}:4040" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4040/ping"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + +volumes: + gardomatic-postgres-data: + driver: local diff --git a/config.mk.example b/config.mk.example new file mode 100644 index 0000000..f3c40e3 --- /dev/null +++ b/config.mk.example @@ -0,0 +1,17 @@ +# Copy this file to config.mk. It contains non-secret Make configuration only. + +# Remote SSH administrator supplied by the hoster or LXC configuration. Use +# root, ubuntu, or another account with passwordless sudo. This is deliberately +# not the unprivileged gardomatic service account. +PRODUCTION_HOST = +PRODUCTION_SSH_USER = root +PRODUCTION_SSH_PORT = 22 + +# Optional private key path on the local workstation. Leave empty to use the +# SSH agent and normal ~/.ssh/config resolution. Paths must not contain spaces. +PRODUCTION_SSH_IDENTITY_FILE = + +# Target operating system and architecture. Supported provisioning architectures +# are amd64 and arm64. +PRODUCTION_GOOS = linux +PRODUCTION_GOARCH = amd64 diff --git a/doc/cli.md b/doc/cli.md new file mode 100644 index 0000000..e62f4fd --- /dev/null +++ b/doc/cli.md @@ -0,0 +1,87 @@ +# Gardomatic administration CLI + +The CLI connects directly to PostgreSQL and is implemented in `cmd/cli`. + +## Configuration + +`GARDOMATIC_DB_DSN` is required for every database command. The following +variables are optional: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `GARDOMATIC_ENV` | `development` | Enables confirmations for risky production operations | +| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | Base URL for activation links | +| `GARDOMATIC_SMTP_MODE` | `file` | Mail delivery mode (`file` or `smtp`) | +| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname | +| `GARDOMATIC_SMTP_PORT` | `25` | SMTP server port | +| `GARDOMATIC_SMTP_USERNAME` | empty | SMTP username | +| `GARDOMATIC_SMTP_PASSWORD` | empty | SMTP password | +| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Sender address | +| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Development mail output | + +Global flags can override the DSN, environment, and public URL. Global flags +must appear before the command. Use `--json` for machine-readable output and +`--yes` to confirm an explicitly configured production operation. + +## Examples + +Create an invited user and print a generated initial password: + +```sh +go run ./cmd/cli users create \ + --name "Alice Example" \ + --email alice@example.com \ + --invite \ + --generate-password +``` + +Create the initial active application administrator atomically: + +```sh +go run ./cmd/cli users create \ + --name "Initial Admin" \ + --email admin@example.com \ + --role application:admin \ + --active \ + --generate-password +``` + +Create an active development user and read the password without exposing it in +the process list: + +```sh +printf '%s\n' 'correct horse battery staple' | \ + go run ./cmd/cli users create \ + --name "Development User" \ + --email dev@example.com \ + --active \ + --password-stdin +``` + +Without `--password-stdin` or `--generate-password`, the CLI securely prompts +for the password twice. An invitation is printed by default; add `--send-email` +to deliver it using the configured mail backend. + +Other common operations: + +```sh +go run ./cmd/cli users list +go run ./cmd/cli users show --email alice@example.com +go run ./cmd/cli users invite --email alice@example.com --send-email +go run ./cmd/cli users activate --email alice@example.com +go run ./cmd/cli users deactivate --email alice@example.com +go run ./cmd/cli users reset-password --email alice@example.com --generate-password +go run ./cmd/cli users set-role --email alice@example.com --role application:admin +go run ./cmd/cli gardens add-user --garden-id 3 --email alice@example.com --role admin +go run ./cmd/cli db ping +``` + +`users create` requires exactly one of `--active` and `--invite` and accepts +`application:user` or `application:admin` as `--role`. User creation, its role, +and its activation token are committed in one database transaction. Password +reset invalidates existing bearer and password reset tokens. The CLI never +accepts passwords as command-line arguments. + +Application roles (`application:user`, `application:admin`) are independent of +garden roles (`owner`, `admin`, `member`, `viewer`, `worker`). `gardens add-user` +uses `member` when `--role` is omitted. diff --git a/doc/ideen.md b/doc/ideen.md new file mode 100644 index 0000000..471b05b --- /dev/null +++ b/doc/ideen.md @@ -0,0 +1,28 @@ +# Muss +- Während man in den Admineinstellungen ist, soll der aktuelle Garten beibehalten werden + +# Demnächst und konkret +- Garten bearbeiten soll auch mit dem Menü Links ausgestattet werden +- Link auf git und kleiax.de +- Seite mit Informationen zu Server Version etc Serverzeit +- Die Instanzrolle brauchen noch eine Berechtigung, ob Gärten angelegt werden dürfen + +# Vielleicht +- Detailansicht und Bearbeitenansicht trennen? +- Impressum + +# Unklar +- pickieren? zwischen aufgabe und in stammdaten entscheiden + +# Später +- Import von Pflanzen-Details z.B Pflanzmich.de und Naturadatenbank +- Bewässerung direkt an die Orte binden + - Auflösen welche Pflanzen dadurch automatisch bewässert werden + - Zapfstellen könnten auch ein Ort sein +- Datenschutz seite + +# Refactoring +- Alle Migrationen für die v1.0 zusammenfassen +- Module prüfen auf logisch Trennung, welche Module wären wiederverwendbar? Mailer ist ein schwieriger Fall, weil spezifische templates drin sind. +- neues Repo für v1.0 +- gesamtes projekt dokumentieren \ No newline at end of file diff --git a/doc/notes.md b/doc/notes.md new file mode 100644 index 0000000..846e8f2 --- /dev/null +++ b/doc/notes.md @@ -0,0 +1,5 @@ +#Migrate +migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable up +migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable force 1 +authentication -> Wer ist es? +authorization -> Darf er das? diff --git a/doc/planung.md b/doc/planung.md new file mode 100644 index 0000000..ff1428e --- /dev/null +++ b/doc/planung.md @@ -0,0 +1,121 @@ +# Gardomatic — Projektbeschreibung und Fahrplan + +*Planungsstand: 01.09.2026* + +## Projektbeschreibung + +Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur Organisation von +Gärten. Nutzer verwalten Gärten, Arten und Sorten, konkrete Pflanzen, Pflanzorte und +anstehende Arbeiten. Artenbezogene Aufgabenvorlagen erzeugen passend zum Kalender, +zum Pflanzdatum oder zur letzten Erledigung automatisch konkrete Aufgaben. Ein Garten +bildet dabei einen abgeschlossenen Daten- und Berechtigungsraum. + +Die Anwendung besteht aus einer JSON-API und einem serverseitig gerenderten +Web-Client in Go. PostgreSQL ist die einzige unterstützte Datenbank. Das Frontend +ist mobile-first, funktioniert in den wesentlichen Abläufen ohne JavaScript und +nutzt htmx für komfortable Teilaktualisierungen. Die installierbare PWA bietet eine +Offline-App-Shell; die eigentlichen Gartendaten bleiben serverseitig geführt. + +Gardomatic richtet sich an Einzelpersonen und kleine Gruppen, die einen oder mehrere +Gärten gemeinsam pflegen. Zeitfenster statt starrer Einzeltermine bilden +gärtnerische Arbeiten realistisch ab. Rollen, Einladungen und konsequente +Gartenisolierung ermöglichen eine sichere Zusammenarbeit. + +## Verbindliche Leitplanken + +- Go mit `internal/`-Layout und getrennten Binaries für API, Web und Administration. +- PostgreSQL 16 mit `database/sql`, `lib/pq` und versionierten SQL-Migrationen. +- Garten-ID im Pfad (`/v1/gardens/{gardenID}/...` und `/g/{gardenID}/...`). +- Fremde Garten-IDs liefern 404; unzulässige Aktionen innerhalb eines sichtbaren + Gartens liefern 403. +- Gartenrollen sind `owner`, `admin`, `member` und `viewer`. +- Artenstammdaten und konkrete Pflanzen bleiben getrennt; Pflanzen dürfen ohne Art + angelegt werden. +- Aufgaben verwenden Fälligkeitsfenster. Wiederkehrende Zeiträume dürfen den + Jahreswechsel überspannen. +- Aus Vorlagen erzeugte Aufgaben bleiben idempotent. Ein separater Scheduler wird + erst bei nachgewiesenem Bedarf eingeführt. +- Authentifizierung und Autorisierung liegen in der API. Das Web reicht nur das + Session-Cookie über einen request-spezifischen API-Client weiter. +- Neue Facharbeit wird als vollständiger Vertical Slice aus Migration, Storage, + API, Client, Web und Tests umgesetzt. + +## Weiterer Fahrplan + +### 1. Kalenderbereitstellung über CalDAV + +**Ziel:** Anstehende Aufgaben können in vorhandenen Kalender- und +Aufgabenanwendungen abonniert werden. + +Vorgeschlagene Umsetzung: + +1. Zuerst einen technischen Prototyp mit einer nur lesbaren Collection pro Nutzer + und Garten erstellen. Die Collection erhält stabile Ressourcen-IDs, ETags und + separat widerrufbare Zugangsdaten. +2. Aufgaben als `VTODO` abbilden: Titel, Beschreibung, Fälligkeitsfenster, + Priorität, Status sowie Pflanzen- und Ortsbezug. Gartenrollen begrenzen auch hier + die sichtbaren Daten. +3. Optional eine schreibgeschützte `VEVENT`-Ansicht für Clients ergänzen, die + `VTODO` nicht sinnvoll darstellen. Mehrtägige Fälligkeitsfenster bleiben dabei + als Zeiträume erkennbar. +4. Einrichtung und Widerruf im Benutzerkonto ergänzen und mindestens mit DAVx⁵, + Apple Kalender/Erinnerungen und Thunderbird prüfen. +5. Schreibzugriffe bewusst zurückstellen, bis Konfliktauflösung, Optimistic + Locking, Zeitzonen und die Semantik externer Erledigungen festgelegt sind. + +**Abnahmekriterien:** Eine gartenbezogene, nur lesbare Collection lässt sich in +mindestens zwei unterstützten Clients einrichten; Änderungen erscheinen nach der +Synchronisation; widerrufene Zugänge und Zugriffe auf fremde Gärten funktionieren +nicht. + +### 2. Erstes Erweiterungsmodul: Bewässerung + +**Ziel:** Den modularen Ausbau mit einem klar abgegrenzten Anwendungsfall erproben. + +Vorgeschlagener Umfang: + +1. Bewässerungszonen und deren Zuordnung zu vorhandenen Orten modellieren. +2. Manuelle Laufzeiten sowie einfache, ausführbare Zeitprogramme bereitstellen. +3. Tabellen mit `mod_irrigation_` benennen und Routen, Migrationen und + Berechtigungsprüfungen vom Kern abgrenzen. +4. Maximale Laufzeit, konkurrierende Programme und Verhalten bei fehlender + Geräteverbindung definieren, bevor reale Ventile angesteuert werden. +5. Hardware-Anbindung und Wetterautomatik erst nach einer nutzbaren manuellen + Planung auswählen. Ein allgemeines Modul-Interface erst einführen, wenn ein + zweites Modul tatsächlich gemeinsame Hooks benötigt. + +**Abnahmekriterien:** Das Modul kann deaktiviert werden, ohne Kernfunktionen zu +beeinträchtigen; Planung und manuelle Ausführung sind nutzbar; Sicherheitsgrenzen +sind automatisiert getestet. + +### 3. Aufgabenhistorie und Auswertung + +**Ziel:** Wiederkehrende Gartenarbeit später auswertbar machen, ohne den aktuellen +Aufgabenablauf unnötig zu verkomplizieren. + +Vorgeschlagene Umsetzung: + +1. Vorab entscheiden, welche Fragen beantwortet werden sollen, beispielsweise + Erledigungen pro Zeitraum, Pflanze oder Ort. +2. Erst danach ein append-only Ereignismodell für Erledigen, Wiederöffnen und + Terminänderungen entwerfen. +3. Einen kompakten Export und wenige zielgerichtete Auswertungen vor komplexen + Dashboards priorisieren. + +**Abnahmekriterien:** Historische Daten verändern den aktuellen Aufgabenstatus nicht +und bleiben gartenisoliert; die gewählten Auswertungen sind durch reale +Nutzerfragen begründet. + +## Empfohlene Reihenfolge + +1. Als nächstes den lesenden CalDAV-Prototypen umsetzen und früh mit realen Clients + testen. Das reduziert das größte technische Kompatibilitätsrisiko. +2. Danach den CalDAV-Zugang im Benutzerkonto produktionsreif machen und + dokumentieren. +3. Anschließend das Bewässerungsmodul zunächst ohne Hardwareintegration liefern. +4. Aufgabenhistorie und Reporting erst beginnen, wenn konkrete Auswertungsfragen + gesammelt wurden. + +Bidirektionales CalDAV, herstellerspezifische Bewässerungshardware und eine +allgemeine Plugin-Architektur sind ausdrücklich keine Bestandteile der jeweils +ersten Ausbaustufe. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b060461 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module gardomatic.kleiax.de + +go 1.26.0 + +require ( + github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de + github.com/alexedwards/scs/v2 v2.9.0 + github.com/go-playground/form/v4 v4.3.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/justinas/alice v1.2.0 + github.com/justinas/nosurf v1.2.0 + github.com/lib/pq v1.12.3 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce + github.com/wneessen/go-mail v0.8.1 + github.com/yuin/goldmark v1.8.5 + golang.org/x/crypto v0.55.0 + golang.org/x/term v0.45.0 + golang.org/x/time v0.15.0 +) + +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect + honnef.co/go/tools v0.8.1 // indirect +) + +tool honnef.co/go/tools/cmd/staticcheck diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f757092 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de h1:LDrMkjj4OCCQsq9SvIPQV1l3leMxqXZTCTxDFwMrqTE= +github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk= +github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= +github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= +github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= diff --git a/internal/api/account.go b/internal/api/account.go new file mode 100644 index 0000000..21a4d66 --- /dev/null +++ b/internal/api/account.go @@ -0,0 +1,250 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type accountSession struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Current bool `json:"current"` +} + +func (app *application) listAccountSessionsHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + currentID := app.sessions.GetString(r.Context(), accountSessionIDKey) + sessions := make([]accountSession, 0) + err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { + if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) != user.ID { + return nil + } + id := app.sessions.GetString(ctx, accountSessionIDKey) + if id == "" { + return nil + } + sessions = append(sessions, accountSession{ + ID: id, + CreatedAt: time.Unix(app.sessions.GetInt64(ctx, accountSessionCreatedAtKey), 0).UTC(), + ExpiresAt: app.sessions.Deadline(ctx), + Current: id == currentID, + }) + return nil + }) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"sessions": sessions}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAccountSessionHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + targetID := httprouter.ParamsFromContext(r.Context()).ByName("sessionID") + if targetID == app.sessions.GetString(r.Context(), accountSessionIDKey) { + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + found := false + err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { + if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) == user.ID && app.sessions.GetString(ctx, accountSessionIDKey) == targetID { + found = true + return app.sessions.Destroy(ctx) + } + return nil + }) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !found { + app.notFoundResponse(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) updateAccountProfileHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user.Name = strings.TrimSpace(input.Name) + if color := strings.TrimSpace(input.Color); color != "" { + user.Color = color + } + v := validate.New() + storage.ValidateUser(v, user) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + updated, err := app.models.Users.Update(user) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAccountPasswordHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + v := validate.New() + auth.ValidatePasswordPlaintext(v, input.CurrentPassword) + auth.ValidatePasswordPlaintext(v, input.NewPassword) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + match, err := user.Password.Matches(input.CurrentPassword) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !match { + app.invalidCredentialsResponse(w, r) + return + } + if err = user.Password.Set(input.NewPassword); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if _, err = app.models.Users.Update(user); err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"message": "password updated"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) requestAccountEmailChangeHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Email string `json:"email"` + CurrentPassword string `json:"current_password"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.CurrentPassword) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + match, err := user.Password.Matches(input.CurrentPassword) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !match { + app.invalidCredentialsResponse(w, r) + return + } + if existing, lookupErr := app.models.Users.GetByEmail(input.Email); lookupErr == nil && existing.ID != user.ID { + v.AddError("email", "email address is already registered") + app.failedValidationResponse(w, r, v.Errors) + return + } else if lookupErr != nil && !errors.Is(lookupErr, storage.ErrRecordNotFound) { + app.serverErrorResponse(w, r, lookupErr) + return + } + token, err := app.models.Users.CreateEmailChange(user.ID, input.Email, 45*time.Minute) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + app.background(func() { + data := map[string]any{"confirmationURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/account/email-confirm?token=" + token} + if sendErr := app.mailer.Send(input.Email, "email_change.tmpl", data); sendErr != nil { + app.logger.Error(sendErr.Error()) + } + }) + if err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "confirmation email sent"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) confirmAccountEmailHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Token string `json:"token"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + v := validate.New() + auth.ValidateTokenPlaintext(v, input.Token) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + updated, err := app.models.Users.ConfirmEmailChange(input.Token, user.ID) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) respondToAccountError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrDuplicateEmail): + app.failedValidationResponse(w, r, map[string]string{"email": "email address is already registered"}) + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..690e0a9 --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,153 @@ +package api + +import ( + "crypto/rand" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +const adminInvitationTTL = 3 * 24 * time.Hour + +func (app *application) listAdminUsersHandler(w http.ResponseWriter, r *http.Request) { + users, err := app.models.Users.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"users": users}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) inviteAdminUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Name = strings.TrimSpace(input.Name) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + candidate := storage.User{Name: input.Name, Email: input.Email, Activated: false} + if passwordErr := candidate.Password.Set(rand.Text()); passwordErr != nil { + app.serverErrorResponse(w, r, passwordErr) + return + } + v := validate.New() + if storage.ValidateUser(v, candidate); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + switch { + case err == nil && user.Activated: + app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) + return + case err == nil: + // Re-sending for an inactive account lets an administrator recover from + // an earlier delivery failure without creating a duplicate account. + case errors.Is(err, storage.ErrRecordNotFound): + user, err = app.models.Users.Insert(candidate) + if err != nil { + if errors.Is(err, storage.ErrDuplicateEmail) { + app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + default: + app.serverErrorResponse(w, r, err) + return + } + + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + token, err := app.models.Tokens.New(user.ID, adminInvitationTTL, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + activationURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/activate?token=" + url.QueryEscape(token.Plaintext) + "&set-password=1" + if err = app.mailer.Send(user.Email, "user_invitation.tmpl", map[string]any{"name": user.Name, "activationURL": activationURL}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminUserRoleHandler(w http.ResponseWriter, r *http.Request) { + userID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetAuthenticatedUser(r) + if actor.ID == userID { + app.permissionDeniedResponse(w, r) + return + } + var input struct { + Role storage.ApplicationRole `json:"role"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + role, err := app.models.Roles.Get(string(input.Role)) + if err != nil || role.Scope != storage.RoleScopeApplication { + app.failedValidationResponse(w, r, map[string]string{"role": "must be an application role"}) + return + } + user, err := app.models.Users.UpdateRole(userID, input.Role) + if err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + app.notFoundResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAdminUserHandler(w http.ResponseWriter, r *http.Request) { + userID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetAuthenticatedUser(r) + if actor.ID == userID { + app.permissionDeniedResponse(w, r) + return + } + if err = app.models.Users.Delete(userID); err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/admin_environment.go b/internal/api/admin_environment.go new file mode 100644 index 0000000..04e9a33 --- /dev/null +++ b/internal/api/admin_environment.go @@ -0,0 +1,78 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type environmentVariable struct { + Component string `json:"component"` + Name string `json:"name"` + Value string `json:"value"` +} + +func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *http.Request) { + variables := []environmentVariable{ + {Component: "API", Name: "GARDOMATIC_ENV", Value: app.config.Env}, + {Component: "API", Name: "GARDOMATIC_DB_DSN", Value: maskedValue(app.config.DB.Dsn)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_OPEN_CONNS", Value: fmt.Sprint(app.config.DB.MaxOpenConns)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_CONNS", Value: fmt.Sprint(app.config.DB.MaxIdleConns)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_TIME", Value: app.config.DB.MaxIdleTime.String()}, + {Component: "API", Name: "GARDOMATIC_API_HOST", Value: app.config.Host}, + {Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)}, + {Component: "API", Name: "GARDOMATIC_WEB_BASE_URL", Value: app.config.WebBaseURL}, + {Component: "API", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.Session.CookieName}, + {Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()}, + {Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()}, + {Component: "API", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.Session.CookieSecure)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_ENABLED", Value: fmt.Sprint(app.config.Limiter.Enabled)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_RPS", Value: fmt.Sprint(app.config.Limiter.Rps)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_BURST", Value: fmt.Sprint(app.config.Limiter.Burst)}, + {Component: "API", Name: "GARDOMATIC_CORS_TRUSTED_ORIGINS", Value: strings.Join(app.config.Cors.TrustedOrigins, ",")}, + {Component: "API", Name: "GARDOMATIC_SMTP_MODE", Value: string(app.config.Mail.Mode)}, + {Component: "API", Name: "GARDOMATIC_SMTP_HOST", Value: app.config.Mail.Host}, + {Component: "API", Name: "GARDOMATIC_SMTP_PORT", Value: fmt.Sprint(app.config.Mail.Port)}, + {Component: "API", Name: "GARDOMATIC_SMTP_USERNAME", Value: app.config.Mail.Username}, + {Component: "API", Name: "GARDOMATIC_SMTP_PASSWORD", Value: maskedValue(app.config.Mail.Password)}, + {Component: "API", Name: "GARDOMATIC_SMTP_SENDER", Value: app.config.Mail.Sender}, + {Component: "API", Name: "GARDOMATIC_SMTP_FILE_PATH", Value: app.config.Mail.FilePath}, + } + if err := app.writeJSON(w, http.StatusOK, envelope{"variables": variables}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func maskedValue(value string) string { + if value == "" { + return "(nicht gesetzt)" + } + return "•••••••• (gesetzt)" +} + +func (app *application) sendAdminTestMailHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + if err := app.mailer.Send(input.Email, "test_mail.tmpl", nil); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusAccepted, envelope{"message": "test mail sent"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go new file mode 100644 index 0000000..89e9d42 --- /dev/null +++ b/internal/api/admin_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type adminInviteUserModel struct { + sessionTestUserModel + stored storage.User + deletedID int + deleteErr error +} + +func (m *adminInviteUserModel) Insert(user storage.User) (storage.User, error) { + user.ID = 42 + m.stored = user + return user, nil +} + +func (m *adminInviteUserModel) GetByEmail(email string) (storage.User, error) { + if m.stored.ID == 0 || m.stored.Email != email { + return storage.User{}, storage.ErrRecordNotFound + } + return m.stored, nil +} + +func (m *adminInviteUserModel) GetForToken(scope, token string) (storage.User, error) { + if scope != auth.ScopeActivation || token == "" || m.stored.ID == 0 { + return storage.User{}, storage.ErrRecordNotFound + } + return m.stored, nil +} + +func (m *adminInviteUserModel) Update(user storage.User) (storage.User, error) { + m.stored = user + return user, nil +} + +func (m *adminInviteUserModel) Delete(userID int) error { + m.deletedID = userID + return m.deleteErr +} + +type adminInviteTokenModel struct { + token auth.Token + deleted bool +} + +func (m *adminInviteTokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) { + m.token = auth.NewToken(userID, ttl, scope) + return m.token, nil +} + +func (m *adminInviteTokenModel) DeleteAllForUser(scope string, userID int) error { + m.deleted = scope == auth.ScopeActivation && userID == 42 + return nil +} + +func TestAdminInvitationCreatesAccountAndAllowsInitialPassword(t *testing.T) { + users := new(adminInviteUserModel) + tokens := new(adminInviteTokenModel) + mailPath := filepath.Join(t.TempDir(), "mail.log") + configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath}) + if err != nil { + t.Fatal(err) + } + app, _, _ := newGardenTestApplication() + app.config.WebBaseURL = "https://garden.example.com" + app.models.Users = users + app.models.Tokens = tokens + app.mailer = configuredMailer + + inviteResponse := httptest.NewRecorder() + inviteRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":" Ada ","email":" ADA@EXAMPLE.COM "}`)) + app.inviteAdminUserHandler(inviteResponse, inviteRequest) + if inviteResponse.Code != http.StatusAccepted { + t.Fatalf("invite status: got %d, want %d; body: %s", inviteResponse.Code, http.StatusAccepted, inviteResponse.Body.String()) + } + if users.stored.Name != "Ada" || users.stored.Email != "ada@example.com" || users.stored.Activated { + t.Fatalf("unexpected invited user: %+v", users.stored) + } + if !tokens.deleted || tokens.token.UserID != users.stored.ID || tokens.token.Scope != auth.ScopeActivation { + t.Fatalf("unexpected invitation token: %+v", tokens.token) + } + firstToken := tokens.token.Plaintext + resendResponse := httptest.NewRecorder() + resendRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) + app.inviteAdminUserHandler(resendResponse, resendRequest) + if resendResponse.Code != http.StatusAccepted { + t.Fatalf("resend status: got %d, want %d; body: %s", resendResponse.Code, http.StatusAccepted, resendResponse.Body.String()) + } + if tokens.token.Plaintext == firstToken { + t.Fatal("resending did not replace the activation token") + } + mailContent, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"ada@example.com", "Einladung zu Gardomatic", "set-password=3D1", tokens.token.Plaintext} { + if !strings.Contains(string(mailContent), want) { + t.Errorf("invitation is missing %q: %s", want, mailContent) + } + } + + activateResponse := httptest.NewRecorder() + activateBody := `{"token":"` + tokens.token.Plaintext + `","password":"correct horse battery staple"}` + activateRequest := httptest.NewRequest(http.MethodPut, "/v1/users/activated", strings.NewReader(activateBody)) + app.activateUserHandler(activateResponse, activateRequest) + if activateResponse.Code != http.StatusOK { + t.Fatalf("activation status: got %d, want %d; body: %s", activateResponse.Code, http.StatusOK, activateResponse.Body.String()) + } + if !users.stored.Activated { + t.Fatal("invited user was not activated") + } + matches, err := users.stored.Password.Matches("correct horse battery staple") + if err != nil || !matches { + t.Fatalf("initial password was not stored: matches=%t err=%v", matches, err) + } + + duplicateResponse := httptest.NewRecorder() + duplicateRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) + app.inviteAdminUserHandler(duplicateResponse, duplicateRequest) + if duplicateResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("active duplicate status: got %d, want %d; body: %s", duplicateResponse.Code, http.StatusUnprocessableEntity, duplicateResponse.Body.String()) + } +} + +func TestDeleteAdminUserProtectsSelfAndReportsOwnerConflict(t *testing.T) { + users := new(adminInviteUserModel) + app, _, _ := newGardenTestApplication() + app.models.Users = users + router := httprouter.New() + router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.deleteAdminUserHandler) + + serve := func(actorID, targetID int) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodDelete, "/v1/admin/users/"+strconv.Itoa(targetID), nil) + request = app.contextSetAuthenticatedUser(request, storage.User{ID: actorID}) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response + } + + if response := serve(7, 7); response.Code != http.StatusForbidden || users.deletedID != 0 { + t.Fatalf("self deletion: status=%d deleted=%d", response.Code, users.deletedID) + } + users.deleteErr = storage.ErrConflict + if response := serve(7, 8); response.Code != http.StatusConflict || users.deletedID != 8 { + t.Fatalf("owner deletion: status=%d deleted=%d", response.Code, users.deletedID) + } + users.deleteErr = nil + if response := serve(7, 9); response.Code != http.StatusNoContent || users.deletedID != 9 { + t.Fatalf("deletion: status=%d deleted=%d", response.Code, users.deletedID) + } +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..f8374fc --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,177 @@ +package api + +import ( + "context" + "database/sql" + "expvar" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + "sync" + "time" + + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" + "gardomatic.kleiax.de/internal/storage/postgres" + "gardomatic.kleiax.de/internal/vcs" + + "github.com/alexedwards/scs/postgresstore" + "github.com/alexedwards/scs/v2" + _ "github.com/lib/pq" +) + +var ( + version = vcs.Version() +) + +// Config contains API server, database, session, mail, and security settings. +type Config struct { + Host string + Port int + Env string + WebBaseURL string + DB DatabaseConfig + Limiter LimiterConfig + Session SessionConfig + Mail mailer.Config + Cors CORSConfig +} + +// DatabaseConfig controls the PostgreSQL connection pool. +type DatabaseConfig struct { + Dsn string + MaxOpenConns int + MaxIdleConns int + MaxIdleTime time.Duration +} + +// LimiterConfig controls per-client HTTP rate limiting. +type LimiterConfig struct { + Enabled bool + Rps float64 + Burst int +} + +// SessionConfig controls the session cookie and server-side session lifetime. +type SessionConfig struct { + Lifetime time.Duration + IdleTimeout time.Duration + CookieName string + CookieSecure bool +} + +// CORSConfig lists origins allowed to make cross-origin API requests. +type CORSConfig struct { + TrustedOrigins []string +} + +type application struct { + config Config + logger *slog.Logger + models storage.Models + mailer *mailer.Mailer + sessions *scs.SessionManager + wg sync.WaitGroup + // db is retained by the application so Run can close the connection pool. + db *sql.DB +} + +// New initializes the API application and its database-backed dependencies. +func New(cfg Config) *application { + var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil) + if cfg.Env == "production" { + handler = slog.NewJSONHandler(os.Stdout, nil) + } + logger := slog.New(handler) + + db, err := openDB(cfg) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + logger.Info("database connection pool established") + + expvar.NewString("version").Set(version) + + expvar.Publish("goroutines", expvar.Func(func() any { + return runtime.NumGoroutine() + })) + + expvar.Publish("database", expvar.Func(func() any { + return db.Stats() + })) + + expvar.Publish("timestamp", expvar.Func(func() any { + return time.Now().Unix() + })) + + mailer, err := mailer.New(cfg.Mail) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + sessions := scs.New() + sessions.Store = postgresstore.New(db) + sessions.Lifetime = cfg.Session.Lifetime + sessions.IdleTimeout = cfg.Session.IdleTimeout + sessions.HashTokenInStore = true + sessions.Cookie.Name = cfg.Session.CookieName + sessions.Cookie.HttpOnly = true + sessions.Cookie.SameSite = http.SameSiteLaxMode + sessions.Cookie.Secure = cfg.Session.CookieSecure + + app := &application{ + config: cfg, + logger: logger, + models: postgres.New(db), + mailer: mailer, + sessions: sessions, + db: db, + } + + sessions.ErrorFunc = app.serverErrorResponse + + return app +} + +// Run serves API requests until shutdown and then releases application resources. +func (app *application) Run() { + defer app.db.Close() + + err := app.serve() + if err != nil { + app.logger.Error(err.Error()) + os.Exit(1) + } +} + +// Version returns the version embedded in the API binary. +func (app *application) Version() string { + return fmt.Sprintf("Version:\t%s\n", version) +} + +func openDB(cfg Config) (*sql.DB, error) { + db, err := sql.Open("postgres", cfg.DB.Dsn) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(cfg.DB.MaxOpenConns) + db.SetMaxIdleConns(cfg.DB.MaxIdleConns) + db.SetConnMaxIdleTime(cfg.DB.MaxIdleTime) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.PingContext(ctx) + if err != nil { + db.Close() + return nil, err + } + + return db, nil +} diff --git a/internal/api/application_settings.go b/internal/api/application_settings.go new file mode 100644 index 0000000..4b63575 --- /dev/null +++ b/internal/api/application_settings.go @@ -0,0 +1,100 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type applicationSettingsInput struct { + LifecycleStatusEnabled *bool `json:"lifecycle_status_enabled"` + LifecycleRemovalMonth *int `json:"lifecycle_removal_month"` + LifecycleRemovalDay *int `json:"lifecycle_removal_day"` + Timezone *string `json:"timezone"` +} + +func (input applicationSettingsInput) apply(settings *storage.ApplicationSettings) { + if input.LifecycleStatusEnabled != nil { + settings.LifecycleStatusEnabled = *input.LifecycleStatusEnabled + } + if input.LifecycleRemovalMonth != nil { + settings.LifecycleRemovalMonth = *input.LifecycleRemovalMonth + } + if input.LifecycleRemovalDay != nil { + settings.LifecycleRemovalDay = *input.LifecycleRemovalDay + } + if input.Timezone != nil { + settings.Timezone = strings.TrimSpace(*input.Timezone) + } +} + +func (app *application) showAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) { + settings, err := app.models.ApplicationSettings.Get() + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) { + settings, err := app.models.ApplicationSettings.Get() + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input applicationSettingsInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&settings) + v := validate.New() + storage.ValidateApplicationSettings(v, settings) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + settings, err = app.models.ApplicationSettings.Update(settings) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) runLifecycleMaintenance(now time.Time) error { + if app.models.ApplicationSettings == nil { + return nil + } + settings, err := app.models.ApplicationSettings.Get() + if err != nil || !settings.LifecycleStatusEnabled { + return err + } + location, err := time.LoadLocation(settings.Timezone) + if err != nil { + return err + } + localNow := now.In(location) + lastDay := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth)+1, 0, 0, 0, 0, 0, location).Day() + day := settings.LifecycleRemovalDay + if day > lastDay { + day = lastDay + } + cutoff := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth), day, 0, 0, 0, 0, location) + if localNow.Before(cutoff) { + return nil + } + count, err := app.models.ApplicationSettings.RemoveExpiredPlants(cutoff, settings.LifecycleRemovalMonth, day) + if err == nil && count > 0 { + app.logger.Info("removed plants which reached their configured lifecycle", "count", count, "cutoff", cutoff.Format("2006-01-02")) + } + return err +} diff --git a/internal/api/application_settings_test.go b/internal/api/application_settings_test.go new file mode 100644 index 0000000..26f43b9 --- /dev/null +++ b/internal/api/application_settings_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" +) + +type applicationSettingsTestModel struct { + settings storage.ApplicationSettings + calls int + asOf time.Time +} + +func (m *applicationSettingsTestModel) Get() (storage.ApplicationSettings, error) { + return m.settings, nil +} +func (m *applicationSettingsTestModel) Update(value storage.ApplicationSettings) (storage.ApplicationSettings, error) { + m.settings = value + return value, nil +} +func (m *applicationSettingsTestModel) RemoveExpiredPlants(asOf time.Time, _, _ int) (int, error) { + m.calls++ + m.asOf = asOf + return 2, nil +} + +func TestLifecycleMaintenanceHonorsEnabledSettingAndCutoff(t *testing.T) { + app, _, _ := newGardenTestApplication() + model := &applicationSettingsTestModel{settings: storage.ApplicationSettings{LifecycleStatusEnabled: true, LifecycleRemovalMonth: 12, LifecycleRemovalDay: 1, Timezone: "Europe/Berlin"}} + app.models.ApplicationSettings = model + + if err := app.runLifecycleMaintenance(time.Date(2026, 11, 30, 12, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if model.calls != 0 { + t.Fatalf("maintenance ran before cutoff") + } + if err := app.runLifecycleMaintenance(time.Date(2026, 12, 1, 12, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if model.calls != 1 || model.asOf.Format("2006-01-02") != "2026-12-01" { + t.Fatalf("maintenance calls=%d cutoff=%v", model.calls, model.asOf) + } +} + +func TestShowAdminEnvironmentMasksSecrets(t *testing.T) { + app, _, _ := newGardenTestApplication() + app.config = Config{ + DB: DatabaseConfig{Dsn: "postgres://user:secret@db/gardomatic"}, + Mail: mailer.Config{Password: "smtp-secret"}, + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/admin/environment", nil) + + app.showAdminEnvironmentHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", response.Code, http.StatusOK) + } + body := response.Body.String() + if strings.Contains(body, "secret") { + t.Fatalf("environment response exposes a secret: %s", body) + } + var result struct { + Variables []environmentVariable `json:"variables"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} { + found := false + for _, variable := range result.Variables { + found = found || variable.Name == name + } + if !found { + t.Errorf("missing environment variable %s", name) + } + } +} + +func TestSendAdminTestMailValidatesAndDelivers(t *testing.T) { + app, _, _ := newGardenTestApplication() + mailPath := filepath.Join(t.TempDir(), "mail.log") + configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath}) + if err != nil { + t.Fatal(err) + } + app.mailer = configuredMailer + + invalidResponse := httptest.NewRecorder() + invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":"not-an-email"}`)) + app.sendAdminTestMailHandler(invalidResponse, invalidRequest) + if invalidResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("invalid status: got %d, want %d; body: %s", invalidResponse.Code, http.StatusUnprocessableEntity, invalidResponse.Body.String()) + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":" Test@Example.com "}`)) + app.sendAdminTestMailHandler(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusAccepted, response.Body.String()) + } + content, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"test@example.com", "Gardomatic Testmail", "Gardomatic funktion"} { + if !strings.Contains(string(content), want) { + t.Errorf("test mail is missing %q: %s", want, content) + } + } +} diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..778f64e --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1 @@ +package api diff --git a/internal/api/care_instructions.go b/internal/api/care_instructions.go new file mode 100644 index 0000000..3ec13ce --- /dev/null +++ b/internal/api/care_instructions.go @@ -0,0 +1,157 @@ +package api + +import ( + "fmt" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "net/http" + "strings" +) + +type careInstructionInput struct { + Text *string `json:"text"` + Status *string `json:"status"` +} + +func (i careInstructionInput) apply(v *storage.CareInstruction) { + if i.Text != nil { + v.Text = strings.TrimSpace(*i.Text) + } + if i.Status != nil { + v.Status = *i.Status + } +} +func (app *application) validateCareInstruction(w http.ResponseWriter, r *http.Request, item storage.CareInstruction) bool { + v := validate.New() + storage.ValidateCareInstruction(v, item) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} +func (app *application) listCareInstructionsHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + items, e := app.models.CareInstructions.GetAllForSpecies(g, s) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + if e = app.writeJSON(w, http.StatusOK, envelope{"care_instructions": items}, nil); e != nil { + app.serverErrorResponse(w, r, e) + } +} +func (app *application) createCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + var input careInstructionInput + if e = app.readJSON(w, r, &input); e != nil { + app.badRequestResponse(w, r, e) + return + } + u, _ := app.contextGetAuthenticatedUser(r) + item := storage.CareInstruction{SpeciesID: s, Status: "untested", CreatedBy: u.ID, UpdatedBy: u.ID} + input.apply(&item) + if !app.validateCareInstruction(w, r, item) { + return + } + item, e = app.models.CareInstructions.Insert(item) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + h := make(http.Header) + h.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/care-instructions/%d", g, s, item.ID)) + _ = app.writeJSON(w, http.StatusCreated, envelope{"care_instruction": item}, h) +} +func (app *application) updateCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + id, e := app.readNamedIDParam(r, "instructionID") + if e != nil { + app.notFoundResponse(w, r) + return + } + item, e := app.models.CareInstructions.Get(g, s, id) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + var input careInstructionInput + if e = app.readJSON(w, r, &input); e != nil { + app.badRequestResponse(w, r, e) + return + } + input.apply(&item) + u, _ := app.contextGetAuthenticatedUser(r) + item.UpdatedBy = u.ID + if !app.validateCareInstruction(w, r, item) { + return + } + item, e = app.models.CareInstructions.Update(g, item) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + _ = app.writeJSON(w, http.StatusOK, envelope{"care_instruction": item}, nil) +} +func (app *application) deleteCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + id, e := app.readNamedIDParam(r, "instructionID") + if e != nil { + app.notFoundResponse(w, r) + return + } + if e = app.models.CareInstructions.Delete(g, s, id); e != nil { + app.respondToEntityModelError(w, r, e) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) canWriteCareInstructions(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) bool { + species, err := app.models.Species.Get(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return false + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if species.GardenID == nil { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return false + } + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return false + } + return true +} diff --git a/internal/api/context.go b/internal/api/context.go new file mode 100644 index 0000000..d2033a6 --- /dev/null +++ b/internal/api/context.go @@ -0,0 +1,33 @@ +package api + +import ( + "context" + "net/http" + + "gardomatic.kleiax.de/internal/storage" +) + +type contextKey string + +const authenticatedUserContextKey = contextKey("authenticatedUser") +const gardenMemberContextKey = contextKey("gardenMember") + +func (app *application) contextSetAuthenticatedUser(r *http.Request, user storage.User) *http.Request { + ctx := context.WithValue(r.Context(), authenticatedUserContextKey, user) + return r.WithContext(ctx) +} + +func (app *application) contextGetAuthenticatedUser(r *http.Request) (storage.User, bool) { + user, ok := r.Context().Value(authenticatedUserContextKey).(storage.User) + return user, ok +} + +func (app *application) contextSetGardenMember(r *http.Request, member storage.GardenMember) *http.Request { + ctx := context.WithValue(r.Context(), gardenMemberContextKey, member) + return r.WithContext(ctx) +} + +func (app *application) contextGetGardenMember(r *http.Request) (storage.GardenMember, bool) { + member, ok := r.Context().Value(gardenMemberContextKey).(storage.GardenMember) + return member, ok +} diff --git a/internal/api/doc.go b/internal/api/doc.go new file mode 100644 index 0000000..6c6c354 --- /dev/null +++ b/internal/api/doc.go @@ -0,0 +1,3 @@ +// Package api implements the Gardomatic HTTP API, including authentication, +// authorization middleware, request validation, and JSON response handling. +package api diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..fd94710 --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,99 @@ +package api + +import ( + "fmt" + "net/http" + "runtime/debug" +) + +func (app *application) logError(r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + ) + + app.logger.Error(err.Error(), "method", method, "uri", uri) + fmt.Printf("%v\n", string(debug.Stack())) +} + +func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) { + env := envelope{"error": message} + + err := app.writeJSON(w, status, env, nil) + if err != nil { + app.logError(r, err) + w.WriteHeader(500) + } +} + +func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + app.logError(r, err) + + message := "the server encountered a problem and could not process your request" + app.errorResponse(w, r, http.StatusInternalServerError, message) +} + +func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { + message := "the requested resource could not be found" + app.errorResponse(w, r, http.StatusNotFound, message) +} + +func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + message := fmt.Sprintf("the %s method is not supported for this resource", r.Method) + app.errorResponse(w, r, http.StatusMethodNotAllowed, message) +} + +func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + app.errorResponse(w, r, http.StatusBadRequest, err.Error()) +} + +func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record due to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) conflictResponse(w http.ResponseWriter, r *http.Request) { + message := "a conflicting record already exists" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { + message := "rate limit exceeded" + app.errorResponse(w, r, http.StatusTooManyRequests, message) +} + +func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + message := "invalid authentication credentials" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + + message := "invalid or missing authentication token" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + message := "you must be authenticated to access this resource" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + message := "your user account must be activated to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.Request) { + message := "you do not have permission to perform this action" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) { + message := "the request origin is not trusted" + app.errorResponse(w, r, http.StatusForbidden, message) +} diff --git a/internal/api/garden_members.go b/internal/api/garden_members.go new file mode 100644 index 0000000..d673c0e --- /dev/null +++ b/internal/api/garden_members.go @@ -0,0 +1,194 @@ +package api + +import ( + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +func (app *application) listGardenMembersHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + members, err := app.models.GardenMembers.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"members": members}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func canManageMember(target, replacement storage.GardenRole) bool { + return target != storage.GardenRoleOwner && replacement != storage.GardenRoleOwner +} + +func (app *application) updateGardenMemberHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + userID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + Role storage.GardenRole `json:"role"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + _, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role)) + if roleErr != nil || input.Role == storage.GardenRoleOwner { + app.failedValidationResponse(w, r, map[string]string{"role": "must be a garden role; ownership must be transferred separately"}) + return + } + target, err := app.models.GardenMembers.Get(gardenID, userID) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if !canManageMember(target.Role, input.Role) { + app.permissionDeniedResponse(w, r) + return + } + target.Role = input.Role + target, err = app.models.GardenMembers.Update(target) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"member": target}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteGardenMemberHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + userID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + target, err := app.models.GardenMembers.Get(gardenID, userID) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if !canManageMember(target.Role, storage.GardenRoleViewer) { + app.permissionDeniedResponse(w, r) + return + } + if err = app.models.GardenMembers.Delete(gardenID, userID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) transferGardenOwnershipHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + toUserID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetGardenMember(r) + if err = app.models.GardenMembers.TransferOwnership(gardenID, actor.UserID, toUserID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) listGardenInvitesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + invites, err := app.models.GardenInvites.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"invites": invites}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + actor, _ := app.contextGetGardenMember(r) + var input struct { + Email string `json:"email"` + Role storage.GardenRole `json:"role"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + _, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role)) + if roleErr != nil || input.Role == storage.GardenRoleOwner { + v.AddError("role", "role may not be granted") + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + invite, err := app.models.GardenInvites.Upsert(storage.GardenInvite{GardenID: gardenID, Email: input.Email, Role: input.Role, InvitedBy: actor.UserID, ExpiresAt: time.Now().Add(7 * 24 * time.Hour)}) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.background(func() { + data := map[string]any{"inviteURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token} + if sendErr := app.mailer.Send(invite.Email, "garden_invite.tmpl", data); sendErr != nil { + app.logger.Error(sendErr.Error()) + } + }) + invite.Token = "" + if err = app.writeJSON(w, http.StatusCreated, envelope{"invite": invite}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + inviteID, err := app.readNamedIDParam(r, "inviteID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.GardenInvites.Delete(gardenID, inviteID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) acceptGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + token := httprouter.ParamsFromContext(r.Context()).ByName("token") + user, _ := app.contextGetAuthenticatedUser(r) + member, err := app.models.GardenInvites.Accept(token, user) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"member": member}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) respondToMemberError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/gardens.go b/internal/api/gardens.go new file mode 100644 index 0000000..0786e80 --- /dev/null +++ b/internal/api/gardens.go @@ -0,0 +1,187 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) createGardenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data"` + ImageID *int `json:"image_id"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + + user, _ := app.contextGetAuthenticatedUser(r) + garden := storage.Garden{ + Name: strings.TrimSpace(input.Name), + Description: strings.TrimSpace(input.Description), + ImageData: input.ImageData, + } + v := validate.New() + validateImageData(v, garden.ImageData) + if storage.ValidateGarden(v, garden); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + garden, err := app.models.Gardens.Insert(garden, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.ImageData != "" || input.ImageID != nil { + garden.ImageID, err = app.resolveImage(garden.ID, input.ImageData, input.ImageID, user.ID, "garden") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + garden.ImageData = "" + garden, err = app.models.Gardens.Update(garden) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + garden.Role = storage.GardenRoleOwner + garden.Permissions = storage.GardenRoleOwner.Permissions() + + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d", garden.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"garden": garden}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listGardensHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + gardens, err := app.models.Gardens.GetAllForUser(user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range gardens { + member, memberErr := app.models.GardenMembers.Get(gardens[i].ID, user.ID) + if memberErr != nil { + app.serverErrorResponse(w, r, memberErr) + return + } + applyGardenMembership(&gardens[i], member) + } + if err := app.writeJSON(w, http.StatusOK, envelope{"gardens": gardens}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + garden, err := app.models.Gardens.Get(gardenID) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + applyGardenMembership(&garden, member) + if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + garden, err := app.models.Gardens.Get(gardenID) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + applyGardenMembership(&garden, member) + + var input struct { + Name *string `json:"name"` + Description *string `json:"description"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.Name != nil { + garden.Name = strings.TrimSpace(*input.Name) + } + if input.Description != nil { + garden.Description = strings.TrimSpace(*input.Description) + } + previousImageID := garden.ImageID + previousImageData := garden.ImageData + if input.ImageData != nil { + garden.ImageData = *input.ImageData + } + + v := validate.New() + validateImageData(v, garden.ImageData) + if storage.ValidateGarden(v, garden); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + if input.ImageData != nil && *input.ImageData != previousImageData { + garden.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "garden") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + garden.ImageData = "" + garden, err = app.models.Gardens.Update(garden) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "garden", EntityID: garden.ID, PreviousImageID: previousImageID, ImageID: garden.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func applyGardenMembership(garden *storage.Garden, member storage.GardenMember) { + garden.Role = member.Role + garden.Permissions = member.Permissions + if garden.Permissions == nil { + garden.Permissions = member.Role.Permissions() + } +} + +func (app *application) deleteGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + if err := app.models.Gardens.Delete(gardenID); err != nil { + app.respondToGardenModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) respondToGardenModelError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/gardens_test.go b/internal/api/gardens_test.go new file mode 100644 index 0000000..052e9a2 --- /dev/null +++ b/internal/api/gardens_test.go @@ -0,0 +1,275 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type gardenTestModel struct { + gardens map[int]storage.Garden + nextID int + insertOwner int + listUserID int + getCallCount int +} + +func (m *gardenTestModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { + m.nextID++ + garden.ID = m.nextID + garden.Version = 1 + garden.CreatedAt = time.Now() + garden.UpdatedAt = garden.CreatedAt + m.gardens[garden.ID] = garden + m.insertOwner = ownerID + return garden, nil +} + +func (m *gardenTestModel) Get(id int) (storage.Garden, error) { + m.getCallCount++ + garden, ok := m.gardens[id] + if !ok { + return storage.Garden{}, storage.ErrRecordNotFound + } + return garden, nil +} + +func (m *gardenTestModel) GetAllForUser(userID int) ([]storage.Garden, error) { + m.listUserID = userID + gardens := make([]storage.Garden, 0, len(m.gardens)) + for _, garden := range m.gardens { + gardens = append(gardens, garden) + } + return gardens, nil +} + +func (m *gardenTestModel) Update(garden storage.Garden) (storage.Garden, error) { + if _, ok := m.gardens[garden.ID]; !ok { + return storage.Garden{}, storage.ErrRecordNotFound + } + garden.Version++ + m.gardens[garden.ID] = garden + return garden, nil +} + +func (m *gardenTestModel) Delete(id int) error { + if _, ok := m.gardens[id]; !ok { + return storage.ErrRecordNotFound + } + delete(m.gardens, id) + return nil +} + +type gardenMemberTestModel struct { + members map[[2]int]storage.GardenMember +} + +func (m *gardenMemberTestModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { + m.members[[2]int{member.GardenID, member.UserID}] = member + return member, nil +} + +func (m *gardenMemberTestModel) Get(gardenID, userID int) (storage.GardenMember, error) { + member, ok := m.members[[2]int{gardenID, userID}] + if !ok { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + return member, nil +} + +func (m *gardenMemberTestModel) GetAllForGarden(int) ([]storage.GardenMember, error) { + return nil, nil +} + +func (m *gardenMemberTestModel) Update(member storage.GardenMember) (storage.GardenMember, error) { + return member, nil +} + +func (m *gardenMemberTestModel) Delete(gardenID, userID int) error { + delete(m.members, [2]int{gardenID, userID}) + return nil +} + +func (m *gardenMemberTestModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { + from := m.members[[2]int{gardenID, fromUserID}] + to := m.members[[2]int{gardenID, toUserID}] + from.Role = storage.GardenRoleAdmin + to.Role = storage.GardenRoleOwner + m.members[[2]int{gardenID, fromUserID}] = from + m.members[[2]int{gardenID, toUserID}] = to + return nil +} + +func newGardenTestApplication() (*application, *gardenTestModel, *gardenMemberTestModel) { + gardens := &gardenTestModel{gardens: make(map[int]storage.Garden), nextID: 40} + members := &gardenMemberTestModel{members: make(map[[2]int]storage.GardenMember)} + app := &application{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + models: storage.Models{Gardens: gardens, GardenMembers: members}, + } + return app, gardens, members +} + +func serveGardenRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.updateGardenHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.deleteGardenHandler))) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = app.contextSetAuthenticatedUser(r, user) + router.ServeHTTP(w, r) + }) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func TestCreateAndListGardens(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}} + + createResponse := serveGardenRequest(app, user, http.MethodPost, "/v1/gardens", []byte(`{"name":" Hinterhof ","description":" Gemüse "}`)) + if createResponse.Code != http.StatusCreated { + t.Fatalf("create status: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) + } + if gardens.insertOwner != user.ID { + t.Errorf("owner: got %d, want %d", gardens.insertOwner, user.ID) + } + if got := createResponse.Header().Get("Location"); got != "/v1/gardens/41" { + t.Errorf("Location: got %q, want %q", got, "/v1/gardens/41") + } + + var createEnvelope struct { + Garden storage.Garden `json:"garden"` + } + if err := json.Unmarshal(createResponse.Body.Bytes(), &createEnvelope); err != nil { + t.Fatal(err) + } + if createEnvelope.Garden.Name != "Hinterhof" || createEnvelope.Garden.Description != "Gemüse" { + t.Errorf("created garden was not normalized: %+v", createEnvelope.Garden) + } + members.members[[2]int{createEnvelope.Garden.ID, user.ID}] = storage.GardenMember{GardenID: createEnvelope.Garden.ID, UserID: user.ID, Role: storage.GardenRoleOwner} + + listResponse := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) + if listResponse.Code != http.StatusOK { + t.Fatalf("list status: got %d, want %d", listResponse.Code, http.StatusOK) + } + if gardens.listUserID != user.ID { + t.Errorf("list user: got %d, want %d", gardens.listUserID, user.ID) + } +} + +func TestCreateGardenRequiresApplicationPermission(t *testing.T) { + app, gardens, _ := newGardenTestApplication() + response := serveGardenRequest(app, storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{}}, http.MethodPost, "/v1/gardens", []byte(`{"name":"Hinterhof"}`)) + if response.Code != http.StatusForbidden { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusForbidden, response.Body.String()) + } + if gardens.insertOwner != 0 { + t.Fatal("garden was inserted without gardens:create permission") + } +} + +func TestGardenMembershipHidesForeignGarden(t *testing.T) { + app, gardens, members := newGardenTestApplication() + gardens.gardens[12] = storage.Garden{ID: 12, Name: "Geheim", Version: 1} + members.members[[2]int{12, 1}] = storage.GardenMember{GardenID: 12, UserID: 1, Role: storage.GardenRoleOwner} + + memberResponse := serveGardenRequest(app, storage.User{ID: 1, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) + if memberResponse.Code != http.StatusOK { + t.Fatalf("member status: got %d, want %d", memberResponse.Code, http.StatusOK) + } + + callsBefore := gardens.getCallCount + foreignResponse := serveGardenRequest(app, storage.User{ID: 2, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) + if foreignResponse.Code != http.StatusNotFound { + t.Fatalf("foreign status: got %d, want %d", foreignResponse.Code, http.StatusNotFound) + } + if gardens.getCallCount != callsBefore { + t.Error("garden was loaded even though membership check failed") + } +} + +func TestGardenResponsesIncludeEffectiveMembershipPermissions(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 4, Activated: true} + gardens.gardens[12] = storage.Garden{ID: 12, Name: "Gemeinschaftsgarten", Version: 1} + members.members[[2]int{12, user.ID}] = storage.GardenMember{ + GardenID: 12, + UserID: user.ID, + Role: "garden:custom", + Permissions: []storage.GardenPermission{storage.GardenPermissionPlantCreate, storage.GardenPermissionTaskCompleteOther}, + } + + response := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens/12", nil) + if response.Code != http.StatusOK { + t.Fatalf("show status: got %d, want %d", response.Code, http.StatusOK) + } + var showEnvelope struct { + Garden storage.Garden `json:"garden"` + } + if err := json.Unmarshal(response.Body.Bytes(), &showEnvelope); err != nil { + t.Fatal(err) + } + if got := showEnvelope.Garden.Permissions; len(got) != 2 || got[0] != storage.GardenPermissionPlantCreate || got[1] != storage.GardenPermissionTaskCompleteOther { + t.Fatalf("show permissions: got %v", got) + } + + response = serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) + if response.Code != http.StatusOK { + t.Fatalf("list status: got %d, want %d", response.Code, http.StatusOK) + } + var listEnvelope struct { + Gardens []storage.Garden `json:"gardens"` + } + if err := json.Unmarshal(response.Body.Bytes(), &listEnvelope); err != nil { + t.Fatal(err) + } + if len(listEnvelope.Gardens) != 1 || len(listEnvelope.Gardens[0].Permissions) != 2 { + t.Fatalf("list permissions: got %+v", listEnvelope.Gardens) + } +} + +func TestUpdateAndDeleteGarden(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 3, Activated: true} + gardens.gardens[9] = storage.Garden{ID: 9, Name: "Alt", Description: "Alt", Version: 1} + members.members[[2]int{9, user.ID}] = storage.GardenMember{GardenID: 9, UserID: user.ID, Role: storage.GardenRoleMember} + + updateResponse := serveGardenRequest(app, user, http.MethodPatch, "/v1/gardens/9", []byte(`{"name":"Neu"}`)) + if updateResponse.Code != http.StatusOK { + t.Fatalf("update status: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) + } + if got := gardens.gardens[9]; got.Name != "Neu" || got.Description != "Alt" || got.Version != 2 { + t.Errorf("updated garden: got %+v", got) + } + + deleteResponse := serveGardenRequest(app, user, http.MethodDelete, "/v1/gardens/9", nil) + if deleteResponse.Code != http.StatusNoContent { + t.Fatalf("delete status: got %d, want %d", deleteResponse.Code, http.StatusNoContent) + } + if _, exists := gardens.gardens[9]; exists { + t.Error("garden still exists after delete") + } +} + +func TestCreateGardenValidation(t *testing.T) { + app, _, _ := newGardenTestApplication() + response := serveGardenRequest(app, storage.User{ID: 1, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}}, http.MethodPost, "/v1/gardens", []byte(`{"name":" "}`)) + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusUnprocessableEntity, response.Body.String()) + } +} diff --git a/internal/api/healthcheck.go b/internal/api/healthcheck.go new file mode 100644 index 0000000..3ff4072 --- /dev/null +++ b/internal/api/healthcheck.go @@ -0,0 +1,22 @@ +package api + +import ( + "net/http" + "time" +) + +func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) { + env := envelope{ + "status": "available", + "server_time": time.Now().UTC(), + "system_info": map[string]string{ + "environment": app.config.Env, + "version": version, + }, + } + + err := app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/healthcheck_test.go b/internal/api/healthcheck_test.go new file mode 100644 index 0000000..3eb1ddf --- /dev/null +++ b/internal/api/healthcheck_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" +) + +func TestHealthcheckIncludesServerTimeAndSystemInformation(t *testing.T) { + app := &application{config: Config{Env: "test"}} + request := httptest.NewRequest("GET", "/v1/healthcheck", nil) + response := httptest.NewRecorder() + app.healthcheckHandler(response, request) + + var body struct { + Status string `json:"status"` + ServerTime time.Time `json:"server_time"` + SystemInfo struct { + Environment string `json:"environment"` + Version string `json:"version"` + } `json:"system_info"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Status != "available" || body.ServerTime.IsZero() || body.SystemInfo.Environment != "test" || body.SystemInfo.Version == "" { + t.Fatalf("unexpected health response: %+v", body) + } +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..b1eebf2 --- /dev/null +++ b/internal/api/helpers.go @@ -0,0 +1,175 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "github.com/julienschmidt/httprouter" +) + +func (app *application) readIDParam(r *http.Request) (int, error) { + return app.readNamedIDParam(r, "id") +} + +func (app *application) readNamedIDParam(r *http.Request, name string) (int, error) { + params := httprouter.ParamsFromContext(r.Context()) + id, err := strconv.Atoi(params.ByName(name)) + if err != nil || id < 1 { + return 0, errors.New("invalid id parameter") + } + + return id, nil +} + +func (app *application) readGardenIDParam(r *http.Request) (int, error) { + params := httprouter.ParamsFromContext(r.Context()) + + id, err := strconv.Atoi(params.ByName("gardenID")) + if err != nil || id < 1 { + return 0, errors.New("invalid garden id parameter") + } + + return id, nil +} + +type envelope map[string]any + +func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { + js, err := json.MarshalIndent(data, "", "\t") + if err != nil { + return err + } + + js = append(js, '\n') + + for key, values := range headers { + for _, value := range values { + w.Header().Add(key, value) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + + return nil +} + +func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, 2_097_152) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + var maxBytesError *http.MaxBytesError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) + } + return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) + + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("body contains unknown key %s", fieldName) + + case errors.As(err, &maxBytesError): + return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit) + + case errors.As(err, &invalidUnmarshalError): + panic(err) + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + return errors.New("body must only contain a single JSON value") + } + + return nil +} + +func validateImageData(v *validate.Validator, imageData string) { + if imageData == "" { + return + } + v.Check(len(imageData) <= 1_500_000, "image_data", "must not be larger than 1.5 MB") + v.Check(strings.HasPrefix(imageData, "data:image/jpeg;base64,") || strings.HasPrefix(imageData, "data:image/png;base64,") || strings.HasPrefix(imageData, "data:image/webp;base64,"), "image_data", "must be a JPEG, PNG or WebP image") +} + +//lint:ignore U1000 retained for the upcoming list filters +func (app *application) readString(qs url.Values, key string, defaultValue string) string { + s := qs.Get(key) + + if s == "" { + return defaultValue + } + + return s +} + +//lint:ignore U1000 retained for the upcoming list filters +func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string { + csv := qs.Get(key) + + if csv == "" { + return defaultValue + } + + return strings.Split(csv, ",") +} + +//lint:ignore U1000 retained for the upcoming pagination support +func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validate.Validator) int { + s := qs.Get(key) + + if s == "" { + return defaultValue + } + + i, err := strconv.Atoi(s) + if err != nil { + v.AddError(key, "must be an integer value") + return defaultValue + } + + return i +} + +func (app *application) background(fn func()) { + app.wg.Go(func() { + defer func() { + pv := recover() + if pv != nil { + app.logger.Error(fmt.Sprintf("%v", pv)) + } + }() + + fn() + }) +} diff --git a/internal/api/images.go b/internal/api/images.go new file mode 100644 index 0000000..c547a0e --- /dev/null +++ b/internal/api/images.go @@ -0,0 +1,72 @@ +package api + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) resolveImage(gardenID int, data string, selectedID *int, userID int, source string) (*int, error) { + if strings.TrimSpace(data) == "" { + if selectedID == nil { + return nil, nil + } + if _, err := app.models.Images.Get(gardenID, *selectedID); err != nil { + return nil, err + } + return selectedID, nil + } + comma := strings.IndexByte(data, ',') + if comma < 1 || !strings.HasPrefix(data, "data:image/") || !strings.HasSuffix(data[:comma], ";base64") { + return nil, errors.New("invalid image data") + } + mediaType := strings.TrimSuffix(strings.TrimPrefix(data[:comma], "data:"), ";base64") + decoded, err := base64.StdEncoding.DecodeString(data[comma+1:]) + if err != nil || len(decoded) == 0 || len(decoded) > storage.MaxImageSize { + return nil, errors.New("invalid image data") + } + image, err := app.models.Images.Insert(storage.Image{GardenID: gardenID, MediaType: mediaType, Data: decoded, Source: source, CreatedBy: userID}) + if err != nil { + return nil, err + } + return &image.ID, nil +} + +func (app *application) recordImageAssignment(change storage.ImageAssignment) error { + if app.models.Images == nil { + return nil + } + return app.models.Images.RecordAssignment(change) +} + +func (app *application) listImagesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + images, err := app.models.Images.GetAllForGarden(gardenID, storage.ImageFilter{Source: r.URL.Query().Get("source"), Query: r.URL.Query().Get("q")}) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"images": images}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showImageHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readNamedIDParam(r, "imageID") + if err != nil { + app.notFoundResponse(w, r) + return + } + image, err := app.models.Images.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.Header().Set("Content-Type", image.MediaType) + w.Header().Set("Cache-Control", "private, max-age=86400") + _, _ = w.Write(image.Data) +} diff --git a/internal/api/journal.go b/internal/api/journal.go new file mode 100644 index 0000000..7c142a7 --- /dev/null +++ b/internal/api/journal.go @@ -0,0 +1,332 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type journalEntryInput struct { + Title *string `json:"title"` + Body *string `json:"body"` + CreatedAt *time.Time `json:"created_at"` + Tags []string `json:"tags"` + EntryType *storage.JournalEntryType `json:"entry_type"` +} + +func (input journalEntryInput) apply(entry *storage.JournalEntry) { + if input.Title != nil { + entry.Title = strings.TrimSpace(*input.Title) + } + if input.Body != nil { + entry.Body = strings.TrimSpace(*input.Body) + } + if input.CreatedAt != nil { + entry.CreatedAt = input.CreatedAt.UTC() + } + if input.EntryType != nil { + entry.EntryType = *input.EntryType + } +} + +func (app *application) createJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + var input journalEntryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + entry := storage.JournalEntry{GardenID: gardenID, AuthorID: user.ID, AuthorName: user.Name, EntryType: storage.JournalEntryTypeJournal} + entry.CreatedAt = time.Now().UTC() + input.apply(&entry) + entry.Tags = storage.NormalizeTags(input.Tags) + if !app.validateJournalEntry(w, r, entry) { + return + } + var err error + entry, err = app.models.Journal.Insert(entry) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, entry.ID, entry.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/journal/%d", gardenID, entry.ID)) + if err = app.writeJSON(w, http.StatusCreated, envelope{"journal_entry": entry}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listJournalEntriesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryType := storage.JournalEntryType(r.URL.Query().Get("type")) + if entryType == "" { + entryType = storage.JournalEntryTypeJournal + } + if entryType != storage.JournalEntryTypeJournal && entryType != storage.JournalEntryTypePinboard { + app.badRequestResponse(w, r, fmt.Errorf("type must be journal or pinboard")) + return + } + entries, err := app.models.Journal.GetAllForGarden(gardenID, entryType) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range entries { + entries[i].Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entries[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entries": entries}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + entry, err := app.models.Journal.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entry.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + entry, err := app.models.Journal.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input journalEntryInput + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&entry) + if input.Tags != nil { + entry.Tags = storage.NormalizeTags(input.Tags) + } else { + entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, id) + } + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !app.validateJournalEntry(w, r, entry) { + return + } + entry, err = app.models.Journal.Update(gardenID, entry) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if input.Tags != nil { + entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, id, entry.Tags) + } + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if stored, getErr := app.models.Journal.Get(gardenID, id); getErr == nil { + entry.Attachments = stored.Attachments + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Journal.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) createJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, storage.MaxJournalAttachmentSize+(1<<20)) + file, header, err := r.FormFile("file") + if err != nil { + app.badRequestResponse(w, r, fmt.Errorf("file must be provided")) + return + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, storage.MaxJournalAttachmentSize+1)) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + if len(data) > storage.MaxJournalAttachmentSize { + app.badRequestResponse(w, r, fmt.Errorf("file must not be larger than 25 MB")) + return + } + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0])) + detected := http.DetectContentType(data) + if mediaType == "" || mediaType == "application/octet-stream" { + mediaType = detected + } + if !allowedJournalMediaType(mediaType) { + app.badRequestResponse(w, r, fmt.Errorf("only photos, videos and audio files are supported")) + return + } + name := filepath.Base(strings.ReplaceAll(header.Filename, "\\", "/")) + if name == "." || name == "" { + name = "attachment" + } + if len(name) > 255 { + name = name[:255] + } + attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: mediaType, Data: data}) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createJournalLibraryAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + ImageID int `json:"image_id"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.ImageID < 1 { + app.badRequestResponse(w, r, fmt.Errorf("image_id must be provided")) + return + } + image, err := app.models.Images.Get(gardenID, input.ImageID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + name := image.FileName + if name == "" { + name = "Bild" + } + attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: image.MediaType, Size: image.Size, ImageID: &image.ID}) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func allowedJournalMediaType(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/mp4", "audio/ogg", "audio/webm", "audio/wav", "audio/x-wav": + return true + default: + return false + } +} + +func (app *application) showJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + attachmentID, err := app.readNamedIDParam(r, "attachmentID") + if err != nil { + app.notFoundResponse(w, r) + return + } + a, err := app.models.Journal.GetAttachment(gardenID, entryID, attachmentID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.Header().Set("Content-Type", a.MediaType) + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": a.FileName})) + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeContent(w, r, a.FileName, a.CreatedAt, bytes.NewReader(a.Data)) +} + +func (app *application) deleteJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + attachmentID, err := app.readNamedIDParam(r, "attachmentID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Journal.DeleteAttachment(gardenID, entryID, attachmentID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateJournalEntry(w http.ResponseWriter, r *http.Request, entry storage.JournalEntry) bool { + v := validate.New() + storage.ValidateJournalEntry(v, entry) + storage.ValidateTags(v, entry.Tags) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/journal_test.go b/internal/api/journal_test.go new file mode 100644 index 0000000..34cfc11 --- /dev/null +++ b/internal/api/journal_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type journalTestModel struct { + items map[int]storage.JournalEntry + nextID int + lastListType storage.JournalEntryType +} + +func (m *journalTestModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) { + m.nextID++ + entry.ID, entry.Version = m.nextID, 1 + m.items[entry.ID] = entry + return entry, nil +} + +func (m *journalTestModel) Get(gardenID, id int) (storage.JournalEntry, error) { + entry, ok := m.items[id] + if !ok || entry.GardenID != gardenID { + return storage.JournalEntry{}, storage.ErrRecordNotFound + } + return entry, nil +} + +func (m *journalTestModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) { + m.lastListType = entryType + entries := []storage.JournalEntry{} + for _, entry := range m.items { + if entry.GardenID == gardenID && entry.EntryType == entryType { + entries = append(entries, entry) + } + } + return entries, nil +} + +func (m *journalTestModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) { + if _, err := m.Get(gardenID, entry.ID); err != nil { + return storage.JournalEntry{}, err + } + entry.Version++ + m.items[entry.ID] = entry + return entry, nil +} + +func (m *journalTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +func (m *journalTestModel) InsertAttachment(int, int, storage.JournalAttachment) (storage.JournalAttachment, error) { + return storage.JournalAttachment{}, nil +} +func (m *journalTestModel) GetAttachment(int, int, int) (storage.JournalAttachment, error) { + return storage.JournalAttachment{}, storage.ErrRecordNotFound +} +func (m *journalTestModel) DeleteAttachment(int, int, int) error { return nil } + +type journalTagTestModel struct{} + +func (journalTagTestModel) Get(int, storage.TagEntity, int) ([]string, error) { return nil, nil } +func (journalTagTestModel) Set(_ int, _ storage.TagEntity, _ int, tags []string) ([]string, error) { + return tags, nil +} +func (journalTagTestModel) GetAllForGarden(int) ([]string, error) { return nil, nil } + +func serveJournalRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + protect := func(handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(handler)) + } + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", protect(app.createJournalEntryHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", protect(app.listJournalEntriesHandler)) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, app.contextSetAuthenticatedUser(request, user)) + return response +} + +func TestJournalAPISeparatesPinboardEntries(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Name: "Ada", Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + model := &journalTestModel{items: map[int]storage.JournalEntry{ + 1: {ID: 1, GardenID: 3, EntryType: storage.JournalEntryTypeJournal, Title: "Ernte"}, + 2: {ID: 2, GardenID: 3, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"}, + }, nextID: 2} + app.models.Journal = model + app.models.Tags = journalTagTestModel{} + + listed := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=pinboard", nil) + if listed.Code != http.StatusOK || model.lastListType != storage.JournalEntryTypePinboard { + t.Fatalf("list pinboard: status=%d type=%q body=%s", listed.Code, model.lastListType, listed.Body.String()) + } + if body := listed.Body.String(); !bytes.Contains([]byte(body), []byte("Sitzecke")) || bytes.Contains([]byte(body), []byte("Ernte")) { + t.Fatalf("list mixes entry types: %s", body) + } + + created := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"title":"Teichidee","entry_type":"pinboard"}`)) + if created.Code != http.StatusCreated || model.items[3].EntryType != storage.JournalEntryTypePinboard { + t.Fatalf("create pinboard: status=%d entry=%+v body=%s", created.Code, model.items[3], created.Body.String()) + } + + imageOnly := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"pinboard"}`)) + if imageOnly.Code != http.StatusCreated || model.items[4].Title != "" { + t.Fatalf("create titleless pinboard entry: status=%d entry=%+v body=%s", imageOnly.Code, model.items[4], imageOnly.Body.String()) + } + untitledJournal := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"journal"}`)) + if untitledJournal.Code != http.StatusUnprocessableEntity { + t.Fatalf("untitled journal status: got %d, want %d", untitledJournal.Code, http.StatusUnprocessableEntity) + } + + invalid := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=unknown", nil) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid type: got %d, want %d", invalid.Code, http.StatusBadRequest) + } +} diff --git a/internal/api/locations.go b/internal/api/locations.go new file mode 100644 index 0000000..74a0266 --- /dev/null +++ b/internal/api/locations.go @@ -0,0 +1,243 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type locationInput struct { + ParentID *int `json:"parent_id"` + ClearParentID bool `json:"clear_parent_id"` + Name *string `json:"name"` + Description *string `json:"description"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + Kind *string `json:"kind"` + AreaSQM *float64 `json:"area_sqm"` + SunExposure *string `json:"sun_exposure"` + SoilCondition *string `json:"soil_condition"` + SoilReaction *string `json:"soil_reaction"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input locationInput) apply(location *storage.Location) { + if input.ParentID != nil { + location.ParentID = input.ParentID + } + if input.ClearParentID { + location.ParentID = nil + } + if input.Name != nil { + location.Name = strings.TrimSpace(*input.Name) + } + if input.Description != nil { + location.Description = strings.TrimSpace(*input.Description) + } + if input.ImageData != nil { + location.ImageData = *input.ImageData + } + if input.Kind != nil { + location.Kind = strings.TrimSpace(*input.Kind) + } + if input.AreaSQM != nil { + location.AreaSQM = input.AreaSQM + } + if input.SunExposure != nil { + assignStringPointer(input.SunExposure, &location.SunExposure) + } + assignStringPointer(input.SoilCondition, &location.SoilCondition) + assignStringPointer(input.SoilReaction, &location.SoilReaction) + if len(input.Attributes) != 0 { + location.Attributes = input.Attributes + } +} + +func (app *application) createLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input locationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + location := storage.Location{GardenID: gardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID} + input.apply(&location) + if !app.validateLocationForGarden(w, r, gardenID, location) { + return + } + imageID, err := app.resolveImage(gardenID, location.ImageData, input.ImageID, user.ID, "location") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + location.ImageID = imageID + location.ImageData = "" + location, err = app.models.Locations.Insert(location) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/locations/%d", gardenID, location.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"location": location}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listLocationsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + locations, err := app.models.Locations.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + member, _ := app.contextGetGardenMember(r) + visible := locations[:0] + for _, location := range locations { + if location.CreatedBy == user.ID && member.Can(storage.GardenPermissionLocationReadOwn) || location.CreatedBy != user.ID && member.Can(storage.GardenPermissionLocationReadOther) { + visible = append(visible, location) + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"locations": visible}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationReadOwn, storage.GardenPermissionLocationReadOther) { + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationUpdateOwn, storage.GardenPermissionLocationUpdateOther) { + return + } + var input locationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousImageID := location.ImageID + previousImageData := location.ImageData + input.apply(&location) + user, _ := app.contextGetAuthenticatedUser(r) + location.UpdatedBy = user.ID + if !app.validateLocationForGarden(w, r, gardenID, location) { + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + location.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "location") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + location.ImageData = "" + location, err = app.models.Locations.Update(gardenID, location) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "location", EntityID: location.ID, PreviousImageID: previousImageID, ImageID: location.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationDeleteOwn, storage.GardenPermissionLocationDeleteOther) { + return + } + if err := app.models.Locations.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, location storage.Location) bool { + v := validate.New() + validateImageData(v, location.ImageData) + storage.ValidateLocation(v, location) + if location.ParentID != nil && *location.ParentID > 0 && *location.ParentID != location.ID { + if _, err := app.models.Locations.Get(gardenID, *location.ParentID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("parent_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if v.Valid() && location.ParentID != nil && location.ID > 0 { + locations, err := app.models.Locations.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return false + } + parents := make(map[int]*int, len(locations)) + for i := range locations { + parents[locations[i].ID] = locations[i].ParentID + } + seen := map[int]bool{} + for current := location.ParentID; current != nil; current = parents[*current] { + if *current == location.ID || seen[*current] { + v.AddError("parent_id", "must not create a cycle") + break + } + seen[*current] = true + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/locations_test.go b/internal/api/locations_test.go new file mode 100644 index 0000000..eb0897f --- /dev/null +++ b/internal/api/locations_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "net/http" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +type locationTestModel struct { + items map[int]storage.Location + nextID int +} + +func (m *locationTestModel) Insert(value storage.Location) (storage.Location, error) { + m.nextID++ + value.ID = m.nextID + value.Version = 1 + m.items[value.ID] = value + return value, nil +} +func (m *locationTestModel) Get(gardenID, id int) (storage.Location, error) { + value, ok := m.items[id] + if !ok || value.GardenID != gardenID { + return storage.Location{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *locationTestModel) GetAllForGarden(gardenID int) ([]storage.Location, error) { + result := []storage.Location{} + for _, value := range m.items { + if value.GardenID == gardenID { + result = append(result, value) + } + } + return result, nil +} +func (m *locationTestModel) Update(gardenID int, value storage.Location) (storage.Location, error) { + if _, err := m.Get(gardenID, value.ID); err != nil { + return storage.Location{}, err + } + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *locationTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +type plantLocationTestModel struct { + items map[int]storage.PlantLocation + nextID int +} + +func (m *plantLocationTestModel) Insert(_ int, value storage.PlantLocation) (storage.PlantLocation, error) { + m.nextID++ + value.ID = m.nextID + value.Version = 1 + m.items[value.ID] = value + return value, nil +} +func (m *plantLocationTestModel) Get(_ int, id int) (storage.PlantLocation, error) { + value, ok := m.items[id] + if !ok { + return storage.PlantLocation{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *plantLocationTestModel) GetAllForPlant(_ int, plantID int) ([]storage.PlantLocation, error) { + result := []storage.PlantLocation{} + for _, value := range m.items { + if value.PlantID == plantID { + result = append(result, value) + } + } + return result, nil +} +func (m *plantLocationTestModel) GetAllForLocation(_ int, locationID int) ([]storage.PlantLocation, error) { + result := []storage.PlantLocation{} + for _, value := range m.items { + if value.LocationID == locationID { + result = append(result, value) + } + } + return result, nil +} +func (m *plantLocationTestModel) Update(_ int, value storage.PlantLocation) (storage.PlantLocation, error) { + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *plantLocationTestModel) Delete(_ int, id int) error { + if _, ok := m.items[id]; !ok { + return storage.ErrRecordNotFound + } + delete(m.items, id) + return nil +} + +func TestLocationsRejectForeignParentsAndCycles(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + rootID := 1 + locations := &locationTestModel{items: map[int]storage.Location{1: {ID: 1, GardenID: 3, Name: "Beet", CreatedBy: user.ID, Version: 1}, 2: {ID: 2, GardenID: 3, ParentID: &rootID, Name: "Reihe", CreatedBy: user.ID, Version: 1}, 9: {ID: 9, GardenID: 4, Name: "Fremd", Version: 1}}, nextID: 9} + app.models.Locations = locations + + foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/locations", []byte(`{"name":"Topf","parent_id":9}`)) + if foreign.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign parent: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String()) + } + cycle := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/1", []byte(`{"parent_id":2}`)) + if cycle.Code != http.StatusUnprocessableEntity { + t.Fatalf("cycle: got %d, want %d; %s", cycle.Code, http.StatusUnprocessableEntity, cycle.Body.String()) + } + foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/locations/9", nil) + if foreignRead.Code != http.StatusNotFound { + t.Fatalf("foreign read: got %d, want %d", foreignRead.Code, http.StatusNotFound) + } + cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/2", []byte(`{"clear_parent_id":true}`)) + if cleared.Code != http.StatusOK || locations.items[2].ParentID != nil { + t.Fatalf("clear parent: status=%d body=%s", cleared.Code, cleared.Body.String()) + } +} + +func TestPlantLocationIsScopedToGarden(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 8, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate", Status: "active"}}} + app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}} + assignments := &plantLocationTestModel{items: map[int]storage.PlantLocation{}, nextID: 10} + app.models.PlantLocations = assignments + + foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":9,"quantity":1}`)) + if foreign.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign assignment: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String()) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":6,"quantity":3}`)) + if created.Code != http.StatusCreated { + t.Fatalf("assignment: got %d, want %d; %s", created.Code, http.StatusCreated, created.Body.String()) + } + if got := assignments.items[11].Quantity; got != 3 { + t.Errorf("quantity: got %d, want 3", got) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..7b3f975 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,351 @@ +package api + +import ( + "errors" + "expvar" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/tomasen/realip" + "golang.org/x/time/rate" +) + +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + pv := recover() + if pv != nil { + w.Header().Set("Connection", "close") + app.serverErrorResponse(w, r, fmt.Errorf("%v", pv)) + } + }() + + next.ServeHTTP(w, r) + }) +} + +func (app *application) rateLimit(next http.Handler) http.Handler { + if !app.config.Limiter.Enabled { + return next + } + + type client struct { + limiter *rate.Limiter + lastSeen time.Time + } + + var ( + mu sync.Mutex + clients = make(map[string]*client) + ) + + go func() { + for { + time.Sleep(time.Minute) + + mu.Lock() + + for ip, client := range clients { + if time.Since(client.lastSeen) > 3*time.Minute { + delete(clients, ip) + } + } + + mu.Unlock() + } + }() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := realip.FromRequest(r) + + mu.Lock() + + if _, found := clients[ip]; !found { + clients[ip] = &client{ + limiter: rate.NewLimiter(rate.Limit(app.config.Limiter.Rps), app.config.Limiter.Burst), + } + } + + clients[ip].lastSeen = time.Now() + + if !clients[ip].limiter.Allow() { + mu.Unlock() + app.rateLimitExceededResponse(w, r) + return + } + + mu.Unlock() + + next.ServeHTTP(w, r) + }) +} + +func (app *application) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Authorization") + + authorizationHeader := r.Header.Get("Authorization") + + if authorizationHeader != "" { + headerParts := strings.Split(authorizationHeader, " ") + if len(headerParts) != 2 || headerParts[0] != "Bearer" { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + token := headerParts[1] + v := validate.New() + + if auth.ValidateTokenPlaintext(v, token); !v.Valid() { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopeAuthentication, token) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidAuthenticationTokenResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + r = app.contextSetAuthenticatedUser(r, user) + next.ServeHTTP(w, r) + return + } + + userID := app.sessions.GetInt(r.Context(), authenticatedUserIDSessionKey) + if userID != 0 { + user, err := app.models.Users.GetByID(userID) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + default: + app.serverErrorResponse(w, r, err) + return + } + } else { + r = app.contextSetAuthenticatedUser(r, user) + } + } + + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticatedUser, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + if !authenticatedUser.Activated { + app.inactiveAccountResponse(w, r) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticatedUser, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + gardenID, err := app.readGardenIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + + member, err := app.models.GardenMembers.Get(gardenID, authenticatedUser.ID) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + r = app.contextSetGardenMember(r, member) + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireGardenPermission(permission storage.GardenPermission, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + member, found := app.contextGetGardenMember(r) + if !found { + app.serverErrorResponse(w, r, errors.New("garden permission check without membership context")) + return + } + if !member.Can(permission) { + app.permissionDeniedResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// authorizeGardenResource checks an object permission after the object was loaded. +// Objects created by the caller use the "own" permission; every other object uses +// the corresponding "other" permission. +func (app *application) authorizeGardenResource(w http.ResponseWriter, r *http.Request, createdBy int, own, other storage.GardenPermission) bool { + member, found := app.contextGetGardenMember(r) + if !found { + app.serverErrorResponse(w, r, errors.New("resource permission check without membership context")) + return false + } + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return false + } + permission := other + if createdBy == user.ID { + permission = own + } + if !member.Can(permission) { + app.permissionDeniedResponse(w, r) + return false + } + return true +} + +func (app *application) requireApplicationPermission(permission storage.ApplicationPermission, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + if !user.Can(permission) { + app.permissionDeniedResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + + origin := r.Header.Get("Origin") + + if origin != "" { + trustedOrigin := false + for i := range app.config.Cors.TrustedOrigins { + if origin == app.config.Cors.TrustedOrigins[i] { + trustedOrigin = true + break + } + } + + if !trustedOrigin { + app.untrustedOriginResponse(w, r) + return + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +type metricsResponseWriter struct { + wrapped http.ResponseWriter + statusCode int + headerWritten bool +} + +func newMetricsResponseWriter(w http.ResponseWriter) *metricsResponseWriter { + return &metricsResponseWriter{ + wrapped: w, + statusCode: http.StatusOK, + } +} + +// Header implements http.ResponseWriter. +func (mw *metricsResponseWriter) Header() http.Header { + return mw.wrapped.Header() +} + +// WriteHeader implements http.ResponseWriter while retaining the first status +// code for metrics. +func (mw *metricsResponseWriter) WriteHeader(statusCode int) { + mw.wrapped.WriteHeader(statusCode) + + if !mw.headerWritten { + mw.statusCode = statusCode + mw.headerWritten = true + } +} + +// Write implements http.ResponseWriter. +func (mw *metricsResponseWriter) Write(b []byte) (int, error) { + mw.headerWritten = true + return mw.wrapped.Write(b) +} + +// Unwrap exposes the underlying writer to net/http response-controller logic. +func (mw *metricsResponseWriter) Unwrap() http.ResponseWriter { + return mw.wrapped +} + +func (app *application) metrics(next http.Handler) http.Handler { + var ( + totalRequestsReceived = expvar.NewInt("total_requests_received") + totalResponsesSent = expvar.NewInt("total_responses_sent") + totalProcessingTimeMicroseconds = expvar.NewInt("total_processing_time_μs") + totalResponsesSentByStatus = expvar.NewMap("total_responses_sent_by_status") + ) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + totalRequestsReceived.Add(1) + + mw := newMetricsResponseWriter(w) + next.ServeHTTP(mw, r) + + totalResponsesSent.Add(1) + totalResponsesSentByStatus.Add(strconv.Itoa(mw.statusCode), 1) + + duration := time.Since(start).Microseconds() + totalProcessingTimeMicroseconds.Add(duration) + }) +} diff --git a/internal/api/permissions_test.go b/internal/api/permissions_test.go new file mode 100644 index 0000000..589766b --- /dev/null +++ b/internal/api/permissions_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestGardenRolePermissions(t *testing.T) { + tests := []struct { + role storage.GardenRole + permission storage.GardenPermission + want int + }{ + {storage.GardenRoleOwner, storage.GardenPermissionGardenDelete, http.StatusNoContent}, + {storage.GardenRoleAdmin, storage.GardenPermissionMembersWrite, http.StatusNoContent}, + {storage.GardenRoleAdmin, storage.GardenPermissionGardenDelete, http.StatusForbidden}, + {storage.GardenRoleMember, storage.GardenPermissionPlantUpdateOwn, http.StatusNoContent}, + {storage.GardenRoleMember, storage.GardenPermissionSpeciesWrite, http.StatusForbidden}, + {storage.GardenRoleViewer, storage.GardenPermissionPlantUpdateOwn, http.StatusForbidden}, + {storage.GardenRoleWorker, storage.GardenPermissionTaskCompleteOther, http.StatusNoContent}, + {storage.GardenRoleWorker, storage.GardenPermissionTaskUpdateOther, http.StatusForbidden}, + } + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + for _, tt := range tests { + t.Run(string(tt.role)+"/"+string(tt.permission), func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := app.requireGardenPermission(tt.permission, next) + request := httptest.NewRequest(http.MethodPost, "/", nil) + request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != tt.want { + t.Fatalf("status = %d, want %d", response.Code, tt.want) + } + }) + } +} + +func TestPersistedGardenAdminWildcardDoesNotGrantOwnershipRights(t *testing.T) { + member := storage.GardenMember{Role: storage.GardenRoleAdmin, Permissions: []storage.GardenPermission{"garden:*"}} + if member.Can(storage.GardenPermissionGardenDelete) { + t.Fatal("garden:* must not grant the owner-only garden:delete permission") + } + if !member.Can(storage.GardenPermissionMembersWrite) { + t.Fatal("garden:* must grant ordinary garden administration permissions") + } +} + +func TestGardenPermissionOverridesResolveWildcards(t *testing.T) { + permissions := storage.ResolveGardenPermissions([]string{"garden:*"}, []storage.GardenRolePermissionOverride{{Permission: string(storage.GardenPermissionMembersWrite), Granted: false}, {Permission: string(storage.GardenPermissionGardenDelete), Granted: true}}) + member := storage.GardenMember{Permissions: permissions} + if member.Can(storage.GardenPermissionMembersWrite) || !member.Can(storage.GardenPermissionGardenDelete) { + t.Fatalf("unexpected effective permissions: %v", permissions) + } +} + +func TestGardenRoleBundlesConcretePermissions(t *testing.T) { + member := storage.GardenRoleMember.Permissions() + found := false + for _, permission := range member { + if permission == storage.GardenPermissionPlantUpdateOther { + found = true + } + } + if found { + t.Fatal("member role must not bundle permissions for other users' plants") + } + if len(storage.GardenRoleAdmin.Permissions()) <= len(member) { + t.Fatal("admin role must bundle more permissions than member") + } +} + +func TestGardenResourcePermissionsDistinguishOwnAndOtherObjects(t *testing.T) { + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + tests := []struct { + name string + role storage.GardenRole + createdBy int + want int + }{ + {"member may update own plant", storage.GardenRoleMember, 7, http.StatusNoContent}, + {"member may not update another plant", storage.GardenRoleMember, 8, http.StatusForbidden}, + {"admin may update another plant", storage.GardenRoleAdmin, 8, http.StatusNoContent}, + {"viewer may read another task", storage.GardenRoleViewer, 8, http.StatusNoContent}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPatch, "/", nil) + request = app.contextSetAuthenticatedUser(request, storage.User{ID: 7}) + request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role}) + response := httptest.NewRecorder() + own, other := storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther + if tt.role == storage.GardenRoleViewer { + own, other = storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther + } + if app.authorizeGardenResource(response, request, tt.createdBy, own, other) { + response.WriteHeader(http.StatusNoContent) + } + if response.Code != tt.want { + t.Fatalf("status = %d, want %d", response.Code, tt.want) + } + }) + } +} + +func TestApplicationRolePermissions(t *testing.T) { + if !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + t.Fatal("admin role must grant global species write permission") + } + if storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + t.Fatal("user role must not grant global species write permission") + } + if !storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGardensCreate) || !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGardensCreate) { + t.Fatal("built-in application roles must retain permission to create gardens") + } + if !storage.ApplicationRoleAdmin.Valid() || !storage.ApplicationRoleUser.Valid() || storage.ApplicationRole("unknown").Valid() { + t.Fatal("application role validation returned an unexpected result") + } +} + +func TestRequireApplicationPermission(t *testing.T) { + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, next) + for _, test := range []struct { + name string + user storage.User + want int + }{ + {"admin role", storage.User{Role: storage.ApplicationRoleAdmin, Permissions: storage.ApplicationRoleAdmin.Permissions()}, http.StatusNoContent}, + {"user role", storage.User{Role: storage.ApplicationRoleUser, Permissions: storage.ApplicationRoleUser.Permissions()}, http.StatusForbidden}, + {"custom server role", storage.User{Role: "application:user-manager", Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionUsersManage}}, http.StatusNoContent}, + } { + request := httptest.NewRequest(http.MethodGet, "/v1/admin/users", nil) + request = app.contextSetAuthenticatedUser(request, test.user) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != test.want { + t.Errorf("%s: got %d, want %d", test.name, response.Code, test.want) + } + } +} diff --git a/internal/api/plant_locations.go b/internal/api/plant_locations.go new file mode 100644 index 0000000..c7a38c1 --- /dev/null +++ b/internal/api/plant_locations.go @@ -0,0 +1,195 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type plantLocationInput struct { + LocationID *int `json:"location_id"` + Quantity *int `json:"quantity"` + PlantedAt *time.Time `json:"planted_at"` + ClearPlantedAt bool `json:"clear_planted_at"` + RemovedAt *time.Time `json:"removed_at"` + Notes *string `json:"notes"` +} + +func (input plantLocationInput) apply(assignment *storage.PlantLocation) { + if input.LocationID != nil { + assignment.LocationID = *input.LocationID + } + if input.Quantity != nil { + assignment.Quantity = *input.Quantity + } + if input.PlantedAt != nil { + assignment.PlantedAt = input.PlantedAt + } + if input.ClearPlantedAt { + assignment.PlantedAt = nil + } + if input.RemovedAt != nil { + assignment.RemovedAt = input.RemovedAt + } + if input.Notes != nil { + assignment.Notes = strings.TrimSpace(*input.Notes) + } +} + +func (app *application) createPlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if _, err := app.models.Plants.Get(gardenID, plantID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input plantLocationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + assignment := storage.PlantLocation{PlantID: plantID, Quantity: 1} + input.apply(&assignment) + if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) { + return + } + assignment, err = app.models.PlantLocations.Insert(gardenID, assignment) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d/locations/%d", gardenID, plantID, assignment.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"plant_location": assignment}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listPlantLocationsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if _, err := app.models.Plants.Get(gardenID, plantID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + assignments, err := app.models.PlantLocations.GetAllForPlant(gardenID, plantID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant_locations": assignments}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updatePlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, assignmentID, ok := app.readPlantLocationIDs(w, r) + if !ok { + return + } + assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID) + if err != nil || assignment.PlantID != plantID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + var input plantLocationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&assignment) + if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) { + return + } + assignment, err = app.models.PlantLocations.Update(gardenID, assignment) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant_location": assignment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deletePlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, assignmentID, ok := app.readPlantLocationIDs(w, r) + if !ok { + return + } + assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID) + if err != nil || assignment.PlantID != plantID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + if err := app.models.PlantLocations.Delete(gardenID, assignmentID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) readPlantLocationIDs(w http.ResponseWriter, r *http.Request) (int, int, bool) { + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return 0, 0, false + } + id, err := app.readPathInt(r, "assignmentID") + if err != nil { + app.notFoundResponse(w, r) + return 0, 0, false + } + return plantID, id, true +} + +func (app *application) readPathInt(r *http.Request, name string) (int, error) { + value := httprouter.ParamsFromContext(r.Context()).ByName(name) + id, err := strconv.Atoi(value) + if err != nil || id < 1 { + return 0, errors.New("invalid path parameter") + } + return id, nil +} + +func (app *application) validatePlantLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, assignment storage.PlantLocation) bool { + v := validate.New() + storage.ValidatePlantLocation(v, assignment) + if assignment.LocationID > 0 { + if _, err := app.models.Locations.Get(gardenID, assignment.LocationID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("location_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/plants.go b/internal/api/plants.go new file mode 100644 index 0000000..8f05b68 --- /dev/null +++ b/internal/api/plants.go @@ -0,0 +1,257 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type plantInput struct { + Tags []string `json:"tags"` + SpeciesID *int `json:"species_id"` + ClearSpeciesID bool `json:"clear_species_id"` + Name *string `json:"name"` + Notes *string `json:"notes"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + AcquiredAt *time.Time `json:"acquired_at"` + ClearAcquiredAt bool `json:"clear_acquired_at"` + Status *string `json:"status"` + RemovedAt *time.Time `json:"removed_at"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input plantInput) apply(plant *storage.Plant) { + if input.SpeciesID != nil { + plant.SpeciesID = input.SpeciesID + } + if input.ClearSpeciesID { + plant.SpeciesID = nil + } + if input.Name != nil { + plant.Name = strings.TrimSpace(*input.Name) + } + if input.Notes != nil { + plant.Notes = strings.TrimSpace(*input.Notes) + } + if input.ImageData != nil { + plant.ImageData = *input.ImageData + } + if input.AcquiredAt != nil { + plant.AcquiredAt = input.AcquiredAt + } + if input.ClearAcquiredAt { + plant.AcquiredAt = nil + } + if input.Status != nil { + plant.Status = *input.Status + } + if input.RemovedAt != nil { + plant.RemovedAt = input.RemovedAt + } + if len(input.Attributes) != 0 { + plant.Attributes = input.Attributes + } +} + +func (app *application) createPlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input plantInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + plant := storage.Plant{GardenID: gardenID, Status: "alive", Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID, PlantedBy: user.ID} + input.apply(&plant) + plant.Tags = storage.NormalizeTags(input.Tags) + if !app.validatePlantForGarden(w, r, gardenID, plant) { + return + } + var err error + plant.ImageID, err = app.resolveImage(gardenID, plant.ImageData, input.ImageID, user.ID, "plant") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + plant.ImageData = "" + plant, err = app.models.Plants.Insert(plant) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d", gardenID, plant.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"plant": plant}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listPlantsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plants, err := app.models.Plants.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + member, _ := app.contextGetGardenMember(r) + visible := plants[:0] + for i := range plants { + if plants[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionPlantReadOther) { + continue + } + if plants[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionPlantReadOwn) { + continue + } + plants[i].Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plants[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + visible = append(visible, plants[i]) + } + plants = visible + if err := app.writeJSON(w, http.StatusOK, envelope{"plants": plants}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showPlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantReadOwn, storage.GardenPermissionPlantReadOther) { + return + } + plant.Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plant.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updatePlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther) { + return + } + var input plantInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousImageID := plant.ImageID + previousImageData := plant.ImageData + input.apply(&plant) + user, _ := app.contextGetAuthenticatedUser(r) + plant.UpdatedBy = user.ID + if input.Tags != nil { + plant.Tags = storage.NormalizeTags(input.Tags) + } + if !app.validatePlantForGarden(w, r, gardenID, plant) { + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + plant.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "plant") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + plant.ImageData = "" + plant, err = app.models.Plants.Update(gardenID, plant) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "plant", EntityID: plant.ID, PreviousImageID: previousImageID, ImageID: plant.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.Tags != nil { + plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deletePlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantDeleteOwn, storage.GardenPermissionPlantDeleteOther) { + return + } + if err := app.models.Plants.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validatePlantForGarden(w http.ResponseWriter, r *http.Request, gardenID int, plant storage.Plant) bool { + v := validate.New() + validateImageData(v, plant.ImageData) + storage.ValidatePlant(v, plant) + if plant.SpeciesID != nil && *plant.SpeciesID > 0 { + if _, err := app.models.Species.Get(gardenID, *plant.SpeciesID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("species_id", "must refer to an available species") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go new file mode 100644 index 0000000..bab2851 --- /dev/null +++ b/internal/api/resources_test.go @@ -0,0 +1,250 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type speciesTestModel struct { + items map[int]storage.Species + nextID int + lastGardenID int +} + +func (m *speciesTestModel) Insert(species storage.Species) (storage.Species, error) { + m.nextID++ + species.ID = m.nextID + species.Version = 1 + m.items[species.ID] = species + return species, nil +} + +func (m *speciesTestModel) Get(gardenID, id int) (storage.Species, error) { + m.lastGardenID = gardenID + species, ok := m.items[id] + if !ok || (species.GardenID != nil && *species.GardenID != gardenID) { + return storage.Species{}, storage.ErrRecordNotFound + } + return species, nil +} + +func (m *speciesTestModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { + m.lastGardenID = gardenID + result := []storage.Species{} + for _, species := range m.items { + if species.GardenID == nil || *species.GardenID == gardenID { + result = append(result, species) + } + } + return result, nil +} + +func (m *speciesTestModel) Update(gardenID int, species storage.Species) (storage.Species, error) { + m.lastGardenID = gardenID + species.Version++ + m.items[species.ID] = species + return species, nil +} + +func (m *speciesTestModel) Delete(gardenID, id int) error { + species, ok := m.items[id] + if !ok || species.GardenID == nil && gardenID != 0 || species.GardenID != nil && *species.GardenID != gardenID { + return storage.ErrRecordNotFound + } + m.lastGardenID = gardenID + delete(m.items, id) + return nil +} + +type plantTestModel struct { + items map[int]storage.Plant + nextID int + lastGardenID int +} + +func (m *plantTestModel) Insert(plant storage.Plant) (storage.Plant, error) { + m.nextID++ + plant.ID = m.nextID + plant.Version = 1 + m.items[plant.ID] = plant + return plant, nil +} + +func (m *plantTestModel) Get(gardenID, id int) (storage.Plant, error) { + m.lastGardenID = gardenID + plant, ok := m.items[id] + if !ok || plant.GardenID != gardenID { + return storage.Plant{}, storage.ErrRecordNotFound + } + return plant, nil +} + +func (m *plantTestModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) { + m.lastGardenID = gardenID + result := []storage.Plant{} + for _, plant := range m.items { + if plant.GardenID == gardenID { + result = append(result, plant) + } + } + return result, nil +} + +func (m *plantTestModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) { + m.lastGardenID = gardenID + plant.Version++ + m.items[plant.ID] = plant + return plant, nil +} + +func (m *plantTestModel) Delete(gardenID, id int) error { + plant, ok := m.items[id] + if !ok || plant.GardenID != gardenID { + return storage.ErrRecordNotFound + } + delete(m.items, id) + return nil +} + +func serveResourceRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + protect := func(handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(handler)) + } + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", protect(app.createSpeciesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", protect(app.listSpeciesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", protect(app.showSpeciesHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", protect(app.updateSpeciesHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", protect(app.deleteSpeciesHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.createSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.listSpeciesTaskTemplatesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.showSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.updateSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.deleteSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", protect(app.createPlantHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", protect(app.listPlantsHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", protect(app.showPlantHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", protect(app.updatePlantHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", protect(app.deletePlantHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", protect(app.createLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", protect(app.listLocationsHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", protect(app.showLocationHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", protect(app.updateLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", protect(app.deleteLocationHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", protect(app.createTaskHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", protect(app.listTasksHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", protect(app.showTaskHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", protect(app.updateTaskHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", protect(app.deleteTaskHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.createPlantLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.listPlantLocationsHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.updatePlantLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.deletePlantLocationHandler)) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + router.ServeHTTP(w, app.contextSetAuthenticatedUser(r, user)) + }) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func TestSpeciesAndPlantsAreScopedToGarden(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 5, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin} + speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} + plantsModel := &plantTestModel{items: make(map[int]storage.Plant), nextID: 20} + app.models.Species = speciesModel + app.models.Plants = plantsModel + + speciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate"}`)) + if speciesResponse.Code != http.StatusCreated { + t.Fatalf("create species status: got %d, want %d; body: %s", speciesResponse.Code, http.StatusCreated, speciesResponse.Body.String()) + } + createdSpecies := speciesModel.items[11] + if createdSpecies.GardenID == nil || *createdSpecies.GardenID != 3 { + t.Errorf("species garden: got %v, want 3", createdSpecies.GardenID) + } + + plantResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":11,"name":"Tomate am Zaun"}`)) + if plantResponse.Code != http.StatusCreated { + t.Fatalf("create plant status: got %d, want %d; body: %s", plantResponse.Code, http.StatusCreated, plantResponse.Body.String()) + } + if got := plantsModel.items[21].GardenID; got != 3 { + t.Errorf("plant garden: got %d, want 3", got) + } + if speciesModel.lastGardenID != 3 { + t.Errorf("species lookup garden: got %d, want 3", speciesModel.lastGardenID) + } + clearSpecies := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/plants/21", []byte(`{"clear_species_id":true,"clear_acquired_at":true}`)) + if clearSpecies.Code != http.StatusOK || plantsModel.items[21].SpeciesID != nil { + t.Fatalf("clear plant species: status=%d body=%s", clearSpecies.Code, clearSpecies.Body.String()) + } + + foreignGardenID := 4 + speciesModel.items[99] = storage.Species{ID: 99, GardenID: &foreignGardenID, CommonName: "Fremd"} + foreignSpeciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":99,"name":"Nicht erlaubt"}`)) + if foreignSpeciesResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign species status: got %d, want %d; body: %s", foreignSpeciesResponse.Code, http.StatusUnprocessableEntity, foreignSpeciesResponse.Body.String()) + } +} + +func TestGlobalSpeciesRequiresApplicationPermission(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 6, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + speciesModel := &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global", Version: 1}}} + app.models.Species = speciesModel + + showResponse := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/1", nil) + if showResponse.Code != http.StatusOK { + t.Fatalf("show global species: got %d, want %d", showResponse.Code, http.StatusOK) + } + updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/1", []byte(`{"common_name":"Geändert"}`)) + if updateResponse.Code != http.StatusForbidden { + t.Fatalf("update global species: got %d, want %d", updateResponse.Code, http.StatusForbidden) + } + deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/1", nil) + if deleteResponse.Code != http.StatusForbidden { + t.Fatalf("delete global species: got %d, want %d", deleteResponse.Code, http.StatusForbidden) + } +} + +func TestAdminCanCreateAndUpdateGlobalSpeciesWithoutGardenWriteRole(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true, Role: storage.ApplicationRoleAdmin} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleViewer} + speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} + app.models.Species = speciesModel + + createResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate","global":true}`)) + if createResponse.Code != http.StatusCreated { + t.Fatalf("create global species: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) + } + if speciesModel.items[11].GardenID != nil { + t.Fatalf("global species has garden id %v", speciesModel.items[11].GardenID) + } + + updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/11", []byte(`{"common_name":"Rispentomate"}`)) + if updateResponse.Code != http.StatusOK { + t.Fatalf("update global species: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) + } + if got := speciesModel.lastGardenID; got != 0 { + t.Fatalf("global update scope: got %d, want 0", got) + } + + deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/11", nil) + if deleteResponse.Code != http.StatusNoContent { + t.Fatalf("delete global species: got %d, want %d; body: %s", deleteResponse.Code, http.StatusNoContent, deleteResponse.Body.String()) + } + if got := speciesModel.lastGardenID; got != 0 { + t.Fatalf("global delete scope: got %d, want 0", got) + } +} diff --git a/internal/api/roles.go b/internal/api/roles.go new file mode 100644 index 0000000..f29e9f3 --- /dev/null +++ b/internal/api/roles.go @@ -0,0 +1,256 @@ +package api + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +var roleNameRX = regexp.MustCompile(`^[a-z][a-z0-9:_-]{1,63}$`) + +func validRolePermissions(scope storage.RoleScope, permissions []string) bool { + for _, permission := range permissions { + if scope == storage.RoleScopeApplication && !storage.ValidApplicationPermission(permission) || + scope == storage.RoleScopeGarden && !storage.ValidGardenPermission(permission) { + return false + } + } + return scope == storage.RoleScopeApplication || scope == storage.RoleScopeGarden +} + +func (app *application) listAdminRolesHandler(w http.ResponseWriter, r *http.Request) { + applicationRoles, err := app.models.Roles.List(storage.RoleScopeApplication) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + gardenRoles, err := app.models.Roles.List(storage.RoleScopeGarden) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"application_roles": applicationRoles, "garden_roles": gardenRoles}, nil) +} + +func (app *application) createAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Scope storage.RoleScope `json:"scope"` + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Name, input.Label = strings.TrimSpace(input.Name), strings.TrimSpace(input.Label) + if input.Scope == storage.RoleScopeApplication && !strings.HasPrefix(input.Name, "application:") { + input.Name = "application:" + input.Name + } + if !roleNameRX.MatchString(input.Name) || input.Label == "" || !validRolePermissions(input.Scope, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "name, scope, label, or permissions are invalid"}) + return + } + role, err := app.models.Roles.Create(storage.Role{Name: input.Name, Scope: input.Scope, Label: input.Label, Permissions: input.Permissions}) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil) +} + +func (app *application) updateAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.Get(name) + if err != nil || role.GardenID != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Label = strings.TrimSpace(input.Label) + if input.Label == "" || !validRolePermissions(role.Scope, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "label or permissions are invalid"}) + return + } + if role.Name == string(storage.GardenRoleOwner) { + app.permissionDeniedResponse(w, r) + return + } + if role.Name == string(storage.ApplicationRoleAdmin) { + hasRoleManagement := false + for _, permission := range input.Permissions { + if permission == string(storage.ApplicationPermissionRolesManage) || permission == "*" { + hasRoleManagement = true + } + } + if !hasRoleManagement { + app.failedValidationResponse(w, r, map[string]string{"permissions": "the administrator role must retain roles:manage"}) + return + } + } + role.Label, role.Permissions = input.Label, input.Permissions + role, err = app.models.Roles.Update(role) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"role": role}, nil) +} + +func (app *application) deleteAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.Get(name) + if err != nil || role.GardenID != nil { + app.notFoundResponse(w, r) + return + } + if err := app.models.Roles.Delete(name); err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +type gardenRoleSetting struct { + Role storage.Role `json:"role"` + EffectivePermissions []storage.GardenPermission `json:"effective_permissions"` +} + +func (app *application) listGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + roles, err := app.models.Roles.ListForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + overrides, err := app.models.Roles.ListGardenOverrides(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + settings := make([]gardenRoleSetting, 0, len(roles)) + for _, role := range roles { + selected := []storage.GardenRolePermissionOverride{} + for _, override := range overrides { + if override.RoleName == role.Name { + selected = append(selected, override) + } + } + settings = append(settings, gardenRoleSetting{Role: role, EffectivePermissions: storage.ResolveGardenPermissions(role.Permissions, selected)}) + } + app.writeJSON(w, http.StatusOK, envelope{"roles": settings}, nil) +} + +func (app *application) updateGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.GetForGarden(gardenID, name) + if err != nil { + app.notFoundResponse(w, r) + return + } + if name == string(storage.GardenRoleOwner) { + app.permissionDeniedResponse(w, r) + return + } + var input struct { + Permissions []string `json:"permissions"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if !validRolePermissions(storage.RoleScopeGarden, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"permissions": "contains an invalid garden permission"}) + return + } + desired := map[string]bool{} + for _, permission := range storage.ResolveGardenPermissions(input.Permissions, nil) { + desired[string(permission)] = true + } + base := storage.ResolveGardenPermissions(role.Permissions, nil) + baseSet := map[string]bool{} + for _, permission := range base { + baseSet[string(permission)] = true + } + overrides := []storage.GardenRolePermissionOverride{} + for _, permission := range storage.AllGardenPermissions() { + want, has := desired[string(permission)], baseSet[string(permission)] + if want != has { + overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: gardenID, RoleName: name, Permission: string(permission), Granted: want}) + } + } + if err = app.models.Roles.ReplaceGardenOverrides(gardenID, name, overrides); err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"effective_permissions": storage.ResolveGardenPermissions(role.Permissions, overrides)}, nil) +} + +func (app *application) createGardenRoleHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input struct { + Name string `json:"name"` + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + slug, label := strings.TrimSpace(input.Name), strings.TrimSpace(input.Label) + name := "garden:" + strconv.Itoa(gardenID) + ":" + slug + if !roleNameRX.MatchString(name) || label == "" || !validRolePermissions(storage.RoleScopeGarden, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "name, label, or permissions are invalid"}) + return + } + role, err := app.models.Roles.Create(storage.Role{Name: name, Scope: storage.RoleScopeGarden, GardenID: &gardenID, Label: label, Permissions: input.Permissions}) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil) +} + +func (app *application) deleteGardenRoleHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.GetForGarden(gardenID, name) + if err != nil || role.GardenID == nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Roles.Delete(name); err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..c728157 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,140 @@ +package api + +import ( + "expvar" + "net/http" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +func (app *application) routes() http.Handler { + router := httprouter.New() + + router.NotFound = http.HandlerFunc(app.notFoundResponse) + router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) + + router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) + + router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) + router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) + router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler) + router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler)) + router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler)) + router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler)) + router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler)) + router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler)) + router.HandlerFunc(http.MethodGet, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.listAdminUsersHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.deleteAdminUserHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminApplicationSettingsHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminApplicationSettingsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/environment", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminEnvironmentHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/test-mail", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.sendAdminTestMailHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.listAdminSpeciesCategoriesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.createAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.updateAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.deleteAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.listAdminTaskPrioritiesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.createAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.deleteAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.listAdminRolesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.createAdminRoleHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.updateAdminRoleHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.deleteAdminRoleHandler))) + router.HandlerFunc(http.MethodGet, "/v1/species-categories", app.requireActivatedUser(app.listSpeciesCategoriesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/task-priorities", app.requireActivatedUser(app.listTaskPrioritiesHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler) + router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler) + + router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/activation", app.createActivationTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) + + router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenUpdate, app.updateGardenHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/members", app.requireActivatedUser(app.requireGardenMember(app.listGardenMembersHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.updateGardenMemberHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenMemberHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/members/:userID/transfer-ownership", app.protectGarden(storage.GardenPermissionGardenDelete, app.transferGardenOwnershipHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenInvitesHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.createGardenInviteHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/invites/:inviteID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenInviteHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenRoleSettingsHandler)) + router.HandlerFunc(http.MethodPut, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.updateGardenRoleSettingsHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionGardenDelete, app.createGardenRoleHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenRoleHandler)) + router.HandlerFunc(http.MethodPost, "/v1/invites/:token/accept", app.requireActivatedUser(app.acceptGardenInviteHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.listCareInstructionsHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.createCareInstructionHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.updateCareInstructionHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.deleteCareInstructionHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesTaskTemplatesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesTaskTemplateHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", app.protectGarden(storage.GardenPermissionPlantCreate, app.createPlantHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", app.requireActivatedUser(app.requireGardenMember(app.listPlantsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.showPlantHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.updatePlantHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.deletePlantHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs", app.requireActivatedUser(app.requireGardenMember(app.listTaskTemplateOptOutsHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.setTaskTemplateOptOutHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteTaskTemplateOptOutHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", app.protectGarden(storage.GardenPermissionLocationCreate, app.createLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", app.requireActivatedUser(app.requireGardenMember(app.listLocationsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.showLocationHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.updateLocationHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteLocationHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", app.protectGarden(storage.GardenPermissionTaskCreate, app.createTaskHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", app.requireActivatedUser(app.requireGardenMember(app.listTasksHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.showTaskHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.updateTaskHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteTaskHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalEntryHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", app.requireActivatedUser(app.requireGardenMember(app.listJournalEntriesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tags", app.requireActivatedUser(app.requireGardenMember(app.listGardenTagsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images", app.requireActivatedUser(app.requireGardenMember(app.listImagesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images/:imageID", app.requireActivatedUser(app.requireGardenMember(app.showImageHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id", app.requireActivatedUser(app.requireGardenMember(app.showJournalEntryHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.updateJournalEntryHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalEntryHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalAttachmentHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments/library", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalLibraryAttachmentHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.requireActivatedUser(app.requireGardenMember(app.showJournalAttachmentHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalAttachmentHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", app.protectGarden(storage.GardenPermissionContentWrite, app.createPlantLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", app.requireActivatedUser(app.requireGardenMember(app.listPlantLocationsHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.updatePlantLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deletePlantLocationHandler)) + + if app.config.Env == "development" { + router.HandlerFunc(http.MethodGet, "/debug/vars", app.requireActivatedUser(expvar.Handler().ServeHTTP)) + } + + return app.sessions.LoadAndSave(app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))))) +} + +func (app *application) protectGarden(permission storage.GardenPermission, handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(app.requireGardenPermission(permission, handler))) +} diff --git a/internal/api/season_templates.go b/internal/api/season_templates.go new file mode 100644 index 0000000..3b01b03 --- /dev/null +++ b/internal/api/season_templates.go @@ -0,0 +1,93 @@ +package api + +import ( + "errors" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type seasonTemplateDefinition struct { + origin storage.TaskTemplateOrigin + title string + trigger storage.TaskTriggerType + monthFrom, dayFrom *int + monthTo, dayTo *int +} + +func (app *application) syncSeasonTaskTemplates(gardenID int, species storage.Species) error { + if app.models.SpeciesTaskTemplates == nil { + return nil + } + existing, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, species.ID) + if err != nil { + return err + } + byOrigin := make(map[storage.TaskTemplateOrigin]storage.SpeciesTaskTemplate, len(existing)) + for _, template := range existing { + if template.Origin != storage.TaskTemplateOriginManual { + byOrigin[template.Origin] = template + } + } + definitions := []seasonTemplateDefinition{ + {storage.TaskTemplateOriginSeasonSowing, "Aussaat", storage.TaskTriggerRelativeToSowing, species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo}, + {storage.TaskTemplateOriginSeasonPlanting, "Pflanzen", storage.TaskTriggerRelativeToSpeciesPlanting, species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo}, + {storage.TaskTemplateOriginSeasonHarvest, "Ernten", storage.TaskTriggerRelativeToHarvest, species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo}, + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + for _, definition := range definitions { + template, found := byOrigin[definition.origin] + if definition.monthFrom == nil { + if found && template.Active { + template.Active = false + if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + return err + } + } + continue + } + if found { + // Preserve all user-editable fields. Only repair legacy generated templates. + if template.TriggerType != definition.trigger { + template.TriggerType = definition.trigger + if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + return err + } + } + continue + } + duration := seasonDurationDays(definition.monthFrom, definition.dayFrom, definition.monthTo, definition.dayTo) + template = storage.SpeciesTaskTemplate{ + SpeciesID: species.ID, Origin: definition.origin, Title: definition.title, + Description: definition.title + " im vorgesehenen Zeitraum", TriggerType: definition.trigger, + TriggerOffsetUnit: storage.TaskDurationDay, Duration: duration, DurationUnit: storage.TaskDurationDay, + Recurrence: storage.TaskRecurrenceNone, RecurrenceInterval: 1, Active: true, + } + if _, err = app.models.SpeciesTaskTemplates.Insert(template); err != nil && !errors.Is(err, storage.ErrConflict) { + return err + } + } + return nil +} + +func seasonDurationDays(fromMonth, fromDay, toMonth, toDay *int) int { + if fromMonth == nil || toMonth == nil { + return 0 + } + startDay, endDay := 1, 1 + if fromDay != nil { + startDay = *fromDay + } + if toDay != nil { + endDay = *toDay + } + start := time.Date(2024, time.Month(*fromMonth), startDay, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, time.Month(*toMonth), endDay, 0, 0, 0, 0, time.UTC) + if end.Before(start) { + end = end.AddDate(1, 0, 0) + } + return int(end.Sub(start).Hours() / 24) +} diff --git a/internal/api/season_templates_test.go b/internal/api/season_templates_test.go new file mode 100644 index 0000000..77e2de7 --- /dev/null +++ b/internal/api/season_templates_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestSyncSeasonTaskTemplatesCreatesAndPreservesDerivedTemplates(t *testing.T) { + gardenID, march, day, april := 3, 3, 10, 4 + model := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 4} + app, _, _ := newGardenTestApplication() + app.models.SpeciesTaskTemplates = model + species := storage.Species{ID: 2, GardenID: &gardenID, PlantingMonthFrom: &march, PlantingDayFrom: &day, PlantingMonthTo: &april, PlantingDayTo: &day} + + if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil { + t.Fatal(err) + } + if len(model.items) != 1 { + t.Fatalf("templates=%d want=1", len(model.items)) + } + var generated storage.SpeciesTaskTemplate + for _, generated = range model.items { + } + if generated.Origin != storage.TaskTemplateOriginSeasonPlanting || generated.TriggerType != storage.TaskTriggerRelativeToSpeciesPlanting { + t.Fatalf("unexpected generated template: %+v", generated) + } + if generated.Duration != 31 || generated.Recurrence != storage.TaskRecurrenceNone { + t.Fatalf("unexpected generated schedule: %+v", generated) + } + + generated.Title, generated.Active = "Eigener Titel", false + model.items[generated.ID] = generated + if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil { + t.Fatal(err) + } + if got := model.items[generated.ID]; got.Title != "Eigener Titel" || got.Active { + t.Fatalf("user changes were overwritten: %+v", got) + } +} + +func TestGeneratedTaskUsesSpeciesPlantingSeason(t *testing.T) { + acquired := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + month, day := 3, 15 + plant := storage.Plant{ID: 8, AcquiredAt: &acquired} + species := storage.Species{PlantingMonthFrom: &month, PlantingDayFrom: &day} + template := storage.SpeciesTaskTemplate{ID: 9, Title: "Pflanzen", TriggerType: storage.TaskTriggerRelativeToSpeciesPlanting, DurationUnit: storage.TaskDurationDay} + tasks := generatedTasksForTemplate(plant, species, template, nil, 3, 1, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if len(tasks) != 1 || tasks[0].DueAtStart.Month() != time.March || tasks[0].DueAtStart.Day() != 15 || tasks[0].DueAtStart.Year() != 2027 { + t.Fatalf("unexpected planting season task: %+v", tasks) + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..5cb0a4e --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" +) + +func (app *application) serve() error { + maintenanceContext, stopMaintenance := context.WithCancel(context.Background()) + defer stopMaintenance() + app.background(func() { + if err := app.runLifecycleMaintenance(time.Now()); err != nil { + app.logger.Error("lifecycle maintenance failed", "error", err) + } + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-maintenanceContext.Done(): + return + case now := <-ticker.C: + if err := app.runLifecycleMaintenance(now); err != nil { + app.logger.Error("lifecycle maintenance failed", "error", err) + } + } + } + }) + srv := &http.Server{ + Addr: net.JoinHostPort(app.config.Host, strconv.Itoa(app.config.Port)), + Handler: app.routes(), + IdleTimeout: time.Minute, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 2 * time.Minute, + } + + shutdownError := make(chan error) + + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + s := <-quit + + app.logger.Info("stopping server", "addr", srv.Addr, "signal", s.String()) + shutdownError <- srv.Shutdown(context.Background()) + }() + + app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.Env) + err := srv.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + return err + } + + err = <-shutdownError + if err != nil { + return err + } + + stopMaintenance() + app.logger.Info("waiting for background tasks") + app.wg.Wait() + + app.logger.Info("shutdown complete") + return nil +} diff --git a/internal/api/sessions.go b/internal/api/sessions.go new file mode 100644 index 0000000..23579ea --- /dev/null +++ b/internal/api/sessions.go @@ -0,0 +1,97 @@ +package api + +import ( + "crypto/rand" + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +const authenticatedUserIDSessionKey = "authenticatedUserID" + +const ( + accountSessionIDKey = "accountSessionID" + accountSessionCreatedAtKey = "accountSessionCreatedAt" +) + +func (app *application) createSessionHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.Password) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidCredentialsResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + match, err := user.Password.Matches(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + if !match { + app.invalidCredentialsResponse(w, r) + return + } + + if err := app.sessions.RenewToken(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.sessions.Put(r.Context(), authenticatedUserIDSessionKey, user.ID) + app.sessions.Put(r.Context(), accountSessionIDKey, rand.Text()) + app.sessions.Put(r.Context(), accountSessionCreatedAtKey, time.Now().UTC().Unix()) + + if err := app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSessionHandler(w http.ResponseWriter, r *http.Request) { + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteSessionHandler(w http.ResponseWriter, r *http.Request) { + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/sessions_test.go b/internal/api/sessions_test.go new file mode 100644 index 0000000..6b69909 --- /dev/null +++ b/internal/api/sessions_test.go @@ -0,0 +1,210 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/alexedwards/scs/v2" + "github.com/julienschmidt/httprouter" + "golang.org/x/crypto/bcrypt" +) + +type sessionTestUserModel struct { + user storage.User +} + +func (m sessionTestUserModel) Insert(user storage.User) (storage.User, error) { + return user, nil +} + +func (m sessionTestUserModel) GetByID(id int) (storage.User, error) { + if id != m.user.ID { + return storage.User{}, storage.ErrRecordNotFound + } + return m.user, nil +} + +func (m sessionTestUserModel) GetByEmail(email string) (storage.User, error) { + if email != m.user.Email { + return storage.User{}, storage.ErrRecordNotFound + } + return m.user, nil +} + +func (m sessionTestUserModel) Update(user storage.User) (storage.User, error) { + return user, nil +} + +func (m sessionTestUserModel) GetForToken(string, string) (storage.User, error) { + return storage.User{}, storage.ErrRecordNotFound +} + +func (m sessionTestUserModel) CreateEmailChange(int, string, time.Duration) (string, error) { + return "", nil +} +func (m sessionTestUserModel) ConfirmEmailChange(string, int) (storage.User, error) { + return storage.User{}, nil +} +func (m sessionTestUserModel) GetAll() ([]storage.User, error) { + return []storage.User{m.user}, nil +} +func (m sessionTestUserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) { + user := m.user + user.Role = role + return user, nil +} + +func (m sessionTestUserModel) Delete(int) error { return nil } + +func newSessionTestApplication(t *testing.T) (*application, http.Handler) { + t.Helper() + + passwordHash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + user := storage.User{ + ID: 42, + Name: "Alice", + Email: "alice@example.com", + Password: *auth.NewPassword(passwordHash), + Activated: true, + } + + sessions := scs.New() + sessions.Cookie.Name = "gardomatic_session" + + app := &application{ + config: Config{ + Cors: struct{ TrustedOrigins []string }{ + TrustedOrigins: []string{"http://localhost:8080"}, + }, + }, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + models: storage.Models{Users: sessionTestUserModel{user: user}}, + sessions: sessions, + } + + router := httprouter.New() + router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler) + router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler)) + + handler := app.sessions.LoadAndSave(app.enableCORS(app.authenticate(router))) + return app, handler +} + +func TestBrowserSessionLifecycle(t *testing.T) { + _, handler := newSessionTestApplication(t) + + loginBody := []byte(`{"email":"alice@example.com","password":"correct horse battery staple"}`) + loginRequest := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader(loginBody)) + loginRequest.Header.Set("Content-Type", "application/json") + loginRequest.Header.Set("Origin", "http://localhost:8080") + loginResponse := httptest.NewRecorder() + + handler.ServeHTTP(loginResponse, loginRequest) + + if loginResponse.Code != http.StatusCreated { + t.Fatalf("login status: got %d, want %d; body: %s", loginResponse.Code, http.StatusCreated, loginResponse.Body.String()) + } + + var sessionCookie *http.Cookie + for _, cookie := range loginResponse.Result().Cookies() { + if cookie.Name == "gardomatic_session" { + sessionCookie = cookie + break + } + } + if sessionCookie == nil { + t.Fatal("login response did not contain a session cookie") + } + if !sessionCookie.HttpOnly { + t.Error("session cookie is not HttpOnly") + } + + showRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + showRequest.Header.Set("Origin", "http://localhost:8080") + showRequest.AddCookie(sessionCookie) + showResponse := httptest.NewRecorder() + handler.ServeHTTP(showResponse, showRequest) + + if showResponse.Code != http.StatusOK { + t.Fatalf("show session status: got %d, want %d; body: %s", showResponse.Code, http.StatusOK, showResponse.Body.String()) + } + if got := showResponse.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials: got %q, want %q", got, "true") + } + + listRequest := httptest.NewRequest(http.MethodGet, "/v1/account/sessions", nil) + listRequest.AddCookie(sessionCookie) + listResponse := httptest.NewRecorder() + handler.ServeHTTP(listResponse, listRequest) + if listResponse.Code != http.StatusOK { + t.Fatalf("list account sessions status: got %d, want %d; body: %s", listResponse.Code, http.StatusOK, listResponse.Body.String()) + } + var listed struct { + Sessions []accountSession `json:"sessions"` + } + if err := json.Unmarshal(listResponse.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if len(listed.Sessions) != 1 || !listed.Sessions[0].Current || listed.Sessions[0].ID == "" { + t.Fatalf("listed sessions = %+v, want one current session", listed.Sessions) + } + + invalidBearerRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + invalidBearerRequest.Header.Set("Origin", "http://localhost:8080") + invalidBearerRequest.Header.Set("Authorization", "Bearer invalid") + invalidBearerRequest.AddCookie(sessionCookie) + invalidBearerResponse := httptest.NewRecorder() + handler.ServeHTTP(invalidBearerResponse, invalidBearerRequest) + + if invalidBearerResponse.Code != http.StatusUnauthorized { + t.Fatalf("invalid bearer status: got %d, want %d", invalidBearerResponse.Code, http.StatusUnauthorized) + } + + logoutRequest := httptest.NewRequest(http.MethodDelete, "/v1/account/sessions/"+listed.Sessions[0].ID, nil) + logoutRequest.Header.Set("Origin", "http://localhost:8080") + logoutRequest.AddCookie(sessionCookie) + logoutResponse := httptest.NewRecorder() + handler.ServeHTTP(logoutResponse, logoutRequest) + + if logoutResponse.Code != http.StatusNoContent { + t.Fatalf("logout status: got %d, want %d", logoutResponse.Code, http.StatusNoContent) + } + + showAfterLogoutRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + showAfterLogoutRequest.Header.Set("Origin", "http://localhost:8080") + showAfterLogoutResponse := httptest.NewRecorder() + handler.ServeHTTP(showAfterLogoutResponse, showAfterLogoutRequest) + + if showAfterLogoutResponse.Code != http.StatusUnauthorized { + t.Fatalf("show after logout status: got %d, want %d", showAfterLogoutResponse.Code, http.StatusUnauthorized) + } +} + +func TestSessionEndpointRejectsUntrustedBrowserOrigin(t *testing.T) { + _, handler := newSessionTestApplication(t) + + request := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader([]byte(`{}`))) + request.Header.Set("Origin", "https://attacker.example") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status: got %d, want %d", response.Code, http.StatusForbidden) + } +} diff --git a/internal/api/species.go b/internal/api/species.go new file mode 100644 index 0000000..7ca5f94 --- /dev/null +++ b/internal/api/species.go @@ -0,0 +1,396 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesInput struct { + Global bool `json:"global"` + Tags []string `json:"tags"` + CommonName *string `json:"common_name"` + Cultivar *string `json:"cultivar"` + BotanicalName *string `json:"botanical_name"` + CategoryID *int `json:"category_id"` + ClearCategoryID bool `json:"clear_category_id"` + SunExposure *string `json:"sun_exposure"` + SoilCondition *string `json:"soil_condition"` + SoilReaction *string `json:"soil_reaction"` + WinterProtection *string `json:"winter_protection"` + SpacingCM *int `json:"spacing_cm"` + HeightCM *int `json:"height_cm"` + SowMonthFrom *int `json:"sow_month_from"` + SowDayFrom *int `json:"sow_day_from"` + ClearSowDayFrom bool `json:"clear_sow_day_from"` + SowMonthTo *int `json:"sow_month_to"` + SowDayTo *int `json:"sow_day_to"` + ClearSowDayTo bool `json:"clear_sow_day_to"` + PlantingMonthFrom *int `json:"planting_month_from"` + PlantingDayFrom *int `json:"planting_day_from"` + ClearPlantingDayFrom bool `json:"clear_planting_day_from"` + PlantingMonthTo *int `json:"planting_month_to"` + PlantingDayTo *int `json:"planting_day_to"` + ClearPlantingDayTo bool `json:"clear_planting_day_to"` + HarvestMonthFrom *int `json:"harvest_month_from"` + HarvestDayFrom *int `json:"harvest_day_from"` + ClearHarvestDayFrom bool `json:"clear_harvest_day_from"` + HarvestMonthTo *int `json:"harvest_month_to"` + HarvestDayTo *int `json:"harvest_day_to"` + ClearHarvestDayTo bool `json:"clear_harvest_day_to"` + ClearSowRange bool `json:"clear_sow_range"` + ClearPlantingRange bool `json:"clear_planting_range"` + ClearHarvestRange bool `json:"clear_harvest_range"` + Notes *string `json:"notes"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input speciesInput) apply(species *storage.Species) { + assignTrimmed(input.CommonName, &species.CommonName) + assignTrimmed(input.Cultivar, &species.Cultivar) + assignTrimmed(input.BotanicalName, &species.BotanicalName) + assignIntPointer(input.CategoryID, &species.CategoryID) + if input.ClearCategoryID { + species.CategoryID = nil + species.Category = "" + } + assignTrimmed(input.Notes, &species.Notes) + if input.ImageData != nil { + species.ImageData = *input.ImageData + } + assignStringPointer(input.SunExposure, &species.SunExposure) + assignStringPointer(input.SoilCondition, &species.SoilCondition) + assignStringPointer(input.SoilReaction, &species.SoilReaction) + assignStringPointer(input.WinterProtection, &species.WinterProtection) + assignIntPointer(input.SpacingCM, &species.SpacingCM) + assignIntPointer(input.HeightCM, &species.HeightCM) + assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom) + assignIntPointer(input.SowDayFrom, &species.SowDayFrom) + assignIntPointer(input.SowMonthTo, &species.SowMonthTo) + assignIntPointer(input.SowDayTo, &species.SowDayTo) + assignIntPointer(input.PlantingMonthFrom, &species.PlantingMonthFrom) + assignIntPointer(input.PlantingDayFrom, &species.PlantingDayFrom) + assignIntPointer(input.PlantingMonthTo, &species.PlantingMonthTo) + assignIntPointer(input.PlantingDayTo, &species.PlantingDayTo) + assignIntPointer(input.HarvestMonthFrom, &species.HarvestMonthFrom) + assignIntPointer(input.HarvestDayFrom, &species.HarvestDayFrom) + assignIntPointer(input.HarvestMonthTo, &species.HarvestMonthTo) + assignIntPointer(input.HarvestDayTo, &species.HarvestDayTo) + if input.ClearSowRange { + species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo = nil, nil, nil, nil + } + if input.ClearHarvestRange { + species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo = nil, nil, nil, nil + } + if input.ClearPlantingRange { + species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo = nil, nil, nil, nil + } + if input.ClearSowDayFrom { + species.SowDayFrom = nil + } + if input.ClearSowDayTo { + species.SowDayTo = nil + } + if input.ClearPlantingDayFrom { + species.PlantingDayFrom = nil + } + if input.ClearPlantingDayTo { + species.PlantingDayTo = nil + } + if input.ClearHarvestDayFrom { + species.HarvestDayFrom = nil + } + if input.ClearHarvestDayTo { + species.HarvestDayTo = nil + } + if len(input.Attributes) != 0 { + species.Attributes = input.Attributes + } +} + +func assignTrimmed(input *string, destination *string) { + if input != nil { + *destination = strings.TrimSpace(*input) + } +} + +func assignStringPointer(input *string, destination **string) { + if input != nil { + value := strings.TrimSpace(*input) + if value == "" { + *destination = nil + } else { + *destination = &value + } + } +} + +func assignIntPointer(input *int, destination **int) { + if input != nil { + *destination = input + } +} + +func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input speciesInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if input.Global { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + var ownerGardenID *int + if !input.Global { + ownerGardenID = &gardenID + } + species := storage.Species{GardenID: ownerGardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID} + input.apply(&species) + changedCategoryID := input.CategoryID + if input.ClearCategoryID { + changedCategoryID = nil + } + if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, nil) { + return + } + species.Tags = storage.NormalizeTags(input.Tags) + v := validate.New() + validateImageData(v, species.ImageData) + if storage.ValidateSpecies(v, species); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + imageID, err := app.resolveImage(gardenID, species.ImageData, input.ImageID, user.ID, "species") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + species.ImageID = imageID + species.ImageData = "" + species, err = app.models.Species.Insert(species) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { + app.serverErrorResponse(w, r, err) + return + } + tagScope := gardenID + if species.GardenID == nil { + tagScope = 0 + } + species.Tags, err = app.saveTags(tagScope, storage.TagEntitySpecies, species.ID, species.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d", gardenID, species.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"species": species}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + allSpecies, err := app.models.Species.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range allSpecies { + tagScope := gardenID + if allSpecies[i].GardenID == nil { + tagScope = 0 + } + allSpecies[i].Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, allSpecies[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": allSpecies}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, err := app.models.Species.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + tagScope := gardenID + if species.GardenID == nil { + tagScope = 0 + } + species.Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, species.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, err := app.models.Species.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if species.GardenID == nil { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + } else if *species.GardenID != gardenID { + app.notFoundResponse(w, r) + return + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + var input speciesInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousCategoryID := species.CategoryID + previousImageID := species.ImageID + previousImageData := species.ImageData + input.apply(&species) + species.UpdatedBy = user.ID + changedCategoryID := input.CategoryID + if input.ClearCategoryID { + changedCategoryID = nil + } + if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, previousCategoryID) { + return + } + if input.Tags != nil { + species.Tags = storage.NormalizeTags(input.Tags) + } + v := validate.New() + validateImageData(v, species.ImageData) + if storage.ValidateSpecies(v, species); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + species.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "species") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + species.ImageData = "" + updateScope := gardenID + if species.GardenID == nil { + updateScope = 0 + } + species, err = app.models.Species.Update(updateScope, species) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "species", EntityID: species.ID, PreviousImageID: previousImageID, ImageID: species.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.Tags != nil { + species.Tags, err = app.saveTags(updateScope, storage.TagEntitySpecies, species.ID, species.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) resolveSpeciesCategory(w http.ResponseWriter, r *http.Request, species *storage.Species, changedID, existingID *int) bool { + if changedID == nil { + return true + } + category, err := app.models.SpeciesCategories.Get(*changedID) + keepsInactiveCategory := existingID != nil && *existingID == *changedID + if err != nil || !category.Active && !keepsInactiveCategory { + app.failedValidationResponse(w, r, map[string]string{"category_id": "must reference an active category"}) + return false + } + species.Category = category.Name + return true +} + +func (app *application) deleteSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, id) + if !writable { + return + } + deleteScope := gardenID + if species.GardenID == nil { + deleteScope = 0 + } + if err := app.models.Species.Delete(deleteScope, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) respondToEntityModelError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/species_categories.go b/internal/api/species_categories.go new file mode 100644 index 0000000..7a9844a --- /dev/null +++ b/internal/api/species_categories.go @@ -0,0 +1,141 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesCategoryInput struct { + Name *string `json:"name"` + SortOrder *int `json:"sort_order"` + Active *bool `json:"active"` + Lifecycle *string `json:"lifecycle"` +} + +func (input speciesCategoryInput) apply(category *storage.SpeciesCategory) { + if input.Name != nil { + category.Name = strings.TrimSpace(*input.Name) + } + if input.SortOrder != nil { + category.SortOrder = *input.SortOrder + } + if input.Active != nil { + category.Active = *input.Active + } + if input.Lifecycle != nil { + value := strings.TrimSpace(*input.Lifecycle) + if value == "" { + category.Lifecycle = nil + } else { + category.Lifecycle = &value + } + } +} + +func (app *application) listSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) { + categories, err := app.models.SpeciesCategories.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listAdminSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) { + categories, err := app.models.SpeciesCategories.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + var input speciesCategoryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + category := storage.SpeciesCategory{Active: true} + input.apply(&category) + if !app.validateSpeciesCategory(w, r, category) { + return + } + category, err := app.models.SpeciesCategories.Insert(category) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/admin/species-categories/%d", category.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"category": category}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + category, err := app.models.SpeciesCategories.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input speciesCategoryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&category) + if !app.validateSpeciesCategory(w, r, category) { + return + } + category, err = app.models.SpeciesCategories.Update(category) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"category": category}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + category, err := app.models.SpeciesCategories.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + category.Active = false + if _, err = app.models.SpeciesCategories.Update(category); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateSpeciesCategory(w http.ResponseWriter, r *http.Request, category storage.SpeciesCategory) bool { + v := validate.New() + storage.ValidateSpeciesCategory(v, category) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/species_categories_test.go b/internal/api/species_categories_test.go new file mode 100644 index 0000000..cd97083 --- /dev/null +++ b/internal/api/species_categories_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type speciesCategoryTestModel struct { + items map[int]storage.SpeciesCategory + nextID int +} + +func (m *speciesCategoryTestModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + m.nextID++ + category.ID, category.Version = m.nextID, 1 + m.items[category.ID] = category + return category, nil +} + +func (m *speciesCategoryTestModel) Get(id int) (storage.SpeciesCategory, error) { + category, ok := m.items[id] + if !ok { + return storage.SpeciesCategory{}, storage.ErrRecordNotFound + } + return category, nil +} + +func (m *speciesCategoryTestModel) GetAll() ([]storage.SpeciesCategory, error) { + categories := make([]storage.SpeciesCategory, 0, len(m.items)) + for _, category := range m.items { + categories = append(categories, category) + } + return categories, nil +} + +func (m *speciesCategoryTestModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + if _, ok := m.items[category.ID]; !ok { + return storage.SpeciesCategory{}, storage.ErrRecordNotFound + } + category.Version++ + m.items[category.ID] = category + return category, nil +} + +func TestAdminSpeciesCategoryLifecycle(t *testing.T) { + app, _, _ := newGardenTestApplication() + model := &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{}, nextID: 4} + app.models.SpeciesCategories = model + + createRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/species-categories", strings.NewReader(`{"name":" Gemüse ","sort_order":10}`)) + createResponse := httptest.NewRecorder() + app.createAdminSpeciesCategoryHandler(createResponse, createRequest) + if createResponse.Code != http.StatusCreated || model.items[5].Name != "Gemüse" || !model.items[5].Active { + t.Fatalf("create category: status=%d category=%+v body=%s", createResponse.Code, model.items[5], createResponse.Body.String()) + } + + deleteRequest := httptest.NewRequest(http.MethodDelete, "/v1/admin/species-categories/5", nil) + deleteRequest = deleteRequest.WithContext(context.WithValue(deleteRequest.Context(), httprouter.ParamsKey, httprouter.Params{{Key: "id", Value: "5"}})) + deleteResponse := httptest.NewRecorder() + app.deleteAdminSpeciesCategoryHandler(deleteResponse, deleteRequest) + if deleteResponse.Code != http.StatusNoContent || model.items[5].Active { + t.Fatalf("deactivate category: status=%d category=%+v", deleteResponse.Code, model.items[5]) + } +} + +func TestResolveSpeciesCategoryRejectsInactiveCategory(t *testing.T) { + app, _, _ := newGardenTestApplication() + app.models.SpeciesCategories = &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{2: {ID: 2, Name: "Alt", Active: false}}} + id := 2 + response := httptest.NewRecorder() + if app.resolveSpeciesCategory(response, httptest.NewRequest(http.MethodPost, "/", nil), &storage.Species{CategoryID: &id}, &id, nil) { + t.Fatal("inactive category was accepted") + } + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} diff --git a/internal/api/species_task_templates.go b/internal/api/species_task_templates.go new file mode 100644 index 0000000..cde87a8 --- /dev/null +++ b/internal/api/species_task_templates.go @@ -0,0 +1,300 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesTaskTemplateInput struct { + Title *string `json:"title"` + Description *string `json:"description"` + TriggerType *storage.TaskTriggerType `json:"trigger_type"` + MonthFrom *int `json:"month_from"` + DayFrom *int `json:"day_from"` + ClearDayFrom bool `json:"clear_day_from"` + MonthTo *int `json:"month_to"` + DayTo *int `json:"day_to"` + ClearDayTo bool `json:"clear_day_to"` + OffsetDaysFrom *int `json:"offset_days_from"` + OffsetDaysTo *int `json:"offset_days_to"` + IntervalDays *int `json:"interval_days"` + ClearIntervalDays bool `json:"clear_interval_days"` + TriggerOffset *int `json:"trigger_offset"` + TriggerOffsetUnit *storage.TaskDurationUnit `json:"trigger_offset_unit"` + Duration *int `json:"duration"` + DurationUnit *storage.TaskDurationUnit `json:"duration_unit"` + Recurrence *storage.TaskRecurrence `json:"recurrence"` + RecurrenceInterval *int `json:"recurrence_interval"` + Priority *int `json:"priority"` + Active *bool `json:"active"` +} + +func (input speciesTaskTemplateInput) apply(template *storage.SpeciesTaskTemplate) { + if input.Title != nil { + template.Title = strings.TrimSpace(*input.Title) + } + if input.Description != nil { + template.Description = strings.TrimSpace(*input.Description) + } + if input.TriggerType != nil { + template.TriggerType = *input.TriggerType + } + if input.MonthFrom != nil { + template.MonthFrom = positivePointer(input.MonthFrom) + } + if input.DayFrom != nil { + template.DayFrom = positivePointer(input.DayFrom) + } + if input.ClearDayFrom { + template.DayFrom = nil + } + if input.MonthTo != nil { + template.MonthTo = positivePointer(input.MonthTo) + } + if input.DayTo != nil { + template.DayTo = positivePointer(input.DayTo) + } + if input.ClearDayTo { + template.DayTo = nil + } + if input.OffsetDaysFrom != nil { + template.OffsetDaysFrom = input.OffsetDaysFrom + } + if input.OffsetDaysTo != nil { + template.OffsetDaysTo = input.OffsetDaysTo + } + if input.IntervalDays != nil { + template.IntervalDays = positivePointer(input.IntervalDays) + } + if input.ClearIntervalDays { + template.IntervalDays = nil + } + if input.TriggerOffset != nil { + template.TriggerOffset = *input.TriggerOffset + } + if input.TriggerOffsetUnit != nil { + template.TriggerOffsetUnit = *input.TriggerOffsetUnit + } + if input.Duration != nil { + template.Duration = *input.Duration + } + if input.DurationUnit != nil { + template.DurationUnit = *input.DurationUnit + } + if input.Recurrence != nil { + template.Recurrence = *input.Recurrence + } + if input.RecurrenceInterval != nil { + template.RecurrenceInterval = *input.RecurrenceInterval + } + if input.Priority != nil { + template.Priority = *input.Priority + } + if input.Active != nil { + template.Active = *input.Active + } + if template.TriggerType == storage.TaskTriggerMonthOfYear { + template.OffsetDaysFrom, template.OffsetDaysTo = nil, nil + } else { + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo = nil, nil, nil, nil + } +} + +func positivePointer(value *int) *int { + if value != nil && *value <= 0 { + return nil + } + return value +} + +func (app *application) createSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + if _, ok := app.requireWritableSpecies(w, r, gardenID, speciesID); !ok { + return + } + var input speciesTaskTemplateInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + template := storage.SpeciesTaskTemplate{SpeciesID: speciesID, Origin: storage.TaskTemplateOriginManual, Active: true, TriggerOffsetUnit: storage.TaskDurationDay, DurationUnit: storage.TaskDurationDay, RecurrenceInterval: 1} + input.apply(&template) + if !app.validateSpeciesTaskTemplate(w, r, template) { + return + } + template, err := app.models.SpeciesTaskTemplates.Insert(template) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/task-templates/%d", gardenID, speciesID, template.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"task_template": template}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listSpeciesTaskTemplatesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + if _, err := app.models.Species.Get(gardenID, speciesID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_templates": templates}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID) + if !writable { + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + var input speciesTaskTemplateInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&template) + if !app.validateSpeciesTaskTemplate(w, r, template) { + return + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + template, err = app.models.SpeciesTaskTemplates.Update(writeScope, template) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID) + if !writable { + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + if template.Origin != storage.TaskTemplateOriginManual { + template.Active = false + if _, err := app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + if err := app.models.SpeciesTaskTemplates.Delete(writeScope, templateID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) requireWritableSpecies(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) (storage.Species, bool) { + species, err := app.models.Species.Get(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return storage.Species{}, false + } + if species.GardenID == nil { + user, _ := app.contextGetAuthenticatedUser(r) + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return storage.Species{}, false + } + return species, true + } + if *species.GardenID != gardenID { + app.respondToEntityModelError(w, r, storage.ErrRecordNotFound) + return storage.Species{}, false + } + member, _ := app.contextGetGardenMember(r) + if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return storage.Species{}, false + } + return species, true +} + +func (app *application) validateSpeciesTaskTemplate(w http.ResponseWriter, r *http.Request, template storage.SpeciesTaskTemplate) bool { + v := validate.New() + storage.ValidateSpeciesTaskTemplate(v, template) + if !app.validateConfiguredPriority(w, r, v, template.Priority) { + return false + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/species_task_templates_test.go b/internal/api/species_task_templates_test.go new file mode 100644 index 0000000..448cbf0 --- /dev/null +++ b/internal/api/species_task_templates_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestSpeciesTaskTemplatesRequireWritePermission(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 14, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin} + gardenID := 3 + app.models.Species = &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global"}, 2: {ID: 2, GardenID: &gardenID, CommonName: "Rose"}}} + templates := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 10} + app.models.SpeciesTaskTemplates = templates + body := []byte(`{"title":"Schneiden","trigger_type":"month_of_year","month_from":2,"day_from":15,"duration":2,"duration_unit":"week","active":true}`) + global := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body) + if global.Code != http.StatusForbidden { + t.Fatalf("global write status=%d", global.Code) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/2/task-templates", body) + if created.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) + } + listed := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/2/task-templates", nil) + if listed.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", listed.Code, listed.Body.String()) + } + user.Role = storage.ApplicationRoleAdmin + global = serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body) + if global.Code != http.StatusCreated { + t.Fatalf("global admin write status=%d body=%s", global.Code, global.Body.String()) + } +} diff --git a/internal/api/tags.go b/internal/api/tags.go new file mode 100644 index 0000000..9222944 --- /dev/null +++ b/internal/api/tags.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) listGardenTagsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + tags, err := app.models.Tags.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"tags": tags}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) loadTags(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) { + if app.models.Tags == nil { + return nil, nil + } + return app.models.Tags.Get(gardenID, entity, entityID) +} + +func (app *application) saveTags(gardenID int, entity storage.TagEntity, entityID int, values []string) ([]string, error) { + values = storage.NormalizeTags(values) + if app.models.Tags == nil { + return values, nil + } + return app.models.Tags.Set(gardenID, entity, entityID, values) +} diff --git a/internal/api/task_generator.go b/internal/api/task_generator.go new file mode 100644 index 0000000..07d195e --- /dev/null +++ b/internal/api/task_generator.go @@ -0,0 +1,142 @@ +package api + +import ( + "errors" + "time" + + "gardomatic.kleiax.de/internal/daterange" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) generateTasks(gardenID, userID int, now time.Time) error { + plants, err := app.models.Plants.GetAllForGarden(gardenID) + if err != nil { + return err + } + existing, err := app.models.Tasks.GetAllForGarden(gardenID) + if err != nil { + return err + } + for _, plant := range plants { + if plant.SpeciesID == nil || (plant.Status != "alive" && plant.Status != "active") { + continue + } + var species storage.Species + templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, *plant.SpeciesID) + if err != nil { + return err + } + for _, template := range templates { + if !template.Active { + continue + } + if template.TriggerType == storage.TaskTriggerRelativeToSowing || template.TriggerType == storage.TaskTriggerRelativeToHarvest || template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting { + species, err = app.models.Species.Get(gardenID, *plant.SpeciesID) + if err != nil { + return err + } + } + if app.models.TaskTemplateOptOuts != nil { + optedOut, optErr := app.models.TaskTemplateOptOuts.IsOptedOut(gardenID, plant.ID, template.ID) + if optErr != nil { + return optErr + } + if optedOut { + continue + } + } + for _, task := range generatedTasksForTemplate(plant, species, template, existing, gardenID, userID, now) { + if _, err := app.models.Tasks.Insert(task); err != nil && !errors.Is(err, storage.ErrConflict) { + return err + } + } + } + } + return nil +} + +func generatedTasksForTemplate(plant storage.Plant, species storage.Species, template storage.SpeciesTaskTemplate, existing []storage.Task, gardenID, userID int, now time.Time) []storage.Task { + var start, generatedFor time.Time + switch template.TriggerType { + case storage.TaskTriggerMonthOfYear: + if template.MonthFrom == nil || template.DayFrom == nil { + return nil + } + start = nextAnnualStart(now, *template.MonthFrom, *template.DayFrom) + generatedFor = daterange.Date(start) + case storage.TaskTriggerRelativeToPlanting: + if plant.AcquiredAt == nil { + return nil + } + base := daterange.Date(*plant.AcquiredAt) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base + case storage.TaskTriggerRelativeToLastTask: + base, ok := latestCompletion(existing, plant.ID, template.ID) + if !ok { + return nil + } + base = daterange.Date(base) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base + case storage.TaskTriggerRelativeToSowing, storage.TaskTriggerRelativeToHarvest, storage.TaskTriggerRelativeToSpeciesPlanting: + month, day := species.SowMonthFrom, species.SowDayFrom + if template.TriggerType == storage.TaskTriggerRelativeToHarvest { + month, day = species.HarvestMonthFrom, species.HarvestDayFrom + } else if template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting { + month, day = species.PlantingMonthFrom, species.PlantingDayFrom + } + if month == nil { + return nil + } + d := 1 + if day != nil { + d = *day + } + base := nextAnnualStart(now, *month, d) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), daterange.Date(base) + default: + return nil + } + makeTask := func(windowStart, windowEnd, slot time.Time) storage.Task { + plantID, templateID := plant.ID, template.ID + return storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: template.Title, Description: template.Description, DueAtStart: &windowStart, DueAtEnd: &windowEnd, GeneratedFor: &slot, Recurrence: template.Recurrence, RecurrenceInterval: template.RecurrenceInterval, Priority: template.Priority, Active: true, CreatedBy: userID} + } + end := endOfDay(addTemplateDuration(start, template.Duration, template.DurationUnit)) + return []storage.Task{makeTask(start, end, generatedFor)} +} + +func nextAnnualStart(now time.Time, month, day int) time.Time { + lastDay := time.Date(now.Year(), time.Month(month)+1, 0, 0, 0, 0, 0, now.Location()).Day() + if day > lastDay { + day = lastDay + } + result := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, now.Location()) + if result.Before(daterange.Date(now)) { + result = result.AddDate(1, 0, 0) + } + return result +} + +func addTemplateDuration(value time.Time, amount int, unit storage.TaskDurationUnit) time.Time { + switch unit { + case storage.TaskDurationWeek: + return value.AddDate(0, 0, amount*7) + case storage.TaskDurationMonth: + return addClampedDate(value, 0, amount) + default: + return value.AddDate(0, 0, amount) + } +} + +func endOfDay(value time.Time) time.Time { + return daterange.Date(value).AddDate(0, 0, 1).Add(-time.Nanosecond) +} + +func latestCompletion(tasks []storage.Task, plantID, templateID int) (time.Time, bool) { + var latest time.Time + for _, task := range tasks { + if task.PlantID != nil && *task.PlantID == plantID && task.TemplateID != nil && *task.TemplateID == templateID && task.CompletedAt != nil && task.CompletedAt.After(latest) { + latest = *task.CompletedAt + } + } + return latest, !latest.IsZero() +} diff --git a/internal/api/task_generator_test.go b/internal/api/task_generator_test.go new file mode 100644 index 0000000..0d34d11 --- /dev/null +++ b/internal/api/task_generator_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type templateTestModel struct { + items map[int]storage.SpeciesTaskTemplate + nextID int +} + +func (m *templateTestModel) Insert(value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + m.nextID++ + value.ID, value.Version = m.nextID, 1 + m.items[value.ID] = value + return value, nil +} +func (m *templateTestModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) { + value, ok := m.items[id] + if !ok { + return storage.SpeciesTaskTemplate{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *templateTestModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) { + result := []storage.SpeciesTaskTemplate{} + for _, value := range m.items { + if value.SpeciesID == speciesID { + result = append(result, value) + } + } + return result, nil +} +func (m *templateTestModel) Update(gardenID int, value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *templateTestModel) Delete(gardenID, id int) error { delete(m.items, id); return nil } + +func TestGenerateTasksIsIdempotentAndHandlesWrapAround(t *testing.T) { + app, _, _ := newGardenTestApplication() + speciesID, monthFrom, dayFrom := 2, 11, 1 + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, SpeciesID: &speciesID, Name: "Rose", Status: "active"}}} + app.models.SpeciesTaskTemplates = &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{7: {ID: 7, SpeciesID: speciesID, Title: "Winterschutz prüfen", TriggerType: storage.TaskTriggerMonthOfYear, MonthFrom: &monthFrom, DayFrom: &dayFrom, Duration: 3, DurationUnit: storage.TaskDurationMonth, Recurrence: storage.TaskRecurrenceWeekly, RecurrenceInterval: 2, Active: true}}} + tasks := &taskTestModel{items: map[int]storage.Task{}, nextID: 10} + app.models.Tasks = tasks + now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + if err := app.generateTasks(3, 12, now); err != nil { + t.Fatal(err) + } + firstCount := len(tasks.items) + if firstCount != 1 { + t.Fatalf("generated count=%d", firstCount) + } + if err := app.generateTasks(3, 12, now); err != nil { + t.Fatal(err) + } + if len(tasks.items) != firstCount { + t.Fatalf("second generation count=%d want=%d", len(tasks.items), firstCount) + } + for _, task := range tasks.items { + if task.Recurrence != storage.TaskRecurrenceWeekly || task.RecurrenceInterval != 2 { + t.Errorf("recurrence not copied to generated task: %+v", task) + } + if task.DueAtStart.Before(time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)) || task.DueAtEnd.After(endOfDay(time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC))) { + t.Errorf("window outside wrap range: %+v", task) + } + } +} diff --git a/internal/api/task_priorities.go b/internal/api/task_priorities.go new file mode 100644 index 0000000..cfa92c2 --- /dev/null +++ b/internal/api/task_priorities.go @@ -0,0 +1,153 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type taskPriorityInput struct { + Name *string `json:"name"` + Value *int `json:"value"` + SortOrder *int `json:"sort_order"` + Active *bool `json:"active"` +} + +func (input taskPriorityInput) apply(value *storage.TaskPriority) { + if input.Name != nil { + value.Name = strings.TrimSpace(*input.Name) + } + if input.Value != nil { + value.Value = *input.Value + } + if input.SortOrder != nil { + value.SortOrder = *input.SortOrder + } + if input.Active != nil { + value.Active = *input.Active + } +} +func (app *application) listTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) { + app.writeTaskPriorities(w, r, false) +} +func (app *application) listAdminTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) { + app.writeTaskPriorities(w, r, true) +} +func (app *application) writeTaskPriorities(w http.ResponseWriter, r *http.Request, includeInactive bool) { + values, err := app.models.TaskPriorities.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !includeInactive { + active := values[:0] + for _, value := range values { + if value.Active { + active = append(active, value) + } + } + values = active + } + if err := app.writeJSON(w, http.StatusOK, envelope{"priorities": values}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) createAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + var input taskPriorityInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + value := storage.TaskPriority{Active: true} + input.apply(&value) + if !app.validateTaskPriority(w, r, value) { + return + } + value, err := app.models.TaskPriorities.Insert(value) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/admin/task-priorities/%d", value.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"priority": value}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) updateAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + value, err := app.models.TaskPriorities.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input taskPriorityInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&value) + if !app.validateTaskPriority(w, r, value) { + return + } + value, err = app.models.TaskPriorities.Update(value) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"priority": value}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) deleteAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + value, err := app.models.TaskPriorities.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + value.Active = false + if _, err = app.models.TaskPriorities.Update(value); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (app *application) validateTaskPriority(w http.ResponseWriter, r *http.Request, value storage.TaskPriority) bool { + v := validate.New() + storage.ValidateTaskPriority(v, value) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} + +func (app *application) validateConfiguredPriority(w http.ResponseWriter, r *http.Request, v *validate.Validator, value int) bool { + if app.models.TaskPriorities == nil { + return true + } + priorities, err := app.models.TaskPriorities.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return false + } + for _, priority := range priorities { + if priority.Value == value { + return true + } + } + v.AddError("priority", "must refer to a configured task priority") + return true +} diff --git a/internal/api/task_template_opt_outs.go b/internal/api/task_template_opt_outs.go new file mode 100644 index 0000000..03aefc6 --- /dev/null +++ b/internal/api/task_template_opt_outs.go @@ -0,0 +1,44 @@ +package api + +import "net/http" + +func (app *application) listTaskTemplateOptOutsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readNamedIDParam(r, "id") + if err != nil { + app.notFoundResponse(w, r) + return + } + ids, err := app.models.TaskTemplateOptOuts.GetAllForPlant(gardenID, plantID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"template_ids": ids}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) setTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) { + app.setTaskTemplateOptOut(w, r, true) +} +func (app *application) deleteTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) { + app.setTaskTemplateOptOut(w, r, false) +} +func (app *application) setTaskTemplateOptOut(w http.ResponseWriter, r *http.Request, optedOut bool) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readNamedIDParam(r, "id") + if err != nil { + app.notFoundResponse(w, r) + return + } + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.TaskTemplateOptOuts.Set(gardenID, plantID, templateID, optedOut); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/tasks.go b/internal/api/tasks.go new file mode 100644 index 0000000..80cabda --- /dev/null +++ b/internal/api/tasks.go @@ -0,0 +1,371 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type taskInput struct { + Tags []string `json:"tags"` + PlantID *int `json:"plant_id"` + LocationID *int `json:"location_id"` + ClearPlantID bool `json:"clear_plant_id"` + ClearLocationID bool `json:"clear_location_id"` + Title *string `json:"title"` + Description *string `json:"description"` + DueAtStart *time.Time `json:"due_at_start"` + DueAtEnd *time.Time `json:"due_at_end"` + ClearDueAtStart bool `json:"clear_due_at_start"` + ClearDueAtEnd bool `json:"clear_due_at_end"` + Recurrence *storage.TaskRecurrence `json:"recurrence"` + RecurrenceInterval *int `json:"recurrence_interval"` + Priority *int `json:"priority"` + Active *bool `json:"active"` + Completed *bool `json:"completed"` + PlantStatusOnCompletion *string `json:"plant_status_on_completion"` +} + +func (input taskInput) isCompletionOnly() bool { + return input.Completed != nil && input.Tags == nil && input.PlantID == nil && input.LocationID == nil && !input.ClearPlantID && !input.ClearLocationID && input.Title == nil && input.Description == nil && input.DueAtStart == nil && input.DueAtEnd == nil && !input.ClearDueAtStart && !input.ClearDueAtEnd && input.Recurrence == nil && input.RecurrenceInterval == nil && input.Priority == nil && input.Active == nil && input.PlantStatusOnCompletion == nil +} + +func (input taskInput) apply(task *storage.Task, userID int) { + if input.PlantID != nil { + task.PlantID = input.PlantID + } + if input.LocationID != nil { + task.LocationID = input.LocationID + } + if input.ClearPlantID { + task.PlantID = nil + } + if input.ClearLocationID { + task.LocationID = nil + } + if input.Title != nil { + task.Title = strings.TrimSpace(*input.Title) + } + if input.Description != nil { + task.Description = strings.TrimSpace(*input.Description) + } + if input.DueAtStart != nil { + task.DueAtStart = input.DueAtStart + } + if input.DueAtEnd != nil { + task.DueAtEnd = input.DueAtEnd + } + if input.ClearDueAtStart { + task.DueAtStart = nil + } + if input.ClearDueAtEnd { + task.DueAtEnd = nil + } + if input.Recurrence != nil { + task.Recurrence = *input.Recurrence + } + if input.RecurrenceInterval != nil { + task.RecurrenceInterval = *input.RecurrenceInterval + } + if input.Priority != nil { + task.Priority = *input.Priority + } + if input.Active != nil { + task.Active = *input.Active + } + if input.Completed != nil { + if *input.Completed { + now := time.Now().UTC() + task.CompletedAt, task.CompletedBy = &now, &userID + } else { + task.CompletedAt, task.CompletedBy = nil, nil + } + } + assignStringPointer(input.PlantStatusOnCompletion, &task.PlantStatusOnCompletion) +} + +func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + var input taskInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + task := storage.Task{GardenID: gardenID, CreatedBy: user.ID, Active: true, RecurrenceInterval: 1} + input.apply(&task, user.ID) + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + task.Tags = storage.NormalizeTags(input.Tags) + if !app.validateTaskForGarden(w, r, gardenID, task) { + return + } + task, err := app.models.Tasks.Insert(task) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags); err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/tasks/%d", gardenID, task.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + if err := app.generateTasks(gardenID, user.ID, time.Now()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + tasks, err := app.models.Tasks.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + visible := tasks[:0] + for i := range tasks { + if tasks[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionTaskReadOther) { + continue + } + if tasks[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionTaskReadOwn) { + continue + } + tasks[i].Tags, err = app.loadTags(gardenID, storage.TagEntityTask, tasks[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + visible = append(visible, tasks[i]) + } + tasks = visible + if err := app.writeJSON(w, http.StatusOK, envelope{"tasks": tasks}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther) { + return + } + task.Tags, err = app.loadTags(gardenID, storage.TagEntityTask, task.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input taskInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.isCompletionOnly() { + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskCompleteOwn, storage.GardenPermissionTaskCompleteOther) { + return + } + } else if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskUpdateOwn, storage.GardenPermissionTaskUpdateOther) { + return + } + user, _ := app.contextGetAuthenticatedUser(r) + input.apply(&task, user.ID) + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + if input.Tags != nil { + task.Tags = storage.NormalizeTags(input.Tags) + } + if !app.validateTaskForGarden(w, r, gardenID, task) { + return + } + task, err = app.models.Tasks.Update(gardenID, task) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if input.Completed != nil && *input.Completed && task.PlantID != nil && task.PlantStatusOnCompletion != nil { + plant, plantErr := app.models.Plants.Get(gardenID, *task.PlantID) + if plantErr != nil { + app.respondToEntityModelError(w, r, plantErr) + return + } + plant.Status, plant.UpdatedBy = *task.PlantStatusOnCompletion, user.ID + if _, plantErr = app.models.Plants.Update(gardenID, plant); plantErr != nil { + app.respondToEntityModelError(w, r, plantErr) + return + } + } + if input.Tags != nil { + task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if input.Completed != nil && *input.Completed && task.Recurrence != storage.TaskRecurrenceNone { + if err := app.ensureNextRecurringTask(gardenID, user.ID, task); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) ensureNextRecurringTask(gardenID, userID int, task storage.Task) error { + next := task + next.ID, next.Version = 0, 0 + next.GeneratedFor = nil + next.CompletedAt, next.CompletedBy = nil, nil + next.CreatedBy = userID + next.CreatedAt, next.UpdatedAt = time.Time{}, time.Time{} + next.RepeatFromID = &task.ID + next.DueAtStart = advanceRecurringTime(task.DueAtStart, task.Recurrence, task.RecurrenceInterval) + next.DueAtEnd = advanceRecurringTime(task.DueAtEnd, task.Recurrence, task.RecurrenceInterval) + if next.TemplateID != nil && next.DueAtStart != nil { + generatedFor := time.Date(next.DueAtStart.Year(), next.DueAtStart.Month(), next.DueAtStart.Day(), 0, 0, 0, 0, next.DueAtStart.Location()) + next.GeneratedFor = &generatedFor + } + + created, err := app.models.Tasks.Insert(next) + if errors.Is(err, storage.ErrConflict) { + return nil + } + if err != nil { + return err + } + tags, err := app.loadTags(gardenID, storage.TagEntityTask, task.ID) + if err != nil { + return err + } + _, err = app.saveTags(gardenID, storage.TagEntityTask, created.ID, tags) + return err +} + +func advanceRecurringTime(value *time.Time, recurrence storage.TaskRecurrence, interval int) *time.Time { + if value == nil { + return nil + } + result := *value + if interval < 1 { + interval = 1 + } + switch recurrence { + case storage.TaskRecurrenceDaily: + result = result.AddDate(0, 0, interval) + case storage.TaskRecurrenceWeekly: + result = result.AddDate(0, 0, 7*interval) + case storage.TaskRecurrenceMonthly: + result = addClampedDate(result, 0, interval) + case storage.TaskRecurrenceYearly: + result = addClampedDate(result, interval, 0) + } + return &result +} + +func addClampedDate(value time.Time, years, months int) time.Time { + targetMonth := int(value.Month()) + months + targetYear := value.Year() + years + (targetMonth-1)/12 + targetMonth = (targetMonth-1)%12 + 1 + lastDay := time.Date(targetYear, time.Month(targetMonth)+1, 0, 0, 0, 0, 0, value.Location()).Day() + day := value.Day() + if day > lastDay { + day = lastDay + } + return time.Date(targetYear, time.Month(targetMonth), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()) +} + +func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskDeleteOwn, storage.GardenPermissionTaskDeleteOther) { + return + } + if err := app.models.Tasks.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateTaskForGarden(w http.ResponseWriter, r *http.Request, gardenID int, task storage.Task) bool { + v := validate.New() + storage.ValidateTask(v, task) + storage.ValidateTags(v, task.Tags) + if !app.validateConfiguredPriority(w, r, v, task.Priority) { + return false + } + if task.PlantID != nil && *task.PlantID > 0 { + if _, err := app.models.Plants.Get(gardenID, *task.PlantID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("plant_id", "must refer to a plant in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if task.LocationID != nil && *task.LocationID > 0 { + if _, err := app.models.Locations.Get(gardenID, *task.LocationID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("location_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/tasks_test.go b/internal/api/tasks_test.go new file mode 100644 index 0000000..97368de --- /dev/null +++ b/internal/api/tasks_test.go @@ -0,0 +1,133 @@ +package api + +import ( + "net/http" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type taskTestModel struct { + items map[int]storage.Task + nextID int +} + +func (m *taskTestModel) Insert(value storage.Task) (storage.Task, error) { + if value.RepeatFromID != nil { + for _, existing := range m.items { + if existing.RepeatFromID != nil && *existing.RepeatFromID == *value.RepeatFromID { + return storage.Task{}, storage.ErrConflict + } + } + } + if value.TemplateID != nil && value.PlantID != nil && value.GeneratedFor != nil { + for _, existing := range m.items { + if existing.TemplateID != nil && *existing.TemplateID == *value.TemplateID && existing.PlantID != nil && *existing.PlantID == *value.PlantID && existing.GeneratedFor != nil && existing.GeneratedFor.Format("2006-01-02") == value.GeneratedFor.Format("2006-01-02") { + return storage.Task{}, storage.ErrConflict + } + } + } + m.nextID++ + value.ID, value.Version = m.nextID, 1 + m.items[value.ID] = value + return value, nil +} + +func TestCompletingRecurringTaskCreatesNextCalendarOccurrence(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + dueStart := time.Date(2026, time.January, 31, 8, 0, 0, 0, time.UTC) + dueEnd := time.Date(2026, time.January, 31, 18, 0, 0, 0, time.UTC) + app.models.Tasks = &taskTestModel{items: map[int]storage.Task{101: { + ID: 101, GardenID: 3, Title: "Düngen", DueAtStart: &dueStart, DueAtEnd: &dueEnd, + Recurrence: storage.TaskRecurrenceMonthly, RecurrenceInterval: 2, Active: true, CreatedBy: user.ID, Version: 1, + }}, nextID: 101} + + completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completed.Code != http.StatusOK { + t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) + } + next := app.models.Tasks.(*taskTestModel).items[102] + if next.RepeatFromID == nil || *next.RepeatFromID != 101 || next.CompletedAt != nil { + t.Fatalf("next occurrence links: %+v", next) + } + if got := next.DueAtStart.Format(time.RFC3339); got != "2026-03-31T08:00:00Z" { + t.Errorf("next start = %s", got) + } + if got := next.DueAtEnd.Format(time.RFC3339); got != "2026-03-31T18:00:00Z" { + t.Errorf("next end = %s", got) + } + + completedAgain := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completedAgain.Code != http.StatusOK || len(app.models.Tasks.(*taskTestModel).items) != 2 { + t.Fatalf("repeated completion created a duplicate: status=%d tasks=%+v", completedAgain.Code, app.models.Tasks.(*taskTestModel).items) + } +} + +func (m *taskTestModel) Get(gardenID, id int) (storage.Task, error) { + value, ok := m.items[id] + if !ok || value.GardenID != gardenID { + return storage.Task{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *taskTestModel) GetAllForGarden(gardenID int) ([]storage.Task, error) { + result := []storage.Task{} + for _, value := range m.items { + if value.GardenID == gardenID { + result = append(result, value) + } + } + return result, nil +} +func (m *taskTestModel) Update(gardenID int, value storage.Task) (storage.Task, error) { + if _, err := m.Get(gardenID, value.ID); err != nil { + return storage.Task{}, err + } + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *taskTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +func TestTasksAreScopedAndValidateReferences(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + app.models.Tasks = &taskTestModel{items: map[int]storage.Task{90: {ID: 90, GardenID: 4, Title: "Fremd", Version: 1}}, nextID: 100} + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}} + app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 8: {ID: 8, GardenID: 4, Name: "Fremd"}}} + + foreignReference := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":9,"location_id":8}`)) + if foreignReference.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign references: got %d; %s", foreignReference.Code, foreignReference.Body.String()) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":5,"location_id":6,"priority":5}`)) + if created.Code != http.StatusCreated { + t.Fatalf("create: got %d; %s", created.Code, created.Body.String()) + } + value := app.models.Tasks.(*taskTestModel).items[101] + if value.CreatedBy != user.ID || value.GardenID != 3 { + t.Errorf("created task scope: %+v", value) + } + completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completed.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].CompletedAt == nil { + t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) + } + cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"clear_plant_id":true,"clear_location_id":true}`)) + if cleared.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].PlantID != nil || app.models.Tasks.(*taskTestModel).items[101].LocationID != nil { + t.Fatalf("clear references: got %d; %s", cleared.Code, cleared.Body.String()) + } + foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/tasks/90", nil) + if foreignRead.Code != http.StatusNotFound { + t.Fatalf("foreign read: got %d", foreignRead.Code) + } +} diff --git a/internal/api/tokens.go b/internal/api/tokens.go new file mode 100644 index 0000000..a55b568 --- /dev/null +++ b/internal/api/tokens.go @@ -0,0 +1,189 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.Password) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidCredentialsResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + match, err := user.Password.Matches(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + if !match { + app.invalidCredentialsResponse(w, r) + return + } + + token, err := app.models.Tokens.New(user.ID, 24*time.Hour, auth.ScopeAuthentication) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if !user.Activated { + v.AddError("email", "user account must be activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + token, err := app.models.Tokens.New(user.ID, 45*time.Minute, auth.ScopePasswordReset) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "passwordResetToken": token.Plaintext, + } + + err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + env := envelope{"message": "an email will be sent to you containing password reset instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if user.Activated { + v.AddError("email", "user has already been activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "activationToken": token.Plaintext, + } + + err := app.mailer.Send(user.Email, "token_activation.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + env := envelope{"message": "an email will be sent to you containing activation instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..bbadfd0 --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,210 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + user := storage.User{ + Name: input.Name, + Email: input.Email, + Activated: false, + } + + err = user.Password.Set(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateUser(v, user); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err = app.models.Users.Insert(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrDuplicateEmail): + v.AddError("email", "a user with this email address already exists") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "activationToken": token.Plaintext, + "userID": user.ID, + } + + err := app.mailer.Send(user.Email, "user_welcome.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + TokenPlaintext string `json:"token"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + auth.ValidateTokenPlaintext(v, input.TokenPlaintext) + if input.Password != "" { + auth.ValidatePasswordPlaintext(v, input.Password) + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopeActivation, input.TokenPlaintext) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("token", "invalid or expired activation token") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + user.Activated = true + if input.Password != "" { + if err = user.Password.Set(input.Password); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + + user, err = app.models.Users.Update(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Password string `json:"password"` + TokenPlaintext string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + auth.ValidatePasswordPlaintext(v, input.Password) + auth.ValidateTokenPlaintext(v, input.TokenPlaintext) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopePasswordReset, input.TokenPlaintext) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("token", "invalid or expired password reset token") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = user.Password.Set(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + user, err = app.models.Users.Update(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + env := envelope{"message": "your password was successfully reset"} + + err = app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/auth/doc.go b/internal/auth/doc.go new file mode 100644 index 0000000..8c0136c --- /dev/null +++ b/internal/auth/doc.go @@ -0,0 +1,3 @@ +// Package auth provides password hashing and secure token primitives used by +// Gardomatic authentication flows. +package auth diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..6a29336 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,71 @@ +package auth + +import ( + "errors" + + "gardomatic.kleiax.de/internal/platform/validate" + "golang.org/x/crypto/bcrypt" +) + +// Password holds a bcrypt hash and, while constructing a new password, its +// plaintext value for policy validation. Plaintext is never exposed. +type Password struct { + plaintext *string + hash []byte +} + +// NewPassword reconstructs a password value from an existing bcrypt hash. +func NewPassword(hash []byte) *Password { + return &Password{hash: hash} +} + +// Set hashes plaintextPassword and replaces the stored hash. +func (p *Password) Set(plaintextPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return err + } + + p.plaintext = &plaintextPassword + p.hash = hash + + return nil +} + +// Get returns a defensive copy of the bcrypt hash. +func (p *Password) Get() []byte { + return p.hash +} + +// Matches reports whether plaintextPassword matches the stored bcrypt hash. +func (p *Password) Matches(plaintextPassword string) (bool, error) { + err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + default: + return false, err + } + } + + return true, nil +} + +// Validate adds password-hash validation errors to v. +func (p *Password) Validate(v *validate.Validator) { + if p.plaintext != nil { + ValidatePasswordPlaintext(v, *p.plaintext) + } + + if p.hash == nil { + panic("missing password hash for user") + } +} + +// ValidatePasswordPlaintext applies the password policy to plaintext input. +func ValidatePasswordPlaintext(v *validate.Validator, plaintext string) { + v.Check(plaintext != "", "password", "must be provided") + v.Check(len(plaintext) >= 8, "password", "must be at least 8 bytes long") + v.Check(len(plaintext) <= 72, "password", "must not be more than 72 bytes long") +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..48d6d83 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,53 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +const ( + // ScopeActivation identifies account activation tokens. + ScopeActivation = "activation" + // ScopeAuthentication identifies bearer authentication tokens. + ScopeAuthentication = "authentication" + // ScopePasswordReset identifies password reset tokens. + ScopePasswordReset = "password-reset" +) + +// Token carries a one-time plaintext token and the hash persisted by storage. +type Token struct { + Plaintext string `json:"token"` + Hash []byte `json:"-"` + UserID int `json:"-"` + Expiry time.Time `json:"expiry"` + Scope string `json:"-"` +} + +// NewToken creates a cryptographically random token for a user and scope. +func NewToken(userID int, ttl time.Duration, scope string) Token { + token := Token{ + Plaintext: rand.Text(), + UserID: userID, + Expiry: time.Now().Add(ttl), + Scope: scope, + } + + hash := sha256.Sum256([]byte(token.Plaintext)) + token.Hash = hash[:] + + return token +} + +// Validate adds token consistency errors to v. +func (tk Token) Validate(v *validate.Validator) { + ValidateTokenPlaintext(v, tk.Plaintext) +} + +// ValidateTokenPlaintext checks the expected format of a user-supplied token. +func ValidateTokenPlaintext(v *validate.Validator, tokenPlaintext string) { + v.Check(tokenPlaintext != "", "token", "must be provided") + v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long") +} diff --git a/internal/daterange/daterange.go b/internal/daterange/daterange.go new file mode 100644 index 0000000..d492e94 --- /dev/null +++ b/internal/daterange/daterange.go @@ -0,0 +1,46 @@ +// Package daterange resolves recurring calendar windows and normalized dates. +package daterange + +import "time" + +// CalendarWindow resolves a recurring month/day range around now. A range whose +// start month is after its end month crosses the year boundary. +func CalendarWindow(now time.Time, monthFrom int, dayFrom *int, monthTo int, dayTo *int) (time.Time, time.Time) { + location := now.Location() + startYear := now.Year() + if monthFrom > monthTo && int(now.Month()) <= monthTo { + startYear-- + } + endYear := startYear + if monthFrom > monthTo { + endYear++ + } + startDay := 1 + if dayFrom != nil { + startDay = clampDay(startYear, time.Month(monthFrom), *dayFrom) + } + endDay := daysInMonth(endYear, time.Month(monthTo)) + if dayTo != nil { + endDay = clampDay(endYear, time.Month(monthTo), *dayTo) + } + return time.Date(startYear, time.Month(monthFrom), startDay, 0, 0, 0, 0, location), time.Date(endYear, time.Month(monthTo), endDay, 23, 59, 59, 0, location) +} + +// Date returns value at midnight in its original location. +func Date(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +func clampDay(year int, month time.Month, day int) int { + if maximum := daysInMonth(year, month); day > maximum { + return maximum + } + if day < 1 { + return 1 + } + return day +} diff --git a/internal/daterange/daterange_test.go b/internal/daterange/daterange_test.go new file mode 100644 index 0000000..788f0ca --- /dev/null +++ b/internal/daterange/daterange_test.go @@ -0,0 +1,36 @@ +package daterange + +import ( + "testing" + "time" +) + +func intPointer(value int) *int { return &value } + +func TestCalendarWindow(t *testing.T) { + tests := []struct { + name string + now time.Time + from int + fromDay *int + to int + toDay *int + wantStart, wantEnd string + }{ + {"ordinary", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), 2, nil, 3, nil, "2026-02-01", "2026-03-31"}, + {"wrap before end", time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2025-11-01", "2026-02-28"}, + {"wrap before start", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2026-11-01", "2027-02-28"}, + {"clamps invalid month day", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 2, intPointer(31), 2, intPointer(31), "2026-02-28", "2026-02-28"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + start, end := CalendarWindow(test.now, test.from, test.fromDay, test.to, test.toDay) + if got := start.Format("2006-01-02"); got != test.wantStart { + t.Errorf("start=%s", got) + } + if got := end.Format("2006-01-02"); got != test.wantEnd { + t.Errorf("end=%s", got) + } + }) + } +} diff --git a/internal/mailer/doc.go b/internal/mailer/doc.go new file mode 100644 index 0000000..ed56af4 --- /dev/null +++ b/internal/mailer/doc.go @@ -0,0 +1,3 @@ +// Package mailer renders and delivers Gardomatic transactional email through +// SMTP or an append-only development file. +package mailer diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..58cf084 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,163 @@ +package mailer + +import ( + "bytes" + "embed" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/wneessen/go-mail" + + ht "html/template" + tt "text/template" +) + +//go:embed "templates" +var templateFS embed.FS + +// Mailer renders embedded templates and delivers the resulting message. +type Mailer struct { + client *mail.Client + mode Mode + filePath string + sender string + fileMu sync.Mutex +} + +// Mode selects the delivery backend used by a Mailer. +type Mode string + +const ( + // ModeSMTP sends messages through an SMTP server. + ModeSMTP Mode = "smtp" + // ModeFile appends rendered messages to a local development file. + ModeFile Mode = "file" +) + +// Config contains SMTP or development-file delivery settings. +type Config struct { + Mode Mode + Host string + Port int + Username string + Password string + Sender string + FilePath string +} + +// New validates config and creates a Mailer. +func New(config Config) (*Mailer, error) { + mailer := &Mailer{ + mode: config.Mode, + filePath: config.FilePath, + sender: config.Sender, + } + + switch config.Mode { + case ModeSMTP: + client, err := mail.NewClient( + config.Host, + mail.WithSMTPAuth(mail.SMTPAuthLogin), + mail.WithPort(config.Port), + mail.WithUsername(config.Username), + mail.WithPassword(config.Password), + mail.WithTimeout(5*time.Second), + ) + if err != nil { + return nil, err + } + mailer.client = client + case ModeFile: + if config.FilePath == "" { + return nil, errors.New("mailer: file path must not be empty in file mode") + } + default: + return nil, fmt.Errorf("mailer: unsupported mode %q", config.Mode) + } + + return mailer, nil +} + +// Send renders templateFile with data and delivers it to recipient. +func (m *Mailer) Send(recipient string, templateFile string, data any) error { + textTmpl, err := tt.New("").ParseFS(templateFS, "templates/"+templateFile) + if err != nil { + return err + } + + subject := new(bytes.Buffer) + err = textTmpl.ExecuteTemplate(subject, "subject", data) + if err != nil { + return err + } + + plainBody := new(bytes.Buffer) + err = textTmpl.ExecuteTemplate(plainBody, "plainBody", data) + if err != nil { + return err + } + + htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile) + if err != nil { + return err + } + + htmlBody := new(bytes.Buffer) + err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data) + if err != nil { + return err + } + + msg := mail.NewMsg() + + err = msg.To(recipient) + if err != nil { + return err + } + + err = msg.From(m.sender) + if err != nil { + return err + } + + msg.Subject(subject.String()) + msg.SetBodyString(mail.TypeTextPlain, plainBody.String()) + msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String()) + + if m.mode == ModeFile { + return m.appendToFile(msg) + } + + return m.client.DialAndSend(msg) +} + +func (m *Mailer) appendToFile(msg *mail.Msg) error { + var content bytes.Buffer + if _, err := msg.WriteTo(&content); err != nil { + return fmt.Errorf("mailer: format message: %w", err) + } + + m.fileMu.Lock() + defer m.fileMu.Unlock() + + file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("mailer: open output file: %w", err) + } + defer file.Close() + + if _, err = file.WriteString("\n=== gardomatic mail ===\n"); err != nil { + return fmt.Errorf("mailer: append separator: %w", err) + } + if _, err = content.WriteTo(file); err != nil { + return fmt.Errorf("mailer: append message: %w", err) + } + if _, err = file.WriteString("\n"); err != nil { + return fmt.Errorf("mailer: finish message: %w", err) + } + + return nil +} diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go new file mode 100644 index 0000000..cf5c4db --- /dev/null +++ b/internal/mailer/mailer_test.go @@ -0,0 +1,64 @@ +package mailer + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileModeAppendsMessages(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "mails.log") + m, err := New(Config{ + Mode: ModeFile, + Sender: "gardomatic@example.com", + FilePath: filePath, + }) + if err != nil { + t.Fatalf("New() returned an error: %v", err) + } + + data := map[string]any{ + "userID": 42, + "activationToken": "test-token", + } + for _, recipient := range []string{"alice@example.com", "bob@example.com"} { + if err = m.Send(recipient, "user_welcome.tmpl", data); err != nil { + t.Fatalf("Send() returned an error: %v", err) + } + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("reading mail output: %v", err) + } + output := string(content) + + if got := strings.Count(output, "=== gardomatic mail ==="); got != 2 { + t.Errorf("message count = %d, want 2", got) + } + for _, expected := range []string{ + "alice@example.com", + "bob@example.com", + "Subject: Welcome to Gardomatic!", + "test-token", + } { + if !strings.Contains(output, expected) { + t.Errorf("output does not contain %q", expected) + } + } +} + +func TestFileModeRequiresPath(t *testing.T) { + _, err := New(Config{Mode: ModeFile, Sender: "gardomatic@example.com"}) + if err == nil { + t.Fatal("New() returned no error without a file path") + } +} + +func TestNewRejectsUnknownMode(t *testing.T) { + _, err := New(Config{Mode: "unknown", Sender: "gardomatic@example.com"}) + if err == nil { + t.Fatal("New() returned no error for an unknown mode") + } +} diff --git a/internal/mailer/templates/email_change.tmpl b/internal/mailer/templates/email_change.tmpl new file mode 100644 index 0000000..9756789 --- /dev/null +++ b/internal/mailer/templates/email_change.tmpl @@ -0,0 +1,3 @@ +{{define "subject"}}E-Mail-Adresse bei Gardomatic bestätigen{{end}} +{{define "plainBody"}}Bestätige deine neue E-Mail-Adresse: {{.confirmationURL}}{{end}} +{{define "htmlBody"}}

Bestätige deine neue E-Mail-Adresse:

E-Mail-Adresse bestätigen

{{end}} diff --git a/internal/mailer/templates/garden_invite.tmpl b/internal/mailer/templates/garden_invite.tmpl new file mode 100644 index 0000000..2233aa1 --- /dev/null +++ b/internal/mailer/templates/garden_invite.tmpl @@ -0,0 +1,3 @@ +{{define "subject"}}Einladung zu Gardomatic{{end}} +{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}} +{{define "htmlBody"}}

Du wurdest zu einem Garten eingeladen.

Einladung annehmen

{{end}} diff --git a/internal/mailer/templates/test_mail.tmpl b/internal/mailer/templates/test_mail.tmpl new file mode 100644 index 0000000..a6123c9 --- /dev/null +++ b/internal/mailer/templates/test_mail.tmpl @@ -0,0 +1,25 @@ +{{define "subject"}}Gardomatic Testmail{{end}} + +{{define "plainBody"}} +Hallo, + +diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert. + +Viele Grüße +Gardomatic +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hallo,

+

diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.

+

Viele Grüße
Gardomatic

+ + +{{end}} diff --git a/internal/mailer/templates/token_activation.tmpl b/internal/mailer/templates/token_activation.tmpl new file mode 100644 index 0000000..9c897f7 --- /dev/null +++ b/internal/mailer/templates/token_activation.tmpl @@ -0,0 +1,48 @@ +{{define "subject"}}Activate your Gardomatic account{{end}} + +{{define "plainBody"}} +Hi, + +{{if .activationURL}}Activate your account using this link: + +{{.activationURL}} + +Alternatively, enter this activation token on the activation page: +{{.activationToken}} +{{else}}Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account: + +{"token": "{{.activationToken}}"} +{{end}} + +Please note that this is a one-time use token and it will expire in 3 days. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hi,

+ {{if .activationURL}} +

Activate your Gardomatic account

+

Alternatively, enter this activation token on the activation page:

+
{{.activationToken}}
+ {{else}} +

Please send a PUT /v1/users/activated request with the following JSON body to activate your account:

+

+    {"token": "{{.activationToken}}"}
+    
+ {{end}} +

Please note that this is a one-time use token and it will expire in 3 days.

+

Thanks,

+

The Gardomatic Team

+ + +{{end}} diff --git a/internal/mailer/templates/token_password_reset.tmpl b/internal/mailer/templates/token_password_reset.tmpl new file mode 100644 index 0000000..685c32a --- /dev/null +++ b/internal/mailer/templates/token_password_reset.tmpl @@ -0,0 +1,37 @@ +{{define "subject"}}Reset your Gardomatic password{{end}} + +{{define "plainBody"}} +Hi, + +Please send a `PUT /v1/users/password` request with the following JSON body to set a new password: + +{"password": "your new password", "token": "{{.passwordResetToken}}"} + +Please note that this is a one-time use token and it will expire in 45 minutes. If you need +another token please make a `POST /v1/tokens/password-reset` request. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hi,

+

Please send a PUT /v1/users/password request with the following JSON body to set a new password:

+

+    {"password": "your new password", "token": "{{.passwordResetToken}}"}
+    
+

Please note that this is a one-time use token and it will expire in 45 minutes. + If you need another token please make a POST /v1/tokens/password-reset request.

+

Thanks,

+

The Gardomatic Team

+ + +{{end}} diff --git a/internal/mailer/templates/user_invitation.tmpl b/internal/mailer/templates/user_invitation.tmpl new file mode 100644 index 0000000..accc1f7 --- /dev/null +++ b/internal/mailer/templates/user_invitation.tmpl @@ -0,0 +1,31 @@ +{{define "subject"}}Einladung zu Gardomatic{{end}} + +{{define "plainBody"}} +Hallo {{.name}}, + +du wurdest zu Gardomatic eingeladen. Öffne den folgenden Link, um deinen Account zu aktivieren und ein Passwort festzulegen: + +{{.activationURL}} + +Der Link ist drei Tage lang gültig. + +Viele Grüße +Gardomatic +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hallo {{.name}},

+

du wurdest zu Gardomatic eingeladen.

+

Account aktivieren und Passwort festlegen

+

Der Link ist drei Tage lang gültig.

+

Viele Grüße
Gardomatic

+ + +{{end}} diff --git a/internal/mailer/templates/user_welcome.tmpl b/internal/mailer/templates/user_welcome.tmpl new file mode 100644 index 0000000..ef68302 --- /dev/null +++ b/internal/mailer/templates/user_welcome.tmpl @@ -0,0 +1,59 @@ +{{define "subject"}}Welcome to Gardomatic!{{end}} + +{{define "plainBody"}} +Hi, + +Thanks for signing up for a Gardomatic account. We're excited to have you on board! + +For future reference, your user ID number is {{.userID}}. + +{{if .activationURL}}Activate your account using this link: + +{{.activationURL}} + +Alternatively, enter this activation token on the activation page: +{{.activationToken}} +{{else}}Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON +body to activate your account: + +{"token": "{{.activationToken}}"} +{{end}} + +Please note that this is a one-time use token and it will expire in 3 days. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + + + +

Hi,

+

Thanks for signing up for a Gardomatic account. We're excited to have you on board!

+

For future reference, your user ID number is {{.userID}}.

+ {{if .activationURL}} +

Activate your Gardomatic account

+

Alternatively, enter this activation token on the activation page:

+
{{.activationToken}}
+ {{else}} +

Please send a request to the PUT /v1/users/activated endpoint with the + following JSON body to activate your account:

+

+    {"token": "{{.activationToken}}"}
+    
+ {{end}} +

Please note that this is a one-time use token and it will expire in 3 days.

+

Thanks,

+

The Gardomatic Team

+ + + +{{end}} diff --git a/internal/platform/environment/environment.go b/internal/platform/environment/environment.go new file mode 100644 index 0000000..23ab36d --- /dev/null +++ b/internal/platform/environment/environment.go @@ -0,0 +1,95 @@ +// Package environment provides strict parsing for runtime configuration. +package environment + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// String returns a trimmed environment value or fallback when it is empty. +func String(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +// Required returns a non-empty environment value. +func Required(name string) (string, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return "", fmt.Errorf("%s must be set", name) + } + return value, nil +} + +// Int parses an integer environment value. +func Int(name string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", name, err) + } + return parsed, nil +} + +// Float parses a floating-point environment value. +func Float(name string, fallback float64) (float64, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("%s must be a number: %w", name, err) + } + return parsed, nil +} + +// Bool parses a boolean environment value. +func Bool(name string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be a boolean: %w", name, err) + } + return parsed, nil +} + +// Duration parses a Go duration environment value. +func Duration(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s must be a duration: %w", name, err) + } + return parsed, nil +} + +// CSV returns non-empty, trimmed comma-separated values. +func CSV(name string) []string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + return result +} diff --git a/internal/platform/environment/environment_test.go b/internal/platform/environment/environment_test.go new file mode 100644 index 0000000..f7710c6 --- /dev/null +++ b/internal/platform/environment/environment_test.go @@ -0,0 +1,44 @@ +package environment + +import ( + "testing" + "time" +) + +func TestParsers(t *testing.T) { + t.Setenv("TEST_INT", "42") + t.Setenv("TEST_FLOAT", "2.5") + t.Setenv("TEST_BOOL", "true") + t.Setenv("TEST_DURATION", "15m") + t.Setenv("TEST_CSV", " one, two ,,three ") + + if value, err := Int("TEST_INT", 0); err != nil || value != 42 { + t.Fatalf("Int() = %d, %v", value, err) + } + if value, err := Float("TEST_FLOAT", 0); err != nil || value != 2.5 { + t.Fatalf("Float() = %f, %v", value, err) + } + if value, err := Bool("TEST_BOOL", false); err != nil || !value { + t.Fatalf("Bool() = %t, %v", value, err) + } + if value, err := Duration("TEST_DURATION", 0); err != nil || value != 15*time.Minute { + t.Fatalf("Duration() = %s, %v", value, err) + } + values := CSV("TEST_CSV") + if len(values) != 3 || values[1] != "two" { + t.Fatalf("CSV() = %#v", values) + } +} + +func TestInvalidValues(t *testing.T) { + t.Setenv("TEST_VALUE", "not-valid") + if _, err := Int("TEST_VALUE", 0); err == nil { + t.Fatal("Int() did not return an error") + } + if _, err := Bool("TEST_VALUE", false); err == nil { + t.Fatal("Bool() did not return an error") + } + if _, err := Duration("TEST_VALUE", 0); err == nil { + t.Fatal("Duration() did not return an error") + } +} diff --git a/internal/platform/validate/doc.go b/internal/platform/validate/doc.go new file mode 100644 index 0000000..3059083 --- /dev/null +++ b/internal/platform/validate/doc.go @@ -0,0 +1,3 @@ +// Package validate contains reusable validation helpers and an error collector +// for API and form inputs. +package validate diff --git a/internal/platform/validate/helper.go b/internal/platform/validate/helper.go new file mode 100644 index 0000000..36bbba0 --- /dev/null +++ b/internal/platform/validate/helper.go @@ -0,0 +1,44 @@ +package validate + +import ( + "regexp" + "slices" + "strings" + "unicode/utf8" +) + +// PermittedValue reports whether value appears in permittedValues. +func PermittedValue[T comparable](value T, permittedValues ...T) bool { + return slices.Contains(permittedValues, value) +} + +// Matches reports whether value satisfies rx. +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} + +// Unique reports whether values contains no duplicate elements. +func Unique[T comparable](values []T) bool { + uniqueValues := make(map[T]bool) + + for _, value := range values { + uniqueValues[value] = true + } + + return len(values) == len(uniqueValues) +} + +// NotBlank reports whether value contains non-whitespace characters. +func NotBlank(value string) bool { + return strings.TrimSpace(value) != "" +} + +// MaxChars reports whether value contains at most n Unicode code points. +func MaxChars(value string, n int) bool { + return utf8.RuneCountInString(value) <= n +} + +// MinChars reports whether value contains at least n Unicode code points. +func MinChars(value string, n int) bool { + return utf8.RuneCountInString(value) >= n +} diff --git a/internal/platform/validate/validator.go b/internal/platform/validate/validator.go new file mode 100644 index 0000000..4e7cefa --- /dev/null +++ b/internal/platform/validate/validator.go @@ -0,0 +1,67 @@ +package validate + +import ( + "regexp" +) + +var ( + // EmailRX matches the email-address format accepted by Gardomatic. + EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + // ColorRX matches the hexadecimal RGB colors accepted for user colors. + ColorRX = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +) + +// Validator collects validation failures keyed by input field. +type Validator struct { + Errors map[string]string + NonFieldErrors []string + FieldErrors map[string]string +} + +// New returns an empty Validator. +func New() *Validator { + return &Validator{Errors: make(map[string]string)} +} + +// Valid reports whether no validation errors have been recorded. +func (v *Validator) Valid() bool { + return len(v.Errors) == 0 && len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0 +} + +// AddError records message for key unless key already has an error. +func (v *Validator) AddError(key, message string) { + if _, exists := v.Errors[key]; !exists { + v.Errors[key] = message + } +} + +// Check records message for key when ok is false. +func (v *Validator) Check(ok bool, key, message string) { + if !ok { + v.AddError(key, message) + } +} + +// AddNonFieldError records an error that is not associated with one field. +func (v *Validator) AddNonFieldError(message string) { + v.NonFieldErrors = append(v.NonFieldErrors, message) +} + +// AddFieldError records a form-specific field error. +func (v *Validator) AddFieldError(key, message string) { + + if v.FieldErrors == nil { + v.FieldErrors = make(map[string]string) + } + + if _, exists := v.FieldErrors[key]; !exists { + v.FieldErrors[key] = message + } +} + +// CheckField records a form-specific field error when ok is false. +func (v *Validator) CheckField(ok bool, key, message string) { + if !ok { + v.AddFieldError(key, message) + } +} diff --git a/internal/storage/application_settings.go b/internal/storage/application_settings.go new file mode 100644 index 0000000..baa05c7 --- /dev/null +++ b/internal/storage/application_settings.go @@ -0,0 +1,36 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// ApplicationSettings contains application-wide automation settings. +type ApplicationSettings struct { + LifecycleStatusEnabled bool `json:"lifecycle_status_enabled"` + LifecycleRemovalMonth int `json:"lifecycle_removal_month"` + LifecycleRemovalDay int `json:"lifecycle_removal_day"` + Timezone string `json:"timezone"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ApplicationSettingsModelInterface persists global settings and applies lifecycle cleanup atomically. +type ApplicationSettingsModelInterface interface { + Get() (ApplicationSettings, error) + Update(settings ApplicationSettings) (ApplicationSettings, error) + RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) +} + +// ValidateApplicationSettings validates global automation configuration. +func ValidateApplicationSettings(v *validate.Validator, settings ApplicationSettings) { + v.Check(settings.LifecycleRemovalMonth >= 1 && settings.LifecycleRemovalMonth <= 12, "lifecycle_removal_month", "must be between 1 and 12") + v.Check(settings.LifecycleRemovalDay >= 1 && settings.LifecycleRemovalDay <= 31, "lifecycle_removal_day", "must be between 1 and 31") + v.Check(strings.TrimSpace(settings.Timezone) != "", "timezone", "must be provided") + if strings.TrimSpace(settings.Timezone) != "" { + _, err := time.LoadLocation(settings.Timezone) + v.Check(err == nil, "timezone", "must be a valid IANA timezone") + } +} diff --git a/internal/storage/care_instructions.go b/internal/storage/care_instructions.go new file mode 100644 index 0000000..a194a95 --- /dev/null +++ b/internal/storage/care_instructions.go @@ -0,0 +1,37 @@ +package storage + +import ( + "gardomatic.kleiax.de/internal/platform/validate" + "strings" + "time" +) + +// CareInstructionModelInterface persists care instructions within their +// species and garden boundary. +type CareInstructionModelInterface interface { + Insert(CareInstruction) (CareInstruction, error) + Get(gardenID, speciesID, id int) (CareInstruction, error) + GetAllForSpecies(gardenID, speciesID int) ([]CareInstruction, error) + Update(gardenID int, instruction CareInstruction) (CareInstruction, error) + Delete(gardenID, speciesID, id int) error +} + +// CareInstruction records garden-specific cultivation knowledge for a species. +type CareInstruction struct { + ID int `json:"id"` + SpeciesID int `json:"species_id"` + Text string `json:"text"` + Status string `json:"status"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ValidateCareInstruction applies the care-instruction input rules. +func ValidateCareInstruction(v *validate.Validator, item CareInstruction) { + v.Check(strings.TrimSpace(item.Text) != "", "text", "must be provided") + v.Check(len(item.Text) <= 10000, "text", "must not be more than 10000 bytes long") + v.Check(validate.PermittedValue(item.Status, "good", "bad", "untested", "testing", "planned"), "status", "is invalid") +} diff --git a/internal/storage/doc.go b/internal/storage/doc.go new file mode 100644 index 0000000..be772c9 --- /dev/null +++ b/internal/storage/doc.go @@ -0,0 +1,3 @@ +// Package storage defines Gardomatic persistence models and the interfaces +// implemented by database-specific adapters. +package storage diff --git a/internal/storage/errors.go b/internal/storage/errors.go new file mode 100644 index 0000000..65e0bae --- /dev/null +++ b/internal/storage/errors.go @@ -0,0 +1,10 @@ +package storage + +import ( + "errors" +) + +var ( + // ErrDuplicateEmail indicates that a user email is already registered. + ErrDuplicateEmail = errors.New("models: duplicate email") +) diff --git a/internal/storage/filters.go b/internal/storage/filters.go new file mode 100644 index 0000000..686c974 --- /dev/null +++ b/internal/storage/filters.go @@ -0,0 +1,76 @@ +//lint:file-ignore U1000 pagination helpers are retained for the upcoming filtered list endpoints + +package storage + +import ( + "slices" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// Filters contains bounded pagination and safe-list-based sorting parameters. +type Filters struct { + Page int + PageSize int + Sort string + SortSafelist []string +} + +func (f Filters) sortColumn() string { + if slices.Contains(f.SortSafelist, f.Sort) { + return strings.TrimPrefix(f.Sort, "-") + } + + panic("unsafe sort parameter: " + f.Sort) +} + +func (f Filters) sortDirection() string { + if strings.HasPrefix(f.Sort, "-") { + return "DESC" + } + + return "ASC" +} + +func (f Filters) limit() int { + return f.PageSize +} + +func (f Filters) offset() int { + return (f.Page - 1) * f.PageSize +} + +// ValidateFilters checks pagination bounds and the requested sort field. +func ValidateFilters(v *validate.Validator, f Filters) { + v.Check(f.Page > 0, "page", "must be greater than zero") + v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million") + v.Check(f.PageSize > 0, "page_size", "must be greater than zero") + v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100") + + v.Check(validate.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value") +} + +// Metadata describes a page within a filtered result set. +type Metadata struct { + CurrentPage int `json:"current_page,omitzero"` + PageSize int `json:"page_size,omitzero"` + FirstPage int `json:"first_page,omitzero"` + LastPage int `json:"last_page,omitzero"` + TotalRecords int `json:"total_records,omitzero"` +} + +func calculateMetadata(totalRecords, page, pageSize int) Metadata { + if totalRecords == 0 { + + return Metadata{} + } + + return Metadata{ + CurrentPage: page, + PageSize: pageSize, + FirstPage: 1, + LastPage: (totalRecords + pageSize - 1) / pageSize, + TotalRecords: totalRecords, + } +} diff --git a/internal/storage/garden_invites.go b/internal/storage/garden_invites.go new file mode 100644 index 0000000..c4e52f5 --- /dev/null +++ b/internal/storage/garden_invites.go @@ -0,0 +1,25 @@ +package storage + +import "time" + +// GardenInviteModelInterface persists pending garden invitations. +type GardenInviteModelInterface interface { + Upsert(invite GardenInvite) (GardenInvite, error) + GetByToken(tokenPlaintext string) (GardenInvite, error) + GetAllForGarden(gardenID int) ([]GardenInvite, error) + Delete(gardenID, inviteID int) error + Accept(tokenPlaintext string, user User) (GardenMember, error) +} + +// GardenInvite grants a user identified by email a garden role. +type GardenInvite struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + Email string `json:"email"` + Role GardenRole `json:"role"` + Token string `json:"token,omitempty"` + InvitedBy int `json:"invited_by"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/storage/garden_members.go b/internal/storage/garden_members.go new file mode 100644 index 0000000..9655d92 --- /dev/null +++ b/internal/storage/garden_members.go @@ -0,0 +1,202 @@ +package storage + +import ( + "slices" + "time" +) + +// GardenRole identifies a member's authorization level within one garden. +type GardenRole string + +// GardenPermission identifies one garden-scoped capability. +type GardenPermission string + +// Garden permission constants identify capabilities resolved by the API's +// garden authorization middleware. +const ( + GardenPermissionGardenRead GardenPermission = "garden:read" + GardenPermissionGardenUpdate GardenPermission = "garden:update" + GardenPermissionGardenDelete GardenPermission = "garden:delete" + // GardenPermissionContentWrite is retained for garden content which has not + // yet been split into object-specific permissions (journal and assignments). + GardenPermissionContentWrite GardenPermission = "content:write" + GardenPermissionMembersWrite GardenPermission = "members:write" + GardenPermissionSpeciesWrite GardenPermission = "species:write" + + GardenPermissionPlantCreate GardenPermission = "plants:create" + GardenPermissionPlantReadOwn GardenPermission = "plants:read:own" + GardenPermissionPlantReadOther GardenPermission = "plants:read:other" + GardenPermissionPlantUpdateOwn GardenPermission = "plants:update:own" + GardenPermissionPlantUpdateOther GardenPermission = "plants:update:other" + GardenPermissionPlantDeleteOwn GardenPermission = "plants:delete:own" + GardenPermissionPlantDeleteOther GardenPermission = "plants:delete:other" + + GardenPermissionLocationCreate GardenPermission = "locations:create" + GardenPermissionLocationReadOwn GardenPermission = "locations:read:own" + GardenPermissionLocationReadOther GardenPermission = "locations:read:other" + GardenPermissionLocationUpdateOwn GardenPermission = "locations:update:own" + GardenPermissionLocationUpdateOther GardenPermission = "locations:update:other" + GardenPermissionLocationDeleteOwn GardenPermission = "locations:delete:own" + GardenPermissionLocationDeleteOther GardenPermission = "locations:delete:other" + + GardenPermissionTaskCreate GardenPermission = "tasks:create" + GardenPermissionTaskReadOwn GardenPermission = "tasks:read:own" + GardenPermissionTaskReadOther GardenPermission = "tasks:read:other" + GardenPermissionTaskUpdateOwn GardenPermission = "tasks:update:own" + GardenPermissionTaskUpdateOther GardenPermission = "tasks:update:other" + GardenPermissionTaskDeleteOwn GardenPermission = "tasks:delete:own" + GardenPermissionTaskDeleteOther GardenPermission = "tasks:delete:other" + GardenPermissionTaskCompleteOwn GardenPermission = "tasks:complete:own" + GardenPermissionTaskCompleteOther GardenPermission = "tasks:complete:other" +) + +const ( + // GardenRoleOwner grants full control over a garden. + GardenRoleOwner GardenRole = "owner" + // GardenRoleAdmin grants administrative access without ownership. + GardenRoleAdmin GardenRole = "admin" + // GardenRoleMember grants ordinary editing access. + GardenRoleMember GardenRole = "member" + // GardenRoleViewer grants read-only access. + GardenRoleViewer GardenRole = "viewer" + // GardenRoleWorker may read and complete tasks, but cannot otherwise edit content. + GardenRoleWorker GardenRole = "worker" +) + +// Can reports whether a role grants permission. +func (role GardenRole) Can(permission GardenPermission) bool { + switch role { + case GardenRoleOwner: + return validGardenPermission(permission) + case GardenRoleAdmin: + return validGardenPermission(permission) && permission != GardenPermissionGardenDelete + case GardenRoleMember: + switch permission { + case GardenPermissionGardenRead, + GardenPermissionContentWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantDeleteOwn, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationDeleteOwn, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskDeleteOwn, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther: + return true + } + return false + case GardenRoleViewer: + return permission == GardenPermissionGardenRead || + permission == GardenPermissionPlantReadOwn || permission == GardenPermissionPlantReadOther || + permission == GardenPermissionLocationReadOwn || permission == GardenPermissionLocationReadOther || + permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther + case GardenRoleWorker: + return permission == GardenPermissionGardenRead || + permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther || + permission == GardenPermissionTaskCompleteOwn || permission == GardenPermissionTaskCompleteOther + default: + return false + } +} + +func validGardenPermission(permission GardenPermission) bool { + switch permission { + case GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete, + GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther: + return true + } + return false +} + +// ValidGardenPermission reports whether permission is a known garden-scoped +// permission or wildcard. +func ValidGardenPermission(permission string) bool { + return permission == "*" || permission == "garden:*" || validGardenPermission(GardenPermission(permission)) +} + +// ResolveGardenPermissions applies garden-specific grants and revocations to a +// role's base permissions. The result is deduplicated and stable-sorted. +func ResolveGardenPermissions(base []string, overrides []GardenRolePermissionOverride) []GardenPermission { + permissions := make(map[GardenPermission]bool) + apply := func(permission string, granted bool) { + if permission == "*" || permission == "garden:*" { + for _, concrete := range AllGardenPermissions() { + if permission == "garden:*" && concrete == GardenPermissionGardenDelete { + continue + } + permissions[concrete] = granted + } + return + } + permissions[GardenPermission(permission)] = granted + } + for _, permission := range base { + apply(permission, true) + } + for _, override := range overrides { + apply(override.Permission, override.Granted) + } + result := make([]GardenPermission, 0, len(permissions)) + for permission, granted := range permissions { + if granted { + result = append(result, permission) + } + } + slices.Sort(result) + return result +} + +// Permissions returns the concrete capabilities bundled into a garden role. +func (role GardenRole) Permissions() []GardenPermission { + permissions := []GardenPermission{} + for _, permission := range AllGardenPermissions() { + if role.Can(permission) { + permissions = append(permissions, permission) + } + } + return permissions +} + +// AllGardenPermissions returns every concrete garden-scoped permission. +func AllGardenPermissions() []GardenPermission { + return []GardenPermission{ + GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete, + GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther, + } +} + +// GardenMemberModelInterface persists garden membership and role assignments. +type GardenMemberModelInterface interface { + Insert(member GardenMember) (GardenMember, error) + Get(gardenID, userID int) (GardenMember, error) + GetAllForGarden(gardenID int) ([]GardenMember, error) + Update(member GardenMember) (GardenMember, error) + Delete(gardenID, userID int) error + TransferOwnership(gardenID, fromUserID, toUserID int) error +} + +// GardenMember links a user to a garden with a role. +type GardenMember struct { + GardenID int `json:"garden_id"` + UserID int `json:"user_id"` + Role GardenRole `json:"role"` + JoinedAt time.Time `json:"joined_at"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Permissions []GardenPermission `json:"permissions,omitempty"` +} + +// Can uses persisted permissions when present, retaining built-in roles for +// compatibility with in-memory tests and pre-migration callers. +func (member GardenMember) Can(permission GardenPermission) bool { + if member.Permissions == nil { + return member.Role.Can(permission) + } + for _, granted := range member.Permissions { + if granted == permission || granted == "*" || granted == "garden:*" && permission != GardenPermissionGardenDelete { + return true + } + } + return false +} diff --git a/internal/storage/gardens.go b/internal/storage/gardens.go new file mode 100644 index 0000000..e6d5f0f --- /dev/null +++ b/internal/storage/gardens.go @@ -0,0 +1,38 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// GardenModelInterface persists gardens and their initial owner membership. +type GardenModelInterface interface { + Insert(garden Garden, ownerID int) (Garden, error) + Get(id int) (Garden, error) + GetAllForUser(userID int) ([]Garden, error) + Update(garden Garden) (Garden, error) + Delete(id int) error +} + +// Garden is the tenant boundary for garden-specific resources. +type Garden struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Role GardenRole `json:"role,omitempty"` + Permissions []GardenPermission `json:"permissions"` +} + +// ValidateGarden applies persistence-independent garden validation rules. +func ValidateGarden(v *validate.Validator, garden Garden) { + v.Check(strings.TrimSpace(garden.Name) != "", "name", "must be provided") + v.Check(len(garden.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(garden.Description) <= 5000, "description", "must not be more than 5000 bytes long") +} diff --git a/internal/storage/images.go b/internal/storage/images.go new file mode 100644 index 0000000..9fd6a94 --- /dev/null +++ b/internal/storage/images.go @@ -0,0 +1,44 @@ +package storage + +import "time" + +// MaxImageSize is the largest image payload accepted by storage, in bytes. +const MaxImageSize = 10 << 20 + +// ImageModelInterface persists garden-owned image data and assignment history. +type ImageModelInterface interface { + Insert(image Image) (Image, error) + Get(gardenID, id int) (Image, error) + GetAllForGarden(gardenID int, filter ImageFilter) ([]Image, error) + CountForGarden(gardenID int) (int, error) + RecordAssignment(change ImageAssignment) error +} + +// Image is a binary image stored in a garden's reusable media library. +type Image struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + FileName string `json:"file_name"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + Source string `json:"source"` + CreatedBy int `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + Data []byte `json:"-"` +} + +// ImageFilter restricts image-library queries by filename or media type. +type ImageFilter struct { + Source string + Query string +} + +// ImageAssignment records an image change on a garden entity. +type ImageAssignment struct { + GardenID int + EntityType string + EntityID int + PreviousImageID *int + ImageID *int + ChangedBy int +} diff --git a/internal/storage/journal.go b/internal/storage/journal.go new file mode 100644 index 0000000..d32e332 --- /dev/null +++ b/internal/storage/journal.go @@ -0,0 +1,77 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// MaxJournalAttachmentSize is the largest journal attachment accepted, in bytes. +const MaxJournalAttachmentSize = 25 << 20 + +// JournalModelInterface persists journal entries and their attachments. +type JournalModelInterface interface { + Insert(entry JournalEntry) (JournalEntry, error) + Get(gardenID, id int) (JournalEntry, error) + GetAllForGarden(gardenID int, entryType JournalEntryType) ([]JournalEntry, error) + Update(gardenID int, entry JournalEntry) (JournalEntry, error) + Delete(gardenID, id int) error + InsertAttachment(gardenID, entryID int, attachment JournalAttachment) (JournalAttachment, error) + GetAttachment(gardenID, entryID, attachmentID int) (JournalAttachment, error) + DeleteAttachment(gardenID, entryID, attachmentID int) error +} + +// JournalEntryType distinguishes chronological journal entries from pinboard +// notes while sharing the same persistence model. +type JournalEntryType string + +// Supported journal entry types. +const ( + JournalEntryTypeJournal JournalEntryType = "journal" + JournalEntryTypePinboard JournalEntryType = "pinboard" +) + +// JournalEntry is a garden note with optional tags and attachments. +type JournalEntry struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + AuthorID int `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorColor string `json:"author_color"` + EntryType JournalEntryType `json:"entry_type"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + Attachments []JournalAttachment `json:"attachments,omitempty"` +} + +// JournalAttachment contains either inline binary data or a reference to a +// reusable image-library item. +type JournalAttachment struct { + ID int `json:"id"` + EntryID int `json:"entry_id"` + FileName string `json:"file_name"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + Data []byte `json:"-"` + ImageID *int `json:"image_id,omitempty"` +} + +// ValidateJournalEntry applies the journal-entry input rules. +func ValidateJournalEntry(v *validate.Validator, entry JournalEntry) { + entryType := entry.EntryType + if entryType == "" { + entryType = JournalEntryTypeJournal + } + v.Check(validate.PermittedValue(entryType, JournalEntryTypeJournal, JournalEntryTypePinboard), "entry_type", "must be journal or pinboard") + if entryType == JournalEntryTypeJournal { + v.Check(strings.TrimSpace(entry.Title) != "", "title", "must be provided") + } + v.Check(len(entry.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(entry.Body) <= 100_000, "body", "must not be more than 100000 bytes long") +} diff --git a/internal/storage/journal_test.go b/internal/storage/journal_test.go new file mode 100644 index 0000000..d801383 --- /dev/null +++ b/internal/storage/journal_test.go @@ -0,0 +1,22 @@ +package storage + +import ( + "strings" + "testing" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +func TestValidateJournalEntry(t *testing.T) { + valid := validate.New() + ValidateJournalEntry(valid, JournalEntry{Title: "Erste Ernte", Body: "**Drei** Tomaten geerntet."}) + if !valid.Valid() { + t.Fatalf("valid entry rejected: %#v", valid.Errors) + } + + invalid := validate.New() + ValidateJournalEntry(invalid, JournalEntry{Title: " ", Body: strings.Repeat("x", 100_001)}) + if invalid.Errors["title"] == "" || invalid.Errors["body"] == "" { + t.Fatalf("expected title and body errors, got %#v", invalid.Errors) + } +} diff --git a/internal/storage/locations.go b/internal/storage/locations.go new file mode 100644 index 0000000..41d3697 --- /dev/null +++ b/internal/storage/locations.go @@ -0,0 +1,59 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// LocationModelInterface persists hierarchical locations within a garden. +type LocationModelInterface interface { + Insert(location Location) (Location, error) + Get(gardenID, id int) (Location, error) + GetAllForGarden(gardenID int) ([]Location, error) + Update(gardenID int, location Location) (Location, error) + Delete(gardenID, id int) error +} + +// ValidateLocation applies persistence-independent location validation rules. +func ValidateLocation(v *validate.Validator, location Location) { + v.Check(strings.TrimSpace(location.Name) != "", "name", "must be provided") + v.Check(len(location.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(location.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(len(location.Kind) <= 100, "kind", "must not be more than 100 bytes long") + v.Check(len(location.Attributes) == 0 || json.Valid(location.Attributes), "attributes", "must be valid JSON") + if location.ParentID != nil { + v.Check(*location.ParentID > 0, "parent_id", "must be a positive integer") + v.Check(*location.ParentID != location.ID, "parent_id", "must not refer to the location itself") + } + if location.AreaSQM != nil { + v.Check(*location.AreaSQM >= 0, "area_sqm", "must be zero or greater") + } + validateOptionalEnum(v, "sun_exposure", location.SunExposure, "sunny", "partial_shade", "shade") + validateOptionalEnum(v, "soil_condition", location.SoilCondition, "dry", "moist", "boggy") + validateOptionalEnum(v, "soil_reaction", location.SoilReaction, "alkaline", "acidic", "neutral") +} + +// Location describes a physical place where plants can be assigned. +type Location struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + ParentID *int `json:"parent_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + Kind string `json:"kind"` + AreaSQM *float64 `json:"area_sqm,omitempty"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` +} diff --git a/internal/storage/models.go b/internal/storage/models.go new file mode 100644 index 0000000..a79cd1d --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,38 @@ +package storage + +import ( + "errors" +) + +var ( + // ErrRecordNotFound indicates that a scoped query matched no record. + ErrRecordNotFound = errors.New("record not found") + // ErrEditConflict indicates a failed optimistic-lock update. + ErrEditConflict = errors.New("edit conflict") + // ErrConflict indicates a uniqueness or equivalent persistence conflict. + ErrConflict = errors.New("conflict") +) + +// Models groups the persistence interfaces required by the API application. +type Models struct { + ApplicationSettings ApplicationSettingsModelInterface + Roles RoleModelInterface + Gardens GardenModelInterface + GardenMembers GardenMemberModelInterface + GardenInvites GardenInviteModelInterface + Locations LocationModelInterface + Plants PlantModelInterface + PlantLocations PlantLocationModelInterface + Species SpeciesModelInterface + CareInstructions CareInstructionModelInterface + SpeciesCategories SpeciesCategoryModelInterface + TaskPriorities TaskPriorityModelInterface + SpeciesTaskTemplates SpeciesTaskTemplateModelInterface + Tasks TaskModelInterface + Journal JournalModelInterface + Images ImageModelInterface + Tags TagModelInterface + TaskTemplateOptOuts TaskTemplateOptOutModelInterface + Tokens TokenModelInterface + Users UserModelInterface +} diff --git a/internal/storage/plant_locations.go b/internal/storage/plant_locations.go new file mode 100644 index 0000000..3d5bcf2 --- /dev/null +++ b/internal/storage/plant_locations.go @@ -0,0 +1,42 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// PlantLocationModelInterface persists assignments between plants and locations. +type PlantLocationModelInterface interface { + Insert(gardenID int, plantLocation PlantLocation) (PlantLocation, error) + Get(gardenID, id int) (PlantLocation, error) + GetAllForPlant(gardenID, plantID int) ([]PlantLocation, error) + GetAllForLocation(gardenID, locationID int) ([]PlantLocation, error) + Update(gardenID int, plantLocation PlantLocation) (PlantLocation, error) + Delete(gardenID, id int) error +} + +// PlantLocation records where and when a quantity of plants was planted. +type PlantLocation struct { + ID int `json:"id"` + PlantID int `json:"plant_id"` + LocationID int `json:"location_id"` + Quantity int `json:"quantity"` + PlantedAt *time.Time `json:"planted_at,omitempty"` + RemovedAt *time.Time `json:"removed_at,omitempty"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + Version int `json:"version"` +} + +// ValidatePlantLocation applies persistence-independent assignment validation rules. +func ValidatePlantLocation(v *validate.Validator, assignment PlantLocation) { + v.Check(assignment.PlantID > 0, "plant_id", "must be a positive integer") + v.Check(assignment.LocationID > 0, "location_id", "must be a positive integer") + v.Check(assignment.Quantity > 0, "quantity", "must be greater than zero") + v.Check(len(strings.TrimSpace(assignment.Notes)) <= 10_000, "notes", "must not be more than 10000 bytes long") + if assignment.PlantedAt != nil && assignment.RemovedAt != nil { + v.Check(!assignment.RemovedAt.Before(*assignment.PlantedAt), "removed_at", "must not be before planted_at") + } +} diff --git a/internal/storage/plants.go b/internal/storage/plants.go new file mode 100644 index 0000000..4ec12f7 --- /dev/null +++ b/internal/storage/plants.go @@ -0,0 +1,54 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// PlantModelInterface persists plant instances within a garden boundary. +type PlantModelInterface interface { + Insert(plant Plant) (Plant, error) + Get(gardenID, id int) (Plant, error) + GetAllForGarden(gardenID int) ([]Plant, error) + Update(gardenID int, plant Plant) (Plant, error) + Delete(gardenID, id int) error +} + +// Plant represents a named plant instance managed by a garden. +type Plant struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + SpeciesID *int `json:"species_id,omitempty"` + Name string `json:"name"` + Notes string `json:"notes"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + AcquiredAt *time.Time `json:"acquired_at,omitempty"` + Status string `json:"status"` + RemovedAt *time.Time `json:"removed_at,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` + PlantedBy int `json:"planted_by"` + PlantedByName string `json:"planted_by_name,omitempty"` +} + +// ValidatePlant applies persistence-independent plant validation rules. +func ValidatePlant(v *validate.Validator, plant Plant) { + v.Check(strings.TrimSpace(plant.Name) != "", "name", "must be provided") + v.Check(len(plant.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(plant.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long") + v.Check(validate.PermittedValue(plant.Status, "alive", "dead", "removed", "infested", "harvested"), "status", "must be alive, dead, removed, infested or harvested") + v.Check(len(plant.Attributes) == 0 || json.Valid(plant.Attributes), "attributes", "must be valid JSON") + ValidateTags(v, plant.Tags) + if plant.SpeciesID != nil { + v.Check(*plant.SpeciesID > 0, "species_id", "must be a positive integer") + } +} diff --git a/internal/storage/postgres/application_settings.go b/internal/storage/postgres/application_settings.go new file mode 100644 index 0000000..b80d546 --- /dev/null +++ b/internal/storage/postgres/application_settings.go @@ -0,0 +1,118 @@ +package postgres + +import ( + "database/sql" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// ApplicationSettingsModel stores global automation configuration. +// ApplicationSettingsModel persists the singleton application configuration +// and performs lifecycle cleanup transactionally. +type ApplicationSettingsModel struct{ DB *sql.DB } + +// Get returns the singleton application settings. +func (m ApplicationSettingsModel) Get() (storage.ApplicationSettings, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var settings storage.ApplicationSettings + err := m.DB.QueryRowContext(ctx, ` + SELECT lifecycle_status_enabled, lifecycle_removal_month, lifecycle_removal_day, + timezone, updated_at, version + FROM application_settings WHERE singleton = true`).Scan( + &settings.LifecycleStatusEnabled, &settings.LifecycleRemovalMonth, + &settings.LifecycleRemovalDay, &settings.Timezone, &settings.UpdatedAt, &settings.Version, + ) + if err != nil { + return storage.ApplicationSettings{}, recordError(err) + } + return settings, nil +} + +// Update replaces the singleton application settings using optimistic locking. +func (m ApplicationSettingsModel) Update(settings storage.ApplicationSettings) (storage.ApplicationSettings, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE application_settings + SET lifecycle_status_enabled = $1, lifecycle_removal_month = $2, + lifecycle_removal_day = $3, timezone = $4, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE singleton = true AND version = $5 + RETURNING updated_at, version`, + settings.LifecycleStatusEnabled, settings.LifecycleRemovalMonth, + settings.LifecycleRemovalDay, settings.Timezone, settings.Version, + ).Scan(&settings.UpdatedAt, &settings.Version) + if err == sql.ErrNoRows { + return storage.ApplicationSettings{}, storage.ErrEditConflict + } + if err != nil { + return storage.ApplicationSettings{}, recordError(err) + } + return settings, nil +} + +// RemoveExpiredPlants closes expired annual and biennial plants together with +// their active placements and records each status transition atomically. +func (m ApplicationSettingsModel) RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, ` + UPDATE plants p + SET status = 'removed', removed_at = $1, updated_at = CURRENT_TIMESTAMP, version = p.version + 1 + FROM species s + JOIN species_categories c ON c.id = s.category_id + WHERE p.species_id = s.id + AND p.status = 'alive' + AND p.acquired_at IS NOT NULL + AND c.lifecycle IN ('annual', 'biennial') + AND EXTRACT(YEAR FROM $1::date)::int >= + EXTRACT(YEAR FROM p.acquired_at)::int + + CASE WHEN (EXTRACT(MONTH FROM p.acquired_at)::int, EXTRACT(DAY FROM p.acquired_at)::int) >= ($2, $3) THEN 1 ELSE 0 END + + CASE c.lifecycle WHEN 'biennial' THEN 1 ELSE 0 END + RETURNING p.id`, asOf, removalMonth, removalDay) + if err != nil { + return 0, err + } + ids := []int64{} + for rows.Next() { + var id int64 + if err = rows.Scan(&id); err != nil { + rows.Close() + return 0, err + } + ids = append(ids, id) + } + if err = rows.Close(); err != nil { + return 0, err + } + if len(ids) == 0 { + if err = tx.Commit(); err != nil { + return 0, err + } + return 0, nil + } + if _, err = tx.ExecContext(ctx, ` + INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at) + SELECT unnest($2::bigint[]), 'alive', 'removed', 'lifecycle_reached', $1`, asOf, pq.Array(ids)); err != nil { + return 0, err + } + if _, err = tx.ExecContext(ctx, `UPDATE plant_locations SET removed_at = $1, version = version + 1 WHERE plant_id = ANY($2) AND removed_at IS NULL`, asOf, pq.Array(ids)); err != nil { + return 0, err + } + if _, err = tx.ExecContext(ctx, `UPDATE tasks SET active = false, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE plant_id = ANY($1) AND template_id IS NOT NULL AND completed_at IS NULL AND active = true`, pq.Array(ids)); err != nil { + return 0, err + } + if err = tx.Commit(); err != nil { + return 0, err + } + return len(ids), nil +} diff --git a/internal/storage/postgres/application_settings_integration_test.go b/internal/storage/postgres/application_settings_integration_test.go new file mode 100644 index 0000000..07d7364 --- /dev/null +++ b/internal/storage/postgres/application_settings_integration_test.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" + "time" + + _ "github.com/lib/pq" +) + +func TestRemoveExpiredPlantsClosesRelatedRecordsAndWritesHistory(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := time.Now().Format("150405.000000000") + var userID, gardenID, categoryID, biennialCategoryID, speciesID, biennialSpeciesID, plantID, biennialPlantID, locationID, templateID, taskID, manualTaskID int + if err = db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Lifecycle integration', $1, 'hash', true) RETURNING id`, "lifecycle-"+stamp+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Lifecycle integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'annual') RETURNING id`, "Annual "+stamp).Scan(&categoryID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'biennial') RETURNING id`, "Biennial "+stamp).Scan(&biennialCategoryID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID) + _, _ = db.Exec(`DELETE FROM species_categories WHERE id IN ($1, $2)`, categoryID, biennialCategoryID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Sommerblume', $2) RETURNING id`, gardenID, categoryID).Scan(&speciesID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Zweijährige Blume', $2) RETURNING id`, gardenID, biennialCategoryID).Scan(&biennialSpeciesID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, day_from) VALUES ($1, 'Pflegen', 'month_of_year', 3, 10) RETURNING id`, speciesID).Scan(&templateID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Sommerblume', '2026-03-10') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Zweijährige Blume', '2026-03-10') RETURNING id`, gardenID, biennialSpeciesID).Scan(&biennialPlantID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO locations (garden_id, name) VALUES ($1, 'Beet') RETURNING id`, gardenID).Scan(&locationID); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`INSERT INTO plant_locations (plant_id, location_id, planted_at) VALUES ($1, $2, '2026-03-10')`, plantID, locationID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, template_id, title, created_by) VALUES ($1, $2, $3, 'Pflegen', $4) RETURNING id`, gardenID, plantID, templateID, userID).Scan(&taskID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, title, created_by) VALUES ($1, $2, 'Dokumentieren', $3) RETURNING id`, gardenID, plantID, userID).Scan(&manualTaskID); err != nil { + t.Fatal(err) + } + + count, err := (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1) + if err != nil || count != 1 { + t.Fatalf("remove expired plants: count=%d err=%v", count, err) + } + var status string + var removedAt time.Time + if err = db.QueryRow(`SELECT status, removed_at FROM plants WHERE id = $1`, plantID).Scan(&status, &removedAt); err != nil || status != "removed" || removedAt.Format("2006-01-02") != "2026-12-01" { + t.Fatalf("plant status=%q removed_at=%v err=%v", status, removedAt, err) + } + var assignmentClosed, taskActive bool + if err = db.QueryRow(`SELECT removed_at IS NOT NULL FROM plant_locations WHERE plant_id = $1`, plantID).Scan(&assignmentClosed); err != nil || !assignmentClosed { + t.Fatalf("assignment closed=%v err=%v", assignmentClosed, err) + } + if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, taskID).Scan(&taskActive); err != nil || taskActive { + t.Fatalf("task active=%v err=%v", taskActive, err) + } + if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, manualTaskID).Scan(&taskActive); err != nil || !taskActive { + t.Fatalf("manual task active=%v err=%v", taskActive, err) + } + var historyCount int + if err = db.QueryRow(`SELECT count(*) FROM plant_status_history WHERE plant_id = $1 AND reason = 'lifecycle_reached'`, plantID).Scan(&historyCount); err != nil || historyCount != 1 { + t.Fatalf("history count=%d err=%v", historyCount, err) + } + if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "alive" { + t.Fatalf("biennial plant was removed too early: status=%q err=%v", status, err) + } + count, err = (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2027, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1) + if err != nil || count != 1 { + t.Fatalf("remove biennial plant: count=%d err=%v", count, err) + } + if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "removed" { + t.Fatalf("biennial plant status=%q err=%v", status, err) + } +} diff --git a/internal/storage/postgres/care_instructions.go b/internal/storage/postgres/care_instructions.go new file mode 100644 index 0000000..d8d2c1e --- /dev/null +++ b/internal/storage/postgres/care_instructions.go @@ -0,0 +1,82 @@ +package postgres + +import ( + "database/sql" + "gardomatic.kleiax.de/internal/storage" +) + +// CareInstructionModel implements storage.CareInstructionModelInterface for +// PostgreSQL and scopes every lookup through the owning garden. +type CareInstructionModel struct{ DB *sql.DB } + +const careInstructionColumns = `ci.id, ci.species_id, ci.text, ci.status, ci.created_by, ci.updated_by, ci.created_at, ci.updated_at, ci.version` + +func scanCareInstruction(s scanner) (storage.CareInstruction, error) { + var item storage.CareInstruction + err := s.Scan(&item.ID, &item.SpeciesID, &item.Text, &item.Status, &item.CreatedBy, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt, &item.Version) + return item, err +} + +// Insert creates a care instruction. +func (m CareInstructionModel) Insert(item storage.CareInstruction) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `INSERT INTO care_instructions(species_id,text,status,created_by,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING id,created_at,updated_at,version`, item.SpeciesID, item.Text, item.Status, item.CreatedBy, item.UpdatedBy).Scan(&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.Version) + return item, recordError(err) +} + +// Get returns a care instruction within its garden and species. +func (m CareInstructionModel) Get(gardenID, speciesID, id int) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + return scanCareInstruction(m.DB.QueryRowContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.id=$1 AND ci.species_id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, id, speciesID, gardenID)) +} + +// GetAllForSpecies lists care instructions for a species visible in a garden. +func (m CareInstructionModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.species_id=$1 AND (s.garden_id IS NULL OR s.garden_id=$2) ORDER BY ci.id`, speciesID, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []storage.CareInstruction{} + for rows.Next() { + item, e := scanCareInstruction(rows) + if e != nil { + return nil, e + } + items = append(items, item) + } + return items, rows.Err() +} + +// Update changes a care instruction using optimistic locking. +func (m CareInstructionModel) Update(gardenID int, item storage.CareInstruction) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE care_instructions ci SET text=$1,status=$2,updated_by=$3,updated_at=now(),version=ci.version+1 FROM species s WHERE ci.species_id=s.id AND ci.id=$4 AND ci.version=$5 AND (s.garden_id IS NULL OR s.garden_id=$6) RETURNING ci.updated_at,ci.version`, item.Text, item.Status, item.UpdatedBy, item.ID, item.Version, gardenID).Scan(&item.UpdatedAt, &item.Version) + if err == sql.ErrNoRows { + return storage.CareInstruction{}, storage.ErrEditConflict + } + return item, recordError(err) +} + +// Delete removes a care instruction within its garden and species. +func (m CareInstructionModel) Delete(gardenID, speciesID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM care_instructions ci USING species s WHERE ci.species_id=s.id AND ci.species_id=$1 AND ci.id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, speciesID, id, gardenID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/doc.go b/internal/storage/postgres/doc.go new file mode 100644 index 0000000..a979e8b --- /dev/null +++ b/internal/storage/postgres/doc.go @@ -0,0 +1,2 @@ +// Package postgres implements the Gardomatic storage interfaces for PostgreSQL. +package postgres diff --git a/internal/storage/postgres/garden_invites.go b/internal/storage/postgres/garden_invites.go new file mode 100644 index 0000000..b6160c7 --- /dev/null +++ b/internal/storage/postgres/garden_invites.go @@ -0,0 +1,126 @@ +package postgres + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "strings" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenInviteModel stores garden invitations in PostgreSQL. +// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL. +type GardenInviteModel struct{ DB *sql.DB } + +// Upsert creates or replaces a pending invitation for a garden and email. +func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) { + invite.Token = rand.Text() + hash := sha256.Sum256([]byte(invite.Token)) + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at) + SELECT $1,$2,$3,$4,$5,$6 + WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET + role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by, + expires_at=EXCLUDED.expires_at, created_at=now() + RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt) + return invite, err +} + +func scanInvite(row scanner) (storage.GardenInvite, error) { + var invite storage.GardenInvite + err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt) + return invite, err +} + +// GetByToken returns an unexpired pending invitation by its plaintext token. +func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := contextWithTimeout() + defer cancel() + invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:])) + if err != nil { + return storage.GardenInvite{}, recordError(err) + } + return invite, nil +} + +// GetAllForGarden lists pending invitations for a garden. +func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.GardenInvite{} + for rows.Next() { + invite, err := scanInvite(rows) + if err != nil { + return nil, err + } + result = append(result, invite) + } + return result, rows.Err() +} + +// Delete revokes an invitation within its garden. +func (m GardenInviteModel) Delete(gardenID, inviteID int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +// Accept consumes an invitation and creates or updates membership in one +// transaction so a token cannot be accepted twice concurrently. +func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.GardenMember{}, err + } + defer tx.Rollback() + invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:])) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) { + return storage.GardenMember{}, storage.ErrConflict + } + member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role} + err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt) + if err != nil { + return storage.GardenMember{}, err + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil { + return storage.GardenMember{}, err + } + if err = tx.Commit(); err != nil { + return storage.GardenMember{}, err + } + if err = GardenMemberModel(m).loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} diff --git a/internal/storage/postgres/garden_members.go b/internal/storage/postgres/garden_members.go new file mode 100644 index 0000000..9e88c61 --- /dev/null +++ b/internal/storage/postgres/garden_members.go @@ -0,0 +1,222 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenMemberModel stores garden memberships in PostgreSQL. +// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL. +type GardenMemberModel struct{ DB *sql.DB } + +func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1 + UNION ALL + SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1 + ORDER BY granted DESC`, member.Role, member.GardenID) + if err != nil { + return err + } + defer rows.Close() + base := []string{} + overrides := []storage.GardenRolePermissionOverride{} + for rows.Next() { + var permission storage.GardenPermission + var granted bool + if err := rows.Scan(&permission, &granted); err != nil { + return err + } + if granted { + base = append(base, string(permission)) + } else { + overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false}) + } + } + if err := rows.Err(); err != nil { + return err + } + member.Permissions = storage.ResolveGardenPermissions(base, overrides) + return nil +} + +func scanGardenMember(s scanner) (storage.GardenMember, error) { + var member storage.GardenMember + err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt) + return member, err +} + +// Insert adds a user to a garden. +func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO garden_members (garden_id, user_id, role) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + RETURNING joined_at`, member.GardenID, member.UserID, member.Role, + ).Scan(&member.JoinedAt) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// Get returns a user's membership and effective permissions in a garden. +func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + member, err := scanGardenMember(m.DB.QueryRowContext(ctx, ` + SELECT garden_id, user_id, role, joined_at + FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID)) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// GetAllForGarden lists garden members with effective permissions. +func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email + FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1 + ORDER BY joined_at, user_id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + members := []storage.GardenMember{} + for rows.Next() { + var member storage.GardenMember + err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email) + if err != nil { + return nil, err + } + members = append(members, member) + if err = m.loadPermissions(&members[len(members)-1]); err != nil { + return nil, err + } + } + return members, rows.Err() +} + +// Update changes a member's garden role. +func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.GardenMember{}, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil { + return storage.GardenMember{}, err + } + var current storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil { + return storage.GardenMember{}, recordError(err) + } + if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner { + var owners int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil { + return storage.GardenMember{}, err + } + if owners < 2 { + return storage.GardenMember{}, storage.ErrConflict + } + } + result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID) + if err != nil { + return storage.GardenMember{}, err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return storage.GardenMember{}, countErr + } else if count == 0 { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + if err = tx.Commit(); err != nil { + return storage.GardenMember{}, err + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// Delete removes a non-owner membership from a garden. +func (m GardenMemberModel) Delete(gardenID, userID int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + var role storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil { + return recordError(err) + } + if role == storage.GardenRoleOwner { + var owners int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil { + return err + } + if owners < 2 { + return storage.ErrConflict + } + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil { + return err + } + return tx.Commit() +} + +// TransferOwnership swaps owner and member roles atomically while preserving +// the invariant that a garden always has one owner. +func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { + if fromUserID == toUserID { + return storage.ErrConflict + } + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + var fromRole, toRole storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil { + return recordError(err) + } + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil { + return recordError(err) + } + if fromRole != storage.GardenRoleOwner { + return storage.ErrConflict + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/storage/postgres/gardens.go b/internal/storage/postgres/gardens.go new file mode 100644 index 0000000..5f82445 --- /dev/null +++ b/internal/storage/postgres/gardens.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenModel stores gardens in PostgreSQL. +// GardenModel implements storage.GardenModelInterface for PostgreSQL. +type GardenModel struct{ DB *sql.DB } + +// Insert creates a garden and its owner membership atomically. +func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.Garden{}, err + } + defer tx.Rollback() + + query := ` + INSERT INTO gardens (name, description, image_data, image_id) + VALUES ($1, $2, '', $3) + RETURNING id, created_at, updated_at, version` + err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan( + &garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, + ) + if err != nil { + return storage.Garden{}, err + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO garden_members (garden_id, user_id, role) + VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner) + if err != nil { + return storage.Garden{}, err + } + if err = tx.Commit(); err != nil { + return storage.Garden{}, err + } + return garden, nil +} + +func scanGarden(s scanner) (storage.Garden, error) { + var garden storage.Garden + err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version) + return garden, err +} + +// Get returns a garden by ID. +func (m GardenModel) Get(id int) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + garden, err := scanGarden(m.DB.QueryRowContext(ctx, ` + SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version + FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id)) + if err != nil { + return storage.Garden{}, recordError(err) + } + return garden, nil +} + +// GetAllForUser lists gardens visible to a user with resolved permissions. +func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role + FROM gardens g + INNER JOIN garden_members gm ON gm.garden_id = g.id + LEFT JOIN images i ON i.id=g.image_id + WHERE gm.user_id = $1 + ORDER BY g.name, g.id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + gardens := []storage.Garden{} + for rows.Next() { + var garden storage.Garden + err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role) + if err != nil { + return nil, err + } + gardens = append(gardens, garden) + } + return gardens, rows.Err() +} + +// Update changes a garden using optimistic locking. +func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE gardens + SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $4 AND version = $5 + RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version, + ).Scan(&garden.UpdatedAt, &garden.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Garden{}, storage.ErrEditConflict + } + return storage.Garden{}, err + } + return garden, nil +} + +// Delete removes a garden and its dependent records. +func (m GardenModel) Delete(id int) error { + return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id) +} diff --git a/internal/storage/postgres/helpers.go b/internal/storage/postgres/helpers.go new file mode 100644 index 0000000..95bd1d2 --- /dev/null +++ b/internal/storage/postgres/helpers.go @@ -0,0 +1,87 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +const queryTimeout = 3 * time.Second + +type scanner interface { + Scan(dest ...any) error +} + +func contextWithTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), queryTimeout) +} + +func jsonValue(value json.RawMessage) json.RawMessage { + if len(value) == 0 { + return json.RawMessage(`{}`) + } + return value +} + +func deleteByID(db *sql.DB, query string, args ...any) error { + ctx, cancel := contextWithTimeout() + defer cancel() + + result, err := db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + + result, err := db.ExecContext(ctx, query, gardenID, id) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +func recordError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return storage.ErrRecordNotFound + } + var pqError *pq.Error + if errors.As(err, &pqError) && pqError.Code == "23505" { + return storage.ErrConflict + } + if errors.As(err, &pqError) && pqError.Code == "23503" { + return storage.ErrConflict + } + return err +} + +func nullableUserID(id int) any { + if id < 1 { + return nil + } + return id +} diff --git a/internal/storage/postgres/images.go b/internal/storage/postgres/images.go new file mode 100644 index 0000000..88f5648 --- /dev/null +++ b/internal/storage/postgres/images.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "strings" + + "gardomatic.kleiax.de/internal/storage" +) + +// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image +// retrieval is always constrained by the owning garden. +type ImageModel struct{ DB *sql.DB } + +// Insert stores an image in its garden's library. +func (m ImageModel) Insert(image storage.Image) (storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + sum := sha256.Sum256(image.Data) + err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName, + image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt) + if err != nil { + return storage.Image{}, recordError(err) + } + image.Data = nil + return image, nil +} + +// Get returns an image within its garden. +func (m ImageModel) Get(gardenID, id int) (storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var image storage.Image + err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan( + &image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data) + if err != nil { + return storage.Image{}, recordError(err) + } + return image, nil +} + +// GetAllForGarden lists image metadata matching filter. +func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at + FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query)) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.Image{} + for rows.Next() { + var image storage.Image + if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil { + return nil, err + } + result = append(result, image) + } + return result, rows.Err() +} + +// CountForGarden returns the number of images in a garden library. +func (m ImageModel) CountForGarden(gardenID int) (int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var count int + err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count) + return count, err +} + +// RecordAssignment appends an entity image-change history record. +func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error { + if sameImage(c.PreviousImageID, c.ImageID) { + return nil + } + ctx, cancel := contextWithTimeout() + defer cancel() + _, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy)) + return err +} + +func sameImage(a, b *int) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/internal/storage/postgres/journal.go b/internal/storage/postgres/journal.go new file mode 100644 index 0000000..29915b3 --- /dev/null +++ b/internal/storage/postgres/journal.go @@ -0,0 +1,175 @@ +package postgres + +import ( + "context" + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// JournalModel implements storage.JournalModelInterface for PostgreSQL and +// enforces the garden boundary on entries and attachments. +type JournalModel struct{ DB *sql.DB } + +const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version` + +func scanJournalEntry(s scanner) (storage.JournalEntry, error) { + var entry storage.JournalEntry + err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + return entry, err +} + +// Insert creates a journal entry and its tags atomically. +func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + if entry.EntryType == "" { + entry.EntryType = storage.JournalEntryTypeJournal + } + err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + if err != nil { + return storage.JournalEntry{}, recordError(err) + } + return entry, nil +} + +// Get returns an entry with tags and attachment metadata within its garden. +func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id)) + if err != nil { + return storage.JournalEntry{}, recordError(err) + } + entry.Attachments, err = m.attachments(ctx, entry.ID) + return entry, err +} + +// GetAllForGarden lists entries of an optional type in a garden. +func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + if entryType == "" { + entryType = storage.JournalEntryTypeJournal + } + rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType) + if err != nil { + return nil, err + } + defer rows.Close() + entries := []storage.JournalEntry{} + for rows.Next() { + entry, scanErr := scanJournalEntry(rows) + if scanErr != nil { + return nil, scanErr + } + entries = append(entries, entry) + } + if err = rows.Err(); err != nil { + return nil, err + } + for i := range entries { + entries[i].Attachments, err = m.attachments(ctx, entries[i].ID) + if err != nil { + return nil, err + } + } + return entries, nil +} + +// Update changes an entry and its tags atomically using optimistic locking. +func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + if err == sql.ErrNoRows { + return storage.JournalEntry{}, storage.ErrEditConflict + } + if err != nil { + return storage.JournalEntry{}, err + } + return entry, nil +} + +// Delete removes an entry within its garden. +func (m JournalModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id) +} + +// InsertAttachment adds inline media or a library-image link to an entry. +func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var entryType storage.JournalEntryType + if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil { + return storage.JournalAttachment{}, recordError(err) + } + if attachment.ImageID != nil { + image, err := ImageModel(m).Get(gardenID, *attachment.ImageID) + if err != nil { + return storage.JournalAttachment{}, err + } + attachment.MediaType, attachment.Size = image.MediaType, image.Size + if attachment.FileName == "" { + attachment.FileName = image.FileName + } + if attachment.FileName == "" { + attachment.FileName = "Bild" + } + } else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" { + image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)}) + if err != nil { + return storage.JournalAttachment{}, err + } + attachment.ImageID = &image.ID + } + data := attachment.Data + if attachment.ImageID != nil { + data = []byte{} + } + size := int64(len(attachment.Data)) + if attachment.ImageID != nil { + size = attachment.Size + } + err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt) + if err != nil { + return storage.JournalAttachment{}, recordError(err) + } + attachment.EntryID, attachment.Size = entryID, size + attachment.Data = nil + return attachment, nil +} + +// GetAttachment returns attachment metadata and data within its garden and entry. +func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var a storage.JournalAttachment + err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID) + if err != nil { + return storage.JournalAttachment{}, recordError(err) + } + return a, nil +} + +// DeleteAttachment removes an attachment within its garden and entry. +func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error { + return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID) +} + +func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) { + rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.JournalAttachment{} + for rows.Next() { + var a storage.JournalAttachment + if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil { + return nil, err + } + result = append(result, a) + } + return result, rows.Err() +} diff --git a/internal/storage/postgres/journal_integration_test.go b/internal/storage/postgres/journal_integration_test.go new file mode 100644 index 0000000..407036d --- /dev/null +++ b/internal/storage/postgres/journal_integration_test.go @@ -0,0 +1,105 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var userID, gardenID, foreignGardenID int + if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID) + }) + + model := JournalModel{DB: db} + entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"}) + if err != nil { + t.Fatalf("insert entry: %v", err) + } + foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"}) + if err != nil { + t.Fatalf("insert foreign entry: %v", err) + } + if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign lookup: %v", err) + } + pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"}) + if err != nil { + t.Fatalf("insert pinboard entry: %v", err) + } + + tags := TagModel{DB: db} + storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"}) + if err != nil || len(storedTags) != 2 { + t.Fatalf("set tags: %#v, %v", storedTags, err) + } + attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")}) + if err != nil { + t.Fatalf("insert attachment: %v", err) + } + pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")}) + if err != nil { + t.Fatalf("insert pinboard attachment: %v", err) + } + if pinboardAttachment.ImageID == nil { + t.Fatal("pinboard image was not added to the shared image library") + } + pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID) + if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) { + t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err) + } + loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID) + if err != nil || string(loaded.Data) != "jpeg" { + t.Fatalf("load attachment: %#v, %v", loaded, err) + } + if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign attachment lookup: %v", err) + } + + entry.Title = "Große Ernte" + updated, err := model.Update(gardenID, entry) + if err != nil || updated.Version != 2 { + t.Fatalf("update entry: %#v, %v", updated, err) + } + listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal) + if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" { + t.Fatalf("list entries: %#v, %v", listed, err) + } + pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard) + if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 { + t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err) + } + if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil { + t.Fatalf("delete attachment: %v", err) + } + if err = model.Delete(gardenID, entry.ID); err != nil { + t.Fatalf("delete entry: %v", err) + } +} diff --git a/internal/storage/postgres/locations.go b/internal/storage/postgres/locations.go new file mode 100644 index 0000000..96d157a --- /dev/null +++ b/internal/storage/postgres/locations.go @@ -0,0 +1,118 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// LocationModel stores garden locations in PostgreSQL. +// LocationModel implements storage.LocationModelInterface for PostgreSQL and +// scopes hierarchical locations to their garden. +type LocationModel struct{ DB *sql.DB } + +const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm, + l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version, + COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)` + +func scanLocation(s scanner) (storage.Location, error) { + var location storage.Location + err := s.Scan( + &location.ID, &location.GardenID, &location.ParentID, &location.Name, + &location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction, + &location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy, + ) + return location, err +} + +// Insert creates a location in its garden. +func (m LocationModel) Insert(location storage.Location) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO locations + (garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13) + RETURNING id, created_at, updated_at, version`, + location.GardenID, location.ParentID, location.Name, location.Description, + location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy), + ).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version) + if err != nil { + return storage.Location{}, err + } + return location, nil +} + +// Get returns a location within its garden. +func (m LocationModel) Get(gardenID, id int) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id)) + if err != nil { + return storage.Location{}, recordError(err) + } + return location, nil +} + +// GetAllForGarden lists locations in a garden. +func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+` + FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + locations := []storage.Location{} + for rows.Next() { + location, err := scanLocation(rows) + if err != nil { + return nil, err + } + locations = append(locations, location) + } + return locations, rows.Err() +} + +// Update changes a location using optimistic locking. +func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4, + area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $12 AND id = $13 AND version = $14 + RETURNING updated_at, version`, + location.ParentID, location.Name, location.Description, location.Kind, + location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy), + gardenID, location.ID, location.Version, + ).Scan(&location.UpdatedAt, &location.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Location{}, storage.ErrEditConflict + } + return storage.Location{}, err + } + return location, nil +} + +// Delete removes a location within its garden. +func (m LocationModel) Delete(gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/locations_integration_test.go b/internal/storage/postgres/locations_integration_test.go new file mode 100644 index 0000000..1464911 --- /dev/null +++ b/internal/storage/postgres/locations_integration_test.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "database/sql" + "encoding/json" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var gardenID, foreignGardenID int + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) }) + + locations := LocationModel{DB: db} + local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("insert local location: %v", err) + } + foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("insert foreign location: %v", err) + } + if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err) + } + + var plantID int + if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil { + t.Fatal(err) + } + plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID) + if err != nil { + t.Fatalf("list plants with planter join: %v", err) + } + if len(plants) != 1 || plants[0].ID != plantID { + t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID) + } + assignments := PlantLocationModel{DB: db} + created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2}) + if err != nil { + t.Fatalf("insert local assignment: %v", err) + } + if created.ID == 0 { + t.Fatal("local assignment has no id") + } + if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err) + } + listed, err := assignments.GetAllForPlant(gardenID, plantID) + if err != nil || len(listed) != 1 { + t.Fatalf("list assignments: values=%+v err=%v", listed, err) + } +} diff --git a/internal/storage/postgres/migrations/000001_initial_schema.down.sql b/internal/storage/postgres/migrations/000001_initial_schema.down.sql new file mode 100644 index 0000000..74eba5e --- /dev/null +++ b/internal/storage/postgres/migrations/000001_initial_schema.down.sql @@ -0,0 +1,49 @@ +DROP TABLE plant_status_history; +DROP TABLE application_settings; +DROP TABLE journal_attachments; +DROP TABLE journal_entry_tags; +DROP TABLE journal_entries; +DROP TABLE entity_image_history; + +ALTER TABLE locations DROP COLUMN image_id; +ALTER TABLE plants DROP COLUMN image_id; +ALTER TABLE species DROP COLUMN image_id; +ALTER TABLE gardens DROP COLUMN image_id; +DROP TABLE images; + +DROP TABLE species_tags; +DROP TABLE plant_tags; +DROP TABLE task_tags; +DROP TABLE tags; +DROP TABLE task_priorities; +DROP TABLE task_template_opt_outs; +DROP TABLE tasks; +DROP TABLE plant_locations; +DROP TABLE plants; +DROP TABLE locations; +DROP TABLE species_task_templates; +DROP TABLE care_instructions; +DROP TABLE species; +DROP TABLE species_categories; +DROP TABLE garden_role_permission_overrides; +DROP TABLE garden_invites; +DROP TABLE garden_members; + +ALTER TABLE users DROP CONSTRAINT users_application_role_fkey; +DROP TABLE role_permissions; +DROP TABLE roles; +DROP TABLE gardens; +DROP TABLE user_email_changes; +DROP TABLE tokens; +DROP TABLE sessions; +DROP TABLE users; + +DROP TYPE task_template_origin; +DROP TYPE task_trigger_type; +DROP TYPE care_instruction_status; +DROP TYPE plant_status; +DROP TYPE plant_lifecycle; +DROP TYPE soil_reaction; +DROP TYPE soil_condition; +DROP TYPE sun_exposure; +DROP EXTENSION citext; diff --git a/internal/storage/postgres/migrations/000001_initial_schema.up.sql b/internal/storage/postgres/migrations/000001_initial_schema.up.sql new file mode 100644 index 0000000..a7e1ff2 --- /dev/null +++ b/internal/storage/postgres/migrations/000001_initial_schema.up.sql @@ -0,0 +1,524 @@ +-- Extensions and domain types +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade'); +CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy'); +CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral'); +CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial'); +CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested'); +CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned'); +CREATE TYPE task_trigger_type AS ENUM ( + 'month_of_year', + 'relative_to_planting', + 'relative_to_last_task', + 'relative_to_sowing', + 'relative_to_harvest', + 'relative_to_species_planting' +); +CREATE TYPE task_template_origin AS ENUM ( + 'manual', + 'season_sowing', + 'season_planting', + 'season_harvest' +); + +-- Authentication and users +CREATE TABLE users ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + email citext NOT NULL UNIQUE, + password_hash bytea NOT NULL, + activated boolean NOT NULL, + application_role text NOT NULL DEFAULT 'application:user', + color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int], + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') +); + +CREATE TABLE sessions ( + token text PRIMARY KEY, + data bytea NOT NULL, + expiry timestamptz NOT NULL +); +CREATE INDEX sessions_expiry_idx ON sessions (expiry); + +CREATE TABLE tokens ( + hash bytea PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expiry timestamptz NOT NULL, + scope text NOT NULL +); + +CREATE TABLE user_email_changes ( + user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + email citext NOT NULL UNIQUE, + token_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Gardens, roles, and permissions +CREATE TABLE gardens ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + description text NOT NULL DEFAULT '', + image_data text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +CREATE TABLE roles ( + name text PRIMARY KEY, + scope text NOT NULL CHECK (scope IN ('application', 'garden')), + label text NOT NULL, + system boolean NOT NULL DEFAULT false, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT roles_scope_garden_check CHECK ( + (scope = 'application' AND garden_id IS NULL) OR scope = 'garden' + ) +); +CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL; + +CREATE TABLE role_permissions ( + role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + permission text NOT NULL, + PRIMARY KEY (role_name, permission) +); + +ALTER TABLE users + ADD CONSTRAINT users_application_role_fkey + FOREIGN KEY (application_role) REFERENCES roles(name); + +CREATE TABLE garden_members ( + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'member' REFERENCES roles(name), + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (garden_id, user_id) +); +CREATE INDEX garden_members_user_id_idx ON garden_members (user_id); + +CREATE TABLE garden_invites ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + email citext NOT NULL, + role text NOT NULL DEFAULT 'member' REFERENCES roles(name), + token_hash bytea NOT NULL UNIQUE, + invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, + accepted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX garden_invites_pending_email_unique + ON garden_invites (garden_id, email) WHERE accepted_at IS NULL; +CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at); + +CREATE TABLE garden_role_permission_overrides ( + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + permission text NOT NULL, + granted boolean NOT NULL, + PRIMARY KEY (garden_id, role_name, permission) +); + +-- Species catalogue and care instructions +CREATE TABLE species_categories ( + id bigserial PRIMARY KEY, + name text NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + lifecycle plant_lifecycle, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name)); + +CREATE TABLE species ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT, + common_name text NOT NULL, + cultivar text NOT NULL DEFAULT '', + botanical_name text NOT NULL DEFAULT '', + sun_exposure sun_exposure, + soil_condition soil_condition, + soil_reaction soil_reaction, + winter_protection text, + spacing_cm integer, + height_cm integer, + sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12), + sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31), + sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12), + sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31), + planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12), + planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31), + planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12), + planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31), + harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12), + harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31), + harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12), + harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31), + notes text NOT NULL DEFAULT '', + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX species_global_unique + ON species (common_name, cultivar) WHERE garden_id IS NULL; +CREATE UNIQUE INDEX species_garden_unique + ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL; + +CREATE TABLE care_instructions ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + text text NOT NULL, + status care_instruction_status NOT NULL DEFAULT 'untested', + created_by bigint NOT NULL REFERENCES users(id), + updated_by bigint NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX care_instructions_species ON care_instructions(species_id); + +CREATE TABLE species_task_templates ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + trigger_type task_trigger_type NOT NULL, + month_from smallint CHECK (month_from BETWEEN 1 AND 12), + day_from smallint CHECK (day_from BETWEEN 1 AND 31), + month_to smallint CHECK (month_to BETWEEN 1 AND 12), + day_to smallint CHECK (day_to BETWEEN 1 AND 31), + offset_days_from integer, + offset_days_to integer, + interval_days smallint, + trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0), + trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')), + duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0), + duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')), + recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), + recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), + origin task_template_origin NOT NULL DEFAULT 'manual', + priority smallint NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to) +); +CREATE INDEX species_task_templates_species_id_idx + ON species_task_templates (species_id) WHERE active = true; +CREATE UNIQUE INDEX species_task_templates_derived_origin_key + ON species_task_templates (species_id, origin) WHERE origin <> 'manual'; + +-- Locations, plants, and tasks +CREATE TABLE locations ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + parent_id bigint REFERENCES locations(id) ON DELETE SET NULL, + name text NOT NULL, + description text NOT NULL DEFAULT '', + kind text NOT NULL DEFAULT '', + area_sqm numeric(10,2), + sun_exposure sun_exposure, + soil_condition soil_condition, + soil_reaction soil_reaction, + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX locations_garden_id_idx ON locations (garden_id); +CREATE INDEX locations_parent_id_idx ON locations (parent_id); + +CREATE TABLE plants ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + species_id bigint REFERENCES species(id) ON DELETE SET NULL, + name text NOT NULL, + notes text NOT NULL DEFAULT '', + acquired_at date, + status plant_status NOT NULL DEFAULT 'alive', + removed_at date, + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + planted_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive'; + +CREATE TABLE plant_locations ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE, + quantity integer NOT NULL DEFAULT 1, + planted_at date, + removed_at date, + notes text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX plant_location_active + ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL; +CREATE INDEX plant_locations_location_id_idx + ON plant_locations (location_id) WHERE removed_at IS NULL; + +CREATE TABLE tasks ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + plant_id bigint REFERENCES plants(id) ON DELETE CASCADE, + location_id bigint REFERENCES locations(id) ON DELETE CASCADE, + template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL, + title text NOT NULL, + description text NOT NULL DEFAULT '', + due_at_start timestamptz, + due_at_end timestamptz, + generated_for date, + completed_at timestamptz, + completed_by bigint REFERENCES users(id), + priority smallint NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), + recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), + repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL, + plant_status_on_completion plant_status, + created_by bigint NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end), + CONSTRAINT tasks_completion_user_check CHECK ( + (completed_at IS NULL AND completed_by IS NULL) OR + (completed_at IS NOT NULL AND completed_by IS NOT NULL) + ) +); +CREATE INDEX tasks_garden_id_due_at_end_idx + ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL; +CREATE INDEX tasks_garden_id_due_at_start_idx + ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL; +CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL; +CREATE UNIQUE INDEX tasks_repeat_from_unique + ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL; +CREATE UNIQUE INDEX tasks_template_slot_unique + ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL; + +CREATE TABLE task_template_opt_outs ( + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plant_id, template_id) +); + +CREATE TABLE task_priorities ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL UNIQUE, + value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100), + sort_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +-- Tags +CREATE TABLE tags ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + name text NOT NULL, + UNIQUE (garden_id, name) +); +CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL; + +CREATE TABLE task_tags ( + task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, tag_id) +); +CREATE TABLE plant_tags ( + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (plant_id, tag_id) +); +CREATE TABLE species_tags ( + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (species_id, tag_id) +); + +-- Image library +CREATE TABLE images ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + file_name text NOT NULL DEFAULT '', + media_type text NOT NULL, + data bytea NOT NULL, + size bigint NOT NULL, + checksum text NOT NULL, + source text NOT NULL DEFAULT 'upload', + created_by bigint REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC); +CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum); + +ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; + +CREATE TABLE entity_image_history ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')), + entity_id bigint NOT NULL, + previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL, + image_id bigint REFERENCES images(id) ON DELETE SET NULL, + changed_by bigint REFERENCES users(id) ON DELETE SET NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX entity_image_history_entity_idx + ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC); + +-- Journal +CREATE TABLE journal_entries ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + author_id bigint NOT NULL REFERENCES users(id), + entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')), + title text NOT NULL, + body text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX journal_entries_garden_created_idx + ON journal_entries(garden_id, created_at DESC, id DESC); +CREATE INDEX journal_entries_garden_type_created_idx + ON journal_entries(garden_id, entry_type, created_at DESC, id DESC); + +CREATE TABLE journal_entry_tags ( + journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (journal_entry_id, tag_id) +); + +CREATE TABLE journal_attachments ( + id bigserial PRIMARY KEY, + journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, + image_id bigint REFERENCES images(id) ON DELETE RESTRICT, + file_name text NOT NULL, + media_type text NOT NULL, + data bytea NOT NULL, + size bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id); + +-- Application settings and lifecycle history +CREATE TABLE application_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + lifecycle_status_enabled boolean NOT NULL DEFAULT false, + lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12), + lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31), + timezone text NOT NULL DEFAULT 'Europe/Berlin', + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +CREATE TABLE plant_status_history ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + from_status plant_status NOT NULL, + to_status plant_status NOT NULL, + reason text NOT NULL, + effective_at date NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX plant_status_history_plant_id_idx + ON plant_status_history (plant_id, created_at DESC); + +-- Initial reference data +INSERT INTO roles (name, scope, label, system) VALUES + ('application:user', 'application', 'Nutzer', true), + ('application:admin', 'application', 'Administrator', true), + ('owner', 'garden', 'Eigentümer', true), + ('admin', 'garden', 'Administrator', true), + ('member', 'garden', 'Mitglied', true), + ('worker', 'garden', 'Mitarbeiter', true), + ('viewer', 'garden', 'Leser', true); + +INSERT INTO role_permissions (role_name, permission) VALUES + ('application:user', 'gardens:create'), + ('application:admin', 'gardens:create'), + ('application:admin', 'global_species:write'), + ('application:admin', 'users:manage'), + ('application:admin', 'application_settings:write'), + ('application:admin', 'roles:manage'), + ('owner', '*'), + ('admin', 'garden:*'), + ('member', 'garden:read'), + ('member', 'content:write'), + ('member', 'plants:create'), + ('member', 'plants:read:own'), + ('member', 'plants:read:other'), + ('member', 'plants:update:own'), + ('member', 'plants:delete:own'), + ('member', 'locations:create'), + ('member', 'locations:read:own'), + ('member', 'locations:read:other'), + ('member', 'locations:update:own'), + ('member', 'locations:delete:own'), + ('member', 'tasks:create'), + ('member', 'tasks:read:own'), + ('member', 'tasks:read:other'), + ('member', 'tasks:update:own'), + ('member', 'tasks:delete:own'), + ('member', 'tasks:complete:own'), + ('member', 'tasks:complete:other'), + ('worker', 'garden:read'), + ('worker', 'tasks:read:own'), + ('worker', 'tasks:read:other'), + ('worker', 'tasks:complete:own'), + ('worker', 'tasks:complete:other'), + ('viewer', 'garden:read'), + ('viewer', 'plants:read:own'), + ('viewer', 'plants:read:other'), + ('viewer', 'locations:read:own'), + ('viewer', 'locations:read:other'), + ('viewer', 'tasks:read:own'), + ('viewer', 'tasks:read:other'); + +INSERT INTO task_priorities (name, value, sort_order) VALUES + ('Niedrig', -5, 10), + ('Normal', 0, 20), + ('Erhöht', 3, 30), + ('Hoch', 5, 40); + +INSERT INTO application_settings (singleton) VALUES (true); + +INSERT INTO species_categories (name, sort_order) VALUES + ('Gehölz', 10), + ('Gemüse', 20), + ('Kraut', 30), + ('Obst', 40), + ('Staude', 50); diff --git a/internal/storage/postgres/models.go b/internal/storage/postgres/models.go new file mode 100644 index 0000000..aa64d21 --- /dev/null +++ b/internal/storage/postgres/models.go @@ -0,0 +1,33 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// New binds all PostgreSQL model implementations to db. +func New(db *sql.DB) storage.Models { + return storage.Models{ + ApplicationSettings: ApplicationSettingsModel{DB: db}, + Roles: RoleModel{DB: db}, + Gardens: GardenModel{DB: db}, + GardenMembers: GardenMemberModel{DB: db}, + GardenInvites: GardenInviteModel{DB: db}, + Locations: LocationModel{DB: db}, + Plants: PlantModel{DB: db}, + PlantLocations: PlantLocationModel{DB: db}, + Species: SpeciesModel{DB: db}, + CareInstructions: CareInstructionModel{DB: db}, + SpeciesCategories: SpeciesCategoryModel{DB: db}, + TaskPriorities: TaskPriorityModel{DB: db}, + SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db}, + Tasks: TaskModel{DB: db}, + Journal: JournalModel{DB: db}, + Images: ImageModel{DB: db}, + Tags: TagModel{DB: db}, + TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db}, + Tokens: TokenModel{DB: db}, + Users: UserModel{DB: db}, + } +} diff --git a/internal/storage/postgres/plant_locations.go b/internal/storage/postgres/plant_locations.go new file mode 100644 index 0000000..5c6ec05 --- /dev/null +++ b/internal/storage/postgres/plant_locations.go @@ -0,0 +1,139 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// PlantLocationModel stores plant-to-location assignments in PostgreSQL. +// PlantLocationModel implements storage.PlantLocationModelInterface for +// PostgreSQL and verifies both plant and location membership in the garden. +type PlantLocationModel struct{ DB *sql.DB } + +const plantLocationQualifiedColumns = `pl.id, pl.plant_id, pl.location_id, pl.quantity, pl.planted_at, + pl.removed_at, pl.notes, pl.created_at, pl.version` + +func scanPlantLocation(s scanner) (storage.PlantLocation, error) { + var plantLocation storage.PlantLocation + err := s.Scan( + &plantLocation.ID, &plantLocation.PlantID, &plantLocation.LocationID, + &plantLocation.Quantity, &plantLocation.PlantedAt, &plantLocation.RemovedAt, + &plantLocation.Notes, &plantLocation.CreatedAt, &plantLocation.Version, + ) + return plantLocation, err +} + +// Insert assigns a plant to a location in the same garden. +func (m PlantLocationModel) Insert(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO plant_locations + (plant_id, location_id, quantity, planted_at, removed_at, notes) + SELECT $2, $3, $4, $5, $6, $7 + FROM plants p, locations l + WHERE p.id = $2 AND p.garden_id = $1 AND l.id = $3 AND l.garden_id = $1 + RETURNING id, created_at, version`, + gardenID, plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity, + plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes, + ).Scan(&plantLocation.ID, &plantLocation.CreatedAt, &plantLocation.Version) + if err != nil { + return storage.PlantLocation{}, recordError(err) + } + return plantLocation, nil +} + +// Get returns a plant assignment within its garden. +func (m PlantLocationModel) Get(gardenID, id int) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + plantLocation, err := scanPlantLocation(m.DB.QueryRowContext(ctx, + `SELECT `+plantLocationQualifiedColumns+` FROM plant_locations pl + JOIN plants p ON p.id = pl.plant_id WHERE p.garden_id = $1 AND pl.id = $2`, gardenID, id)) + if err != nil { + return storage.PlantLocation{}, recordError(err) + } + return plantLocation, nil +} + +func (m PlantLocationModel) getAll(query string, gardenID, id int) ([]storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, query, gardenID, id) + if err != nil { + return nil, err + } + defer rows.Close() + + plantLocations := []storage.PlantLocation{} + for rows.Next() { + plantLocation, err := scanPlantLocation(rows) + if err != nil { + return nil, err + } + plantLocations = append(plantLocations, plantLocation) + } + return plantLocations, rows.Err() +} + +// GetAllForPlant lists a plant's current and historical placements. +func (m PlantLocationModel) GetAllForPlant(gardenID, plantID int) ([]storage.PlantLocation, error) { + return m.getAll(`SELECT `+plantLocationQualifiedColumns+` + FROM plant_locations pl JOIN plants p ON p.id = pl.plant_id + WHERE p.garden_id = $1 AND pl.plant_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, plantID) +} + +// GetAllForLocation lists current and historical plant placements at a location. +func (m PlantLocationModel) GetAllForLocation(gardenID, locationID int) ([]storage.PlantLocation, error) { + return m.getAll(`SELECT `+plantLocationQualifiedColumns+` + FROM plant_locations pl JOIN locations l ON l.id = pl.location_id + WHERE l.garden_id = $1 AND pl.location_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, locationID) +} + +// Update changes a plant assignment using optimistic locking. +func (m PlantLocationModel) Update(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, ` + UPDATE plant_locations SET plant_id = $1, location_id = $2, quantity = $3, + planted_at = $4, removed_at = $5, notes = $6, version = version + 1 + WHERE id = $7 AND version = $8 + AND EXISTS (SELECT 1 FROM plants p WHERE p.id = plant_locations.plant_id AND p.garden_id = $9) + AND EXISTS (SELECT 1 FROM plants p WHERE p.id = $1 AND p.garden_id = $9) + AND EXISTS (SELECT 1 FROM locations l WHERE l.id = $2 AND l.garden_id = $9)`, + plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity, + plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes, + plantLocation.ID, plantLocation.Version, gardenID) + if err != nil { + return storage.PlantLocation{}, err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return storage.PlantLocation{}, err + } + if rowsAffected == 0 { + return storage.PlantLocation{}, storage.ErrEditConflict + } + plantLocation.Version++ + return plantLocation, nil +} + +// Delete removes a plant assignment within its garden. +func (m PlantLocationModel) Delete(gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM plant_locations pl USING plants p + WHERE pl.id = $2 AND p.id = pl.plant_id AND p.garden_id = $1`, gardenID, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/plants.go b/internal/storage/postgres/plants.go new file mode 100644 index 0000000..b703df7 --- /dev/null +++ b/internal/storage/postgres/plants.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// PlantModel stores garden-scoped plant instances in PostgreSQL. +// PlantModel implements storage.PlantModelInterface for PostgreSQL and scopes +// every concrete plant operation to its garden. +type PlantModel struct{ DB *sql.DB } + +const plantColumns = `p.id, p.garden_id, p.species_id, p.name, p.notes, p.acquired_at, p.status, + p.removed_at, p.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), p.image_id, p.created_at, p.updated_at, p.version, + COALESCE(p.created_by, 0), COALESCE(p.updated_by, 0), COALESCE(p.planted_by, 0), COALESCE(u.name, '')` + +func scanPlant(s scanner) (storage.Plant, error) { + var plant storage.Plant + err := s.Scan( + &plant.ID, &plant.GardenID, &plant.SpeciesID, &plant.Name, &plant.Notes, + &plant.AcquiredAt, &plant.Status, &plant.RemovedAt, &plant.Attributes, &plant.ImageData, &plant.ImageID, + &plant.CreatedAt, &plant.UpdatedAt, &plant.Version, &plant.CreatedBy, &plant.UpdatedBy, &plant.PlantedBy, &plant.PlantedByName, + ) + return plant, err +} + +// Insert creates a plant in its garden. +func (m PlantModel) Insert(plant storage.Plant) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO plants + (garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_data, image_id, created_by, updated_by, planted_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10, $11, $12) + RETURNING id, created_at, updated_at, version`, + plant.GardenID, plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, + plant.Status, plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.CreatedBy), nullableUserID(plant.UpdatedBy), nullableUserID(plant.PlantedBy), + ).Scan(&plant.ID, &plant.CreatedAt, &plant.UpdatedAt, &plant.Version) + if err != nil { + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// Get returns a plant within its garden. +func (m PlantModel) Get(gardenID, id int) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + plant, err := scanPlant(m.DB.QueryRowContext(ctx, `SELECT `+plantColumns+` FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 AND p.id = $2`, gardenID, id)) + if err != nil { + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// GetAllForGarden lists plants in a garden. +func (m PlantModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+plantColumns+` + FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 ORDER BY p.name, p.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + plants := []storage.Plant{} + for rows.Next() { + plant, err := scanPlant(rows) + if err != nil { + return nil, err + } + plants = append(plants, plant) + } + return plants, rows.Err() +} + +// Update changes a plant using optimistic locking. +func (m PlantModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE plants SET species_id = $1, name = $2, notes = $3, acquired_at = $4, + status = $5, removed_at = $6, attributes = $7, image_data = '', image_id = $8, updated_by = $9, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $10 AND id = $11 AND version = $12 + RETURNING updated_at, version`, + plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, plant.Status, + plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.UpdatedBy), gardenID, plant.ID, plant.Version, + ).Scan(&plant.UpdatedAt, &plant.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Plant{}, storage.ErrEditConflict + } + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// Delete removes a plant within its garden. +func (m PlantModel) Delete(gardenID, id int) error { + return deleteByGardenID(m.DB, `DELETE FROM plants WHERE garden_id = $1 AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/roles.go b/internal/storage/postgres/roles.go new file mode 100644 index 0000000..4165ead --- /dev/null +++ b/internal/storage/postgres/roles.go @@ -0,0 +1,201 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// RoleModel implements storage.RoleModelInterface for shared and garden-owned +// PostgreSQL roles. +type RoleModel struct{ DB *sql.DB } + +// List returns shared roles in a scope. +func (m RoleModel) List(scope storage.RoleScope) ([]storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope=$1 AND r.garden_id IS NULL GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, scope) + return scanRoles(rows, err) +} + +// ListForGarden returns shared and custom roles available in a garden. +func (m RoleModel) ListForGarden(gardenID int) ([]storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope='garden' AND (r.garden_id IS NULL OR r.garden_id=$1) GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, gardenID) + return scanRoles(rows, err) +} + +func scanRoles(rows *sql.Rows, err error) ([]storage.Role, error) { + if err != nil { + return nil, err + } + defer rows.Close() + roles := []storage.Role{} + for rows.Next() { + var role storage.Role + var permissions pq.StringArray + if err := rows.Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions); err != nil { + return nil, err + } + role.Permissions = []string(permissions) + roles = append(roles, role) + } + return roles, rows.Err() +} + +// Get returns a shared role by name. +func (m RoleModel) Get(name string) (storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var role storage.Role + var permissions pq.StringArray + err := m.DB.QueryRowContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.name=$1 GROUP BY r.name`, name).Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions) + if err != nil { + return storage.Role{}, recordError(err) + } + role.Permissions = []string(permissions) + return role, nil +} + +// GetForGarden returns a shared or custom role available in a garden. +func (m RoleModel) GetForGarden(gardenID int, name string) (storage.Role, error) { + role, err := m.Get(name) + if err != nil { + return storage.Role{}, err + } + if role.Scope != storage.RoleScopeGarden || role.GardenID != nil && *role.GardenID != gardenID { + return storage.Role{}, storage.ErrRecordNotFound + } + return role, nil +} + +// Create adds a role and its base permissions atomically. +func (m RoleModel) Create(role storage.Role) (storage.Role, error) { + if role.Scope != storage.RoleScopeApplication && role.Scope != storage.RoleScopeGarden { + return storage.Role{}, storage.ErrConflict + } + if role.Scope == storage.RoleScopeApplication && role.GardenID != nil { + return storage.Role{}, storage.ErrConflict + } + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return role, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `INSERT INTO roles(name,scope,garden_id,label) VALUES($1,$2,$3,$4)`, role.Name, role.Scope, role.GardenID, role.Label); err != nil { + return role, recordError(err) + } + for _, p := range role.Permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil { + return role, err + } + } + if err = tx.Commit(); err != nil { + return role, err + } + return m.Get(role.Name) +} + +// Update replaces a role's label and base permissions atomically. +func (m RoleModel) Update(role storage.Role) (storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return role, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE roles SET label=$1,updated_at=CURRENT_TIMESTAMP WHERE name=$2`, role.Label, role.Name) + if err != nil { + return role, err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return role, countErr + } else if count == 0 { + return storage.Role{}, storage.ErrRecordNotFound + } + if _, err = tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_name=$1`, role.Name); err != nil { + return role, err + } + for _, p := range role.Permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil { + return role, err + } + } + if err = tx.Commit(); err != nil { + return role, err + } + return m.Get(role.Name) +} + +// Delete removes a non-system shared role that is not assigned. +func (m RoleModel) Delete(name string) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM roles WHERE name=$1 AND system=false`, name) + if err != nil { + return recordError(err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return storage.ErrConflict + } + return nil +} + +// ListGardenOverrides lists explicit permission grants and revocations in a garden. +func (m RoleModel) ListGardenOverrides(gardenID int) ([]storage.GardenRolePermissionOverride, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT garden_id,role_name,permission,granted FROM garden_role_permission_overrides WHERE garden_id=$1 ORDER BY role_name,permission`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.GardenRolePermissionOverride{} + for rows.Next() { + var o storage.GardenRolePermissionOverride + if err := rows.Scan(&o.GardenID, &o.RoleName, &o.Permission, &o.Granted); err != nil { + return nil, err + } + result = append(result, o) + } + return result, rows.Err() +} + +// ReplaceGardenOverrides atomically replaces all permission overrides for a +// role in one garden. +func (m RoleModel) ReplaceGardenOverrides(gardenID int, roleName string, overrides []storage.GardenRolePermissionOverride) error { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, roleName, gardenID).Scan(&exists); err != nil { + return err + } + if !exists { + return storage.ErrRecordNotFound + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_role_permission_overrides WHERE garden_id=$1 AND role_name=$2`, gardenID, roleName); err != nil { + return err + } + for _, o := range overrides { + if _, err = tx.ExecContext(ctx, `INSERT INTO garden_role_permission_overrides(garden_id,role_name,permission,granted) VALUES($1,$2,$3,$4)`, gardenID, roleName, o.Permission, o.Granted); err != nil { + return err + } + } + return tx.Commit() +} + +var _ = sql.ErrNoRows diff --git a/internal/storage/postgres/roles_integration_test.go b/internal/storage/postgres/roles_integration_test.go new file mode 100644 index 0000000..a436f2e --- /dev/null +++ b/internal/storage/postgres/roles_integration_test.go @@ -0,0 +1,103 @@ +package postgres + +import ( + "database/sql" + "os" + "strconv" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + _ "github.com/lib/pq" +) + +func TestScopedRolesAndPersistedPermissions(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := time.Now().Format("150405.000000000") + password := auth.NewPassword([]byte("integration-test-hash")) + users := UserModel{DB: db} + user, err := users.Insert(storage.User{Name: "Role integration", Email: "roles-" + stamp + "@example.com", Password: *password, Activated: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, user.ID) }) + if user.Role != storage.ApplicationRoleUser || !user.Can(storage.ApplicationPermissionGardensCreate) { + t.Fatalf("new user role=%q permissions=%v", user.Role, user.Permissions) + } + + user, err = users.UpdateRole(user.ID, storage.ApplicationRoleAdmin) + if err != nil { + t.Fatal(err) + } + if !user.Can(storage.ApplicationPermissionUsersManage) || !user.Can(storage.ApplicationPermissionRolesManage) { + t.Fatalf("admin permissions were not loaded: %v", user.Permissions) + } + + var gardenID int + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Role integration "+stamp).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) }) + members := GardenMemberModel{DB: db} + member, err := members.Insert(storage.GardenMember{GardenID: gardenID, UserID: user.ID, Role: storage.GardenRoleOwner}) + if err != nil { + t.Fatal(err) + } + if !member.Can(storage.GardenPermissionGardenDelete) { + t.Fatal("owner wildcard permission was not expanded") + } + if err = (RoleModel{DB: db}).ReplaceGardenOverrides(gardenID, string(storage.GardenRoleOwner), []storage.GardenRolePermissionOverride{{GardenID: gardenID, RoleName: string(storage.GardenRoleOwner), Permission: string(storage.GardenPermissionGardenDelete), Granted: false}}); err != nil { + t.Fatal(err) + } + member, err = members.Get(gardenID, user.ID) + if err != nil { + t.Fatal(err) + } + if member.Can(storage.GardenPermissionGardenDelete) || !member.Can(storage.GardenPermissionGardenUpdate) { + t.Fatalf("garden override was not applied: %v", member.Permissions) + } + + var otherGardenID int + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Other role integration "+stamp).Scan(&otherGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, otherGardenID) }) + roleName := "garden:" + strconv.Itoa(gardenID) + ":integration" + role, err := (RoleModel{DB: db}).Create(storage.Role{ + Name: roleName, Scope: storage.RoleScopeGarden, GardenID: &gardenID, + Label: "Integration", Permissions: []string{string(storage.GardenPermissionGardenRead)}, + }) + if err != nil { + t.Fatal(err) + } + if role.GardenID == nil || *role.GardenID != gardenID { + t.Fatalf("garden-specific role has garden_id=%v", role.GardenID) + } + roles, err := (RoleModel{DB: db}).ListForGarden(gardenID) + if err != nil { + t.Fatal(err) + } + found := false + for _, candidate := range roles { + found = found || candidate.Name == roleName + } + if !found { + t.Fatalf("garden-specific role %q is missing", roleName) + } + if _, err = (RoleModel{DB: db}).GetForGarden(otherGardenID, roleName); err != storage.ErrRecordNotFound { + t.Fatalf("role leaked into another garden: %v", err) + } +} diff --git a/internal/storage/postgres/species.go b/internal/storage/postgres/species.go new file mode 100644 index 0000000..cd8b4e1 --- /dev/null +++ b/internal/storage/postgres/species.go @@ -0,0 +1,142 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesModel stores global and garden-specific species data in PostgreSQL. +// SpeciesModel implements storage.SpeciesModelInterface for global and +// garden-owned species records. +type SpeciesModel struct{ DB *sql.DB } + +const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name, + s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction, + s.winter_protection, s.spacing_cm, s.height_cm, + s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to, + s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to, + s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to, + s.notes, s.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), s.image_id, s.created_at, s.updated_at, s.version, + COALESCE(s.created_by, 0), COALESCE(s.updated_by, 0)` + +func scanSpecies(s scanner) (storage.Species, error) { + var species storage.Species + err := s.Scan( + &species.ID, &species.GardenID, &species.CommonName, &species.Cultivar, + &species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition, + &species.SoilReaction, &species.WinterProtection, &species.SpacingCM, + &species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo, + &species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom, + &species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID, + &species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy, + ) + return species, err +} + +func speciesArgs(species storage.Species) []any { + return []any{ + species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName, + species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction, + species.WinterProtection, species.SpacingCM, species.HeightCM, + species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo, + species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo, + species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, + species.HarvestDayTo, species.Notes, jsonValue(species.Attributes), species.ImageID, nullableUserID(species.CreatedBy), nullableUserID(species.UpdatedBy), + } +} + +// Insert creates a global or garden-owned species record. +func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + query := ` + INSERT INTO species ( + garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure, + soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm, + sow_month_from, sow_day_from, sow_month_to, sow_day_to, + planting_month_from, planting_day_from, planting_month_to, planting_day_to, + harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to, + notes, attributes, image_data, image_id, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28) + RETURNING id, created_at, updated_at, version` + err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan( + &species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version, + ) + if err != nil { + return storage.Species{}, recordError(err) + } + return species, nil +} + +// Get returns a global or garden-owned species visible in gardenID. +func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + species, err := scanSpecies(m.DB.QueryRowContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.id = $1 AND (s.garden_id IS NULL OR s.garden_id = $2)`, id, gardenID)) + if err != nil { + return storage.Species{}, recordError(err) + } + return species, nil +} + +// GetAllForGarden lists global species and species owned by a garden. +func (m SpeciesModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesColumns+` + FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id + WHERE s.garden_id IS NULL OR s.garden_id = $1 + ORDER BY s.common_name, s.cultivar, s.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + allSpecies := []storage.Species{} + for rows.Next() { + species, err := scanSpecies(rows) + if err != nil { + return nil, err + } + allSpecies = append(allSpecies, species) + } + return allSpecies, rows.Err() +} + +// Update changes a visible species using optimistic locking. +func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + args := speciesArgs(species) + // created_by is immutable and is intentionally not part of the UPDATE. + // Remove it from the shared insert argument list so PostgreSQL does not + // receive an unused, untyped parameter between image_id and updated_by. + args = append(args[:26], args[27]) + args = append(args, gardenID, species.ID, species.Version) + err := m.DB.QueryRowContext(ctx, ` + UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3, + botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7, + soil_reaction = $8, winter_protection = $9, spacing_cm = $10, + height_cm = $11, sow_month_from = $12, sow_day_from = $13, + sow_month_to = $14, sow_day_to = $15, planting_month_from=$16, + planting_day_from=$17, planting_month_to=$18, planting_day_to=$19, + harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23, + notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30 + RETURNING updated_at, version`, args..., + ).Scan(&species.UpdatedAt, &species.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Species{}, storage.ErrEditConflict + } + return storage.Species{}, recordError(err) + } + return species, nil +} + +// Delete removes a species owned by the supplied garden. +func (m SpeciesModel) Delete(gardenID, id int) error { + return deleteByGardenID(m.DB, `DELETE FROM species WHERE garden_id IS NOT DISTINCT FROM NULLIF($1, 0) AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/species_categories.go b/internal/storage/postgres/species_categories.go new file mode 100644 index 0000000..39d0a86 --- /dev/null +++ b/internal/storage/postgres/species_categories.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesCategoryModel stores globally configured species categories. +// SpeciesCategoryModel implements storage.SpeciesCategoryModelInterface for +// the application-wide category catalogue. +type SpeciesCategoryModel struct{ DB *sql.DB } + +func scanSpeciesCategory(s scanner) (storage.SpeciesCategory, error) { + var category storage.SpeciesCategory + err := s.Scan(&category.ID, &category.Name, &category.SortOrder, &category.Active, &category.CreatedAt, &category.UpdatedAt, &category.Version, &category.Lifecycle) + return category, err +} + +// Insert creates a species category. +func (m SpeciesCategoryModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO species_categories (name, sort_order, active, lifecycle) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at, version`, category.Name, category.SortOrder, category.Active, + category.Lifecycle).Scan(&category.ID, &category.CreatedAt, &category.UpdatedAt, &category.Version) + if err != nil { + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} + +// Get returns a species category by ID. +func (m SpeciesCategoryModel) Get(id int) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + category, err := scanSpeciesCategory(m.DB.QueryRowContext(ctx, ` + SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle + FROM species_categories WHERE id = $1`, id)) + if err != nil { + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} + +// GetAll lists all species categories in display order. +func (m SpeciesCategoryModel) GetAll() ([]storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle + FROM species_categories ORDER BY sort_order, lower(name), id`) + if err != nil { + return nil, err + } + defer rows.Close() + categories := []storage.SpeciesCategory{} + for rows.Next() { + category, scanErr := scanSpeciesCategory(rows) + if scanErr != nil { + return nil, scanErr + } + categories = append(categories, category) + } + return categories, rows.Err() +} + +// Update changes a species category using optimistic locking. +func (m SpeciesCategoryModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE species_categories + SET name = $1, sort_order = $2, active = $3, lifecycle = $4, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $5 AND version = $6 + RETURNING updated_at, version`, category.Name, category.SortOrder, category.Active, category.Lifecycle, category.ID, category.Version).Scan(&category.UpdatedAt, &category.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.SpeciesCategory{}, storage.ErrEditConflict + } + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} diff --git a/internal/storage/postgres/species_integration_test.go b/internal/storage/postgres/species_integration_test.go new file mode 100644 index 0000000..560764f --- /dev/null +++ b/internal/storage/postgres/species_integration_test.go @@ -0,0 +1,35 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" +) + +func TestSpeciesModelListsSeededGlobalSpecies(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + species, err := (SpeciesModel{DB: db}).GetAllForGarden(2_147_483_647) + if err != nil { + t.Fatalf("list global species: %v", err) + } + + for _, item := range species { + if item.GardenID == nil && item.CommonName == "Tomate" { + return + } + } + t.Fatal("seeded global species Tomate was not returned") +} diff --git a/internal/storage/postgres/species_task_templates.go b/internal/storage/postgres/species_task_templates.go new file mode 100644 index 0000000..31b4a58 --- /dev/null +++ b/internal/storage/postgres/species_task_templates.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesTaskTemplateModel stores recurring species task rules in PostgreSQL. +// SpeciesTaskTemplateModel implements storage.SpeciesTaskTemplateModelInterface +// and resolves global species through the requesting garden boundary. +type SpeciesTaskTemplateModel struct{ DB *sql.DB } + +const speciesTaskTemplateColumns = `t.id, t.species_id, t.origin, t.title, t.description, t.trigger_type, + t.month_from, t.day_from, t.month_to, t.day_to, t.offset_days_from, t.offset_days_to, + t.interval_days, t.trigger_offset, t.trigger_offset_unit, t.duration, t.duration_unit, + t.recurrence, t.recurrence_interval, t.priority, t.active, t.created_at, t.updated_at, t.version` + +func scanSpeciesTaskTemplate(s scanner) (storage.SpeciesTaskTemplate, error) { + var template storage.SpeciesTaskTemplate + err := s.Scan( + &template.ID, &template.SpeciesID, &template.Origin, &template.Title, &template.Description, + &template.TriggerType, &template.MonthFrom, &template.DayFrom, &template.MonthTo, + &template.DayTo, &template.OffsetDaysFrom, &template.OffsetDaysTo, + &template.IntervalDays, &template.TriggerOffset, &template.TriggerOffsetUnit, &template.Duration, &template.DurationUnit, + &template.Recurrence, &template.RecurrenceInterval, &template.Priority, &template.Active, &template.CreatedAt, + &template.UpdatedAt, &template.Version, + ) + return template, err +} + +// Insert creates a task template for a species. +func (m SpeciesTaskTemplateModel) Insert(template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO species_task_templates ( + species_id, origin, title, description, trigger_type, month_from, day_from, + month_to, day_to, offset_days_from, offset_days_to, interval_days, + trigger_offset, trigger_offset_unit, duration, duration_unit, recurrence, recurrence_interval, priority, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING id, created_at, updated_at, version`, + template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType, + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo, + template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays, + template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit, + template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, + ).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt, &template.Version) + if err != nil { + return storage.SpeciesTaskTemplate{}, err + } + return template, nil +} + +// Get returns a task template visible in a garden. +func (m SpeciesTaskTemplateModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + template, err := scanSpeciesTaskTemplate(m.DB.QueryRowContext(ctx, + `SELECT `+speciesTaskTemplateColumns+` FROM species_task_templates t JOIN species s ON s.id = t.species_id WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.id = $2`, gardenID, id)) + if err != nil { + return storage.SpeciesTaskTemplate{}, recordError(err) + } + return template, nil +} + +// GetAllForSpecies lists task templates for a species visible in a garden. +func (m SpeciesTaskTemplateModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesTaskTemplateColumns+` + FROM species_task_templates t JOIN species s ON s.id = t.species_id + WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.species_id = $2 + ORDER BY t.active DESC, t.priority DESC, t.title, t.id`, gardenID, speciesID) + if err != nil { + return nil, err + } + defer rows.Close() + + templates := []storage.SpeciesTaskTemplate{} + for rows.Next() { + template, err := scanSpeciesTaskTemplate(rows) + if err != nil { + return nil, err + } + templates = append(templates, template) + } + return templates, rows.Err() +} + +// Update changes a task template using optimistic locking. +func (m SpeciesTaskTemplateModel) Update(gardenID int, template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE species_task_templates SET species_id = $1, origin = $2, title = $3, description = $4, + trigger_type = $5, month_from = $6, day_from = $7, month_to = $8, + day_to = $9, offset_days_from = $10, offset_days_to = $11, + interval_days = $12, trigger_offset = $13, trigger_offset_unit = $14, + duration = $15, duration_unit = $16, recurrence = $17, recurrence_interval = $18, + priority = $19, active = $20, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $21 AND version = $22 + AND EXISTS (SELECT 1 FROM species s WHERE s.id = species_task_templates.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($23, 0)) + RETURNING updated_at, version`, + template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType, + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo, + template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays, + template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit, + template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, template.ID, template.Version, gardenID, + ).Scan(&template.UpdatedAt, &template.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.SpeciesTaskTemplate{}, storage.ErrEditConflict + } + return storage.SpeciesTaskTemplate{}, err + } + return template, nil +} + +// Delete removes a task template visible in a garden. +func (m SpeciesTaskTemplateModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM species_task_templates t USING species s WHERE t.id = $1 AND s.id = t.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($2, 0)`, id, gardenID) +} diff --git a/internal/storage/postgres/tags.go b/internal/storage/postgres/tags.go new file mode 100644 index 0000000..b17cff6 --- /dev/null +++ b/internal/storage/postgres/tags.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "database/sql" + "fmt" + + "gardomatic.kleiax.de/internal/storage" +) + +// TagModel manages global and garden-local tags for supported entity types. +type TagModel struct{ DB *sql.DB } + +// GetAllForGarden lists global and garden-local tag names. +func (m TagModel) GetAllForGarden(gardenID int) ([]string, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT DISTINCT name FROM tags WHERE garden_id=$1 ORDER BY name`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func tagRelation(entity storage.TagEntity) (string, string, error) { + switch entity { + case storage.TagEntityTask: + return "task_tags", "task_id", nil + case storage.TagEntityPlant: + return "plant_tags", "plant_id", nil + case storage.TagEntitySpecies: + return "species_tags", "species_id", nil + case storage.TagEntityJournal: + return "journal_entry_tags", "journal_entry_id", nil + default: + return "", "", fmt.Errorf("unknown tag entity %q", entity) + } +} + +// Get lists tags assigned to an entity within its garden. +func (m TagModel) Get(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) { + table, column, err := tagRelation(entity) + if err != nil { + return nil, err + } + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT t.name FROM tags t JOIN `+table+` et ON et.tag_id=t.id WHERE t.garden_id IS NOT DISTINCT FROM NULLIF($1,0) AND et.`+column+`=$2 ORDER BY t.name`, gardenID, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +// Set atomically replaces an entity's tags after verifying that the entity +// belongs to the supplied garden. +func (m TagModel) Set(gardenID int, entity storage.TagEntity, entityID int, tags []string) ([]string, error) { + table, column, err := tagRelation(entity) + if err != nil { + return nil, err + } + tags = storage.NormalizeTags(tags) + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE `+column+`=$1`, entityID); err != nil { + return nil, err + } + for _, tag := range tags { + var tagID int + if gardenID == 0 { + err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES(NULL,$1) ON CONFLICT(name) WHERE garden_id IS NULL DO UPDATE SET name=EXCLUDED.name RETURNING id`, tag).Scan(&tagID) + } else { + err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES($1,$2) ON CONFLICT(garden_id,name) DO UPDATE SET name=EXCLUDED.name RETURNING id`, gardenID, tag).Scan(&tagID) + } + if err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO `+table+`(`+column+`,tag_id) VALUES($1,$2)`, entityID, tagID); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + return tags, nil +} diff --git a/internal/storage/postgres/task_generation_integration_test.go b/internal/storage/postgres/task_generation_integration_test.go new file mode 100644 index 0000000..8e670d8 --- /dev/null +++ b/internal/storage/postgres/task_generation_integration_test.go @@ -0,0 +1,81 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "sync" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestGeneratedTaskInsertIsIdempotentUnderConcurrency(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatal(err) + } + var userID, gardenID, speciesID, plantID, templateID int + if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Generator integration', $1, 'hash', true) RETURNING id`, "generator-"+time.Now().Format("150405.000000000")+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Generator integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + if err := db.QueryRow(`INSERT INTO species (garden_id, common_name) VALUES ($1, 'Parallelrose') RETURNING id`, gardenID).Scan(&speciesID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO plants (garden_id, species_id, name) VALUES ($1, $2, 'Rose') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, month_to) VALUES ($1, 'Schneiden', 'month_of_year', 2, 3) RETURNING id`, speciesID).Scan(&templateID); err != nil { + t.Fatal(err) + } + + generatedFor := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + dueStart, dueEnd := generatedFor, time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC) + model := TaskModel{DB: db} + const workers = 12 + results := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := model.Insert(storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: "Schneiden", DueAtStart: &dueStart, DueAtEnd: &dueEnd, GeneratedFor: &generatedFor, CreatedBy: userID}) + results <- err + }() + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, storage.ErrConflict) { + conflicts++ + } else { + t.Fatalf("insert error: %v", err) + } + } + if successes != 1 || conflicts != workers-1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + var count int + if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE plant_id = $1 AND template_id = $2 AND generated_for = $3`, plantID, templateID, generatedFor).Scan(&count); err != nil || count != 1 { + t.Fatalf("count=%d err=%v", count, err) + } +} diff --git a/internal/storage/postgres/task_priorities.go b/internal/storage/postgres/task_priorities.go new file mode 100644 index 0000000..7dc8875 --- /dev/null +++ b/internal/storage/postgres/task_priorities.go @@ -0,0 +1,73 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskPriorityModel implements storage.TaskPriorityModelInterface for the +// application-wide priority catalogue. +type TaskPriorityModel struct{ DB *sql.DB } + +func scanTaskPriority(s scanner) (storage.TaskPriority, error) { + var priority storage.TaskPriority + err := s.Scan(&priority.ID, &priority.Name, &priority.Value, &priority.SortOrder, &priority.Active, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version) + return priority, err +} + +// Insert creates a priority catalogue entry. +func (m TaskPriorityModel) Insert(priority storage.TaskPriority) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `INSERT INTO task_priorities (name, value, sort_order, active) VALUES ($1,$2,$3,$4) RETURNING id, created_at, updated_at, version`, priority.Name, priority.Value, priority.SortOrder, priority.Active).Scan(&priority.ID, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version) + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} + +// Get returns a priority catalogue entry by ID. +func (m TaskPriorityModel) Get(id int) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + priority, err := scanTaskPriority(m.DB.QueryRowContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities WHERE id=$1`, id)) + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} + +// GetAll lists all priority catalogue entries in display order. +func (m TaskPriorityModel) GetAll() ([]storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities ORDER BY sort_order, value, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.TaskPriority{} + for rows.Next() { + value, err := scanTaskPriority(rows) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +// Update changes a priority catalogue entry using optimistic locking. +func (m TaskPriorityModel) Update(priority storage.TaskPriority) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE task_priorities SET name=$1,value=$2,sort_order=$3,active=$4,updated_at=CURRENT_TIMESTAMP,version=version+1 WHERE id=$5 AND version=$6 RETURNING updated_at,version`, priority.Name, priority.Value, priority.SortOrder, priority.Active, priority.ID, priority.Version).Scan(&priority.UpdatedAt, &priority.Version) + if err == sql.ErrNoRows { + return storage.TaskPriority{}, storage.ErrEditConflict + } + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} diff --git a/internal/storage/postgres/task_template_opt_outs.go b/internal/storage/postgres/task_template_opt_outs.go new file mode 100644 index 0000000..2b9f756 --- /dev/null +++ b/internal/storage/postgres/task_template_opt_outs.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskTemplateOptOutModel stores per-plant task-generation suppression while +// enforcing the garden boundary. +type TaskTemplateOptOutModel struct{ DB *sql.DB } + +// IsOptedOut reports whether task generation is suppressed for a plant-template pair. +func (m TaskTemplateOptOutModel) IsOptedOut(gardenID, plantID, templateID int) (bool, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var value bool + err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id JOIN species_task_templates st ON st.id=o.template_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3 AND st.species_id=p.species_id)`, gardenID, plantID, templateID).Scan(&value) + return value, err +} + +// GetAllForPlant lists suppressed template IDs for a plant. +func (m TaskTemplateOptOutModel) GetAllForPlant(gardenID, plantID int) ([]int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT o.template_id FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id WHERE p.garden_id=$1 AND p.id=$2 ORDER BY o.template_id`, gardenID, plantID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []int{} + for rows.Next() { + var id int + if err = rows.Scan(&id); err != nil { + return nil, err + } + result = append(result, id) + } + return result, rows.Err() +} + +// Set creates or removes suppression for a plant-template pair. +func (m TaskTemplateOptOutModel) Set(gardenID, plantID, templateID int, optedOut bool) error { + ctx, cancel := contextWithTimeout() + defer cancel() + var valid bool + err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM plants p JOIN species_task_templates st ON st.species_id=p.species_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3)`, gardenID, plantID, templateID).Scan(&valid) + if err != nil { + return err + } + if !valid { + return storage.ErrRecordNotFound + } + if optedOut { + _, err = m.DB.ExecContext(ctx, `INSERT INTO task_template_opt_outs(plant_id,template_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, plantID, templateID) + } else { + _, err = m.DB.ExecContext(ctx, `DELETE FROM task_template_opt_outs WHERE plant_id=$1 AND template_id=$2`, plantID, templateID) + } + return err +} diff --git a/internal/storage/postgres/tasks.go b/internal/storage/postgres/tasks.go new file mode 100644 index 0000000..6a5083c --- /dev/null +++ b/internal/storage/postgres/tasks.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskModel stores garden tasks in PostgreSQL. +// TaskModel implements storage.TaskModelInterface for garden-scoped work items. +type TaskModel struct{ DB *sql.DB } + +const taskColumns = `id, garden_id, plant_id, location_id, template_id, title, + description, due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by, + priority, active, created_by, created_at, updated_at, version, plant_status_on_completion` + +func scanTask(s scanner) (storage.Task, error) { + var task storage.Task + err := s.Scan( + &task.ID, &task.GardenID, &task.PlantID, &task.LocationID, &task.TemplateID, + &task.Title, &task.Description, &task.DueAtStart, &task.DueAtEnd, + &task.GeneratedFor, &task.Recurrence, &task.RecurrenceInterval, &task.RepeatFromID, &task.CompletedAt, &task.CompletedBy, &task.Priority, &task.Active, + &task.CreatedBy, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.PlantStatusOnCompletion, + ) + return task, err +} + +// Insert creates a task; generated task slots remain idempotent under concurrency. +func (m TaskModel) Insert(task storage.Task) (storage.Task, error) { + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO tasks ( + garden_id, plant_id, location_id, template_id, title, description, + due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by, + priority, active, created_by, plant_status_on_completion) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING id, created_at, updated_at, version`, + task.GardenID, task.PlantID, task.LocationID, task.TemplateID, task.Title, + task.Description, task.DueAtStart, task.DueAtEnd, task.GeneratedFor, + task.Recurrence, task.RecurrenceInterval, task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.CreatedBy, task.PlantStatusOnCompletion, + ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt, &task.Version) + if err != nil { + return storage.Task{}, recordError(err) + } + return task, nil +} + +// Get returns a task within its garden. +func (m TaskModel) Get(gardenID, id int) (storage.Task, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + task, err := scanTask(m.DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id)) + if err != nil { + return storage.Task{}, recordError(err) + } + return task, nil +} + +// GetAllForGarden lists tasks in a garden. +func (m TaskModel) GetAllForGarden(gardenID int) ([]storage.Task, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+taskColumns+` + FROM tasks WHERE garden_id = $1 + ORDER BY completed_at NULLS FIRST, due_at_end NULLS LAST, priority DESC, id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + tasks := []storage.Task{} + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +// Update changes a task using optimistic locking. +func (m TaskModel) Update(gardenID int, task storage.Task) (storage.Task, error) { + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE tasks SET plant_id = $1, location_id = $2, template_id = $3, + title = $4, description = $5, due_at_start = $6, due_at_end = $7, + generated_for = $8, recurrence = $9, recurrence_interval = $10, repeat_from_id = $11, completed_at = $12, completed_by = $13, + priority = $14, active = $15, plant_status_on_completion=$16, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $17 AND id = $18 AND version = $19 + RETURNING updated_at, version`, + task.PlantID, task.LocationID, task.TemplateID, task.Title, task.Description, + task.DueAtStart, task.DueAtEnd, task.GeneratedFor, task.Recurrence, task.RecurrenceInterval, + task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.PlantStatusOnCompletion, gardenID, task.ID, task.Version, + ).Scan(&task.UpdatedAt, &task.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Task{}, storage.ErrEditConflict + } + return storage.Task{}, err + } + return task, nil +} + +// Delete removes a task within its garden. +func (m TaskModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/tasks_integration_test.go b/internal/storage/postgres/tasks_integration_test.go new file mode 100644 index 0000000..bff037e --- /dev/null +++ b/internal/storage/postgres/tasks_integration_test.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestTaskModelEnforcesGardenBoundary(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var userID, gardenID, foreignGardenID int + if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Task integration', $1, 'hash', true) RETURNING id`, "task-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + + model := TaskModel{DB: db} + local, err := model.Insert(storage.Task{GardenID: gardenID, Title: "Gießen", CreatedBy: userID}) + if err != nil { + t.Fatalf("insert task: %v", err) + } + foreign, err := model.Insert(storage.Task{GardenID: foreignGardenID, Title: "Fremd", CreatedBy: userID}) + if err != nil { + t.Fatalf("insert foreign task: %v", err) + } + if _, err := model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign lookup: got %v", err) + } + local.Title = "Kräftig gießen" + updated, err := model.Update(gardenID, local) + if err != nil || updated.Version != 2 { + t.Fatalf("update: task=%+v err=%v", updated, err) + } + if err := model.Delete(foreignGardenID, local.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign delete: got %v", err) + } + if listed, err := model.GetAllForGarden(gardenID); err != nil || len(listed) != 1 || listed[0].ID != local.ID { + t.Fatalf("list: tasks=%+v err=%v", listed, err) + } +} diff --git a/internal/storage/postgres/tokens.go b/internal/storage/postgres/tokens.go new file mode 100644 index 0000000..2e4e563 --- /dev/null +++ b/internal/storage/postgres/tokens.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "gardomatic.kleiax.de/internal/auth" +) + +// TokenModel stores scoped authentication token hashes in PostgreSQL. +// TokenModel persists only token hashes; plaintext tokens are returned once to +// the caller and never stored. +type TokenModel struct { + DB *sql.DB +} + +// New creates a token and persists only its hash. +func (m TokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) { + token := auth.NewToken(userID, ttl, scope) + + err := m.insert(token) + return token, err +} + +func (m TokenModel) insert(token auth.Token) error { + query := ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)` + + args := []any{token.Hash, token.UserID, token.Expiry, token.Scope} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, args...) + return err +} + +// DeleteAllForUser revokes every token for one user and scope. +func (m TokenModel) DeleteAllForUser(scope string, userID int) error { + query := ` + DELETE FROM tokens + WHERE scope = $1 AND user_id = $2` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, scope, userID) + return err +} diff --git a/internal/storage/postgres/users.go b/internal/storage/postgres/users.go new file mode 100644 index 0000000..3006cac --- /dev/null +++ b/internal/storage/postgres/users.go @@ -0,0 +1,424 @@ +package postgres + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "errors" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// UserModel stores user accounts in PostgreSQL. +// UserModel implements storage.UserModelInterface for PostgreSQL accounts. +type UserModel struct { + DB *sql.DB +} + +// Insert creates a user account. +func (m UserModel) Insert(user storage.User) (storage.User, error) { + query := ` + INSERT INTO users (name, email, password_hash, activated, application_role) + VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM users WHERE deleted_at IS NULL) THEN $5 ELSE $6 END) + RETURNING id, created_at, color, application_role, version` + + args := []any{user.Name, user.Email, user.Password.Get(), user.Activated, storage.ApplicationRoleUser, storage.ApplicationRoleAdmin} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.User{}, err + } + defer tx.Rollback() + // Serialize registrations until a first administrator exists. Without this, + // two simultaneous registrations could both observe an empty users table. + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(71432026)`); err != nil { + return storage.User{}, err + } + err = tx.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Color, &user.Role, &user.Version) + if err != nil { + var pqErr *pq.Error + switch { + case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key": + return storage.User{}, storage.ErrDuplicateEmail + default: + return storage.User{}, err + } + } + if err = tx.Commit(); err != nil { + return storage.User{}, err + } + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetByID returns a non-deleted account with resolved application permissions. +func (m UserModel) GetByID(id int) (storage.User, error) { + query := ` + SELECT id, created_at, updated_at, name, email, password_hash, activated, color, application_role, version + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.CreatedAt, + &user.UpdatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetByEmail returns a non-deleted account by case-insensitive email. +func (m UserModel) GetByEmail(email string) (storage.User, error) { + query := ` + SELECT id, created_at, name, email, password_hash, activated, color, application_role, version + FROM users + WHERE email = $1 AND deleted_at IS NULL` + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.CreatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// Update changes an account using optimistic locking. +func (m UserModel) Update(user storage.User) (storage.User, error) { + query := ` + UPDATE users + SET name = $1, email = $2, password_hash = $3, activated = $4, color = $5, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $6 AND version = $7 AND deleted_at IS NULL + RETURNING updated_at, version` + + args := []any{ + user.Name, + user.Email, + user.Password.Get(), + user.Activated, + user.Color, + user.ID, + user.Version, + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.UpdatedAt, &user.Version) + if err != nil { + var pqErr *pq.Error + switch { + case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key": + return storage.User{}, storage.ErrDuplicateEmail + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrEditConflict + default: + return storage.User{}, err + } + } + + return user, nil +} + +// GetForToken resolves a non-expired hashed token to its user. +func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (storage.User, error) { + tokenHash := sha256.Sum256([]byte(tokenPlaintext)) + + query := ` + SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.color, users.application_role, users.version + FROM users + INNER JOIN tokens + ON users.id = tokens.user_id + WHERE tokens.hash = $1 + AND tokens.scope = $2 + AND tokens.expiry > $3 + AND users.deleted_at IS NULL` + + args := []any{tokenHash[:], tokenScope, time.Now()} + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan( + &user.ID, + &user.CreatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetAll lists non-deleted accounts with resolved application permissions. +func (m UserModel) GetAll() ([]storage.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id, created_at, updated_at, name, email, activated, color, application_role, version FROM users WHERE deleted_at IS NULL ORDER BY name, email, id`) + if err != nil { + return nil, err + } + users := []storage.User{} + for rows.Next() { + var user storage.User + if err := rows.Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt, &user.Name, &user.Email, &user.Activated, &user.Color, &user.Role, &user.Version); err != nil { + return nil, err + } + users = append(users, user) + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err = rows.Close(); err != nil { + return nil, err + } + for i := range users { + if err = m.loadPermissions(&users[i]); err != nil { + return nil, err + } + } + return users, nil +} + +// UpdateRole changes an account's application role. +func (m UserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := m.DB.ExecContext(ctx, `UPDATE users SET application_role=$1, updated_at=CURRENT_TIMESTAMP, version=version+1 WHERE id=$2 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='application')`, role, userID) + if err != nil { + return storage.User{}, err + } + count, err := result.RowsAffected() + if err != nil { + return storage.User{}, err + } + if count == 0 { + return storage.User{}, storage.ErrRecordNotFound + } + return m.GetByID(userID) +} + +// Delete removes access and personal account data while retaining the user row +// as an anonymized author for historical garden content. +// Delete anonymizes an account while preserving authored garden content and +// removes authentication material and memberships transactionally. +func (m UserModel) Delete(userID int) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var email string + err = tx.QueryRowContext(ctx, `SELECT email FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&email) + if err != nil { + return recordError(err) + } + rows, err := tx.QueryContext(ctx, `SELECT garden_id FROM garden_members WHERE user_id=$1 ORDER BY garden_id`, userID) + if err != nil { + return err + } + gardenIDs := []int{} + for rows.Next() { + var gardenID int + if err = rows.Scan(&gardenID); err != nil { + rows.Close() + return err + } + gardenIDs = append(gardenIDs, gardenID) + } + if err = rows.Close(); err != nil { + return err + } + if err = rows.Err(); err != nil { + return err + } + for _, gardenID := range gardenIDs { + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + } + var ownsGarden bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM garden_members WHERE user_id=$1 AND role='owner')`, userID).Scan(&ownsGarden); err != nil { + return err + } + if ownsGarden { + return storage.ErrConflict + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE user_id=$1`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_invites WHERE invited_by=$1 OR email=$2`, userID, email); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id=$1`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil { + return err + } + result, err := tx.ExecContext(ctx, ` + UPDATE users SET name='Gelöschter Nutzer', email='deleted-' || id::text || '@invalid', + activated=false, application_role=$2, deleted_at=now(), updated_at=now(), version=version+1 + WHERE id=$1 AND deleted_at IS NULL`, userID, storage.ApplicationRoleUser) + if err != nil { + return err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return countErr + } else if count == 0 { + return storage.ErrRecordNotFound + } + return tx.Commit() +} + +func (m UserModel) loadPermissions(user *storage.User) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT permission FROM role_permissions WHERE role_name=$1 ORDER BY permission`, user.Role) + if err != nil { + return err + } + defer rows.Close() + user.Permissions = []storage.ApplicationPermission{} + for rows.Next() { + var permission storage.ApplicationPermission + if err := rows.Scan(&permission); err != nil { + return err + } + user.Permissions = append(user.Permissions, permission) + } + return rows.Err() +} + +// CreateEmailChange stores a pending address and returns its one-time plaintext token. +func (m UserModel) CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) { + plaintext := rand.Text() + hash := sha256.Sum256([]byte(plaintext)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := m.DB.ExecContext(ctx, `INSERT INTO user_email_changes (user_id,email,token_hash,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id) DO UPDATE SET email=EXCLUDED.email,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,created_at=now()`, userID, email, hash[:], time.Now().Add(ttl)) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { + return "", storage.ErrDuplicateEmail + } + return "", err + } + return plaintext, nil +} + +// ConfirmEmailChange consumes a token and applies its pending address atomically. +func (m UserModel) ConfirmEmailChange(tokenPlaintext string, expectedUserID int) (storage.User, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.User{}, err + } + defer tx.Rollback() + var userID int + var email string + err = tx.QueryRowContext(ctx, `SELECT user_id,email FROM user_email_changes WHERE token_hash=$1 AND user_id=$2 AND expires_at>now() FOR UPDATE`, hash[:], expectedUserID).Scan(&userID, &email) + if err != nil { + return storage.User{}, recordError(err) + } + _, err = tx.ExecContext(ctx, `UPDATE users SET email=$1,updated_at=now(),version=version+1 WHERE id=$2 AND deleted_at IS NULL`, email, userID) + if err != nil { + return storage.User{}, err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil { + return storage.User{}, err + } + if err = tx.Commit(); err != nil { + return storage.User{}, err + } + return m.GetByID(userID) +} diff --git a/internal/storage/postgres/users_integration_test.go b/internal/storage/postgres/users_integration_test.go new file mode 100644 index 0000000..2f3458b --- /dev/null +++ b/internal/storage/postgres/users_integration_test.go @@ -0,0 +1,89 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" + _ "github.com/lib/pq" +) + +func TestDeleteUserAnonymizesAccountAndPreservesAuthoredContent(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "") + originalEmail := "delete-" + stamp + "@example.com" + var ownerID, targetID, gardenID, entryID int + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Owner',$1,'hash',true) RETURNING id`, "owner-delete-"+stamp+"@example.com").Scan(&ownerID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Personal Name',$1,'hash',true) RETURNING id`, originalEmail).Scan(&targetID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Deletion integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1,$2)`, ownerID, targetID) + }) + if _, err = db.Exec(`INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,'owner'),($1,$3,'member')`, gardenID, ownerID, targetID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO journal_entries (garden_id,author_id,title) VALUES ($1,$2,'Bleibt erhalten') RETURNING id`, gardenID, targetID).Scan(&entryID); err != nil { + t.Fatal(err) + } + + users := UserModel{DB: db} + if err = users.Delete(targetID); err != nil { + t.Fatal(err) + } + if _, err = users.GetByEmail(originalEmail); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("deleted email still resolves: %v", err) + } + if _, err = users.GetByID(targetID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("deleted user ID still resolves: %v", err) + } + var name, email string + var activated bool + var deletedAt time.Time + if err = db.QueryRow(`SELECT name,email,activated,deleted_at FROM users WHERE id=$1`, targetID).Scan(&name, &email, &activated, &deletedAt); err != nil { + t.Fatal(err) + } + if name != "Gelöschter Nutzer" || email == originalEmail || activated || deletedAt.IsZero() { + t.Fatalf("account was not anonymized: name=%q email=%q activated=%t deleted_at=%v", name, email, activated, deletedAt) + } + var membershipCount, entryCount int + if err = db.QueryRow(`SELECT count(*) FROM garden_members WHERE user_id=$1`, targetID).Scan(&membershipCount); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`SELECT count(*) FROM journal_entries WHERE id=$1 AND author_id=$2`, entryID, targetID).Scan(&entryCount); err != nil { + t.Fatal(err) + } + if membershipCount != 0 || entryCount != 1 { + t.Fatalf("membership=%d preserved entries=%d", membershipCount, entryCount) + } + var replacementID int + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Replacement',$1,'hash',true) RETURNING id`, originalEmail).Scan(&replacementID); err != nil { + t.Fatalf("original email was not released: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, replacementID) }) + if err = users.Delete(ownerID); !errors.Is(err, storage.ErrConflict) { + t.Fatalf("owner deletion error: got %v, want conflict", err) + } +} diff --git a/internal/storage/roles.go b/internal/storage/roles.go new file mode 100644 index 0000000..7dc424f --- /dev/null +++ b/internal/storage/roles.go @@ -0,0 +1,47 @@ +package storage + +import "time" + +// RoleScope separates application-wide privileges from garden membership roles. +type RoleScope string + +// Supported role scopes. +const ( + RoleScopeApplication RoleScope = "application" + RoleScopeGarden RoleScope = "garden" +) + +// Role is a reusable, globally defined bundle of permissions. +type Role struct { + Name string `json:"name"` + Scope RoleScope `json:"scope"` + GardenID *int `json:"garden_id,omitempty"` + Label string `json:"label"` + System bool `json:"system"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GardenRolePermissionOverride grants or revokes one role permission in a +// single garden without modifying the shared role template. +type GardenRolePermissionOverride struct { + GardenID int `json:"garden_id"` + RoleName string `json:"role_name"` + Permission string `json:"permission"` + Granted bool `json:"granted"` +} + +// RoleModelInterface manages application roles, garden role templates, and +// garden-specific permission overrides. +type RoleModelInterface interface { + List(scope RoleScope) ([]Role, error) + ListForGarden(gardenID int) ([]Role, error) + Get(name string) (Role, error) + GetForGarden(gardenID int, name string) (Role, error) + Create(role Role) (Role, error) + Update(role Role) (Role, error) + Delete(name string) error + ListGardenOverrides(gardenID int) ([]GardenRolePermissionOverride, error) + ReplaceGardenOverrides(gardenID int, roleName string, overrides []GardenRolePermissionOverride) error +} diff --git a/internal/storage/species.go b/internal/storage/species.go new file mode 100644 index 0000000..786f12b --- /dev/null +++ b/internal/storage/species.go @@ -0,0 +1,106 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// SpeciesModelInterface persists global and garden-specific species data. +type SpeciesModelInterface interface { + Insert(species Species) (Species, error) + Get(gardenID, id int) (Species, error) + GetAllForGarden(gardenID int) ([]Species, error) + Update(gardenID int, species Species) (Species, error) + Delete(gardenID, id int) error +} + +// Species contains reusable botanical and cultivation master data. +type Species struct { + ID int `json:"id"` + GardenID *int `json:"garden_id,omitempty"` + CommonName string `json:"common_name"` + Cultivar string `json:"cultivar"` + BotanicalName string `json:"botanical_name"` + CategoryID *int `json:"category_id,omitempty"` + Category string `json:"category"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + WinterProtection *string `json:"winter_protection,omitempty"` + SpacingCM *int `json:"spacing_cm,omitempty"` + HeightCM *int `json:"height_cm,omitempty"` + SowMonthFrom *int `json:"sow_month_from,omitempty"` + SowDayFrom *int `json:"sow_day_from,omitempty"` + SowMonthTo *int `json:"sow_month_to,omitempty"` + SowDayTo *int `json:"sow_day_to,omitempty"` + PlantingMonthFrom *int `json:"planting_month_from,omitempty"` + PlantingDayFrom *int `json:"planting_day_from,omitempty"` + PlantingMonthTo *int `json:"planting_month_to,omitempty"` + PlantingDayTo *int `json:"planting_day_to,omitempty"` + HarvestMonthFrom *int `json:"harvest_month_from,omitempty"` + HarvestDayFrom *int `json:"harvest_day_from,omitempty"` + HarvestMonthTo *int `json:"harvest_month_to,omitempty"` + HarvestDayTo *int `json:"harvest_day_to,omitempty"` + Notes string `json:"notes"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` +} + +// ValidateSpecies applies persistence-independent species validation rules. +func ValidateSpecies(v *validate.Validator, species Species) { + v.Check(strings.TrimSpace(species.CommonName) != "", "common_name", "must be provided") + v.Check(len(species.CommonName) <= 500, "common_name", "must not be more than 500 bytes long") + v.Check(len(species.Cultivar) <= 500, "cultivar", "must not be more than 500 bytes long") + v.Check(len(species.BotanicalName) <= 500, "botanical_name", "must not be more than 500 bytes long") + if species.CategoryID != nil { + v.Check(*species.CategoryID > 0, "category_id", "must be a positive integer") + } + v.Check(len(species.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long") + v.Check(len(species.Attributes) == 0 || json.Valid(species.Attributes), "attributes", "must be valid JSON") + ValidateTags(v, species.Tags) + validateOptionalEnum(v, "sun_exposure", species.SunExposure, "sunny", "partial_shade", "shade") + validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy") + validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral") + validateOptionalPositive(v, "spacing_cm", species.SpacingCM) + validateOptionalPositive(v, "height_cm", species.HeightCM) + validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12) + validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31) + validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12) + validateOptionalCalendarPart(v, "sow_day_to", species.SowDayTo, 31) + validateOptionalCalendarPart(v, "planting_month_from", species.PlantingMonthFrom, 12) + validateOptionalCalendarPart(v, "planting_day_from", species.PlantingDayFrom, 31) + validateOptionalCalendarPart(v, "planting_month_to", species.PlantingMonthTo, 12) + validateOptionalCalendarPart(v, "planting_day_to", species.PlantingDayTo, 31) + validateOptionalCalendarPart(v, "harvest_month_from", species.HarvestMonthFrom, 12) + validateOptionalCalendarPart(v, "harvest_day_from", species.HarvestDayFrom, 31) + validateOptionalCalendarPart(v, "harvest_month_to", species.HarvestMonthTo, 12) + validateOptionalCalendarPart(v, "harvest_day_to", species.HarvestDayTo, 31) +} + +func validateOptionalEnum(v *validate.Validator, field string, value *string, allowed ...string) { + if value != nil { + v.Check(validate.PermittedValue(*value, allowed...), field, "is invalid") + } +} + +func validateOptionalPositive(v *validate.Validator, field string, value *int) { + if value != nil { + v.Check(*value > 0, field, "must be greater than zero") + } +} + +func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) { + if value != nil { + v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range") + } +} diff --git a/internal/storage/species_categories.go b/internal/storage/species_categories.go new file mode 100644 index 0000000..de6b3fb --- /dev/null +++ b/internal/storage/species_categories.go @@ -0,0 +1,35 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// SpeciesCategoryModelInterface persists the configurable species taxonomy. +type SpeciesCategoryModelInterface interface { + Insert(category SpeciesCategory) (SpeciesCategory, error) + Get(id int) (SpeciesCategory, error) + GetAll() ([]SpeciesCategory, error) + Update(category SpeciesCategory) (SpeciesCategory, error) +} + +// SpeciesCategory is a globally managed option for classifying species. +type SpeciesCategory struct { + ID int `json:"id"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Lifecycle *string `json:"lifecycle,omitempty"` +} + +// ValidateSpeciesCategory applies validation rules independent of persistence. +func ValidateSpeciesCategory(v *validate.Validator, category SpeciesCategory) { + v.Check(strings.TrimSpace(category.Name) != "", "name", "must be provided") + v.Check(len(category.Name) <= 200, "name", "must not be more than 200 bytes long") + validateOptionalEnum(v, "lifecycle", category.Lifecycle, "annual", "biennial", "perennial") +} diff --git a/internal/storage/species_task_templates.go b/internal/storage/species_task_templates.go new file mode 100644 index 0000000..e8524c3 --- /dev/null +++ b/internal/storage/species_task_templates.go @@ -0,0 +1,117 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskTriggerType identifies how a species task template calculates its due window. +type TaskTriggerType string + +// TaskDurationUnit is the calendar unit used for offsets and durations. +type TaskDurationUnit string + +// TaskTemplateOrigin identifies whether a template was entered manually or derived from species data. +type TaskTemplateOrigin string + +// Supported task duration units. +const ( + TaskDurationDay TaskDurationUnit = "day" + TaskDurationWeek TaskDurationUnit = "week" + TaskDurationMonth TaskDurationUnit = "month" +) + +// Supported task trigger types. +const ( + // TaskTriggerMonthOfYear repeats within a calendar-year date window. + TaskTriggerMonthOfYear TaskTriggerType = "month_of_year" + // TaskTriggerRelativeToPlanting is offset from the planting date. + TaskTriggerRelativeToPlanting TaskTriggerType = "relative_to_planting" + // TaskTriggerRelativeToLastTask is offset from the last completion. + TaskTriggerRelativeToLastTask TaskTriggerType = "relative_to_last_task" + TaskTriggerRelativeToSowing TaskTriggerType = "relative_to_sowing" + TaskTriggerRelativeToHarvest TaskTriggerType = "relative_to_harvest" + TaskTriggerRelativeToSpeciesPlanting TaskTriggerType = "relative_to_species_planting" +) + +// Supported template origins. +const ( + TaskTemplateOriginManual TaskTemplateOrigin = "manual" + TaskTemplateOriginSeasonSowing TaskTemplateOrigin = "season_sowing" + TaskTemplateOriginSeasonPlanting TaskTemplateOrigin = "season_planting" + TaskTemplateOriginSeasonHarvest TaskTemplateOrigin = "season_harvest" +) + +// SpeciesTaskTemplateModelInterface persists recurring rules for species tasks. +type SpeciesTaskTemplateModelInterface interface { + Insert(template SpeciesTaskTemplate) (SpeciesTaskTemplate, error) + Get(gardenID, id int) (SpeciesTaskTemplate, error) + GetAllForSpecies(gardenID, speciesID int) ([]SpeciesTaskTemplate, error) + Update(gardenID int, template SpeciesTaskTemplate) (SpeciesTaskTemplate, error) + Delete(gardenID, id int) error +} + +// ValidateSpeciesTaskTemplate applies scheduling and recurrence rules to a +// species task template. +func ValidateSpeciesTaskTemplate(v *validate.Validator, template SpeciesTaskTemplate) { + v.Check(strings.TrimSpace(template.Title) != "", "title", "must be provided") + v.Check(len(template.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(template.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(template.Priority >= -100 && template.Priority <= 100, "priority", "must be between -100 and 100") + v.Check(template.TriggerType == TaskTriggerMonthOfYear || template.TriggerType == TaskTriggerRelativeToPlanting || template.TriggerType == TaskTriggerRelativeToLastTask || template.TriggerType == TaskTriggerRelativeToSowing || template.TriggerType == TaskTriggerRelativeToHarvest || template.TriggerType == TaskTriggerRelativeToSpeciesPlanting, "trigger_type", "is invalid") + v.Check(template.Origin == TaskTemplateOriginManual || template.Origin == TaskTemplateOriginSeasonSowing || template.Origin == TaskTemplateOriginSeasonPlanting || template.Origin == TaskTemplateOriginSeasonHarvest, "origin", "is invalid") + if template.TriggerType == TaskTriggerMonthOfYear { + v.Check(template.MonthFrom != nil && *template.MonthFrom >= 1 && *template.MonthFrom <= 12, "month_from", "must be between 1 and 12") + validateTemplateDay(v, "day_from", template.DayFrom) + v.Check(template.DayFrom != nil, "day_from", "must be provided") + } else { + v.Check(template.TriggerOffset >= 0, "trigger_offset", "must not be negative") + } + v.Check(template.Duration >= 0, "duration", "must not be negative") + v.Check(validTaskDurationUnit(template.DurationUnit), "duration_unit", "is invalid") + v.Check(validTaskDurationUnit(template.TriggerOffsetUnit), "trigger_offset_unit", "is invalid") + v.Check(template.Recurrence == TaskRecurrenceNone || template.Recurrence == TaskRecurrenceDaily || template.Recurrence == TaskRecurrenceWeekly || template.Recurrence == TaskRecurrenceMonthly || template.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid") + if template.Recurrence != TaskRecurrenceNone { + v.Check(template.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero") + } +} + +func validTaskDurationUnit(unit TaskDurationUnit) bool { + return unit == TaskDurationDay || unit == TaskDurationWeek || unit == TaskDurationMonth +} + +func validateTemplateDay(v *validate.Validator, field string, value *int) { + if value != nil { + v.Check(*value >= 1 && *value <= 31, field, "must be between 1 and 31") + } +} + +// SpeciesTaskTemplate defines a recurring task rule for a species. +type SpeciesTaskTemplate struct { + ID int `json:"id"` + SpeciesID int `json:"species_id"` + Origin TaskTemplateOrigin `json:"origin"` + Title string `json:"title"` + Description string `json:"description"` + TriggerType TaskTriggerType `json:"trigger_type"` + MonthFrom *int `json:"month_from,omitempty"` + DayFrom *int `json:"day_from,omitempty"` + MonthTo *int `json:"month_to,omitempty"` + DayTo *int `json:"day_to,omitempty"` + OffsetDaysFrom *int `json:"offset_days_from,omitempty"` + OffsetDaysTo *int `json:"offset_days_to,omitempty"` + IntervalDays *int `json:"interval_days,omitempty"` + TriggerOffset int `json:"trigger_offset"` + TriggerOffsetUnit TaskDurationUnit `json:"trigger_offset_unit"` + Duration int `json:"duration"` + DurationUnit TaskDurationUnit `json:"duration_unit"` + Recurrence TaskRecurrence `json:"recurrence,omitempty"` + RecurrenceInterval int `json:"recurrence_interval"` + Priority int `json:"priority"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} diff --git a/internal/storage/tags.go b/internal/storage/tags.go new file mode 100644 index 0000000..b6de087 --- /dev/null +++ b/internal/storage/tags.go @@ -0,0 +1,50 @@ +package storage + +import ( + "sort" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TagEntity identifies a resource type that can be tagged. +type TagEntity string + +// Supported taggable entity types. +const ( + TagEntityTask TagEntity = "task" + TagEntityPlant TagEntity = "plant" + TagEntitySpecies TagEntity = "species" + TagEntityJournal TagEntity = "journal_entry" +) + +// TagModelInterface lists tags and atomically replaces an entity's tag set. +type TagModelInterface interface { + Get(gardenID int, entity TagEntity, entityID int) ([]string, error) + Set(gardenID int, entity TagEntity, entityID int, tags []string) ([]string, error) + GetAllForGarden(gardenID int) ([]string, error) +} + +// NormalizeTags trims, removes empty values, and deduplicates tags while +// preserving their first occurrence. +func NormalizeTags(values []string) []string { + seen := map[string]bool{} + result := []string{} + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value != "" && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + sort.Strings(result) + return result +} + +// ValidateTags applies count and length limits to a normalized tag list. +func ValidateTags(v *validate.Validator, tags []string) { + v.Check(len(tags) <= 20, "tags", "must contain at most 20 tags") + for _, tag := range tags { + v.Check(len(tag) <= 50, "tags", "each tag must contain at most 50 bytes") + } +} diff --git a/internal/storage/task_priorities.go b/internal/storage/task_priorities.go new file mode 100644 index 0000000..3c405d5 --- /dev/null +++ b/internal/storage/task_priorities.go @@ -0,0 +1,35 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskPriorityModelInterface manages the application-wide priority catalogue. +type TaskPriorityModelInterface interface { + Insert(priority TaskPriority) (TaskPriority, error) + Get(id int) (TaskPriority, error) + GetAll() ([]TaskPriority, error) + Update(priority TaskPriority) (TaskPriority, error) +} + +// TaskPriority maps a user-facing label to the numeric value stored on tasks. +type TaskPriority struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + SortOrder int `json:"sort_order"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ValidateTaskPriority applies catalogue-entry validation rules. +func ValidateTaskPriority(v *validate.Validator, priority TaskPriority) { + v.Check(strings.TrimSpace(priority.Name) != "", "name", "must be provided") + v.Check(len(priority.Name) <= 100, "name", "must not be more than 100 bytes long") + v.Check(priority.Value >= -100 && priority.Value <= 100, "value", "must be between -100 and 100") +} diff --git a/internal/storage/task_template_opt_outs.go b/internal/storage/task_template_opt_outs.go new file mode 100644 index 0000000..26fdb25 --- /dev/null +++ b/internal/storage/task_template_opt_outs.go @@ -0,0 +1,9 @@ +package storage + +// TaskTemplateOptOutModelInterface records per-plant suppression of automatic +// task generation from a species template. +type TaskTemplateOptOutModelInterface interface { + IsOptedOut(gardenID, plantID, templateID int) (bool, error) + GetAllForPlant(gardenID, plantID int) ([]int, error) + Set(gardenID, plantID, templateID int, optedOut bool) error +} diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go new file mode 100644 index 0000000..67d71c2 --- /dev/null +++ b/internal/storage/tasks.go @@ -0,0 +1,88 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskRecurrence identifies the calendar interval of a manual task. +type TaskRecurrence string + +// Supported task recurrence values. An empty value disables recurrence. +const ( + TaskRecurrenceNone TaskRecurrence = "" + TaskRecurrenceDaily TaskRecurrence = "daily" + TaskRecurrenceWeekly TaskRecurrence = "weekly" + TaskRecurrenceMonthly TaskRecurrence = "monthly" + TaskRecurrenceYearly TaskRecurrence = "yearly" +) + +// TaskModelInterface persists garden work items and generated task instances. +type TaskModelInterface interface { + Insert(task Task) (Task, error) + Get(gardenID, id int) (Task, error) + GetAllForGarden(gardenID int) ([]Task, error) + Update(gardenID int, task Task) (Task, error) + Delete(gardenID, id int) error +} + +// ValidateTask applies persistence-independent rules for manual garden tasks. +func ValidateTask(v *validate.Validator, task Task) { + v.Check(strings.TrimSpace(task.Title) != "", "title", "must be provided") + v.Check(len(task.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(task.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(task.Priority >= -100 && task.Priority <= 100, "priority", "must be between -100 and 100") + if task.PlantID != nil { + v.Check(*task.PlantID > 0, "plant_id", "must be a positive integer") + } + if task.LocationID != nil { + v.Check(*task.LocationID > 0, "location_id", "must be a positive integer") + } + if task.DueAtStart != nil && task.DueAtEnd != nil { + v.Check(!task.DueAtStart.After(*task.DueAtEnd), "due_at_end", "must not be before due_at_start") + } + v.Check(task.Recurrence == TaskRecurrenceNone || task.Recurrence == TaskRecurrenceDaily || task.Recurrence == TaskRecurrenceWeekly || task.Recurrence == TaskRecurrenceMonthly || task.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid") + if task.Recurrence != TaskRecurrenceNone { + v.Check(task.DueAtStart != nil || task.DueAtEnd != nil, "recurrence", "requires a due date") + v.Check(task.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero") + } + validateOptionalEnum(v, "plant_status_on_completion", task.PlantStatusOnCompletion, "alive", "dead", "removed", "infested", "harvested") + if task.PlantStatusOnCompletion != nil { + v.Check(task.PlantID != nil, "plant_status_on_completion", "requires a plant") + } + if task.CompletedAt != nil { + v.Check(task.CompletedBy != nil && *task.CompletedBy > 0, "completed_by", "must be set for a completed task") + } + if task.CompletedBy != nil { + v.Check(task.CompletedAt != nil, "completed_at", "must be set when completed_by is set") + } +} + +// Task represents a manual or template-generated garden work item. +type Task struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + PlantID *int `json:"plant_id,omitempty"` + LocationID *int `json:"location_id,omitempty"` + TemplateID *int `json:"template_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + DueAtStart *time.Time `json:"due_at_start,omitempty"` + DueAtEnd *time.Time `json:"due_at_end,omitempty"` + GeneratedFor *time.Time `json:"generated_for,omitempty"` + Recurrence TaskRecurrence `json:"recurrence,omitempty"` + RecurrenceInterval int `json:"recurrence_interval"` + RepeatFromID *int `json:"repeat_from_id,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CompletedBy *int `json:"completed_by,omitempty"` + Priority int `json:"priority"` + Active bool `json:"active"` + CreatedBy int `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"` +} diff --git a/internal/storage/tokens.go b/internal/storage/tokens.go new file mode 100644 index 0000000..eb65dc8 --- /dev/null +++ b/internal/storage/tokens.go @@ -0,0 +1,13 @@ +package storage + +import ( + "time" + + "gardomatic.kleiax.de/internal/auth" +) + +// TokenModelInterface persists scoped, expiring authentication token hashes. +type TokenModelInterface interface { + New(userID int, ttl time.Duration, scope string) (auth.Token, error) + DeleteAllForUser(scope string, userID int) error +} diff --git a/internal/storage/users.go b/internal/storage/users.go new file mode 100644 index 0000000..294d7dd --- /dev/null +++ b/internal/storage/users.go @@ -0,0 +1,141 @@ +package storage + +import ( + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" +) + +// UserModelInterface persists accounts and resolves token ownership. +type UserModelInterface interface { + Insert(user User) (User, error) + GetByID(id int) (User, error) + GetByEmail(email string) (User, error) + Update(user User) (User, error) + GetForToken(tokenScope, tokenPlaintext string) (User, error) + CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) + ConfirmEmailChange(tokenPlaintext string, userID int) (User, error) + GetAll() ([]User, error) + UpdateRole(userID int, role ApplicationRole) (User, error) + Delete(userID int) error +} + +// ApplicationRole groups garden-independent permissions for an account. +type ApplicationRole string + +// ApplicationPermission identifies one garden-independent capability. +type ApplicationPermission string + +// Built-in application roles and permissions. +const ( + ApplicationRoleUser ApplicationRole = "application:user" + ApplicationRoleAdmin ApplicationRole = "application:admin" + + ApplicationPermissionGlobalSpeciesWrite ApplicationPermission = "global_species:write" + ApplicationPermissionUsersManage ApplicationPermission = "users:manage" + ApplicationPermissionSettingsWrite ApplicationPermission = "application_settings:write" + ApplicationPermissionRolesManage ApplicationPermission = "roles:manage" + ApplicationPermissionGardensCreate ApplicationPermission = "gardens:create" +) + +// Valid reports whether role is assignable to an account. +func (role ApplicationRole) Valid() bool { + return role == ApplicationRoleUser || role == ApplicationRoleAdmin +} + +// Can reports whether an application role grants a permission. +func (role ApplicationRole) Can(permission ApplicationPermission) bool { + if permission == ApplicationPermissionGardensCreate { + return role == ApplicationRoleUser || role == ApplicationRoleAdmin + } + return role == ApplicationRoleAdmin && (permission == ApplicationPermissionGlobalSpeciesWrite || + permission == ApplicationPermissionUsersManage || + permission == ApplicationPermissionSettingsWrite || + permission == ApplicationPermissionRolesManage) +} + +// Permissions returns the capabilities bundled into the role. +func (role ApplicationRole) Permissions() []ApplicationPermission { + permissions := []ApplicationPermission{} + for _, permission := range []ApplicationPermission{ + ApplicationPermissionGlobalSpeciesWrite, + ApplicationPermissionUsersManage, + ApplicationPermissionSettingsWrite, + ApplicationPermissionRolesManage, + ApplicationPermissionGardensCreate, + } { + if role.Can(permission) { + permissions = append(permissions, permission) + } + } + return permissions +} + +// AllApplicationPermissions returns a copy of the supported application-level +// permissions. +func AllApplicationPermissions() []ApplicationPermission { + return []ApplicationPermission{ + ApplicationPermissionGlobalSpeciesWrite, + ApplicationPermissionUsersManage, + ApplicationPermissionSettingsWrite, + ApplicationPermissionRolesManage, + ApplicationPermissionGardensCreate, + } +} + +// ValidApplicationPermission reports whether permission is application-scoped +// or the global wildcard. +func ValidApplicationPermission(permission string) bool { + for _, candidate := range AllApplicationPermissions() { + if string(candidate) == permission { + return true + } + } + return permission == "*" +} + +// User is an account that can own or join gardens. +type User struct { + ID int `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Email string `json:"email"` + Color string `json:"color"` + Password auth.Password `json:"-"` + Activated bool `json:"activated"` + Role ApplicationRole `json:"role"` + Permissions []ApplicationPermission `json:"permissions"` + Version int `json:"-"` +} + +// Can reports whether the user's resolved permissions grant permission. It +// falls back to the built-in role only when no resolved list is present. +func (user User) Can(permission ApplicationPermission) bool { + if user.Permissions != nil { + for _, granted := range user.Permissions { + if granted == permission || granted == "*" { + return true + } + } + return false + } + return user.Role.Can(permission) +} + +// ValidateEmail applies the accepted email-address rules. +func ValidateEmail(v *validate.Validator, email string) { + v.Check(email != "", "email", "must be provided") + v.Check(validate.Matches(email, validate.EmailRX), "email", "must be a valid email address") +} + +// ValidateUser applies account and password validation rules. +func ValidateUser(v *validate.Validator, user User) { + v.Check(user.Name != "", "name", "must be provided") + v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") + + ValidateEmail(v, user.Email) + v.Check(user.Color == "" || validate.Matches(user.Color, validate.ColorRX), "color", "must be a valid hex color") + user.Password.Validate(v) +} diff --git a/internal/vcs/doc.go b/internal/vcs/doc.go new file mode 100644 index 0000000..c35dd83 --- /dev/null +++ b/internal/vcs/doc.go @@ -0,0 +1,2 @@ +// Package vcs derives build version information from Go build metadata. +package vcs diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..437f3bd --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,15 @@ +package vcs + +import ( + "runtime/debug" +) + +// Version returns the main module version from Go build metadata. +func Version() string { + bi, ok := debug.ReadBuildInfo() + if ok { + return bi.Main.Version + } + + return "" +} diff --git a/internal/web/account.go b/internal/web/account.go new file mode 100644 index 0000000..c68d8c5 --- /dev/null +++ b/internal/web/account.go @@ -0,0 +1,165 @@ +package web + +import ( + "errors" + "net/http" + "strings" + + "gardomatic.kleiax.de/lib/client" + "github.com/julienschmidt/httprouter" +) + +type accountForm struct { + CSRFToken string `form:"csrf_token"` + Name string `form:"name"` + Color string `form:"color"` + Email string `form:"email"` + CurrentPassword string `form:"current_password"` + NewPassword string `form:"new_password"` + NewPasswordConfirmation string `form:"new_password_confirmation"` + Errors map[string]string + Message string +} + +func (app *application) account(w http.ResponseWriter, r *http.Request) { + user, _ := userFromContext(r.Context()) + app.renderAccount(w, r, accountForm{Name: user.Name, Color: user.Color, Email: user.Email, Errors: map[string]string{}}, http.StatusOK) +} + +func (app *application) accountSessionDeletePost(w http.ResponseWriter, r *http.Request) { + id := httprouter.ParamsFromContext(r.Context()).ByName("sessionID") + response, err := client.FromContext(r.Context()).DeleteAccountSession(r.Context(), id) + if err != nil { + app.handleAPIError(w, r, err) + return + } + client.ForwardCookies(w, response) + http.Redirect(w, r, webPath("account")+gardenQuerySuffix(r), http.StatusSeeOther) +} +func (app *application) accountProfilePost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Name = strings.TrimSpace(form.Name) + form.Color = strings.TrimSpace(form.Color) + form.Errors = map[string]string{} + if form.Name == "" { + form.Errors["name"] = "Ein Name ist erforderlich." + } + if len(form.Errors) == 0 { + user, _, err := client.FromContext(r.Context()).UpdateAccountProfile(r.Context(), form.Name, form.Color) + if err == nil { + form.Name, form.Color, form.Email, form.Message = user.Name, user.Color, user.Email, "Profil gespeichert." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountPasswordPost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + user, _ := userFromContext(r.Context()) + form.Name, form.Color, form.Email, form.Errors = user.Name, user.Color, user.Email, map[string]string{} + if form.NewPassword != form.NewPasswordConfirmation { + form.Errors["new_password_confirmation"] = "Die Passwörter stimmen nicht überein." + } + if len(form.NewPassword) < 8 { + form.Errors["new_password"] = "Mindestens 8 Zeichen erforderlich." + } + if len(form.Errors) == 0 { + _, err := client.FromContext(r.Context()).UpdateAccountPassword(r.Context(), form.CurrentPassword, form.NewPassword) + if err == nil { + form.CurrentPassword, form.NewPassword, form.NewPasswordConfirmation = "", "", "" + form.Message = "Passwort geändert." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountEmailPost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + user, _ := userFromContext(r.Context()) + form.Name, form.Color, form.Errors = user.Name, user.Color, map[string]string{} + form.Email = strings.TrimSpace(form.Email) + if form.Email == "" { + form.Errors["email"] = "Eine E-Mail-Adresse ist erforderlich." + } + if len(form.Errors) == 0 { + _, err := client.FromContext(r.Context()).RequestAccountEmailChange(r.Context(), form.Email, form.CurrentPassword) + if err == nil { + form.CurrentPassword = "" + form.Message = "Bestätigungslink wurde an die neue Adresse gesendet." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountEmailConfirm(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + data.Form = acceptInviteForm{Token: r.URL.Query().Get("token")} + app.render(w, http.StatusOK, "email_confirm.tmpl", data) +} +func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.Request) { + var form acceptInviteForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, _, err := client.FromContext(r.Context()).ConfirmAccountEmail(r.Context(), form.Token); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, webPath("account"), http.StatusSeeOther) +} +func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) { + data := app.newTemplateData(r) + data.Form = form + if !app.loadOptionalGarden(w, r, data) { + return + } + sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + data.AccountSessions = sessions + app.render(w, status, "account.tmpl", data) +} +func (app *application) copyAccountError(form *accountForm, err error) { + var apiError *client.APIError + if errors.As(err, &apiError) { + if apiError.StatusCode == http.StatusUnprocessableEntity { + form.Errors = apiError.Validation + } + if apiError.StatusCode == http.StatusUnauthorized { + form.Errors["current_password"] = "Das aktuelle Passwort ist falsch." + } + } +} diff --git a/internal/web/admin.go b/internal/web/admin.go new file mode 100644 index 0000000..c2db761 --- /dev/null +++ b/internal/web/admin.go @@ -0,0 +1,416 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/lib/client" +) + +type adminRoleForm struct { + Role string `form:"role"` +} + +type adminUserInviteForm struct { + Name string `form:"name"` + Email string `form:"email"` +} + +type roleSettingsForm struct { + Name string `form:"name"` + Scope string `form:"scope"` + Label string `form:"label"` + Permissions []string `form:"permissions"` +} + +func applicationPermissionOptions() []permissionOption { + return []permissionOption{{"gardens:create", "Gärten anlegen"}, {"global_species:write", "Globale Pflanzen verwalten"}, {"users:manage", "Nutzerrollen verwalten"}, {"application_settings:write", "Instanzeinstellungen verwalten"}, {"roles:manage", "Rollen verwalten"}} +} + +func gardenPermissionOptions() []permissionOption { + return []permissionOption{ + {"garden:read", "Garten sehen"}, {"garden:update", "Garten bearbeiten"}, {"garden:delete", "Garten löschen"}, {"members:write", "Mitglieder verwalten"}, {"species:write", "Pflanzenarten verwalten"}, {"content:write", "Tagebuch und Zuordnungen bearbeiten"}, + {"plants:create", "Pflanzen anlegen"}, {"plants:read:own", "Eigene Pflanzen sehen"}, {"plants:read:other", "Andere Pflanzen sehen"}, {"plants:update:own", "Eigene Pflanzen bearbeiten"}, {"plants:update:other", "Andere Pflanzen bearbeiten"}, {"plants:delete:own", "Eigene Pflanzen löschen"}, {"plants:delete:other", "Andere Pflanzen löschen"}, + {"locations:create", "Orte anlegen"}, {"locations:read:own", "Eigene Orte sehen"}, {"locations:read:other", "Andere Orte sehen"}, {"locations:update:own", "Eigene Orte bearbeiten"}, {"locations:update:other", "Andere Orte bearbeiten"}, {"locations:delete:own", "Eigene Orte löschen"}, {"locations:delete:other", "Andere Orte löschen"}, + {"tasks:create", "Aufgaben anlegen"}, {"tasks:read:own", "Eigene Aufgaben sehen"}, {"tasks:read:other", "Andere Aufgaben sehen"}, {"tasks:update:own", "Eigene Aufgaben bearbeiten"}, {"tasks:update:other", "Andere Aufgaben bearbeiten"}, {"tasks:delete:own", "Eigene Aufgaben löschen"}, {"tasks:delete:other", "Andere Aufgaben löschen"}, {"tasks:complete:own", "Eigene Aufgaben erledigen"}, {"tasks:complete:other", "Andere Aufgaben erledigen"}, + } +} + +type adminSpeciesCategoryForm struct { + Name string `form:"name"` + SortOrder int `form:"sort_order"` + Active bool `form:"active"` + Lifecycle string `form:"lifecycle"` +} + +type adminTaskPriorityForm struct { + Name string `form:"name"` + Value int `form:"value"` + SortOrder int `form:"sort_order"` + Active bool `form:"active"` +} + +type adminApplicationSettingsForm struct { + LifecycleStatusEnabled bool `form:"lifecycle_status_enabled"` + LifecycleRemovalMonth int `form:"lifecycle_removal_month"` + LifecycleRemovalDay int `form:"lifecycle_removal_day"` + Timezone string `form:"timezone"` +} + +type adminTestMailForm struct { + Email string `form:"email"` +} + +func (app *application) requireAdmin(next http.Handler) http.Handler { + return app.requireActivatedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok || !user.IsAdmin() { + app.clientError(w, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + })) +} + +func (app *application) requireApplicationPermission(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok || !user.Can(permission) { + app.clientError(w, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) admin(w http.ResponseWriter, r *http.Request) { + apiClient := client.FromContext(r.Context()) + users, _, err := apiClient.AdminUsers(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + categories, _, err := client.FromContext(r.Context()).AdminSpeciesCategories(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + priorities, _, err := client.FromContext(r.Context()).AdminTaskPriorities(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + settings, _, err := client.FromContext(r.Context()).AdminApplicationSettings(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + environmentVariables, _, err := apiClient.AdminEnvironment(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + applicationRoles, gardenRoles, _, err := apiClient.AdminRoles(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + data := app.newTemplateData(r) + if !app.loadOptionalGarden(w, r, data) { + return + } + data.AdminUsers, data.SpeciesCategories, data.TaskPriorities = users, categories, priorities + data.ApplicationRoles, data.GardenRoles = applicationRoles, gardenRoles + data.ApplicationPermissions, data.GardenPermissions = applicationPermissionOptions(), gardenPermissionOptions() + data.RoleEditors = []roleEditorData{ + globalRoleEditor("roles-settings", "Instanzrollen", "Diese Rollen gelten für die gesamte Serverinstanz.", "application", applicationRoles, data.ApplicationPermissions, data.CSRFToken), + globalRoleEditor("garden-role-templates", "Gartenrollen", "Diese Vorlagen gelten in allen Gärten und können je Garten überschrieben werden.", "garden", gardenRoles, data.GardenPermissions, data.CSRFToken), + } + for i := range data.RoleEditors { + data.RoleEditors[i].Garden = data.Garden + } + data.ApplicationSettings = &settings + data.EnvironmentVariables = append(environmentVariables, app.webEnvironmentVariables()...) + if r.URL.Query().Get("test-mail") == "sent" { + data.Flash = "Die Testmail wurde versendet." + } + if r.URL.Query().Get("test-mail") == "invalid" { + data.Flash = "Bitte gib eine gültige Empfängeradresse für die Testmail ein." + } + if r.URL.Query().Get("invitation") == "sent" { + data.Flash = "Die Einladung wurde versendet." + } + if r.URL.Query().Get("invitation") == "duplicate" { + data.Flash = "Für diese E-Mail-Adresse existiert bereits ein aktiver Account." + } + if r.URL.Query().Get("invitation") == "invalid" { + data.Flash = "Die Einladung ist ungültig. Bitte prüfe Name und E-Mail-Adresse." + } + if r.URL.Query().Get("deletion") == "done" { + data.Flash = "Der Nutzer wurde gelöscht und seine Kontodaten wurden anonymisiert." + } + if r.URL.Query().Get("deletion") == "owner" { + data.Flash = "Der Nutzer besitzt noch mindestens einen Garten. Übertrage zuerst das Eigentum." + } + app.render(w, http.StatusOK, "admin.tmpl", data) +} + +func (app *application) adminUserInvitePost(w http.ResponseWriter, r *http.Request) { + var form adminUserInviteForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + input := client.AdminUserInviteInput{Name: strings.TrimSpace(form.Name), Email: strings.TrimSpace(form.Email)} + if _, _, err := client.FromContext(r.Context()).InviteAdminUser(r.Context(), input); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + status := "invalid" + if apiError.Validation["email"] == "a user with this email address already exists" { + status = "duplicate" + } + http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", status), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", "sent"), http.StatusSeeOther) +} + +func (app *application) adminUserDeletePost(w http.ResponseWriter, r *http.Request) { + userID, err := app.readPathID(r, "userID") + if err != nil { + app.notFound(w) + return + } + if _, err = client.FromContext(r.Context()).DeleteAdminUser(r.Context(), userID); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict { + http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "owner"), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "done"), http.StatusSeeOther) +} + +func (app *application) webEnvironmentVariables() []client.EnvironmentVariable { + return []client.EnvironmentVariable{ + {Component: "Web", Name: "GARDOMATIC_ENV", Value: app.config.Env}, + {Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host}, + {Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)}, + {Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL}, + {Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName}, + {Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)}, + } +} + +func (app *application) adminRoleCreatePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + _, _, err := client.FromContext(r.Context()).CreateAdminRole(r.Context(), client.RoleInput{Name: strings.TrimSpace(form.Name), Scope: form.Scope, Label: strings.TrimSpace(form.Label), Permissions: form.Permissions}) + if err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func (app *application) adminRoleUpdatePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + _, _, err := client.FromContext(r.Context()).UpdateAdminRole(r.Context(), form.Name, client.RoleInput{Label: strings.TrimSpace(form.Label), Permissions: form.Permissions}) + if err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func (app *application) adminRoleDeletePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminRole(r.Context(), form.Name); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func adminRoleEditorAnchor(scope string) string { + if scope == "garden" { + return "#garden-role-templates" + } + return "#roles-settings" +} + +func (app *application) adminApplicationSettingsPost(w http.ResponseWriter, r *http.Request) { + var form adminApplicationSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + timezone := strings.TrimSpace(form.Timezone) + input := client.ApplicationSettingsInput{ + LifecycleStatusEnabled: &form.LifecycleStatusEnabled, + LifecycleRemovalMonth: &form.LifecycleRemovalMonth, + LifecycleRemovalDay: &form.LifecycleRemovalDay, + Timezone: &timezone, + } + if _, _, err := client.FromContext(r.Context()).UpdateAdminApplicationSettings(r.Context(), input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#lifecycle-settings"), http.StatusSeeOther) +} + +func (app *application) adminTestMailPost(w http.ResponseWriter, r *http.Request) { + var form adminTestMailForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, err := client.FromContext(r.Context()).SendAdminTestMail(r.Context(), strings.TrimSpace(form.Email)); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "invalid"), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "sent"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityCreatePost(w http.ResponseWriter, r *http.Request) { + var form adminTaskPriorityForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name, active := strings.TrimSpace(form.Name), true + if _, _, err := client.FromContext(r.Context()).CreateAdminTaskPriority(r.Context(), client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &active}); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityUpdatePost(w http.ResponseWriter, r *http.Request) { + id, err := app.readPathID(r, "priorityID") + if err != nil { + app.notFound(w) + return + } + var form adminTaskPriorityForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name := strings.TrimSpace(form.Name) + if _, _, err := client.FromContext(r.Context()).UpdateAdminTaskPriority(r.Context(), id, client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &form.Active}); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityDeletePost(w http.ResponseWriter, r *http.Request) { + id, err := app.readPathID(r, "priorityID") + if err != nil { + app.notFound(w) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminTaskPriority(r.Context(), id); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryCreatePost(w http.ResponseWriter, r *http.Request) { + var form adminSpeciesCategoryForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name, active := strings.TrimSpace(form.Name), true + input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &active, Lifecycle: &form.Lifecycle} + if _, _, err := client.FromContext(r.Context()).CreateAdminSpeciesCategory(r.Context(), input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryUpdatePost(w http.ResponseWriter, r *http.Request) { + categoryID, err := app.readPathID(r, "categoryID") + if err != nil { + app.notFound(w) + return + } + var form adminSpeciesCategoryForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name := strings.TrimSpace(form.Name) + input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &form.Active, Lifecycle: &form.Lifecycle} + if _, _, err := client.FromContext(r.Context()).UpdateAdminSpeciesCategory(r.Context(), categoryID, input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryDeletePost(w http.ResponseWriter, r *http.Request) { + categoryID, err := app.readPathID(r, "categoryID") + if err != nil { + app.notFound(w) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminSpeciesCategory(r.Context(), categoryID); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminUserRolePost(w http.ResponseWriter, r *http.Request) { + userID, err := app.readPathID(r, "userID") + if err != nil { + app.notFound(w) + return + } + var form adminRoleForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, _, err := client.FromContext(r.Context()).UpdateAdminUserRole(r.Context(), userID, form.Role); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, ""), http.StatusSeeOther) +} + +func (app *application) settings(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + if !app.loadOptionalGarden(w, r, data) { + return + } + app.render(w, http.StatusOK, "settings.tmpl", data) +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..82f8393 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,191 @@ +package web + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +type signInForm struct { + CSRFToken string `form:"csrf_token"` + Email string `form:"email"` + Password string `form:"password"` + RememberEmail bool `form:"remember_email"` + Errors map[string]string + Message string +} + +type activationForm struct { + CSRFToken string `form:"csrf_token"` + Token string `form:"token"` + Password string `form:"password"` + PasswordConfirm string `form:"password_confirm"` + SetPassword bool `form:"set_password"` + Errors map[string]string + Message string +} + +func (app *application) signIn(w http.ResponseWriter, r *http.Request) { + if app.isAuthenticated(r) { + http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther) + return + } + data := app.newTemplateData(r) + form := signInForm{Errors: make(map[string]string)} + if cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil { + if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil { + form.Email, form.RememberEmail = string(decoded), true + } + } + data.Form = form + app.render(w, http.StatusOK, "signin.tmpl", data) +} + +func (app *application) signInPost(w http.ResponseWriter, r *http.Request) { + var form signInForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Email = strings.TrimSpace(form.Email) + form.Errors = make(map[string]string) + if form.Email == "" { + form.Errors["email"] = "E-Mail-Adresse ist erforderlich." + } + if form.Password == "" { + form.Errors["password"] = "Passwort ist erforderlich." + } + if len(form.Errors) != 0 { + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data) + return + } + + apiClient := client.FromContext(r.Context()) + user, response, err := apiClient.CreateSession(r.Context(), client.Credentials{Email: form.Email, Password: form.Password}) + if err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && (apiError.StatusCode == http.StatusUnauthorized || apiError.StatusCode == http.StatusUnprocessableEntity) { + form.Message = "E-Mail-Adresse oder Passwort ist ungültig." + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data) + return + } + app.serverError(w, err) + return + } + client.ForwardCookies(w, response) + remembered := &http.Cookie{Name: "gardomatic_remembered_email", Path: webPath("login"), HttpOnly: true, Secure: app.config.CookieSecure, SameSite: http.SameSiteLaxMode} + if form.RememberEmail { + remembered.Value = base64.RawURLEncoding.EncodeToString([]byte(form.Email)) + remembered.Expires = time.Now().Add(365 * 24 * time.Hour) + remembered.MaxAge = 365 * 24 * 60 * 60 + } else if _, err := r.Cookie("gardomatic_remembered_email"); err == nil { + remembered.Expires = time.Unix(1, 0) + remembered.MaxAge = -1 + } else { + remembered = nil + } + if remembered != nil { + http.SetCookie(w, remembered) + } + if !user.Activated { + http.Redirect(w, r, webPath("activate"), http.StatusSeeOther) + return + } + http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther) +} + +func (app *application) activateUser(w http.ResponseWriter, r *http.Request) { + if user, ok := userFromContext(r.Context()); ok && user.Activated { + http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther) + return + } + + data := app.newTemplateData(r) + data.Form = activationForm{ + Token: strings.TrimSpace(r.URL.Query().Get("token")), + SetPassword: r.URL.Query().Get("set-password") == "1", + Errors: make(map[string]string), + } + app.render(w, http.StatusOK, "activate.tmpl", data) +} + +func (app *application) activateUserPost(w http.ResponseWriter, r *http.Request) { + var form activationForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + form.Token = strings.TrimSpace(form.Token) + form.Errors = make(map[string]string) + if form.Token == "" { + form.Errors["token"] = "Aktivierungstoken ist erforderlich." + } + if form.SetPassword { + if len(form.Password) < 8 { + form.Errors["password"] = "Das Passwort muss mindestens 8 Zeichen lang sein." + } else if len(form.Password) > 72 { + form.Errors["password"] = "Das Passwort darf höchstens 72 Zeichen lang sein." + } + if form.Password != form.PasswordConfirm { + form.Errors["password_confirm"] = "Die Passwörter stimmen nicht überein." + } + } + + if len(form.Errors) == 0 { + apiClient := client.FromContext(r.Context()) + var err error + if form.SetPassword { + _, _, err = apiClient.ActivateInvitedUser(r.Context(), form.Token, form.Password) + } else { + _, _, err = apiClient.ActivateUser(r.Context(), form.Token) + } + if err == nil { + http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther) + return + } + + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + if _, ok := apiError.Validation["token"]; ok { + form.Errors["token"] = "Aktivierungstoken ist ungültig oder abgelaufen." + } else { + form.Errors = apiError.Validation + form.Message = "Der Account konnte nicht aktiviert werden." + } + } else { + app.handleAPIError(w, r, err) + return + } + } + + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "activate.tmpl", data) +} + +func (app *application) authenticatedLandingPage(r *http.Request) string { + if user, ok := userFromContext(r.Context()); ok && !user.Activated { + return webPath("activate") + } + return webPath("gardens") +} + +func (app *application) signOutPost(w http.ResponseWriter, r *http.Request) { + apiClient := client.FromContext(r.Context()) + response, err := apiClient.DeleteSession(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + client.ForwardCookies(w, response) + http.Redirect(w, r, webPath("home"), http.StatusSeeOther) +} diff --git a/internal/web/care_instructions.go b/internal/web/care_instructions.go new file mode 100644 index 0000000..4e4cd64 --- /dev/null +++ b/internal/web/care_instructions.go @@ -0,0 +1,93 @@ +package web + +import ( + "gardomatic.kleiax.de/lib/client" + "net/http" + "strings" +) + +type careInstructionForm struct { + Text string `form:"text"` + Status string `form:"status"` +} + +func (app *application) careInstructionSave(w http.ResponseWriter, r *http.Request) { + gardenID, e := app.readPathID(r, "gardenID") + if e != nil { + app.notFound(w) + return + } + speciesID, e := app.readPathID(r, "speciesID") + if e != nil { + app.notFound(w) + return + } + var form careInstructionForm + if e = app.decodePostForm(r, &form); e != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Text = strings.TrimSpace(form.Text) + if form.Status == "" { + form.Status = "untested" + } + input := client.CareInstructionInput{Text: &form.Text, Status: &form.Status} + instructionID, _ := app.readPathID(r, "instructionID") + if instructionID > 0 { + _, _, e = client.FromContext(r.Context()).UpdateCareInstruction(r.Context(), gardenID, speciesID, instructionID, input) + } else { + _, _, e = client.FromContext(r.Context()).CreateCareInstruction(r.Context(), gardenID, speciesID, input) + } + if e != nil { + app.handleAPIError(w, r, e) + return + } + if r.Header.Get("HX-Request") == "true" { + apiClient := client.FromContext(r.Context()) + instructions, _, loadErr := apiClient.CareInstructions(r.Context(), gardenID, speciesID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + data := app.newTemplateData(r) + garden, _, loadErr := apiClient.Garden(r.Context(), gardenID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + species, _, loadErr := apiClient.Species(r.Context(), gardenID, speciesID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + data.Garden = &garden + data.SpeciesID = speciesID + data.SpeciesGlobal = species.GardenID == nil + data.CareInstructions = instructions + app.renderTemplate(w, http.StatusOK, "species_form.tmpl", "care_instruction_list", data) + return + } + http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther) +} +func (app *application) careInstructionDelete(w http.ResponseWriter, r *http.Request) { + gardenID, e := app.readPathID(r, "gardenID") + if e != nil { + app.notFound(w) + return + } + speciesID, e := app.readPathID(r, "speciesID") + if e != nil { + app.notFound(w) + return + } + instructionID, e := app.readPathID(r, "instructionID") + if e != nil { + app.notFound(w) + return + } + if _, e = client.FromContext(r.Context()).DeleteCareInstruction(r.Context(), gardenID, speciesID, instructionID); e != nil { + app.handleAPIError(w, r, e) + return + } + http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther) +} diff --git a/internal/web/collection_filters.go b/internal/web/collection_filters.go new file mode 100644 index 0000000..21367a4 --- /dev/null +++ b/internal/web/collection_filters.go @@ -0,0 +1,219 @@ +package web + +import ( + "net/http" + "sort" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func collectionFilters(r *http.Request, keys ...string) map[string]string { + filters := make(map[string]string, len(keys)) + for _, key := range keys { + filters[key] = strings.TrimSpace(r.URL.Query().Get(key)) + } + return filters +} + +func filterAndSortGardens(values []client.Garden, filters map[string]string) []client.Garden { + query := strings.ToLower(filters["q"]) + result := make([]client.Garden, 0, len(values)) + for _, value := range values { + if query != "" && !strings.Contains(strings.ToLower(value.Name+" "+value.Description), query) { + continue + } + if filters["role"] != "" && value.Role != filters["role"] { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func filterAndSortPlants(values []client.Plant, assignments map[int][]client.PlantLocation, filters map[string]string) []client.Plant { + query := strings.ToLower(filters["q"]) + speciesID, _ := strconv.Atoi(filters["species"]) + locationID, _ := strconv.Atoi(filters["location"]) + result := make([]client.Plant, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.Name, value.Notes, strings.Join(value.Tags, " ")) { + continue + } + if filters["status"] != "" && value.Status != filters["status"] { + continue + } + if speciesID > 0 && (value.SpeciesID == nil || *value.SpeciesID != speciesID) { + continue + } + if locationID > 0 && !plantHasLocation(assignments[value.ID], locationID) { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func plantHasLocation(assignments []client.PlantLocation, locationID int) bool { + for _, assignment := range assignments { + if assignment.LocationID == locationID && assignment.RemovedAt == nil { + return true + } + } + return false +} + +func filterAndSortSpecies(values []client.Species, filters map[string]string) []client.Species { + query := strings.ToLower(filters["q"]) + result := make([]client.Species, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.CommonName, value.Cultivar, value.BotanicalName, value.Category, strings.Join(value.Tags, " ")) { + continue + } + if filters["origin"] == "global" && value.GardenID != nil || filters["origin"] == "garden" && value.GardenID == nil { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + left, right := strings.ToLower(result[i].CommonName+" "+result[i].Cultivar), strings.ToLower(result[j].CommonName+" "+result[j].Cultivar) + switch filters["sort"] { + case "name_desc": + return left > right + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return left < right + } + }) + return result +} + +func filterAndSortLocations(values []client.Location, filters map[string]string) []client.Location { + query, kind := strings.ToLower(filters["q"]), strings.ToLower(filters["kind"]) + result := make([]client.Location, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.Name, value.Description, value.Kind) { + continue + } + if kind != "" && !strings.Contains(strings.ToLower(value.Kind), kind) { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func sortTasks(values []client.Task, sortBy string) { + sort.SliceStable(values, func(i, j int) bool { + switch sortBy { + case "due_desc": + return taskSortTime(values[i]).After(taskSortTime(values[j])) + case "month_asc": + return taskMonthBefore(values[i], values[j], false) + case "month_desc": + return taskMonthBefore(values[i], values[j], true) + case "period_asc": + return taskPeriodBefore(values[i], values[j], false) + case "period_desc": + return taskPeriodBefore(values[i], values[j], true) + case "name_asc": + return strings.ToLower(values[i].Title) < strings.ToLower(values[j].Title) + case "name_desc": + return strings.ToLower(values[i].Title) > strings.ToLower(values[j].Title) + case "priority_desc": + return values[i].Priority > values[j].Priority + default: + return taskSortTime(values[i]).Before(taskSortTime(values[j])) + } + }) +} + +// taskSortMonth returns the calendar month of the task's due window. Missing +// dates sort after dated tasks in both directions. +func taskSortMonth(task client.Task) int { + value := task.DueAtStart + if value == nil { + value = task.DueAtEnd + } + if value == nil { + return 13 + } + return int(value.Month()) +} + +func taskMonthBefore(left, right client.Task, descending bool) bool { + leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil + rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil + if leftMissing || rightMissing { + return !leftMissing && rightMissing + } + if descending { + return taskSortMonth(left) > taskSortMonth(right) + } + return taskSortMonth(left) < taskSortMonth(right) +} + +// taskSortPeriod returns the length of a task's due window. A single date is +// a zero-length window; tasks without dates sort after dated tasks. +func taskSortPeriod(task client.Task) time.Duration { + if task.DueAtStart == nil && task.DueAtEnd == nil { + return time.Duration(1<<63 - 1) + } + if task.DueAtStart == nil || task.DueAtEnd == nil { + return 0 + } + return task.DueAtEnd.Sub(*task.DueAtStart) +} + +func taskPeriodBefore(left, right client.Task, descending bool) bool { + leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil + rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil + if leftMissing || rightMissing { + return !leftMissing && rightMissing + } + if descending { + return taskSortPeriod(left) > taskSortPeriod(right) + } + return taskSortPeriod(left) < taskSortPeriod(right) +} diff --git a/internal/web/collection_filters_test.go b/internal/web/collection_filters_test.go new file mode 100644 index 0000000..5236667 --- /dev/null +++ b/internal/web/collection_filters_test.go @@ -0,0 +1,94 @@ +package web + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func TestCollectionFiltersAndSorting(t *testing.T) { + older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + gardens := filterAndSortGardens([]client.Garden{ + {Name: "Ziergarten", Role: "viewer", CreatedAt: newer}, + {Name: "Acker", Description: "Gemüse", Role: "owner", CreatedAt: older}, + }, map[string]string{"q": "gemüse", "role": "owner", "sort": "newest"}) + if len(gardens) != 1 || gardens[0].Name != "Acker" { + t.Fatalf("unexpected gardens result: %#v", gardens) + } + + speciesID, locationID := 4, 8 + plants := filterAndSortPlants([]client.Plant{ + {ID: 1, Name: "Zucchini", Status: "active", SpeciesID: &speciesID, CreatedAt: newer}, + {ID: 2, Name: "Aster", Status: "dormant", CreatedAt: older}, + }, map[int][]client.PlantLocation{1: {{PlantID: 1, LocationID: locationID}}}, map[string]string{"status": "active", "species": "4", "location": "8", "sort": "name_desc"}) + if len(plants) != 1 || plants[0].Name != "Zucchini" { + t.Fatalf("unexpected plants result: %#v", plants) + } + + gardenID := 3 + species := filterAndSortSpecies([]client.Species{ + {CommonName: "Tomate", GardenID: &gardenID}, + {CommonName: "Bohne", GardenID: nil}, + }, map[string]string{"origin": "global", "sort": "name_asc"}) + if len(species) != 1 || species[0].CommonName != "Bohne" { + t.Fatalf("unexpected species result: %#v", species) + } + + locations := filterAndSortLocations([]client.Location{ + {Name: "Nordbeet", Kind: "Beet"}, + {Name: "Terrasse", Kind: "Topf"}, + }, map[string]string{"kind": "beet", "sort": "name_asc"}) + if len(locations) != 1 || locations[0].Name != "Nordbeet" { + t.Fatalf("unexpected locations result: %#v", locations) + } +} + +func TestTaskSortingByMonthAndPeriod(t *testing.T) { + date := func(year int, month time.Month, day int) *time.Time { + value := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) + return &value + } + tasks := []client.Task{ + {ID: 1, Title: "Langer Zeitraum", DueAtStart: date(2026, time.March, 1), DueAtEnd: date(2026, time.March, 11)}, + {ID: 2, Title: "Kurzer Zeitraum", DueAtStart: date(2026, time.January, 1), DueAtEnd: date(2026, time.January, 2)}, + {ID: 3, Title: "Einzeltermin", DueAtStart: date(2026, time.February, 1)}, + } + + sortTasks(tasks, "month_asc") + if tasks[0].ID != 2 || tasks[1].ID != 3 || tasks[2].ID != 1 { + t.Fatalf("month sort = %#v, want January, February, March", tasks) + } + sortTasks(tasks, "period_desc") + if tasks[0].ID != 1 || tasks[1].ID != 2 || tasks[2].ID != 3 { + t.Fatalf("period sort = %#v, want longest first", tasks) + } +} + +func TestTaskSearchDateFiltersUseStartOrEnd(t *testing.T) { + start := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, time.February, 2, 0, 0, 0, 0, time.UTC) + task := client.Task{DueAtStart: &start, DueAtEnd: &end} + if !taskInMonth(task, int(time.January)) || !taskInMonth(task, int(time.February)) { + t.Fatal("task should match both its start and end month") + } + if taskInMonth(task, int(time.March)) { + t.Fatal("task should not match a month between start and end") + } + if !taskInYear(task, 2026) || taskInYear(task, 2025) { + t.Fatal("task year filter matched the wrong year") + } + middleYear := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + if got := taskYears([]client.Task{task, {DueAtStart: &middleYear}}); len(got) != 3 || got[0] != 2024 || got[1] != 2025 || got[2] != 2026 { + t.Fatalf("task years = %#v, want [2024 2025 2026]", got) + } + filtered := filterTasks([]client.Task{ + {ID: 1, DueAtStart: &start, DueAtEnd: &end}, + {ID: 2, DueAtStart: func() *time.Time { value := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC); return &value }()}, + }, map[string]string{"month": "2", "year": "2026"}) + if len(filtered) != 1 || filtered[0].ID != 1 { + t.Fatalf("filtered tasks = %#v, want task 1", filtered) + } +} diff --git a/internal/web/context.go b/internal/web/context.go new file mode 100644 index 0000000..71800fb --- /dev/null +++ b/internal/web/context.go @@ -0,0 +1,19 @@ +package web + +import ( + "context" + + "gardomatic.kleiax.de/lib/client" +) + +type contextKey string + +const ( + isAuthenticatedContextKey = contextKey("isAuthenticated") + userContextKey = contextKey("user") +) + +func userFromContext(ctx context.Context) (client.User, bool) { + user, ok := ctx.Value(userContextKey).(client.User) + return user, ok +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go new file mode 100644 index 0000000..7ef8d4a --- /dev/null +++ b/internal/web/dashboard.go @@ -0,0 +1,475 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func (app *application) gardenDashboard(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + plants, _, err := apiClient.Plants(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + locations, _, err := apiClient.Locations(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard) + if err != nil { + app.handleAPIError(w, r, err) + return + } + images, _, err := apiClient.Images(r.Context(), gardenID, "", "") + if err != nil { + var apiError *client.APIError + if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound { + app.handleAPIError(w, r, err) + return + } + } + horizon := time.Now().AddDate(0, 0, 30) + upcoming := make([]client.Task, 0) + for _, task := range tasks { + if task.CompletedAt != nil { + continue + } + due := task.DueAtEnd + if due == nil { + due = task.DueAtStart + } + if due != nil && !due.After(horizon) { + upcoming = append(upcoming, task) + } + } + sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) }) + if len(upcoming) > 8 { + upcoming = upcoming[:8] + } + data := app.newTemplateData(r) + data.Garden = &garden + data.Tasks = upcoming + data.Plants = plants + data.Locations = locations + data.JournalEntries = journalEntries + data.PinboardEntries = pinboardEntries + data.PhotoCount = len(images) + app.render(w, http.StatusOK, "dashboard.tmpl", data) +} + +func taskSortTime(task client.Task) time.Time { + if task.DueAtEnd != nil { + return *task.DueAtEnd + } + if task.DueAtStart != nil { + return *task.DueAtStart + } + return time.Unix(1<<62, 0) +} + +func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + view := r.URL.Query().Get("view") + if view != "month" { + view = "week" + } + anchor := time.Now() + if value := r.URL.Query().Get("date"); value != "" { + if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil { + anchor = parsed + } + } + start, end := calendarRange(anchor, view) + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + days := make([]calendarDay, 0) + for day := start; day.Before(end); day = day.AddDate(0, 0, 1) { + entry := calendarDay{Date: day} + for _, task := range tasks { + if task.CompletedAt == nil && taskOverlapsDay(task, day) { + entry.Tasks = append(entry.Tasks, task) + } + } + days = append(days, entry) + } + step := 7 + if view == "month" { + step = 1 + } + previous := start.AddDate(0, 0, -step) + next := end + if view == "month" { + previous = start.AddDate(0, -1, 0) + next = start.AddDate(0, 1, 0) + } + data := app.newTemplateData(r) + data.Garden = &garden + data.CalendarDays = days + data.CalendarStart = start + data.CalendarEnd = end.Add(-time.Nanosecond) + data.CalendarView = view + data.PreviousDate = previous.Format("2006-01-02") + data.NextDate = next.Format("2006-01-02") + app.render(w, http.StatusOK, "task_calendar.tmpl", data) +} + +func calendarRange(anchor time.Time, view string) (time.Time, time.Time) { + y, m, d := anchor.Date() + loc := anchor.Location() + start := time.Date(y, m, d, 0, 0, 0, 0, loc) + if view == "month" { + start = time.Date(y, m, 1, 0, 0, 0, 0, loc) + return start, start.AddDate(0, 1, 0) + } + offset := (int(start.Weekday()) + 6) % 7 + start = start.AddDate(0, 0, -offset) + return start, start.AddDate(0, 0, 7) +} + +func taskOverlapsDay(task client.Task, day time.Time) bool { + dayEnd := day.AddDate(0, 0, 1) + start, end := task.DueAtStart, task.DueAtEnd + if start == nil { + start = end + } + if end == nil { + end = start + } + if start == nil { + return false + } + return start.Before(dayEnd) && !end.Before(day) +} + +func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + mode := r.URL.Query().Get("mode") + month, _ := strconv.Atoi(r.URL.Query().Get("month")) + if month < 1 || month > 12 { + month = 0 + } + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year < 1 { + year = 0 + } + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)} + if query != "" || month > 0 || year > 0 { + plants, _, err := apiClient.Plants(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + journal, _, err := apiClient.JournalEntries(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tags, _, err := apiClient.GardenTags(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + if mode != "month" { + if queryMonth := parseGermanMonth(query); queryMonth > 0 { + month = queryMonth + } + } + matchedTags := map[string]string{} + if mode != "month" { + for _, tag := range tags { + if containsTerms(query, tag) { + matchedTags[strings.ToLower(tag)] = tag + } + } + } + addMonthTags := func(values []string) { + if mode == "month" { + for _, tag := range values { + matchedTags[strings.ToLower(tag)] = tag + } + } + } + for _, item := range tasks { + textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " ")) + monthMatch := month == 0 || taskInMonth(item, month) + yearMatch := year == 0 || taskInYear(item, year) + if textMatch && monthMatch && yearMatch { + addMonthTags(item.Tags) + result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item)) + result.All = append(result.All, result.Tasks[len(result.Tasks)-1]) + } + } + for _, item := range plants { + if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Plants = append(result.Plants, plantSearchCard(gardenID, item)) + result.All = append(result.All, result.Plants[len(result.Plants)-1]) + } + } + for _, item := range species { + if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) { + addMonthTags(item.Tags) + result.Species = append(result.Species, speciesSearchCard(gardenID, item)) + result.All = append(result.All, result.Species[len(result.Species)-1]) + } + } + for _, item := range journal { + if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item)) + result.All = append(result.All, result.Journal[len(result.Journal)-1]) + } + } + for _, item := range pinboard { + if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item)) + result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1]) + } + } + seenTagUsages := map[string]bool{} + addTagUsages := func(card searchCard, values []string) { + for _, tag := range values { + if _, ok := matchedTags[strings.ToLower(tag)]; !ok { + continue + } + if seenTagUsages[card.URL] { + continue + } + seenTagUsages[card.URL] = true + usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)]) + result.Tags = append(result.Tags, usage) + allSeen := false + for _, existing := range result.All { + if existing.URL == usage.URL { + allSeen = true + break + } + } + if !allSeen { + result.All = append(result.All, usage) + } + } + } + for _, item := range tasks { + addTagUsages(taskSearchCard(gardenID, item), item.Tags) + } + for _, item := range plants { + addTagUsages(plantSearchCard(gardenID, item), item.Tags) + } + for _, item := range species { + addTagUsages(speciesSearchCard(gardenID, item), item.Tags) + } + for _, item := range journal { + addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags) + } + for _, item := range pinboard { + addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags) + } + } + data := app.newTemplateData(r) + data.Garden = &garden + data.Search = result + app.render(w, http.StatusOK, "search.tmpl", data) +} + +func taskSearchCard(gardenID int, item client.Task) searchCard { + return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)} +} +func plantSearchCard(gardenID int, item client.Plant) searchCard { + return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)} +} +func speciesSearchCard(gardenID int, item client.Species) searchCard { + title := item.CommonName + if item.Cultivar != "" { + title += " · " + item.Cultivar + } + return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)} +} +func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard { + path, label := "journal", "Tagebuch" + if item.EntryType == client.JournalEntryTypePinboard { + path, label = "pinboard", "Pinnwand" + } + return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)} +} +func tagUsageCard(card searchCard, tag string) searchCard { + card.Meta = "#" + tag + " · " + card.Meta + return card +} +func speciesSeason(item client.Species) string { + if item.HarvestMonthFrom != nil { + return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom)) + } + return item.Category +} +func plantInMonth(item client.Plant, month int) bool { + return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month +} +func germanMonthName(month int) string { + names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"} + if month >= 1 && month <= 12 { + return names[month] + } + return "" +} + +func containsTerms(query string, values ...string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + for _, term := range strings.Fields(query) { + term = strings.TrimPrefix(term, "#") + found := false + for _, value := range values { + if strings.Contains(strings.ToLower(value), term) { + found = true + break + } + } + if !found { + return false + } + } + return query != "" +} + +func parseKeywords(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + return result +} +func parseGermanMonth(value string) int { + names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"} + value = strings.ToLower(strings.TrimSpace(value)) + for i, name := range names { + if value == name || value == strings.TrimSuffix(name, "uar") { + return i + 1 + } + } + return 0 +} +func taskInMonth(task client.Task, month int) bool { + return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) || + (task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month) +} + +func taskInYear(task client.Task, year int) bool { + return (task.DueAtStart != nil && task.DueAtStart.Year() == year) || + (task.DueAtEnd != nil && task.DueAtEnd.Year() == year) +} + +func taskYears(tasks []client.Task) []int { + minYear, maxYear := 0, 0 + setYear := func(year int) { + if minYear == 0 || year < minYear { + minYear = year + } + if year > maxYear { + maxYear = year + } + } + for _, task := range tasks { + if task.DueAtStart != nil { + setYear(task.DueAtStart.Year()) + } + if task.DueAtEnd != nil { + setYear(task.DueAtEnd.Year()) + } + } + if minYear == 0 { + return nil + } + result := make([]int, 0, maxYear-minYear+1) + for year := minYear; year <= maxYear; year++ { + result = append(result, year) + } + return result +} +func speciesInMonth(item client.Species, month int) bool { + return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo) +} +func monthInRange(month int, from, to *int) bool { + if from == nil || to == nil { + return false + } + return monthRangeMatches(month, *from, *to) +} + +func monthRangeMatches(month, from, to int) bool { + if from <= to { + return month >= from && month <= to + } + return month >= from || month <= to +} diff --git a/internal/web/doc.go b/internal/web/doc.go new file mode 100644 index 0000000..84879cd --- /dev/null +++ b/internal/web/doc.go @@ -0,0 +1,3 @@ +// Package web implements Gardomatic's server-rendered browser application and +// proxies authenticated user operations through the typed API client. +package web diff --git a/internal/web/edit_flows_test.go b/internal/web/edit_flows_test.go new file mode 100644 index 0000000..4d943bf --- /dev/null +++ b/internal/web/edit_flows_test.go @@ -0,0 +1,218 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gardomatic.kleiax.de/lib/client" + "github.com/julienschmidt/httprouter" +) + +func TestPlantAndLocationCanBeEdited(t *testing.T) { + var plantInput client.PlantInput + var locationInput client.LocationInput + assignmentUpdated := false + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &plantInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Neue Rose"}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9": + assignmentUpdated = true + _, _ = w.Write([]byte(`{"plant_location":{"id":9,"plant_id":5,"location_id":6,"quantity":2}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/locations/6": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &locationInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"location":{"id":6,"garden_id":3,"name":"Südbeet"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + + plantForm := url.Values{"name": {"Neue Rose"}, "species_id": {"0"}, "location_id": {"6"}, "assignment_id": {"9"}, "quantity": {"2"}, "status": {"active"}} + plantRequest := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(plantForm.Encode())) + plantRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + plantRequest = taskWebRequest(plantRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}}) + plantResponse := httptest.NewRecorder() + app.plantEditPost(plantResponse, plantRequest) + if plantResponse.Code != http.StatusSeeOther || !plantInput.ClearSpeciesID || !assignmentUpdated { + t.Fatalf("plant edit: status=%d input=%+v assignment=%v body=%s", plantResponse.Code, plantInput, assignmentUpdated, plantResponse.Body.String()) + } + + locationForm := url.Values{"name": {"Südbeet"}, "parent_id": {"0"}, "area_sqm": {"4,5"}, "return_to": {"/g/3/locations"}} + locationRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/edit/6", strings.NewReader(locationForm.Encode())) + locationRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + locationRequest = taskWebRequest(locationRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "locationID", Value: "6"}}) + locationResponse := httptest.NewRecorder() + app.locationEditPost(locationResponse, locationRequest) + if locationResponse.Code != http.StatusSeeOther || !locationInput.ClearParentID || locationInput.AreaSQM == nil || *locationInput.AreaSQM != 4.5 { + t.Fatalf("location edit: status=%d input=%+v body=%s", locationResponse.Code, locationInput, locationResponse.Body.String()) + } +} + +func TestGardenAndSpeciesCanBeEdited(t *testing.T) { + var gardenInput client.UpdateGardenInput + var speciesInput client.SpeciesInput + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &gardenInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Neuer Garten"}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/7": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &speciesInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"species":{"id":7,"garden_id":3,"common_name":"Neue Rose"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + gardenForm := url.Values{"name": {"Neuer Garten"}, "description": {"Südseite"}} + gardenRequest := httptest.NewRequest(http.MethodPost, "/gardens/edit/3", strings.NewReader(gardenForm.Encode())) + gardenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + gardenRequest = taskWebRequest(gardenRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + gardenResponse := httptest.NewRecorder() + app.gardenEditPost(gardenResponse, gardenRequest) + if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" { + t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput) + } + speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}} + speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode())) + speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}}) + speciesResponse := httptest.NewRecorder() + app.speciesSave(speciesResponse, speciesRequest) + if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange { + t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput) + } +} + +func TestGardenCanBeDeleted(t *testing.T) { + deleted := false + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/v1/gardens/3" { + http.NotFound(w, r) + return + } + deleted = true + w.WriteHeader(http.StatusNoContent) + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodPost, "/gardens/delete/3", nil) + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + + app.gardenDeletePost(response, request) + + if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/gardens" || !deleted { + t.Fatalf("garden delete: status=%d location=%q deleted=%v body=%s", response.Code, response.Header().Get("Location"), deleted, response.Body.String()) + } +} + +func TestTaskTemplateCanBeCreated(t *testing.T) { + var input client.SpeciesTaskTemplateInput + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost || r.URL.Path != "/v1/gardens/3/species/7/task-templates" { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &input); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Schneiden"}}`)) + }) + app := newAPIBackedTestApplication(t, apiHandler) + form := url.Values{"title": {"Schneiden"}, "trigger_type": {"month_of_year"}, "month_from": {"11"}, "day_from": {"15"}, "duration": {"2"}, "duration_unit": {"week"}, "recurrence": {"monthly"}, "recurrence_interval": {"2"}, "priority": {"5"}, "active": {"true"}} + request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + app.taskTemplateSave(response, request) + if response.Code != http.StatusSeeOther || input.TriggerType == nil || *input.TriggerType != "month_of_year" || input.MonthFrom == nil || *input.MonthFrom != 11 || input.DayFrom == nil || *input.DayFrom != 15 || input.Duration == nil || *input.Duration != 2 || input.RecurrenceInterval == nil || *input.RecurrenceInterval != 2 { + t.Fatalf("template create: status=%d input=%+v body=%s", response.Code, input, response.Body.String()) + } + if input.TriggerOffset != nil || input.TriggerOffsetUnit != nil { + t.Errorf("calendar template unexpectedly sends disabled relative fields: %+v", input) + } +} + +func TestTaskTemplateDialogShowsMissingNameError(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7": + _, _ = w.Write([]byte(`{"species":{"id":7,"common_name":"Tomate"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/task-priorities": + _, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`)) + default: + t.Errorf("unexpected API request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + form := url.Values{"trigger_type": {"month_of_year"}, "month_from": {"9"}, "day_from": {"1"}, "duration": {"0"}, "duration_unit": {"day"}, "trigger_offset_unit": {"day"}, "recurrence_interval": {"1"}} + request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("HX-Request", "true") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + + app.taskTemplateSave(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + if body := response.Body.String(); !strings.Contains(body, "Ein Name ist erforderlich.") { + t.Errorf("missing name validation message: %s", body) + } + if response.Header().Get("HX-Retarget") != "#task-template-dialog-host" { + t.Errorf("HX-Retarget: got %q", response.Header().Get("HX-Retarget")) + } + + form.Set("title", "Ausgegeizte Triebe entfernen") + form.Set("month_from", "0") + request = httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("HX-Request", "true") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response = httptest.NewRecorder() + + app.taskTemplateSave(response, request) + + body := response.Body.String() + if response.Code != http.StatusOK { + t.Fatalf("status with filled name: got %d, want %d; body: %s", response.Code, http.StatusOK, body) + } + if !strings.Contains(body, "value='Ausgegeizte Triebe entfernen'") { + t.Errorf("filled name was not preserved: %s", body) + } + if strings.Contains(body, "Ein Name ist erforderlich.") { + t.Errorf("filled name was incorrectly rejected: %s", body) + } + if !strings.Contains(body, "Tag und Monat für „Ab“ sind erforderlich.") { + t.Errorf("actual date validation message is missing: %s", body) + } +} diff --git a/internal/web/efs.go b/internal/web/efs.go new file mode 100644 index 0000000..9006454 --- /dev/null +++ b/internal/web/efs.go @@ -0,0 +1,8 @@ +package web + +import ( + "embed" +) + +//go:embed "templates" "static" +var files embed.FS diff --git a/internal/web/flows_test.go b/internal/web/flows_test.go new file mode 100644 index 0000000..c2b1532 --- /dev/null +++ b/internal/web/flows_test.go @@ -0,0 +1,626 @@ +package web + +import ( + "html" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "gardomatic.kleiax.de/lib/client" + "github.com/go-playground/form/v4" +) + +type webHandlerTransport struct { + handler http.Handler +} + +func (transport webHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + transport.handler.ServeHTTP(recorder, request) + response := recorder.Result() + response.Request = request + return response, nil +} + +func newAPIBackedTestApplication(t *testing.T, handler http.Handler) *application { + t.Helper() + templateCache, err := newTemplateCache() + if err != nil { + t.Fatal(err) + } + apiClient, err := client.New("https://api.example", client.WithHTTPClient(&http.Client{Transport: webHandlerTransport{handler: handler}})) + if err != nil { + t.Fatal(err) + } + return &application{ + config: Config{SessionCookieName: "gardomatic_session", CookieSecure: false}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + templateCache: templateCache, + formDecoder: form.NewDecoder(), + apiClient: apiClient, + } +} + +func TestSettingsKeepsSelectedGarden(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/settings?garden=3", nil) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, "href='/g/3'>Hinterhof") || !strings.Contains(body, "href='/settings?garden=3'") { + t.Fatalf("selected garden was not kept in settings: %s", body) + } +} + +func TestHealthAndPrivacyPagesArePublic(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`)) + case "/v1/healthcheck": + _, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + + for _, test := range []struct { + path string + want string + }{ + {"/healtcheck", "Serverzeit"}, + {"/datenschutz", "Verarbeitete Daten"}, + } { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), test.want) { + t.Errorf("GET %s: status=%d, missing %q: %s", test.path, response.Code, test.want, response.Body.String()) + } + } +} + +func TestPinboardRequestsOnlyPinboardEntries(t *testing.T) { + requestedType := "" + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + case "/v1/gardens/3/journal": + requestedType = r.URL.Query().Get("type") + _, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"garden_id":3,"entry_type":"pinboard","title":"Sitzecke","body":"Bank bauen"}]}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/g/3/pinboard", nil) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + if requestedType != client.JournalEntryTypePinboard { + t.Fatalf("entry type: got %q, want %q", requestedType, client.JournalEntryTypePinboard) + } + if body := response.Body.String(); !strings.Contains(body, "Sitzecke") || !strings.Contains(body, "/g/3/pinboard/edit/8") { + t.Fatalf("pinboard entry missing: %s", body) + } +} + +func TestGardenPageUsesIncomingSession(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + cookie, err := r.Cookie("gardomatic_session") + if err != nil || cookie.Value != "browser-session" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`)) + return + } + http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "browser-session", Path: "/", HttpOnly: true, MaxAge: 1800}) + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens": + _, _ = w.Write([]byte(`{"gardens":[{"id":3,"name":"Hinterhof","description":"Gemüse"}]}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/gardens", nil) + request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"}) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + var refreshedSession *http.Cookie + for _, cookie := range response.Result().Cookies() { + if cookie.Name == "gardomatic_session" { + refreshedSession = cookie + break + } + } + if refreshedSession == nil || refreshedSession.Value != "browser-session" || refreshedSession.MaxAge != 1800 { + t.Errorf("refreshed session cookie was not forwarded: got %+v", refreshedSession) + } + if body := response.Body.String(); !strings.Contains(body, "Hinterhof") || !strings.Contains(body, "Alice") { + t.Errorf("page does not contain garden and user: %s", body) + } + body := response.Body.String() + if !strings.Contains(body, `href='/gardens/new'`) { + t.Errorf("page does not link to the garden creation page: %s", body) + } + for _, want := range []string{"class='user-menu'", `href='/gardens'`, `href='/account'`, ">Nutzer ", "Abmelden"} { + if !strings.Contains(body, want) { + t.Errorf("user menu does not contain %q: %s", want, body) + } + } + if strings.Contains(body, `