@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.env*
|
||||
.envrc
|
||||
config.mk
|
||||
/bin
|
||||
/dist
|
||||
/remote
|
||||
/request
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
*.log
|
||||
*.out
|
||||
*.prof
|
||||
coverage.*
|
||||
**/__debug_bin*
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
-->
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Vendored
+37
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main starts the Gardomatic JSON API service.
|
||||
package main
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main provides the Gardomatic administration command-line tool.
|
||||
package main
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package main starts the Gardomatic server-rendered web application.
|
||||
package main
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package api
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package api implements the Gardomatic HTTP API, including authentication,
|
||||
// authorization middleware, request validation, and JSON response handling.
|
||||
package api
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package auth provides password hashing and secure token primitives used by
|
||||
// Gardomatic authentication flows.
|
||||
package auth
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package mailer renders and delivers Gardomatic transactional email through
|
||||
// SMTP or an append-only development file.
|
||||
package mailer
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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
Reference in New Issue
Block a user