Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.env*
.envrc
config.mk
/bin
/dist
/remote
/request
*.key
*.pem
*.p12
*.pfx
*.log
*.out
*.prof
coverage.*
**/__debug_bin*
*.md
+70
View File
@@ -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
+7
View File
@@ -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'
+23
View File
@@ -0,0 +1,23 @@
## Änderung
<!-- Was wurde geändert und welches Problem wird damit gelöst? -->
## Prüfung
<!-- Welche automatisierten und manuellen Prüfungen wurden ausgeführt? -->
- [ ] 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.**
<!--
Die beitragende Person muss diese Checkbox selbst markieren. Beiträge im Namen
eines Unternehmens oder einer Organisation vorab mit alex@kleiax.de abstimmen.
-->
+44
View File
@@ -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
+22
View File
@@ -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
+36
View File
@@ -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
+37
View File
@@ -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
}
]
}
+165
View File
@@ -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.
+182
View File
@@ -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:
<https://www.harmonyagreements.org/docs/ha-combined-v1>
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.
+79
View File
@@ -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.
+33
View File
@@ -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"]
+136
View File
@@ -0,0 +1,136 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/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.
+197
View File
@@ -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
+456
View File
@@ -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.
+99
View File
@@ -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...)
}
+24
View File
@@ -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")
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package main starts the Gardomatic JSON API service.
package main
+16
View File
@@ -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()
}
+663
View File
@@ -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)
}
+272
View File
@@ -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)
}
}
}
+61
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
// Package main provides the Gardomatic administration command-line tool.
package main
+15
View File
@@ -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)
}
}
+336
View File
@@ -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
}
+34
View File
@@ -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...)
}
+16
View File
@@ -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)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package main starts the Gardomatic server-rendered web application.
package main
+20
View File
@@ -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()
}
+94
View File
@@ -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
+17
View File
@@ -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
+87
View File
@@ -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.
+28
View File
@@ -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
+5
View File
@@ -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?
+121
View File
@@ -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.
+32
View File
@@ -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
+49
View File
@@ -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=
+250
View File
@@ -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)
}
}
+153
View File
@@ -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)
}
+78
View File
@@ -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)
}
}
+166
View File
@@ -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)
}
}
+177
View File
@@ -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
}
+100
View File
@@ -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
}
+121
View File
@@ -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)
}
}
}
+1
View File
@@ -0,0 +1 @@
package api
+157
View File
@@ -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
}
+33
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
// Package api implements the Gardomatic HTTP API, including authentication,
// authorization middleware, request validation, and JSON response handling.
package api
+99
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
+187
View File
@@ -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)
}
}
+275
View File
@@ -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())
}
}
+22
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+175
View File
@@ -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()
})
}
+72
View File
@@ -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)
}
+332
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+243
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+351
View File
@@ -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)
})
}
+147
View File
@@ -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)
}
}
}
+195
View File
@@ -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
}
+257
View File
@@ -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
}
+250
View File
@@ -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)
}
}
+256
View File
@@ -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)
}
+140
View File
@@ -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)))
}
+93
View File
@@ -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)
}
+53
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+97
View File
@@ -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)
}
+210
View File
@@ -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)
}
}
+396
View File
@@ -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)
}
}
+141
View File
@@ -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
}
+83
View File
@@ -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())
}
}
+300
View File
@@ -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
}
@@ -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())
}
}
+34
View File
@@ -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)
}
+142
View File
@@ -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()
}
+73
View File
@@ -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)
}
}
}
+153
View File
@@ -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
}
+44
View File
@@ -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)
}
+371
View File
@@ -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
}
+133
View File
@@ -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)
}
}
+189
View File
@@ -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)
}
}
+210
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package auth provides password hashing and secure token primitives used by
// Gardomatic authentication flows.
package auth
+71
View File
@@ -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")
}
+53
View File
@@ -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")
}
+46
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package mailer renders and delivers Gardomatic transactional email through
// SMTP or an append-only development file.
package mailer
+163
View File
@@ -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
}
+64
View File
@@ -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")
}
}
@@ -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"}}<p>Bestätige deine neue E-Mail-Adresse:</p><p><a href="{{.confirmationURL}}">E-Mail-Adresse bestätigen</a></p>{{end}}
@@ -0,0 +1,3 @@
{{define "subject"}}Einladung zu Gardomatic{{end}}
{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}}
{{define "htmlBody"}}<p>Du wurdest zu einem Garten eingeladen.</p><p><a href="{{.inviteURL}}">Einladung annehmen</a></p>{{end}}
+25
View File
@@ -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"}}
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Hallo,</p>
<p>diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.</p>
<p>Viele Grüße<br>Gardomatic</p>
</body>
</html>
{{end}}
@@ -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"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
{{if .activationURL}}
<p><a href="{{.activationURL}}">Activate your Gardomatic account</a></p>
<p>Alternatively, enter this activation token on the activation page:</p>
<pre><code>{{.activationToken}}</code></pre>
{{else}}
<p>Please send a <code>PUT /v1/users/activated</code> request with the following JSON body to activate your account:</p>
<pre><code>
{"token": "{{.activationToken}}"}
</code></pre>
{{end}}
<p>Please note that this is a one-time use token and it will expire in 3 days.</p>
<p>Thanks,</p>
<p>The Gardomatic Team</p>
</body>
</html>
{{end}}
@@ -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"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hi,</p>
<p>Please send a <code>PUT /v1/users/password</code> request with the following JSON body to set a new password:</p>
<pre><code>
{"password": "your new password", "token": "{{.passwordResetToken}}"}
</code></pre>
<p>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 <code>POST /v1/tokens/password-reset</code> request.</p>
<p>Thanks,</p>
<p>The Gardomatic Team</p>
</body>
</html>
{{end}}
@@ -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"}}
<!doctype html>
<html lang="de">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Hallo {{.name}},</p>
<p>du wurdest zu Gardomatic eingeladen.</p>
<p><a href="{{.activationURL}}">Account aktivieren und Passwort festlegen</a></p>
<p>Der Link ist drei Tage lang gültig.</p>
<p>Viele Grüße<br>Gardomatic</p>
</body>
</html>
{{end}}

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