Compare commits

..
8 Commits
Author SHA1 Message Date
kleiax 6670100248 Clear tag input after initializing tag editors
CI / test (push) Failing after 2m58s
2026-09-16 20:30:38 +02:00
kleiax ef608551ad Support species height and width ranges
CI / test (push) Failing after 3m6s
2026-09-16 20:21:41 +02:00
kleiax 87809b2344 Preserve safe return paths through sign-in
CI / test (push) Failing after 3m18s
2026-09-16 05:36:36 +02:00
kleiax b6d264c9ec Clean up project planning documentation
CI / test (push) Failing after 2m50s
2026-09-15 20:31:05 +02:00
kleiax df8544e04b Capture invite details before starting background email delivery
CI / test (push) Failing after 3m22s
2026-09-15 20:19:22 +02:00
kleiax d06ff4a94a Add separate production, testserver, and demo deployments
CI / test (push) Failing after 10s
2026-09-14 19:58:31 +02:00
kleiax b87aa0aa17 Use scp for production provisioning script transfer
CI / test (push) Canceled after 0s
2026-09-12 23:04:26 +02:00
kleiax 64f11e4960 remove caddy from readme 2026-09-12 22:28:04 +02:00
62 changed files with 1793 additions and 333 deletions
+6
View File
@@ -56,6 +56,12 @@ GARDOMATIC_RATE_LIMIT_BURST=40
# https://garden.example.com,https://admin.example.com
GARDOMATIC_CORS_TRUSTED_ORIGINS=http://localhost:4040
# Destructive demo reset. Never enable this for a database containing real data.
GARDOMATIC_DEMO_RESET_ENABLED=false
# When set, this shared account cannot change its identity, password or sessions.
# Use the same address as `demo reset --email` on a disposable demo instance.
GARDOMATIC_DEMO_ACCOUNT_EMAIL=
# Delivery mode. Allowed: file or smtp.
GARDOMATIC_SMTP_MODE=file
# SMTP hostname and TCP port. Used only in smtp mode.
+83 -46
View File
@@ -3,8 +3,8 @@ SHELL := /bin/bash
.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.
# Non-secret deployment settings. Runtime secrets remain in separate shell-compatible
# files below remote/setup instead of being parsed by Make.
-include config.mk
define load_envrc
@@ -32,6 +32,8 @@ config/init:
@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
@if [[ -e ./remote/setup/.env.testserver ]]; then echo 'keep remote/setup/.env.testserver'; else install -m 0600 ./remote/setup/.env.example ./remote/setup/.env.testserver; echo 'create remote/setup/.env.testserver'; fi
@if [[ -e ./remote/setup/.env.demo ]]; then echo 'keep remote/setup/.env.demo'; else install -m 0600 ./remote/setup/.env.example ./remote/setup/.env.demo; echo 'create remote/setup/.env.demo'; fi
# ==================================================================================== #
# DEVELOPMENT
@@ -104,7 +106,7 @@ test/integration:
.PHONY: build/dirs
build/dirs:
mkdir -p ./bin ${PRODUCTION_BUILD_DIR}
mkdir -p ./bin
## build/api: build the cmd/api application
.PHONY: build/api
@@ -121,12 +123,21 @@ build/web: build/dirs
build/cli: build/dirs
go build -ldflags='-s' -o=./bin/cli ./cmd/cli
## build/production: cross-compile all production binaries
DEPLOYMENT_GOOS ?= linux
DEPLOYMENT_GOARCH ?= amd64
DEPLOYMENT_BUILD_DIR = ./bin/${DEPLOYMENT_GOOS}_${DEPLOYMENT_GOARCH}
.PHONY: build/deployment
build/deployment: build/dirs
mkdir -p ${DEPLOYMENT_BUILD_DIR}
GOOS=${DEPLOYMENT_GOOS} GOARCH=${DEPLOYMENT_GOARCH} go build -trimpath -ldflags='-s -w' -o=${DEPLOYMENT_BUILD_DIR}/api ./cmd/api
GOOS=${DEPLOYMENT_GOOS} GOARCH=${DEPLOYMENT_GOARCH} go build -trimpath -ldflags='-s -w' -o=${DEPLOYMENT_BUILD_DIR}/web ./cmd/web
GOOS=${DEPLOYMENT_GOOS} GOARCH=${DEPLOYMENT_GOARCH} go build -trimpath -ldflags='-s -w' -o=${DEPLOYMENT_BUILD_DIR}/cli ./cmd/cli
## build/production: cross-compile all production binaries (compatibility alias)
.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
build/production:
@$(MAKE) --no-print-directory build/deployment DEPLOYMENT_GOOS=${PRODUCTION_GOOS} DEPLOYMENT_GOARCH=${PRODUCTION_GOARCH}
## code/stats: print source lines and their estimated number of book pages
.PHONY: code/stats
@@ -143,7 +154,7 @@ build/all: build/api build/web build/cli
@$(MAKE) --no-print-directory code/stats
# ==================================================================================== #
# PRODUCTION
# REMOTE DEPLOYMENTS
# ==================================================================================== #
PRODUCTION_HOST ?=
@@ -152,46 +163,72 @@ 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
TESTSERVER_HOST ?=
TESTSERVER_SSH_USER ?= root
TESTSERVER_SSH_PORT ?= 22
TESTSERVER_SSH_IDENTITY_FILE ?=
TESTSERVER_GOOS ?= linux
TESTSERVER_GOARCH ?= amd64
.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
DEMO_HOST ?=
DEMO_SSH_USER ?= root
DEMO_SSH_PORT ?= 22
DEMO_SSH_IDENTITY_FILE ?=
DEMO_GOOS ?= linux
DEMO_GOARCH ?= amd64
## production/connect: connect to the production server
.PHONY: production/connect
production/connect: production/check-config
ssh ${production_ssh_options} ${production_target}
deployments := production testserver demo
deployment_prefix.production := PRODUCTION
deployment_prefix.testserver := TESTSERVER
deployment_prefix.demo := DEMO
deployment_env.production := ./remote/setup/.env
deployment_env.testserver := ./remote/setup/.env.testserver
deployment_env.demo := ./remote/setup/.env.demo
## 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
deployment_setting = $($(deployment_prefix.$1)_$2)
deployment_target = $(call deployment_setting,$1,SSH_USER)@$(call deployment_setting,$1,HOST)
deployment_identity_option = $(if $(strip $(call deployment_setting,$1,SSH_IDENTITY_FILE)),-i $(call deployment_setting,$1,SSH_IDENTITY_FILE))
deployment_ssh_options = -p $(call deployment_setting,$1,SSH_PORT) $(call deployment_identity_option,$1)
deployment_scp_options = -P $(call deployment_setting,$1,SSH_PORT) $(call deployment_identity_option,$1)
deployment_build_dir = ./bin/$(call deployment_setting,$1,GOOS)_$(call deployment_setting,$1,GOARCH)
## 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"'
deployment_check_targets := $(addsuffix /check-config,$(deployments))
deployment_connect_targets := $(addsuffix /connect,$(deployments))
deployment_provision_targets := $(addsuffix /provision,$(deployments))
deployment_deploy_targets := $(addsuffix /deploy,$(deployments))
deployment_admin_targets := $(addsuffix /create-admin,$(deployments))
## 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
.PHONY: $(deployment_check_targets) $(deployment_connect_targets) $(deployment_provision_targets) $(deployment_deploy_targets) $(deployment_admin_targets)
$(deployment_check_targets): %/check-config:
@test -n "$(call deployment_setting,$*,HOST)" || { echo '$(deployment_prefix.$*)_HOST is required; configure it in config.mk' >&2; exit 1; }
@test -n "$(call deployment_setting,$*,SSH_USER)" || { echo '$(deployment_prefix.$*)_SSH_USER is required' >&2; exit 1; }
@[[ "$(call deployment_setting,$*,SSH_PORT)" =~ ^[0-9]+$$ ]] && (( $(call deployment_setting,$*,SSH_PORT) >= 1 && $(call deployment_setting,$*,SSH_PORT) <= 65535 )) || { echo '$(deployment_prefix.$*)_SSH_PORT must be between 1 and 65535' >&2; exit 1; }
@[[ "$(call deployment_setting,$*,GOOS)" == linux ]] || { echo '$(deployment_prefix.$*)_GOOS must be linux' >&2; exit 1; }
@[[ "$(call deployment_setting,$*,GOARCH)" == amd64 || "$(call deployment_setting,$*,GOARCH)" == arm64 ]] || { echo '$(deployment_prefix.$*)_GOARCH must be amd64 or arm64' >&2; exit 1; }
@if [[ -n "$(call deployment_setting,$*,SSH_IDENTITY_FILE)" && ! -f "$(call deployment_setting,$*,SSH_IDENTITY_FILE)" ]]; then echo '$(deployment_prefix.$*)_SSH_IDENTITY_FILE does not exist' >&2; exit 1; fi
$(deployment_connect_targets): %/connect: %/check-config
ssh $(call deployment_ssh_options,$*) $(call deployment_target,$*)
$(deployment_provision_targets): %/provision: %/check-config
@test -f $(deployment_env.$*) || { echo 'Copy remote/setup/.env.example to $(deployment_env.$*) and configure it first' >&2; exit 1; }
@permissions=$$(stat -c '%a' $(deployment_env.$*)); (( (8#$$permissions & 077) == 0 )) || { echo '$(deployment_env.$*) must have mode 0600' >&2; exit 1; }
scp $(call deployment_scp_options,$*) ./remote/setup/provision-server.sh $(call deployment_target,$*):/tmp/gardomatic-provision-server.sh
ssh $(call deployment_ssh_options,$*) $(call deployment_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' < $(deployment_env.$*)
$(deployment_deploy_targets): %/deploy: %/check-config
@$(MAKE) --no-print-directory build/deployment DEPLOYMENT_GOOS=$(call deployment_setting,$*,GOOS) DEPLOYMENT_GOARCH=$(call deployment_setting,$*,GOARCH)
ssh $(call deployment_ssh_options,$*) $(call deployment_target,$*) 'mkdir -p "$$HOME/gardomatic-deploy/migrations"'
rsync -P -e "ssh $(call deployment_ssh_options,$*)" $(call deployment_build_dir,$*)/api $(call deployment_build_dir,$*)/web $(call deployment_build_dir,$*)/cli $(call deployment_target,$*):gardomatic-deploy/
rsync -rP --delete -e "ssh $(call deployment_ssh_options,$*)" ./internal/storage/postgres/migrations/ $(call deployment_target,$*):gardomatic-deploy/migrations/
rsync -P -e "ssh $(call deployment_ssh_options,$*)" ./remote/production/api.service ./remote/production/web.service ./remote/production/demo-reset.service ./remote/production/demo-reset.timer ./remote/production/deploy-server.sh ./remote/setup/create-admin.sh $(call deployment_target,$*):gardomatic-deploy/
ssh -t $(call deployment_ssh_options,$*) $(call deployment_target,$*) 'chmod 700 "$$HOME/gardomatic-deploy/deploy-server.sh" && "$$HOME/gardomatic-deploy/deploy-server.sh"'
$(deployment_admin_targets): %/create-admin: %/check-config
ssh -t $(call deployment_ssh_options,$*) $(call deployment_target,$*) /usr/local/sbin/gardomatic-create-admin
## production/connect|provision|deploy|create-admin: manage the production server
## testserver/connect|provision|deploy|create-admin: manage the test server
## demo/connect|provision|deploy|create-admin: manage the public demo server
+102 -25
View File
@@ -350,7 +350,7 @@ internal/
lib/
client/ typisierter Go-Client für die JSON-API
doc/ Planung und weiterführende Dokumentation
remote/ Produktionsbeispiele für systemd und Caddy
remote/ Produktionsbeispiele für systemd
request/ manuelle HTTP-Beispielanfragen
```
@@ -383,28 +383,44 @@ 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.
liegen außerdem Beispielkonfigurationen für systemd. Die
Präfixe `production/*`, `testserver/*` und `demo/*` verwenden denselben
Deployment-Ablauf, aber getrennte Zielhosts und Laufzeitkonfigurationen. Sie sind
auf die vorhandene Gardomatic-Infrastruktur zugeschnitten, führen Migrationen
sowie Dienstneustarts aus 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.
Die drei SSH-Verbindungen werden gemeinsam in `config.mk` konfiguriert. Eine
kommentierte Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel
und Zielarchitektur steht in `config.mk.example`. Die Präfixe der Variablen sind
`PRODUCTION_`, `TESTSERVER_` und `DEMO_`. 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`.
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`.
aus einer zum Ziel gehörenden Datei. Alle drei Dateien werden von `make config/init`
mit Modus `0600` angelegt und bleiben von Git ausgeschlossen:
| Make-Präfix | SSH-Einstellungen | Laufzeitkonfiguration |
| --- | --- | --- |
| `production/` | `PRODUCTION_*` in `config.mk` | `remote/setup/.env` |
| `testserver/` | `TESTSERVER_*` in `config.mk` | `remote/setup/.env.testserver` |
| `demo/` | `DEMO_*` in `config.mk` | `remote/setup/.env.demo` |
Jede Laufzeitdatei benötigt eine eigene Datenbank-DSN, eigene Passwörter und die
URL des jeweiligen Servers. Für Produktion wird `GARDOMATIC_ENV=production`
verwendet. Der Testserver kann `GARDOMATIC_ENV=test` und dateibasierten
Mailversand nutzen. Die öffentliche Demo läuft gehärtet mit
`GARDOMATIC_ENV=production`, aber mit `GARDOMATIC_DEMO_RESET_ENABLED=true`,
`GARDOMATIC_DEMO_ACCOUNT_EMAIL=demo@example.com` und
`GARDOMATIC_SMTP_MODE=file`. Ein gemeinsames Runtime-Environment wird absichtlich
nicht verwendet, damit ein Demo-Deployment keine Produktionsdatenbank erreichen
kann.
`*/provision` überträgt das Setup und streamt ausschließlich die zugehörige
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 auf dem
jeweiligen Server als `/etc/gardomatic/gardomatic.env`.
Ein vollständiger Erstbetrieb besteht aus:
@@ -414,13 +430,21 @@ make config/init
make production/provision
make production/deploy
make production/create-admin
# Entsprechend für die anderen Instanzen:
make testserver/provision
make testserver/deploy
make testserver/create-admin
make demo/provision
make demo/deploy
```
`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.
Jedes Präfix bietet `connect`, `provision`, `deploy` und `create-admin`.
`*/deploy` überträgt Binärdateien, Migrationen, systemd-Units und die Admin-Hilfe,
wendet Migrationen an und startet die Dienste. `*/create-admin` erstellt
interaktiv einen aktivierten Anwendungsadministrator und gibt das generierte
Passwort einmalig aus.
Für Produktion gelten mindestens folgende Anforderungen:
@@ -432,6 +456,59 @@ Für Produktion gelten mindestens folgende Anforderungen:
- korrekte öffentliche Web-URL und vertrauenswürdige CORS-Origins
- SMTP statt dateibasiertem Mailversand, sofern E-Mails zugestellt werden sollen
### Öffentliche Demo-Instanz
Für eine öffentliche Demo empfiehlt sich eine eigene Gardomatic-Installation mit
eigener PostgreSQL-Datenbank und eigener Domain, nicht ein Demo-Garten in einer
Produktivdatenbank. Die Anwendung sollte weiterhin mit
`GARDOMATIC_ENV=production`, HTTPS und sicheren Cookies laufen. Ausschließlich auf
dieser wegwerfbaren Instanz wird `GARDOMATIC_DEMO_RESET_ENABLED=true` gesetzt.
`GARDOMATIC_DEMO_ACCOUNT_EMAIL=demo@example.com` schützt das gemeinsame Konto
serverseitig: Profil, E-Mail-Adresse, Passwort, Passwort-Zurücksetzen und die
globale Sitzungsverwaltung können von Besuchern nicht verändert werden. Die
Garteninhalte bleiben vollständig bedienbar.
Der Befehl `gardomatic-cli --yes demo reset --password-stdin` ersetzt alle
Benutzer- und Gartendaten atomar durch einen zeitlich aktuellen Beispieldatensatz.
Er erstellt den Garten `Sonnengarten` mit drei Mitgliedern, Einladung und eigener
Gartenrolle, acht detaillierten Orten, zwölf Arten, Pflegehinweisen,
Aufgabenvorlagen, sechzehn Pflanzen in unterschiedlichen Zuständen, fälligen,
wiederkehrenden und erledigten Aufgaben, Tags, längeren Tagebuch- und
Pinnwandeinträgen sowie einer bebilderten Medienbibliothek. Das gewünschte Login
lautet standardmäßig
`demo@example.com`; das Passwort wird nicht im Repository gespeichert.
Die mitgelieferte systemd-Konfiguration setzt die Demo jede Nacht um 04:00 Uhr
zurück. Für einen frisch aufgesetzten Demo-Server:
1. In `remote/setup/.env.demo` `GARDOMATIC_DEMO_RESET_ENABLED='true'`,
`GARDOMATIC_DEMO_ACCOUNT_EMAIL='demo@example.com'` und
`GARDOMATIC_SMTP_MODE='file'` setzen.
2. Den Server mit `make demo/provision` provisionieren.
3. Auf dem Demo-Server das Passwort verdeckt einlesen und mit `Enter` bestätigen:
```sh
make demo/connect
sudo install -d -m 0750 -o root -g gardomatic /etc/gardomatic
systemd-ask-password 'Demo-Passwort:' | \
sudo tee /etc/gardomatic/demo-password >/dev/null
sudo chown root:gardomatic /etc/gardomatic/demo-password
sudo chmod 0640 /etc/gardomatic/demo-password
exit
```
4. `make demo/deploy` ausführen. Das Deployment befüllt die Demo sofort
und aktiviert `gardomatic-demo-reset.timer`.
Mit `systemctl list-timers gardomatic-demo-reset.timer` lässt sich der nächste
Lauf prüfen; `journalctl -u gardomatic-demo-reset.service` zeigt die Reset-Läufe.
Ein Reverse Proxy sollte zusätzlich Request-Größen begrenzen und die vorhandene
Rate-Limitierung aktiviert lassen. Für die Demo sollte der Mailversand im
`file`-Modus bleiben, damit Besucher keine E-Mails an Dritte auslösen können. Da
Besucher Schreibrechte besitzen, dürfen
auf diesem Host keine anderen schützenswerten Anwendungen oder Datenbanken mit
denselben Zugangsdaten betrieben werden.
## Weiterführende Dokumentation
- [`doc/planung.md`](doc/planung.md) Produktbeschreibung, Leitplanken und Fahrplan
+11
View File
@@ -10,6 +10,8 @@ import (
"gardomatic.kleiax.de/internal/api"
"gardomatic.kleiax.de/internal/mailer"
"gardomatic.kleiax.de/internal/platform/environment"
"gardomatic.kleiax.de/internal/platform/validate"
"gardomatic.kleiax.de/internal/storage"
)
func configFromEnvironment() (api.Config, error) {
@@ -55,6 +57,9 @@ func configFromEnvironment() (api.Config, error) {
Port: integer("GARDOMATIC_API_PORT", 4000),
Env: environment.String("GARDOMATIC_ENV", "development"),
WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
DemoAccountEmail: strings.ToLower(strings.TrimSpace(
environment.String("GARDOMATIC_DEMO_ACCOUNT_EMAIL", ""),
)),
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),
@@ -92,6 +97,12 @@ func configFromEnvironment() (api.Config, error) {
if cfg.Env == "production" && !cfg.Session.CookieSecure {
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
}
if cfg.DemoAccountEmail != "" {
v := validate.New()
if storage.ValidateEmail(v, cfg.DemoAccountEmail); !v.Valid() {
errs = append(errs, errors.New("GARDOMATIC_DEMO_ACCOUNT_EMAIL must be a valid email address"))
}
}
if _, err := mailer.New(cfg.Mail); err != nil {
errs = append(errs, fmt.Errorf("mail configuration: %w", err))
}
+10 -1
View File
@@ -7,15 +7,24 @@ func TestConfigFromEnvironment(t *testing.T) {
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")
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", " Demo@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 {
if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 || cfg.DemoAccountEmail != "demo@example.com" {
t.Fatalf("unexpected config: %#v", cfg)
}
}
func TestConfigRejectsInvalidDemoAccountEmail(t *testing.T) {
t.Setenv("GARDOMATIC_DB_DSN", "postgres://example")
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", "not-an-email")
if _, err := configFromEnvironment(); err == nil {
t.Fatal("expected invalid demo account email error")
}
}
func TestConfigRequiresDSN(t *testing.T) {
t.Setenv("GARDOMATIC_DB_DSN", "")
if _, err := configFromEnvironment(); err == nil {
+50
View File
@@ -92,11 +92,60 @@ func (app *application) run(ctx context.Context, args []string) error {
return app.runGardens(ctx, cfg, store, remaining[1:])
case "db":
return app.runDB(ctx, cfg, store, remaining[1:])
case "demo":
return app.runDemo(ctx, cfg, store, remaining[1:])
default:
return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
}
}
func (app *application) runDemo(ctx context.Context, cfg config, store adminStore, args []string) error {
if len(args) == 0 || args[0] != "reset" {
return errors.New("usage: gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL]")
}
if !cfg.demoResetEnabled {
return errors.New("demo reset is disabled; set GARDOMATIC_DEMO_RESET_ENABLED=true only for a disposable demo database")
}
if !cfg.yes {
return errors.New("demo reset deletes all user and garden data; pass the global --yes flag")
}
fs := newFlagSet("demo reset", app.stderr)
var name, email string
var passwordStdin bool
fs.StringVar(&name, "name", "Demo-Besucher", "demo account display name")
fs.StringVar(&email, "email", "demo@example.com", "demo account email address")
fs.BoolVar(&passwordStdin, "password-stdin", false, "read the demo account password from standard input (required)")
if err := parseFlags(fs, args[1:]); err != nil {
return err
}
name, email = strings.TrimSpace(name), strings.TrimSpace(email)
if !passwordStdin {
return errors.New("--password-stdin is required so the demo password is not exposed in the process list")
}
if err := validateIdentity(name, email); err != nil {
return err
}
password, _, err := app.obtainPassword(false, true)
if err != nil {
return err
}
passwordHash, err := hashPassword(password)
if err != nil {
return err
}
result, err := store.ResetDemo(ctx, demoResetInput{Name: name, Email: email, PasswordHash: passwordHash})
if err != nil {
return fmt.Errorf("reset demo data: %w", err)
}
if cfg.json {
return writeJSON(app.stdout, result)
}
_, err = fmt.Fprintf(app.stdout, "Demo reset complete.\nLogin: %s\nGarden: %s\nImages: %d\nLocations: %d\nSpecies: %d\nPlants: %d\nTasks: %d\nJournal entries: %d\nAttachments: %d\n",
result.Email, result.Garden, result.Images, result.Locations, result.Species, result.Plants, result.Tasks, result.Journal, result.Attachments)
return err
}
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")
@@ -554,6 +603,7 @@ Usage:
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 --yes demo reset --password-stdin [--name NAME] [--email EMAIL]
gardomatic [global options] version
Global options:`)
+60
View File
@@ -22,6 +22,8 @@ type fakeAdminStore struct {
setRoleCalls int
addMemberCalls int
lastRole string
demoResetCalls int
demoResetInput demoResetInput
}
func (s *fakeAdminStore) Ping(context.Context) error { return nil }
@@ -71,6 +73,11 @@ func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email,
s.lastRole = role
return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
}
func (s *fakeAdminStore) ResetDemo(_ context.Context, input demoResetInput) (demoResetResult, error) {
s.demoResetCalls++
s.demoResetInput = input
return demoResetResult{Email: input.Email, Garden: "Sonnengarten", Images: 3, Locations: 8, Species: 12, Plants: 16, Tasks: 16, Journal: 6, Attachments: 1}, nil
}
func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -124,6 +131,59 @@ func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) {
}
}
func TestDemoResetRequiresExplicitEnablement(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "correct horse battery staple\n", store)
err := app.run(context.Background(), []string{"--yes", "demo", "reset", "--password-stdin"})
if err == nil || !strings.Contains(err.Error(), "GARDOMATIC_DEMO_RESET_ENABLED") {
t.Fatalf("run() error = %v, want disabled demo reset error", err)
}
if store.demoResetCalls != 0 {
t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls)
}
}
func TestDemoResetRequiresYes(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "correct horse battery staple\n", store)
t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true")
err := app.run(context.Background(), []string{"demo", "reset", "--password-stdin"})
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("run() error = %v, want --yes error", err)
}
if store.demoResetCalls != 0 {
t.Fatalf("ResetDemo calls = %d, want 0", store.demoResetCalls)
}
}
func TestDemoResetHashesPasswordAndWritesSummary(t *testing.T) {
store := new(fakeAdminStore)
app, stdout, _ := newTestApplication(t, "correct horse battery staple\n", store)
t.Setenv("GARDOMATIC_DEMO_RESET_ENABLED", "true")
err := app.run(context.Background(), []string{
"--yes", "demo", "reset", "--password-stdin",
"--name", " Schaugarten ", "--email", "besuch@example.com",
})
if err != nil {
t.Fatalf("run() returned an error: %v", err)
}
if store.demoResetCalls != 1 {
t.Fatalf("ResetDemo calls = %d, want 1", store.demoResetCalls)
}
if store.demoResetInput.Name != "Schaugarten" || store.demoResetInput.Email != "besuch@example.com" {
t.Errorf("ResetDemo input = %+v", store.demoResetInput)
}
if err = bcrypt.CompareHashAndPassword(store.demoResetInput.PasswordHash, []byte("correct horse battery staple")); err != nil {
t.Errorf("stored password hash does not match input: %v", err)
}
if !strings.Contains(stdout.String(), "Demo reset complete") || !strings.Contains(stdout.String(), "Plants: 16") {
t.Errorf("output = %q", stdout.String())
}
}
func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
store := new(fakeAdminStore)
app, _, _ := newTestApplication(t, "", store)
+27 -9
View File
@@ -10,12 +10,13 @@ import (
)
type config struct {
dsn string
environment string
webBaseURL string
json bool
yes bool
mail mailer.Config
dsn string
environment string
webBaseURL string
demoResetEnabled bool
json bool
yes bool
mail mailer.Config
}
func configFromEnvironment() (config, error) {
@@ -23,11 +24,16 @@ func configFromEnvironment() (config, error) {
if err != nil {
return config{}, err
}
demoResetEnabled, err := envBool("GARDOMATIC_DEMO_RESET_ENABLED", false)
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"),
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
environment: envString("GARDOMATIC_ENV", "development"),
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
demoResetEnabled: demoResetEnabled,
mail: mailer.Config{
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
@@ -40,6 +46,18 @@ func configFromEnvironment() (config, error) {
}, nil
}
func envBool(name string, fallback bool) (bool, error) {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback, nil
}
b, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("%s must be a boolean: %w", name, err)
}
return b, nil
}
func envString(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

+616
View File
@@ -0,0 +1,616 @@
package main
import (
"context"
"crypto/sha256"
"database/sql"
_ "embed"
"encoding/hex"
"fmt"
"time"
)
const demoGardenName = "Sonnengarten"
//go:embed demo_assets/sonnengarten.jpg
var demoGardenImage []byte
//go:embed demo_assets/tomaten-gewaechshaus.jpg
var demoTomatoImage []byte
//go:embed demo_assets/apfelbaum-obstwiese.jpg
var demoAppleImage []byte
// ResetDemo replaces all user-created data with a coherent demo data set. The
// caller is responsible for guarding this destructive operation.
func (s *postgresStore) ResetDemo(ctx context.Context, input demoResetInput) (demoResetResult, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return demoResetResult{}, err
}
defer tx.Rollback()
// Serialize resets and keep the old demo usable until the complete new data
// set can be committed.
if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1279348146)`); err != nil {
return demoResetResult{}, fmt.Errorf("lock demo reset: %w", err)
}
if _, err = tx.ExecContext(ctx, `
DELETE FROM sessions;
DELETE FROM journal_attachments;
DELETE FROM gardens;
DELETE FROM species;
DELETE FROM tags;
DELETE FROM users;
DELETE FROM roles WHERE system = false;
DELETE FROM species_categories;
UPDATE application_settings
SET lifecycle_status_enabled = true,
lifecycle_removal_month = 12,
lifecycle_removal_day = 1,
timezone = 'Europe/Berlin',
updated_at = CURRENT_TIMESTAMP,
version = version + 1;
INSERT INTO species_categories (name, sort_order, lifecycle) VALUES
('Gehölz', 10, 'perennial'),
('Gemüse', 20, 'annual'),
('Kraut', 30, 'annual'),
('Obst', 40, 'perennial'),
('Staude', 50, 'perennial'),
('Blume', 60, 'annual')`); err != nil {
return demoResetResult{}, fmt.Errorf("clear demo data: %w", err)
}
var demoUserID, memberID, workerID, gardenID int64
if err = tx.QueryRowContext(ctx, `
INSERT INTO users (name, email, password_hash, activated, application_role, color)
VALUES ($1, $2, $3, true, 'application:user', '#1b9e77') RETURNING id`,
input.Name, input.Email, input.PasswordHash).Scan(&demoUserID); err != nil {
return demoResetResult{}, fmt.Errorf("create demo account: %w", err)
}
if err = tx.QueryRowContext(ctx, `
INSERT INTO users (name, email, password_hash, activated, application_role, color)
VALUES ('Mara Beispiel', 'mara@example.com', $1, true, 'application:user', '#7570b3') RETURNING id`,
input.PasswordHash).Scan(&memberID); err != nil {
return demoResetResult{}, fmt.Errorf("create example member: %w", err)
}
if err = tx.QueryRowContext(ctx, `
INSERT INTO users (name, email, password_hash, activated, application_role, color)
VALUES ('Timo Gartenfreund', 'timo@example.com', $1, true, 'application:user', '#d95f02') RETURNING id`,
input.PasswordHash).Scan(&workerID); err != nil {
return demoResetResult{}, fmt.Errorf("create example worker: %w", err)
}
if err = tx.QueryRowContext(ctx, `
INSERT INTO gardens (name, description)
VALUES ($1, 'Ein lebendiger Gemeinschaftsgarten mit Gemüsebeeten, Gewächshaus und kleiner Obstwiese.')
RETURNING id`, demoGardenName).Scan(&gardenID); err != nil {
return demoResetResult{}, fmt.Errorf("create demo garden: %w", err)
}
customRole := fmt.Sprintf("erntehilfe-%d", gardenID)
if _, err = tx.ExecContext(ctx, `
INSERT INTO roles (name, scope, label, garden_id) VALUES ($1, 'garden', 'Erntehilfe', $2)`, customRole, gardenID); err != nil {
return demoResetResult{}, fmt.Errorf("create demo garden role: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO role_permissions (role_name, permission) VALUES
($1, 'garden:read'), ($1, 'tasks:read:own'), ($1, 'tasks:read:other'),
($1, 'tasks:complete:own'), ($1, 'tasks:complete:other')`, customRole); err != nil {
return demoResetResult{}, fmt.Errorf("create demo garden role permissions: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO garden_members (garden_id, user_id, role) VALUES
($1, $2, 'owner'),
($1, $3, 'member'),
($1, $4, $5)`, gardenID, demoUserID, memberID, workerID, customRole); err != nil {
return demoResetResult{}, fmt.Errorf("create demo memberships: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO garden_role_permission_overrides (garden_id, role_name, permission, granted) VALUES
($1, 'member', 'plants:delete:own', false),
($1, 'viewer', 'tasks:complete:other', true)`, gardenID); err != nil {
return demoResetResult{}, fmt.Errorf("create demo permission overrides: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO garden_invites (garden_id, email, role, token_hash, invited_by, expires_at)
VALUES ($1, 'lea-beispiel@example.com', 'viewer', decode('b462f5f8befe77ecf5c611fd7e2f72138fefd4fa5b41f2f591cc8331187e94f7', 'hex'), $2, CURRENT_TIMESTAMP + interval '5 days')`, gardenID, demoUserID); err != nil {
return demoResetResult{}, fmt.Errorf("create demo invitation: %w", err)
}
imageIDs, err := seedDemoImages(ctx, tx, gardenID, demoUserID)
if err != nil {
return demoResetResult{}, err
}
if _, err = tx.ExecContext(ctx, `UPDATE gardens SET image_id = $2 WHERE id = $1`, gardenID, imageIDs["garden"]); err != nil {
return demoResetResult{}, fmt.Errorf("assign demo garden image: %w", err)
}
locationIDs, err := seedDemoLocations(ctx, tx, gardenID, demoUserID, imageIDs)
if err != nil {
return demoResetResult{}, err
}
speciesIDs, templateIDs, err := seedDemoSpecies(ctx, tx, gardenID, demoUserID, imageIDs)
if err != nil {
return demoResetResult{}, err
}
plantIDs, err := seedDemoPlants(ctx, tx, gardenID, demoUserID, memberID, locationIDs, speciesIDs, templateIDs, imageIDs)
if err != nil {
return demoResetResult{}, err
}
taskCount, err := seedDemoTasks(ctx, tx, gardenID, demoUserID, memberID, locationIDs, plantIDs, templateIDs)
if err != nil {
return demoResetResult{}, err
}
journalCount, attachmentCount, err := seedDemoJournal(ctx, tx, gardenID, demoUserID, memberID, imageIDs)
if err != nil {
return demoResetResult{}, err
}
if err = tx.Commit(); err != nil {
return demoResetResult{}, fmt.Errorf("commit demo reset: %w", err)
}
return demoResetResult{
Email: input.Email,
Garden: demoGardenName,
Images: len(imageIDs),
Locations: len(locationIDs),
Species: len(speciesIDs),
Plants: len(plantIDs),
Tasks: taskCount,
Journal: journalCount,
Attachments: attachmentCount,
}, nil
}
func seedDemoImages(ctx context.Context, tx *sql.Tx, gardenID, userID int64) (map[string]int64, error) {
images := []struct {
key, fileName, source string
data []byte
}{
{"garden", "sonnengarten-uebersicht.jpg", "demo", demoGardenImage},
{"tomato", "tomaten-im-gewaechshaus.jpg", "demo", demoTomatoImage},
{"apple", "apfelbaum-auf-der-obstwiese.jpg", "demo", demoAppleImage},
}
ids := make(map[string]int64, len(images))
for _, image := range images {
sum := sha256.Sum256(image.data)
var id int64
if err := tx.QueryRowContext(ctx, `
INSERT INTO images (garden_id, file_name, media_type, data, size, checksum, source, created_by)
VALUES ($1, $2, 'image/jpeg', $3, $4, $5, $6, $7) RETURNING id`,
gardenID, image.fileName, image.data, len(image.data), hex.EncodeToString(sum[:]), image.source, userID).Scan(&id); err != nil {
return nil, fmt.Errorf("create demo image %q: %w", image.fileName, err)
}
ids[image.key] = id
}
return ids, nil
}
func seedDemoLocations(ctx context.Context, tx *sql.Tx, gardenID, userID int64, images map[string]int64) (map[string]int64, error) {
ids := make(map[string]int64)
rows := []struct {
key, name, description, kind, parent string
area float64
sun, soil, reaction, attributes, imageKey string
}{
{"vegetable", "Gemüsegarten", "Der sonnige Bereich für Frucht-, Wurzel- und Blattgemüse. Eine Tropfleitung versorgt die Hauptreihen.", "Bereich", "", 42, "sunny", "moist", "neutral", `{"bewässerung":"Tropfschlauch","mulch":"Rasenschnitt","bodenprobe":"2026-03-18"}`, ""},
{"raised", "Hochbeet Süd", "Kompostreiche Erde, morgens zuerst kontrollieren. Die Beetkante wurde im Frühjahr erneuert.", "Hochbeet", "vegetable", 6.5, "sunny", "moist", "neutral", `{"baujahr":2024,"material":"Lärche","füllung":"Kompost und Gartenerde"}`, ""},
{"north_bed", "Beet Nord", "Halbschattiges Beet für Salate, Mangold und empfindliche Jungpflanzen.", "Beet", "vegetable", 9.75, "partial_shade", "moist", "neutral", `{"reihen":4,"fruchtfolge":"Blattgemüse","netz_vorhanden":true}`, ""},
{"greenhouse", "Gewächshaus", "Geschützt für Tomaten und wärmeliebende Kulturen. Automatische Fensterheber lüften ab 24 °C.", "Gewächshaus", "", 12, "sunny", "moist", "alkaline", `{"temperatur_sensor":"GH-01","bewässerung":"Tropfer","fensterheber":true}`, "tomato"},
{"herbs", "Kräuterecke", "Trockener Standort an der Natursteinmauer mit durchlässigem, kalkhaltigem Substrat.", "Beet", "", 5, "sunny", "dry", "alkaline", `{"substrat":"sandig-kiesig","trockenmauer":true}`, ""},
{"orchard", "Obstwiese", "Junge Obstgehölze mit insektenfreundlichem Unterwuchs und gestaffelter Mahd.", "Wiese", "", 85, "partial_shade", "moist", "neutral", `{"mahd":"zweimal jährlich","nistkästen":2,"bewässerung":"Gießringe"}`, "apple"},
{"compost", "Kompostplatz", "Drei Kammern für Frischmaterial, Rotte und reifen Kompost.", "Arbeitsbereich", "", 7.2, "partial_shade", "moist", "neutral", `{"kammern":3,"letztes_umsetzen":"2026-05-02","thermometer":true}`, ""},
{"pond", "Miniteich", "Flacher Naturteich mit Ausstiegshilfen für Insekten und Kleintiere.", "Wasserstelle", "", 3.4, "sunny", "boggy", "acidic", `{"tiefe_cm":55,"regenwasser":true,"fischfrei":true}`, ""},
}
for _, row := range rows {
var parentID any
if row.parent != "" {
parentID = ids[row.parent]
}
var imageID any
if row.imageKey != "" {
imageID = images[row.imageKey]
}
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO locations (garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_id, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7::sun_exposure, $8::soil_condition, $9::soil_reaction, $10::jsonb, $11, $12, $12)
RETURNING id`, gardenID, parentID, row.name, row.description, row.kind, row.area, row.sun, row.soil, row.reaction, row.attributes, imageID, userID).Scan(&id)
if err != nil {
return nil, fmt.Errorf("create demo location %q: %w", row.name, err)
}
ids[row.key] = id
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
($1, 'garden', $1, $2, $4),
($1, 'location', $5, $3, $4),
($1, 'location', $6, $7, $4)`, gardenID, images["garden"], images["tomato"], userID, ids["greenhouse"], ids["orchard"], images["apple"]); err != nil {
return nil, fmt.Errorf("record demo location images: %w", err)
}
return ids, nil
}
func seedDemoSpecies(ctx context.Context, tx *sql.Tx, gardenID, userID int64, images map[string]int64) (map[string]int64, map[string]int64, error) {
ids := make(map[string]int64)
type speciesSeed struct {
key, category, commonName, cultivar, botanicalName, notes string
spacing, height int
sowFrom, sowTo, plantFrom, plantTo, harvestFrom, harvestTo int
}
rows := []speciesSeed{
{"tomato", "Gemüse", "Tomate", "San Marzano", "Solanum lycopersicum", "Robuste Flaschentomate für Sauce und Salat.", 60, 180, 2, 4, 5, 6, 7, 10},
{"zucchini", "Gemüse", "Zucchini", "Cocozelle von Tripolis", "Cucurbita pepo", "Regelmäßig jung ernten, dann trägt die Pflanze lange.", 100, 70, 4, 5, 5, 6, 6, 10},
{"basil", "Kraut", "Basilikum", "Genoveser", "Ocimum basilicum", "Blüten ausknipsen und nur von oben ernten.", 25, 40, 3, 6, 5, 7, 6, 9},
{"strawberry", "Obst", "Erdbeere", "Mieze Schindler", "Fragaria × ananassa", "Aromatische Sorte; eine Befruchtersorte steht in der Nachbarparzelle.", 30, 25, 0, 0, 3, 8, 5, 7},
{"lavender", "Staude", "Lavendel", "Hidcote Blue", "Lavandula angustifolia", "Nach der Blüte leicht zurückschneiden.", 40, 55, 0, 0, 3, 6, 7, 8},
{"apple", "Gehölz", "Apfel", "Topaz", "Malus domestica", "Junger Halbstamm, im Sommer auf ausreichende Wasserversorgung achten.", 400, 350, 0, 0, 10, 3, 9, 10},
{"lettuce", "Gemüse", "Romanasalat", "Forellenschluss", "Lactuca sativa var. longifolia", "Historischer, rot gesprenkelter Romanasalat für den Satzanbau.", 28, 35, 2, 8, 3, 9, 5, 10},
{"carrot", "Gemüse", "Möhre", "Nantaise 2", "Daucus carota subsp. sativus", "Gleichmäßige, stumpfe Wurzeln; das Saatbeet bis zur Keimung feucht halten.", 5, 25, 3, 7, 0, 0, 6, 11},
{"chard", "Gemüse", "Mangold", "Bright Lights", "Beta vulgaris subsp. vulgaris", "Bunter Stielmangold für die fortlaufende Ernte der äußeren Blätter.", 35, 60, 3, 6, 4, 7, 6, 11},
{"rosemary", "Kraut", "Rosmarin", "Arp", "Salvia rosmarinus", "Relativ winterharte Sorte für den durchlässigen Platz an der warmen Mauer.", 70, 120, 3, 5, 4, 6, 4, 10},
{"currant", "Obst", "Rote Johannisbeere", "Rovada", "Ribes rubrum", "Späte Sorte mit langen Trauben, als dreitriebiger Strauch erzogen.", 140, 160, 0, 0, 10, 3, 7, 8},
{"sunflower", "Blume", "Sonnenblume", "Velvet Queen", "Helianthus annuus", "Dunkelrote, verzweigte Sonnenblume für Bestäuber und Schnittblumen.", 45, 180, 4, 5, 5, 6, 8, 10},
}
for _, row := range rows {
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO species (
garden_id, category_id, common_name, cultivar, botanical_name,
sun_exposure, soil_condition, soil_reaction, spacing_cm, height_cm_to,
sow_month_from, sow_month_to, planting_month_from, planting_month_to,
harvest_month_from, harvest_month_to, notes, created_by, updated_by)
SELECT $1, id, $2, $3, $4, 'sunny', 'moist', 'neutral', $5, $6,
NULLIF($7, 0), NULLIF($8, 0), NULLIF($9, 0), NULLIF($10, 0),
NULLIF($11, 0), NULLIF($12, 0), $13, $14, $14
FROM species_categories WHERE name = $15
RETURNING id`, gardenID, row.commonName, row.cultivar, row.botanicalName,
row.spacing, row.height, row.sowFrom, row.sowTo, row.plantFrom, row.plantTo,
row.harvestFrom, row.harvestTo, row.notes, userID, row.category).Scan(&id)
if err != nil {
return nil, nil, fmt.Errorf("create demo species %q: %w", row.commonName, err)
}
ids[row.key] = id
}
type speciesDetails struct {
key, sun, soil, reaction, winter, attributes, imageKey string
sowFromDay, sowToDay, plantFromDay, plantToDay int
harvestFromDay, harvestToDay int
}
details := []speciesDetails{
{"tomato", "sunny", "moist", "neutral", "Frostfrei kultivieren; erst nach den Eisheiligen auspflanzen.", `{"saatgut":"bio","charge":"SM-2026-02","keimtemperatur_c":24,"partnerpflanzen":["Basilikum","Tagetes"]}`, "tomato", 15, 15, 15, 15, 1, 15},
{"zucchini", "sunny", "moist", "neutral", "Bei kalten Nächten mit Vlies schützen.", `{"saatgut":"samenfest","farbe":"grün gestreift","starkzehrer":true}`, "", 1, 15, 15, 15, 20, 10},
{"basil", "sunny", "moist", "neutral", "Unter 10 °C ins Haus oder Gewächshaus holen.", `{"verwendung":["Pesto","Tomatensalat"],"duft":"würzig","einjährig":true}`, "", 1, 15, 15, 15, 1, 30},
{"strawberry", "sunny", "moist", "acidic", "Im Winter mit Laub mulchen; Herzblätter frei lassen.", `{"geschmack":"walderdbeerartig","befruchter_benoetigt":true,"standjahr":2}`, "", 0, 0, 1, 31, 20, 10},
{"lavender", "sunny", "dry", "alkaline", "Vor Winternässe schützen; keine dichte Laubabdeckung.", `{"bluetenfarbe":"dunkelblau","insektenwert":"hoch","duft":true}`, "", 1, 31, 15, 30, 1, 31},
{"apple", "sunny", "moist", "neutral", "Stamm in den ersten Jahren mit Weißanstrich vor Frostrissen schützen.", `{"unterlage":"M7","wuchsform":"Halbstamm","pflanzjahr":2024,"lagerfaehig":true}`, "apple", 0, 0, 1, 31, 20, 31},
{"lettuce", "partial_shade", "moist", "neutral", "Bei starkem Frost mit Vlies abdecken.", `{"satzanbau":true,"schossfest":"mittel","ernte":"Blatt oder Kopf"}`, "", 15, 15, 15, 15, 1, 15},
{"carrot", "sunny", "moist", "neutral", "Späte Sätze mit Stroh abdecken oder frostfrei einlagern.", `{"kulturzeit_tage":105,"saatband":false,"lagerung":"kühl und sandig"}`, "", 1, 15, 0, 0, 15, 15},
{"chard", "sunny", "moist", "neutral", "Wurzelstock anhäufeln und mit Reisig schützen.", `{"stiele":["gelb","orange","rot","rosa"],"mehrfachernte":true}`, "", 15, 30, 15, 15, 1, 30},
{"rosemary", "sunny", "dry", "alkaline", "Topf mit Jute umwickeln und bei Kahlfrost mit Vlies beschatten.", `{"winterhaerte_c":-18,"immergruen":true,"verwendung":"Küche und Duft"}`, "", 1, 31, 15, 30, 1, 31},
{"currant", "partial_shade", "moist", "neutral", "Winterhart; Wurzelbereich im Herbst mit Kompost mulchen.", `{"erziehungsform":"Strauch","triebe":3,"netzeinsatz":true}`, "", 0, 0, 1, 31, 1, 15},
{"sunflower", "sunny", "moist", "neutral", "Jungpflanzen vor Spätfrost schützen.", `{"bluetenfarbe":"dunkelrot","verzweigt":true,"insektenwert":"hoch"}`, "", 1, 31, 15, 15, 1, 15},
}
for _, detail := range details {
var imageID any
if detail.imageKey != "" {
imageID = images[detail.imageKey]
}
if _, err := tx.ExecContext(ctx, `
UPDATE species SET sun_exposure=$2, soil_condition=$3, soil_reaction=$4,
winter_protection=$5, attributes=$6::jsonb, image_id=$7,
sow_day_from=NULLIF($8,0), sow_day_to=NULLIF($9,0),
planting_day_from=NULLIF($10,0), planting_day_to=NULLIF($11,0),
harvest_day_from=NULLIF($12,0), harvest_day_to=NULLIF($13,0)
WHERE id=$1`, ids[detail.key], detail.sun, detail.soil, detail.reaction, detail.winter,
detail.attributes, imageID, detail.sowFromDay, detail.sowToDay, detail.plantFromDay,
detail.plantToDay, detail.harvestFromDay, detail.harvestToDay); err != nil {
return nil, nil, fmt.Errorf("complete demo species %q: %w", detail.key, err)
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO care_instructions (species_id, text, status, created_by, updated_by) VALUES
($1, 'Morgens bodennah gießen und Blätter trocken halten.', 'good', $7, $7),
($1, 'Seitentriebe regelmäßig ausgeizen und locker anbinden.', 'testing', $7, $7),
($2, 'Früchte ab etwa 15 cm Länge laufend ernten.', 'good', $7, $7),
($3, 'Nur bei trockenem Boden gießen; Staunässe vermeiden.', 'good', $7, $7),
($4, 'Nach der Ernte alte Blätter entfernen und mit Kompost versorgen.', 'planned', $7, $7),
($5, 'Nach der Hauptblüte höchstens um ein Drittel einkürzen.', 'untested', $7, $7),
($6, 'In längeren Trockenphasen einmal pro Woche durchdringend wässern.', 'testing', $7, $7)`,
ids["tomato"], ids["zucchini"], ids["lavender"], ids["strawberry"], ids["basil"], ids["apple"], userID); err != nil {
return nil, nil, fmt.Errorf("create demo care instructions: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO care_instructions (species_id, text, status, created_by, updated_by) VALUES
($1, 'Morgens ernten und bei Hitze mit einem Schattiernetz vor dem Schossen schützen.', 'testing', $7, $7),
($2, 'Saatrillen bis zur Keimung gleichmäßig feucht halten und früh vereinzeln.', 'good', $7, $7),
($3, 'Immer nur die äußeren Blätter abbrechen; das Herz stehen lassen.', 'good', $7, $7),
($4, 'Sparsam gießen und überschüssiges Wasser aus dem Untersetzer entfernen.', 'good', $7, $7),
($5, 'Nach der Ernte alte Fruchttriebe bodennah entfernen.', 'planned', $7, $7),
($6, 'Junge Pflanzen rechtzeitig an einen stabilen Stab anbinden.', 'untested', $7, $7)`,
ids["lettuce"], ids["carrot"], ids["chard"], ids["rosemary"], ids["currant"], ids["sunflower"], userID); err != nil {
return nil, nil, fmt.Errorf("create extended demo care instructions: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
($1, 'species', $2, $3, $6),
($1, 'species', $4, $5, $6)`, gardenID, ids["tomato"], images["tomato"], ids["apple"], images["apple"], userID); err != nil {
return nil, nil, fmt.Errorf("record demo species images: %w", err)
}
templates := make(map[string]int64)
templateRows := []struct {
key, species, title, description, trigger string
monthFrom, monthTo, offsetFrom, offsetTo any
priority int
}{
{"tomato_fertilize", "tomato", "Tomaten düngen", "Organischen Flüssigdünger sparsam dosieren.", "relative_to_planting", nil, nil, 21, 28, 3},
{"tomato_harvest", "tomato", "Tomaten ernten", "Reife Früchte pflücken und Schadstellen kontrollieren.", "month_of_year", 7, 10, nil, nil, 0},
{"zucchini_harvest", "zucchini", "Zucchini ernten", "Früchte bei 15 bis 20 cm Länge mit einem sauberen Messer schneiden.", "month_of_year", 6, 10, nil, nil, 0},
{"basil_pinching", "basil", "Basilikumspitzen ernten", "Triebspitzen oberhalb eines Blattpaares abschneiden.", "relative_to_last_task", nil, nil, 7, 10, 0},
{"strawberry_mulch", "strawberry", "Erdbeeren mulchen", "Stroh unter die Fruchtstände legen.", "month_of_year", 5, 5, nil, nil, 0},
{"apple_prune", "apple", "Apfelbaum schneiden", "Krone auslichten und steile Wasserschosse entfernen.", "month_of_year", 2, 3, nil, nil, 3},
{"lettuce_water", "lettuce", "Salat kontrollieren und gießen", "Bodenfeuchte prüfen und bei Bedarf morgens wässern.", "relative_to_sowing", nil, nil, 3, 5, 3},
{"carrot_thin", "carrot", "Möhren vereinzeln", "Schwächere Sämlinge entfernen und fünf Zentimeter Abstand herstellen.", "relative_to_sowing", nil, nil, 18, 24, 0},
{"rosemary_winter", "rosemary", "Rosmarin winterfest machen", "Topf schützen und Regenschutz kontrollieren.", "month_of_year", 11, 12, nil, nil, 3},
{"currant_prune", "currant", "Johannisbeere auslichten", "Einen alten Haupttrieb entfernen und Jungtriebe anbinden.", "relative_to_harvest", nil, nil, 7, 21, 0},
{"sunflower_support", "sunflower", "Sonnenblumen stützen", "Stäbe kontrollieren und Bindung locker nachführen.", "relative_to_species_planting", nil, nil, 14, 21, 0},
}
for _, row := range templateRows {
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO species_task_templates (
species_id, title, description, trigger_type, month_from, month_to,
offset_days_from, offset_days_to, priority)
VALUES ($1, $2, $3, $4::task_trigger_type, $5, $6, $7, $8, $9)
RETURNING id`, ids[row.species], row.title, row.description, row.trigger,
row.monthFrom, row.monthTo, row.offsetFrom, row.offsetTo, row.priority).Scan(&id)
if err != nil {
return nil, nil, fmt.Errorf("create demo task template %q: %w", row.title, err)
}
templates[row.key] = id
}
type templateDetails struct {
key, recurrence, unit, durationUnit string
interval, triggerOffset, duration int
}
templateUpdates := []templateDetails{
{"tomato_fertilize", "weekly", "day", "day", 2, 21, 1},
{"tomato_harvest", "weekly", "day", "day", 1, 0, 2},
{"zucchini_harvest", "weekly", "day", "day", 1, 0, 2},
{"basil_pinching", "weekly", "day", "day", 1, 7, 1},
{"strawberry_mulch", "yearly", "day", "week", 1, 0, 1},
{"apple_prune", "yearly", "week", "week", 1, 0, 2},
{"lettuce_water", "weekly", "day", "day", 1, 3, 1},
{"carrot_thin", "", "day", "day", 1, 18, 2},
{"rosemary_winter", "yearly", "day", "week", 1, 0, 2},
{"currant_prune", "yearly", "week", "week", 1, 1, 2},
{"sunflower_support", "weekly", "week", "day", 1, 2, 1},
}
for _, detail := range templateUpdates {
if _, err := tx.ExecContext(ctx, `
UPDATE species_task_templates SET recurrence=$2, recurrence_interval=$3,
trigger_offset=$4, trigger_offset_unit=$5, duration=$6, duration_unit=$7,
interval_days=CASE WHEN $2='weekly' THEN 7 ELSE NULL END
WHERE id=$1`, templates[detail.key], detail.recurrence, detail.interval,
detail.triggerOffset, detail.unit, detail.duration, detail.durationUnit); err != nil {
return nil, nil, fmt.Errorf("complete demo task template %q: %w", detail.key, err)
}
}
return ids, templates, nil
}
func seedDemoPlants(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, locations, species, templates, images map[string]int64) (map[string]int64, error) {
ids := make(map[string]int64)
type plantSeed struct {
key, species, name, notes, location, status, attributes, imageKey string
quantity, ageDays, removedDays int
owner int64
}
rows := []plantSeed{
{"tomato", "tomato", "Tomatenreihe am Fenster", "Vier kräftige Pflanzen, zweitriebig gezogen. Pflanze zwei trägt bereits den ersten Fruchtstand.", "greenhouse", "alive", `{"anzucht":"Fensterbank","topfgroesse_l":12,"triebzahl":2,"messpunkt":"GH-T1"}`, "tomato", 4, 92, 0, demoUserID},
{"zucchini", "zucchini", "Zucchini im Hochbeet", "Die westliche Pflanze ist etwas kleiner, bildet aber neue Blätter.", "raised", "alive", `{"pflanzen":["Ost","West"],"mulch":"Rasenschnitt","erste_bluete":true}`, "", 2, 70, 0, memberID},
{"basil", "basil", "Basilikum zwischen den Tomaten", "Mischkultur für Küche und Gewächshausklima.", "greenhouse", "alive", `{"ernteintervall_tage":7,"letzte_ernte":"vor 4 Tagen"}`, "", 6, 48, 0, demoUserID},
{"strawberry", "strawberry", "Erdbeerreihe", "Im Frühjahr mit Kompost versorgt und mit Stroh unterlegt.", "vegetable", "alive", `{"standjahr":2,"reihenlaenge_m":4.5,"bewässerung":"Tropfschlauch"}`, "", 12, 410, 0, memberID},
{"lavender", "lavender", "Lavendel an der Mauer", "Wichtige Insektenweide am Hauptweg; Rückschnitt nach der Blüte vormerken.", "herbs", "alive", `{"pflanzabstand_cm":45,"bienenbesuche":"sehr häufig"}`, "", 3, 760, 0, demoUserID},
{"apple", "apple", "Apfelbaum am Nordrand", "Baumscheibe frei halten; Bindung am Pfahl monatlich kontrollieren.", "orchard", "alive", `{"stammumfang_cm":12,"baumpfahl":true,"giessring_l":60}`, "apple", 1, 980, 0, demoUserID},
{"seedling", "", "Überraschungssämling", "Noch ohne bestimmte Art. Blattform und Entwicklung weiter fotografisch dokumentieren.", "herbs", "alive", `{"bestimmung":"offen","fundort":"Kompostrand","markierung":"gelber Stab"}`, "", 1, 24, 0, memberID},
{"lettuce", "lettuce", "Salatsatz Juni", "Gestaffelt gepflanzt; die äußeren Blätter können bereits geerntet werden.", "north_bed", "alive", `{"satz":3,"jungpflanzenquelle":"eigene Anzucht","schattiernetz":true}`, "", 18, 38, 0, memberID},
{"carrots", "carrot", "Möhrenreihe am Rand", "Keimung war lückig; nach dem Vereinzeln sind fünf Zentimeter Abstand geplant.", "raised", "alive", `{"reihen":2,"reihenabstand_cm":25,"markiersaat":"Radieschen"}`, "", 36, 54, 0, demoUserID},
{"chard", "chard", "Bunter Mangold", "Vier Farbtypen gemischt, regelmäßig nur außen beernten.", "north_bed", "alive", `{"farben":["gelb","orange","rot","rosa"],"überwinterungsversuch":true}`, "", 9, 110, 0, demoUserID},
{"rosemary", "rosemary", "Rosmarin im Terrakottatopf", "Steht regengeschützt direkt an der warmen Natursteinmauer.", "herbs", "alive", `{"topf_l":35,"substrat":"mineralisch","mobil":true}`, "", 1, 620, 0, memberID},
{"currant", "currant", "Johannisbeerstrauch Rovada", "Drei Haupttriebe, Beerennetz wird erst bei beginnender Reife aufgelegt.", "orchard", "alive", `{"haupttriebe":3,"ernteprognose_kg":2.5,"netz":"eingelagert"}`, "", 1, 830, 0, demoUserID},
{"sunflowers", "sunflower", "Sonnenblumen am Zaun", "Dunkelrote Blüten als Windschutz und Futterquelle für Insekten.", "vegetable", "alive", `{"reihe_m":6,"stützen":8,"schnittblumen":true}`, "", 14, 65, 0, memberID},
{"infested_currant", "currant", "Johannisbeere mit Blattläusen", "An den jungen Triebspitzen sitzen Blattlauskolonien; Nützlingsaktivität beobachten.", "orchard", "infested", `{"schädling":"Blattlaus","befall":"leicht","maßnahme":"abwarten und Nützlinge fördern"}`, "", 1, 810, 0, memberID},
{"old_zucchini", "zucchini", "Zucchini vom Vorjahr", "Abgeerntete Pflanze als Beispiel für eine abgeschlossene Saison.", "raised", "harvested", `{"ernte_kg":18.4,"letzte_frucht_cm":24,"kompostiert":true}`, "", 1, 430, 180, demoUserID},
{"dead_lettuce", "lettuce", "Ausgefallener Frühjahrssalat", "Nach einem starken Schneckenfraß nicht wieder ausgetrieben.", "north_bed", "dead", `{"ursache":"Schneckenfraß","ersatzpflanzung":"Salatsatz Juni"}`, "", 6, 120, 72, memberID},
}
for _, row := range rows {
var speciesID any
if row.species != "" {
speciesID = species[row.species]
}
var imageID any
if row.imageKey != "" {
imageID = images[row.imageKey]
}
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO plants (garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_id, created_by, updated_by, planted_by)
VALUES ($1, $2, $3, $4, CURRENT_DATE - $5::integer, $6::plant_status,
CASE WHEN $7 > 0 THEN CURRENT_DATE - $7::integer ELSE NULL END, $8::jsonb, $9, $10, $10, $10)
RETURNING id`, gardenID, speciesID, row.name, row.notes, row.ageDays, row.status, row.removedDays, row.attributes, imageID, row.owner).Scan(&id)
if err != nil {
return nil, fmt.Errorf("create demo plant %q: %w", row.name, err)
}
ids[row.key] = id
if _, err = tx.ExecContext(ctx, `
INSERT INTO plant_locations (plant_id, location_id, quantity, planted_at, removed_at, notes)
VALUES ($1, $2, $3, CURRENT_DATE - $4::integer,
CASE WHEN $5 > 0 THEN CURRENT_DATE - $5::integer ELSE NULL END,
CASE WHEN $5 > 0 THEN 'Zuordnung mit dem Pflanzenstatus abgeschlossen' ELSE 'Standort geprüft und dokumentiert' END)`,
id, locations[row.location], row.quantity, row.ageDays, row.removedDays); err != nil {
return nil, fmt.Errorf("place demo plant %q: %w", row.name, err)
}
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at)
VALUES
($1, 'alive', 'harvested', 'Saison beendet und letzte Früchte geerntet', CURRENT_DATE - 180),
($2, 'alive', 'dead', 'Nach starkem Schneckenfraß nicht wieder ausgetrieben', CURRENT_DATE - 72),
($3, 'alive', 'infested', 'Leichter Blattlausbefall an den Triebspitzen entdeckt', CURRENT_DATE - 4)`,
ids["old_zucchini"], ids["dead_lettuce"], ids["infested_currant"]); err != nil {
return nil, fmt.Errorf("create demo plant history: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO entity_image_history (garden_id, entity_type, entity_id, image_id, changed_by) VALUES
($1, 'plant', $2, $3, $6),
($1, 'plant', $4, $5, $6)`, gardenID, ids["tomato"], images["tomato"], ids["apple"], images["apple"], demoUserID); err != nil {
return nil, fmt.Errorf("record demo plant images: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO task_template_opt_outs (plant_id, template_id)
VALUES ($1, $2)`, ids["rosemary"], templates["rosemary_winter"]); err != nil {
return nil, fmt.Errorf("create demo task template opt-out: %w", err)
}
return ids, nil
}
func seedDemoTasks(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, locations, plants, templates map[string]int64) (int, error) {
type taskSeed struct {
key, title, description, plant, location, template, recurrence, repeatFrom, statusOnCompletion string
startDays, endDays, priority, recurrenceInterval int
completed, active bool
owner int64
}
rows := []taskSeed{
{"vent_previous", "Gewächshaus lüften", "Beide Türen öffnen, Dachfenster prüfen und abends wieder schließen.", "", "greenhouse", "", "daily", "", "", -1, -1, 0, 1, true, true, demoUserID},
{"vent_today", "Gewächshaus lüften", "Morgens Temperatur und Luftfeuchte notieren, anschließend querlüften.", "", "greenhouse", "", "daily", "vent_previous", "", 0, 0, 0, 1, false, true, demoUserID},
{"tomato_prune", "Tomaten ausgeizen", "Seitentriebe unterhalb des ersten Fruchtstands entfernen und Schnüre nachspannen.", "tomato", "greenhouse", "", "weekly", "", "", -3, -1, 5, 1, false, true, demoUserID},
{"raised_water", "Hochbeet gründlich gießen", "Am frühen Morgen etwa 15 Liter langsam unter der Mulchschicht verteilen.", "", "raised", "", "", "", "", 0, 0, 3, 1, false, true, memberID},
{"tomato_fertilize", "Tomaten düngen", "Organischen Flüssigdünger 1:100 dosieren und nur auf bereits feuchten Boden geben.", "tomato", "greenhouse", "tomato_fertilize", "weekly", "", "", 2, 4, 3, 2, false, true, demoUserID},
{"strawberry_check", "Erdbeeren auf Schnecken prüfen", "Besonders unter dem Stroh kontrollieren und reife Früchte direkt ernten.", "strawberry", "vegetable", "", "weekly", "", "", 5, 7, 0, 1, false, true, memberID},
{"apple_water", "Apfelbaum wässern", "Sechzig Liter langsam im Gießring verteilen und die Baumbindung kontrollieren.", "apple", "orchard", "", "weekly", "", "", 9, 9, 3, 1, false, true, demoUserID},
{"lettuce_water", "Salat kontrollieren und gießen", "Bodenfeuchte prüfen, äußere erntereife Blätter schneiden und Läuse kontrollieren.", "lettuce", "north_bed", "lettuce_water", "weekly", "", "", 1, 2, 3, 1, false, true, memberID},
{"carrot_thin", "Möhren vereinzeln", "Auf fünf Zentimeter Abstand ausdünnen und anschließend vorsichtig einschlämmen.", "carrots", "raised", "carrot_thin", "", "", "", 3, 5, 0, 1, false, true, demoUserID},
{"chard_harvest", "Mangold ernten", "Je Pflanze zwei große Außenblätter mit sauberem Messer schneiden.", "chard", "north_bed", "", "weekly", "", "", 4, 4, 0, 1, false, true, memberID},
{"aphid_check", "Blattlausbefall kontrollieren", "Triebspitzen fotografieren, Marienkäferlarven zählen und Befallsstärke dokumentieren.", "infested_currant", "orchard", "", "daily", "", "", 0, 2, 5, 1, false, true, memberID},
{"compost_turn", "Kompost umsetzen", "Material aus Kammer zwei mischen, Feuchtigkeit prüfen und Temperatur messen.", "", "compost", "", "monthly", "", "", 12, 14, 3, 1, false, true, demoUserID},
{"pond_level", "Wasserstand im Miniteich prüfen", "Nur aufgefangenes Regenwasser nachfüllen und die Ausstiegshilfe freihalten.", "", "pond", "", "weekly", "", "", 6, 6, 0, 1, false, true, memberID},
{"herbs_weed", "Kräuterecke jäten", "Quecke vollständig mit Wurzeln entfernen und freien Boden mit Splitt abdecken.", "", "herbs", "", "monthly", "", "", -8, -7, 0, 1, true, true, memberID},
{"zucchini_harvest", "Zucchini ernten", "Sechs junge Früchte geerntet und das Gesamtgewicht im Gartentagebuch notiert.", "zucchini", "raised", "zucchini_harvest", "weekly", "", "", -6, -5, 0, 1, true, true, demoUserID},
{"old_zucchini_close", "Zucchinisaison abschließen", "Letzte Frucht ernten, Pflanze entfernen und gesundes Material kompostieren.", "old_zucchini", "raised", "", "", "", "harvested", -182, -180, 0, 1, true, false, demoUserID},
}
ids := make(map[string]int64, len(rows))
for _, row := range rows {
var plantID, locationID, templateID, repeatFromID any
if row.plant != "" {
plantID = plants[row.plant]
}
if row.location != "" {
locationID = locations[row.location]
}
if row.template != "" {
templateID = templates[row.template]
}
if row.repeatFrom != "" {
repeatFromID = ids[row.repeatFrom]
}
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO tasks (
garden_id, plant_id, location_id, template_id, title, description,
due_at_start, due_at_end, generated_for, completed_at, completed_by,
priority, active, recurrence, recurrence_interval, repeat_from_id,
plant_status_on_completion, created_by)
VALUES ($1, $2, $3, $4, $5, $6,
CURRENT_DATE + $7::integer, CURRENT_DATE + $8::integer,
CASE WHEN $4::bigint IS NULL THEN NULL ELSE CURRENT_DATE END,
CASE WHEN $9 THEN CURRENT_TIMESTAMP - interval '6 days' ELSE NULL END,
CASE WHEN $9 THEN $10::bigint ELSE NULL END,
$11, $12, $13, $14, $15, NULLIF($16, '')::plant_status, $10)
RETURNING id`,
gardenID, plantID, locationID, templateID, row.title, row.description,
row.startDays, row.endDays, row.completed, row.owner, row.priority, row.active,
row.recurrence, row.recurrenceInterval, repeatFromID, row.statusOnCompletion).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create demo task %q: %w", row.title, err)
}
ids[row.key] = id
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO tags (garden_id, name) VALUES
($1, 'Dringend'), ($1, 'Bewässerung'), ($1, 'Ernte'),
($1, 'Beobachtung'), ($1, 'Gemeinschaft'), ($1, 'Nützlinge'), ($1, 'Gewächshaus')`, gardenID); err != nil {
return 0, fmt.Errorf("create demo tags: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO task_tags (task_id, tag_id)
SELECT tasks.id, tags.id FROM tasks JOIN tags ON tags.garden_id=tasks.garden_id
WHERE tasks.garden_id=$1 AND (
(tasks.title='Tomaten ausgeizen' AND tags.name IN ('Dringend','Gewächshaus')) OR
(tasks.title='Hochbeet gründlich gießen' AND tags.name='Bewässerung') OR
(tasks.title='Apfelbaum wässern' AND tags.name='Bewässerung') OR
(tasks.title='Blattlausbefall kontrollieren' AND tags.name IN ('Dringend','Beobachtung','Nützlinge')) OR
(tasks.title='Zucchini ernten' AND tags.name='Ernte'))`, gardenID); err != nil {
return 0, fmt.Errorf("tag demo tasks: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO plant_tags (plant_id, tag_id)
SELECT plants.id, tags.id FROM plants JOIN tags ON tags.garden_id=plants.garden_id
WHERE plants.garden_id=$1 AND (
(plants.name='Erdbeerreihe' AND tags.name='Ernte') OR
(plants.name='Johannisbeere mit Blattläusen' AND tags.name IN ('Beobachtung','Nützlinge')) OR
(plants.name='Tomatenreihe am Fenster' AND tags.name='Gewächshaus'))`, gardenID); err != nil {
return 0, fmt.Errorf("tag demo plants: %w", err)
}
return len(rows), nil
}
func seedDemoJournal(ctx context.Context, tx *sql.Tx, gardenID, demoUserID, memberID int64, images map[string]int64) (int, int, error) {
if _, err := tx.ExecContext(ctx, `
INSERT INTO journal_entries (garden_id, author_id, entry_type, title, body, created_at, updated_at) VALUES
($1, $2, 'pinboard', 'Willkommen im Sonnengarten', E'Diese Demo zeigt einen vollständig gepflegten Gemeinschaftsgarten.\n\n**Zum Ausprobieren:** Aufgaben filtern, Pflanzen nach Standort öffnen, Pflegehinweise bewerten und im Tagebuch stöbern. Die Daten werden stündlich zurückgesetzt.', CURRENT_TIMESTAMP - interval '21 days', CURRENT_TIMESTAMP - interval '20 days'),
($1, $3, 'pinboard', 'Gießdienst am Wochenende', E'Für Samstag sind **28 °C** angekündigt. Bitte zuerst Gewächshaus und Hochbeet versorgen, danach die Gießringe auf der Obstwiese.\n\nDie Kräuterecke nur gießen, wenn Rosmarin und Lavendel deutlich schlapp wirken.', CURRENT_TIMESTAMP - interval '3 days', CURRENT_TIMESTAMP - interval '2 days'),
($1, $3, 'journal', 'Die ersten Erdbeeren', E'Heute konnten wir die ersten reifen Erdbeeren probieren. Das Stroh hält die Früchte sauber.\n\n- Ernte: 620 g\n- Geschmack: sehr aromatisch\n- Beobachtung: zwei Früchte mit Vogelfraß\n\nDas Netz bringen wir erst an, wenn der Schaden zunimmt.', CURRENT_TIMESTAMP - interval '12 days', CURRENT_TIMESTAMP - interval '12 days'),
($1, $2, 'journal', 'Tomaten wachsen gut an', E'Alle vier Pflanzen haben neue Blätter und kräftige Seitentriebe gebildet. Pflanze zwei trägt den ersten Fruchtstand.\n\nDie Tropfbewässerung läuft morgens für zwanzig Minuten. Beim nächsten Termin werden die Schnüre nachgespannt und die unteren Blätter ausgelichtet.', CURRENT_TIMESTAMP - interval '7 days', CURRENT_TIMESTAMP - interval '6 days'),
($1, $3, 'journal', 'Nützlinge an der Johannisbeere', E'Am befallenen Strauch wurden heute **vier Marienkäferlarven** und mehrere Schwebfliegen beobachtet. Deshalb verzichten wir vorerst auf eine Behandlung.\n\nIn zwei Tagen vergleichen wir Fotos derselben markierten Triebspitze.', CURRENT_TIMESTAMP - interval '2 days', CURRENT_TIMESTAMP - interval '2 days'),
($1, $2, 'journal', 'Komposttemperatur steigt', E'Kammer zwei erreicht nach dem Umsetzen 54 °C. Das Material ist feucht wie ein ausgedrückter Schwamm und riecht angenehm erdig.\n\nBeim nächsten Umsetzen trockenes Häckselmaterial aus Kammer eins beimischen.', CURRENT_TIMESTAMP - interval '1 day', CURRENT_TIMESTAMP - interval '1 day')`,
gardenID, demoUserID, memberID); err != nil {
return 0, 0, fmt.Errorf("create demo journal entries: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO journal_entry_tags (journal_entry_id, tag_id)
SELECT journal_entries.id, tags.id
FROM journal_entries JOIN tags ON tags.garden_id = journal_entries.garden_id
WHERE journal_entries.garden_id = $1
AND ((journal_entries.title = 'Die ersten Erdbeeren' AND tags.name = 'Ernte')
OR (journal_entries.title = 'Tomaten wachsen gut an' AND tags.name IN ('Beobachtung','Gewächshaus'))
OR (journal_entries.title = 'Willkommen im Sonnengarten' AND tags.name = 'Gemeinschaft')
OR (journal_entries.title = 'Gießdienst am Wochenende' AND tags.name IN ('Gemeinschaft','Bewässerung'))
OR (journal_entries.title = 'Nützlinge an der Johannisbeere' AND tags.name IN ('Beobachtung','Nützlinge'))
OR (journal_entries.title = 'Komposttemperatur steigt' AND tags.name = 'Beobachtung'))`, gardenID); err != nil {
return 0, 0, fmt.Errorf("tag demo journal entries: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO species_tags (species_id, tag_id)
SELECT species.id, tags.id FROM species JOIN tags ON tags.garden_id=species.garden_id
WHERE species.garden_id=$1 AND (
(species.common_name IN ('Tomate','Basilikum') AND tags.name='Gewächshaus') OR
(species.common_name IN ('Erdbeere','Zucchini','Rote Johannisbeere') AND tags.name='Ernte') OR
(species.common_name IN ('Lavendel','Sonnenblume') AND tags.name='Nützlinge'))`, gardenID); err != nil {
return 0, 0, fmt.Errorf("tag demo species: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO journal_attachments (journal_entry_id, image_id, file_name, media_type, data, size)
SELECT id, $2, 'tomaten-gewaechshaus.jpg', 'image/jpeg', $3, $4
FROM journal_entries WHERE garden_id=$1 AND title='Tomaten wachsen gut an'`,
gardenID, images["tomato"], demoTomatoImage, len(demoTomatoImage)); err != nil {
return 0, 0, fmt.Errorf("create demo journal attachment: %w", err)
}
return 6, 1, nil
}
+19
View File
@@ -49,6 +49,24 @@ type createUserResult struct {
Token *auth.Token
}
type demoResetInput struct {
Name string
Email string
PasswordHash []byte
}
type demoResetResult struct {
Email string `json:"email"`
Garden string `json:"garden"`
Images int `json:"images"`
Locations int `json:"locations"`
Species int `json:"species"`
Plants int `json:"plants"`
Tasks int `json:"tasks"`
Journal int `json:"journal_entries"`
Attachments int `json:"attachments"`
}
type adminStore interface {
Ping(context.Context) error
CreateUser(context.Context, createUserInput) (createUserResult, error)
@@ -59,6 +77,7 @@ type adminStore interface {
ResetPassword(context.Context, string, []byte) (userView, error)
SetUserRole(context.Context, string, string) (userView, error)
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
ResetDemo(context.Context, demoResetInput) (demoResetResult, error)
Close() error
}
+10 -1
View File
@@ -4,8 +4,11 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"gardomatic.kleiax.de/internal/platform/environment"
"gardomatic.kleiax.de/internal/platform/validate"
"gardomatic.kleiax.de/internal/storage"
"gardomatic.kleiax.de/internal/web"
)
@@ -18,7 +21,7 @@ func configFromEnvironment() (web.Config, error) {
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}
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"), DemoAccountEmail: strings.ToLower(strings.TrimSpace(environment.String("GARDOMATIC_DEMO_ACCOUNT_EMAIL", ""))), 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"))
@@ -30,5 +33,11 @@ func configFromEnvironment() (web.Config, error) {
if cfg.Env == "production" && !cfg.CookieSecure {
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
}
if cfg.DemoAccountEmail != "" {
v := validate.New()
if storage.ValidateEmail(v, cfg.DemoAccountEmail); !v.Valid() {
errs = append(errs, errors.New("GARDOMATIC_DEMO_ACCOUNT_EMAIL must be a valid email address"))
}
}
return cfg, errors.Join(errs...)
}
+9 -1
View File
@@ -6,11 +6,19 @@ 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")
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", " Demo@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" {
if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" || cfg.DemoAccountEmail != "demo@example.com" {
t.Fatalf("unexpected config: %#v", cfg)
}
}
func TestConfigRejectsInvalidDemoAccountEmail(t *testing.T) {
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", "not-an-email")
if _, err := configFromEnvironment(); err == nil {
t.Fatal("expected invalid demo account email error")
}
}
+25 -9
View File
@@ -1,17 +1,33 @@
# 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.
# Each deployment has its own host configuration. The SSH administrator is
# supplied by the hoster or LXC configuration. Use root, ubuntu, or another
# account with passwordless sudo, not the unprivileged gardomatic service account.
# Production ------------------------------------------------------------------
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
# Test server -----------------------------------------------------------------
TESTSERVER_HOST =
TESTSERVER_SSH_USER = root
TESTSERVER_SSH_PORT = 22
TESTSERVER_SSH_IDENTITY_FILE =
TESTSERVER_GOOS = linux
TESTSERVER_GOARCH = amd64
# Public demo -----------------------------------------------------------------
DEMO_HOST =
DEMO_SSH_USER = root
DEMO_SSH_PORT = 22
DEMO_SSH_IDENTITY_FILE =
DEMO_GOOS = linux
DEMO_GOARCH = amd64
# Identity-file paths are local and must not contain spaces. Leave them empty to
# use the SSH agent and normal ~/.ssh/config resolution. Supported architectures
# are amd64 and arm64; all deployment targets run Linux.
+30
View File
@@ -11,6 +11,7 @@ variables are optional:
| --- | --- | --- |
| `GARDOMATIC_ENV` | `development` | Enables confirmations for risky production operations |
| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | Base URL for activation links |
| `GARDOMATIC_DEMO_RESET_ENABLED` | `false` | Explicitly enables destructive demo resets |
| `GARDOMATIC_SMTP_MODE` | `file` | Mail delivery mode (`file` or `smtp`) |
| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname |
| `GARDOMATIC_SMTP_PORT` | `25` | SMTP server port |
@@ -85,3 +86,32 @@ 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.
## Demo data
`demo reset` atomically removes all sessions, users, gardens, categories, and
their dependent content and replaces them with a fresh `Sonnengarten`. It
contains three members, a pending invitation, a custom garden role, eight
hierarchical locations, twelve richly described species with care instructions
and task templates, sixteen plants (including one without a species and several
lifecycle states), sixteen current, recurring, completed, and generated tasks,
tags, six journal or pinboard entries, and a reusable image library. Task due
dates and journal dates are calculated relative to the reset, so the data remains
useful over time.
The command is deliberately guarded twice. It only runs when
`GARDOMATIC_DEMO_RESET_ENABLED=true`, and it additionally requires the global
`--yes` flag. Never enable it for a database containing real data. The password
must be supplied via standard input:
```sh
systemd-ask-password 'Demo password:' | \
GARDOMATIC_DEMO_RESET_ENABLED=true \
go run ./cmd/cli --yes demo reset \
--email demo@example.com \
--password-stdin
```
Concurrent resets are serialized with a PostgreSQL advisory transaction lock.
The old data remains visible until the complete replacement commits; existing
login sessions are invalidated by every reset.
+7 -10
View File
@@ -1,15 +1,14 @@
# 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
OpenApi einbauen
# Vielleicht
- Detailansicht und Bearbeitenansicht trennen?
- Detailansicht und Bearbeitenansicht trennen? Dann in der Ansicht nur gesetzte Sachen anzeigen
- Impressum
- Einladungslinks, direkt Nutzer auswählen statt über Mail zu gehen
- Benachrichtigunssystem
- Tag bearbeitung und Ansicht, Es werden alle Tags angezeigt, diese können umbenannt oder zusammengeführt werden
# Unklar
- pickieren? zwischen aufgabe und in stammdaten entscheiden
@@ -20,9 +19,7 @@
- Auflösen welche Pflanzen dadurch automatisch bewässert werden
- Zapfstellen könnten auch ein Ort sein
- Datenschutz seite
- Finanzielles Ausgaben Einnahmen mit Tags
# 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
- Module prüfen auf logisch Trennung, welche Module wären wiederverwendbar? Mailer ist ein schwieriger Fall, weil spezifische templates drin sind
-5
View File
@@ -1,5 +0,0 @@
#Migrate
migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable up
migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable force 1
authentication -> Wer ist es?
authorization -> Darf er das?
-121
View File
@@ -1,121 +0,0 @@
# 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.
+1
View File
@@ -25,6 +25,7 @@ func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *ht
{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_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
{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()},
+10 -9
View File
@@ -28,15 +28,16 @@ var (
// 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
Host string
Port int
Env string
WebBaseURL string
DemoAccountEmail string
DB DatabaseConfig
Limiter LimiterConfig
Session SessionConfig
Mail mailer.Config
Cors CORSConfig
}
// DatabaseConfig controls the PostgreSQL connection pool.
+1 -1
View File
@@ -76,7 +76,7 @@ func TestShowAdminEnvironmentMasksSecrets(t *testing.T) {
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"} {
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_DEMO_ACCOUNT_EMAIL", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
found := false
for _, variable := range result.Variables {
found = found || variable.Name == name
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gardomatic.kleiax.de/internal/storage"
)
type demoAccountTokenUserModel struct {
sessionTestUserModel
}
func (m demoAccountTokenUserModel) GetForToken(string, string) (storage.User, error) {
return m.user, nil
}
func TestRequireMutableAccountProtectsConfiguredDemoUser(t *testing.T) {
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
called := false
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "Demo@Example.com"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || called {
t.Fatalf("protected account: got status %d and called=%t, want 403 and called=false", response.Code, called)
}
}
func TestRequireMutableAccountAllowsOtherUsers(t *testing.T) {
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "alice@example.com"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("ordinary account: got status %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestPasswordResetCannotChangeProtectedDemoAccount(t *testing.T) {
app := &application{
config: Config{DemoAccountEmail: "demo@example.com"},
models: storage.Models{Users: demoAccountTokenUserModel{sessionTestUserModel: sessionTestUserModel{
user: storage.User{ID: 42, Email: "demo@example.com", Activated: true},
}}},
}
t.Run("request token", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/tokens/password-reset", bytes.NewBufferString(`{"email":"DEMO@example.com"}`))
response := httptest.NewRecorder()
app.createPasswordResetTokenHandler(response, request)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
}
})
t.Run("use existing token", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPut, "/v1/users/password", bytes.NewBufferString(`{"password":"a-new-demo-password","token":"abcdefghijklmnopqrstuvwxyz"}`))
response := httptest.NewRecorder()
app.updateUserPasswordHandler(response, request)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
}
})
}
+5
View File
@@ -93,6 +93,11 @@ func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.
app.errorResponse(w, r, http.StatusForbidden, message)
}
func (app *application) demoAccountProtectedResponse(w http.ResponseWriter, r *http.Request) {
message := "the shared demo account cannot be changed"
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)
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gardomatic.kleiax.de/internal/mailer"
"gardomatic.kleiax.de/internal/storage"
"github.com/julienschmidt/httprouter"
)
type gardenInviteTestModel struct {
invite storage.GardenInvite
}
func (m *gardenInviteTestModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) {
invite.ID = 23
invite.Token = "garden-invite-token"
m.invite = invite
return invite, nil
}
func (m *gardenInviteTestModel) GetByToken(string) (storage.GardenInvite, error) {
return storage.GardenInvite{}, storage.ErrRecordNotFound
}
func (m *gardenInviteTestModel) GetAllForGarden(int) ([]storage.GardenInvite, error) {
return nil, nil
}
func (m *gardenInviteTestModel) Delete(int, int) error { return nil }
func (m *gardenInviteTestModel) Accept(string, storage.User) (storage.GardenMember, error) {
return storage.GardenMember{}, storage.ErrRecordNotFound
}
type gardenInviteRoleTestModel struct{}
func (gardenInviteRoleTestModel) List(storage.RoleScope) ([]storage.Role, error) { return nil, nil }
func (gardenInviteRoleTestModel) ListForGarden(int) ([]storage.Role, error) { return nil, nil }
func (gardenInviteRoleTestModel) Get(string) (storage.Role, error) {
return storage.Role{}, storage.ErrRecordNotFound
}
func (gardenInviteRoleTestModel) GetForGarden(gardenID int, name string) (storage.Role, error) {
return storage.Role{Name: name, Scope: storage.RoleScopeGarden, GardenID: &gardenID}, nil
}
func (gardenInviteRoleTestModel) Create(role storage.Role) (storage.Role, error) { return role, nil }
func (gardenInviteRoleTestModel) Update(role storage.Role) (storage.Role, error) { return role, nil }
func (gardenInviteRoleTestModel) Delete(string) error { return nil }
func (gardenInviteRoleTestModel) ListGardenOverrides(int) ([]storage.GardenRolePermissionOverride, error) {
return nil, nil
}
func (gardenInviteRoleTestModel) ReplaceGardenOverrides(int, string, []storage.GardenRolePermissionOverride) error {
return nil
}
func TestCreateGardenInviteMailContainsToken(t *testing.T) {
invites := new(gardenInviteTestModel)
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.GardenInvites = invites
app.models.Roles = gardenInviteRoleTestModel{}
app.mailer = configuredMailer
request := httptest.NewRequest(http.MethodPost, "/v1/gardens/17/invites", strings.NewReader(`{"email":" ADA@EXAMPLE.COM ","role":"member"}`))
request = request.WithContext(context.WithValue(request.Context(), httprouter.ParamsKey, httprouter.Params{{Key: "gardenID", Value: "17"}}))
request = app.contextSetGardenMember(request, storage.GardenMember{GardenID: 17, UserID: 9, Role: storage.GardenRoleOwner})
response := httptest.NewRecorder()
app.createGardenInviteHandler(response, request)
app.wg.Wait()
if response.Code != http.StatusCreated {
t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusCreated, response.Body.String())
}
if strings.Contains(response.Body.String(), "garden-invite-token") {
t.Fatalf("API response exposes invitation token: %s", response.Body.String())
}
if invites.invite.Email != "ada@example.com" || invites.invite.ExpiresAt.Before(time.Now().Add(6*24*time.Hour)) {
t.Fatalf("unexpected stored invitation: %+v", invites.invite)
}
mailContent, err := os.ReadFile(mailPath)
if err != nil {
t.Fatal(err)
}
want := "token=3Dgarden-invite-token"
if !strings.Contains(string(mailContent), want) {
t.Errorf("invitation mail is missing %q: %s", want, mailContent)
}
}
+4 -2
View File
@@ -143,9 +143,11 @@ func (app *application) createGardenInviteHandler(w http.ResponseWriter, r *http
app.serverErrorResponse(w, r, err)
return
}
inviteEmail := invite.Email
inviteURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token
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 {
data := map[string]any{"inviteURL": inviteURL}
if sendErr := app.mailer.Send(inviteEmail, "garden_invite.tmpl", data); sendErr != nil {
app.logger.Error(sendErr.Error())
}
})
+20
View File
@@ -164,6 +164,26 @@ func (app *application) requireActivatedUser(next http.HandlerFunc) http.Handler
})
}
func (app *application) requireMutableAccount(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 app.isProtectedDemoAccount(user.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) isProtectedDemoAccount(email string) bool {
return app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(email), app.config.DemoAccountEmail)
}
func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
+5 -5
View File
@@ -19,12 +19,12 @@ func (app *application) routes() http.Handler {
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.MethodPatch, "/v1/account", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountProfileHandler)))
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountPasswordHandler)))
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requireMutableAccount(app.requestAccountEmailChangeHandler)))
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.requireMutableAccount(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.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.requireMutableAccount(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)))
+27 -1
View File
@@ -25,6 +25,14 @@ type speciesInput struct {
WinterProtection *string `json:"winter_protection"`
SpacingCM *int `json:"spacing_cm"`
HeightCM *int `json:"height_cm"`
HeightCMFrom *int `json:"height_cm_from"`
HeightCMTo *int `json:"height_cm_to"`
WidthCMFrom *int `json:"width_cm_from"`
WidthCMTo *int `json:"width_cm_to"`
ClearHeightCMFrom bool `json:"clear_height_cm_from"`
ClearHeightCMTo bool `json:"clear_height_cm_to"`
ClearWidthCMFrom bool `json:"clear_width_cm_from"`
ClearWidthCMTo bool `json:"clear_width_cm_to"`
SowMonthFrom *int `json:"sow_month_from"`
SowDayFrom *int `json:"sow_day_from"`
ClearSowDayFrom bool `json:"clear_sow_day_from"`
@@ -70,7 +78,19 @@ func (input speciesInput) apply(species *storage.Species) {
assignStringPointer(input.SoilReaction, &species.SoilReaction)
assignStringPointer(input.WinterProtection, &species.WinterProtection)
assignIntPointer(input.SpacingCM, &species.SpacingCM)
assignIntPointer(input.HeightCM, &species.HeightCM)
assignIntPointer(input.HeightCMFrom, &species.HeightCMFrom)
if input.HeightCMTo != nil {
assignIntPointer(input.HeightCMTo, &species.HeightCMTo)
} else {
assignIntPointer(input.HeightCM, &species.HeightCMTo)
}
assignIntPointer(input.WidthCMFrom, &species.WidthCMFrom)
assignIntPointer(input.WidthCMTo, &species.WidthCMTo)
clearIntPointer(input.ClearHeightCMFrom, &species.HeightCMFrom)
clearIntPointer(input.ClearHeightCMTo, &species.HeightCMTo)
clearIntPointer(input.ClearWidthCMFrom, &species.WidthCMFrom)
clearIntPointer(input.ClearWidthCMTo, &species.WidthCMTo)
species.HeightCM = species.HeightCMTo
assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom)
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
assignIntPointer(input.SowMonthTo, &species.SowMonthTo)
@@ -138,6 +158,12 @@ func assignIntPointer(input *int, destination **int) {
}
}
func clearIntPointer(clear bool, destination **int) {
if clear {
*destination = nil
}
}
func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
var input speciesInput
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"testing"
"gardomatic.kleiax.de/internal/storage"
)
func TestSpeciesInputAppliesAndClearsDimensions(t *testing.T) {
heightFrom, heightTo, widthFrom, widthTo := 20, 80, 30, 60
species := storage.Species{
HeightCMFrom: &heightFrom,
HeightCMTo: &heightTo,
WidthCMFrom: &widthFrom,
WidthCMTo: &widthTo,
}
newHeightFrom, newWidthTo := 40, 70
speciesInput{
HeightCMFrom: &newHeightFrom,
ClearHeightCMTo: true,
ClearWidthCMFrom: true,
WidthCMTo: &newWidthTo,
}.apply(&species)
if species.HeightCMFrom == nil || *species.HeightCMFrom != 40 {
t.Fatalf("height from = %v, want 40", species.HeightCMFrom)
}
if species.HeightCMTo != nil {
t.Fatalf("height to = %v, want nil", species.HeightCMTo)
}
if species.WidthCMFrom != nil {
t.Fatalf("width from = %v, want nil", species.WidthCMFrom)
}
if species.WidthCMTo == nil || *species.WidthCMTo != 70 {
t.Fatalf("width to = %v, want 70", species.WidthCMTo)
}
}
func TestSpeciesInputAcceptsDeprecatedHeight(t *testing.T) {
height := 90
var species storage.Species
speciesInput{HeightCM: &height}.apply(&species)
if species.HeightCMTo == nil || *species.HeightCMTo != height {
t.Fatalf("height to = %v, want %d", species.HeightCMTo, height)
}
if species.HeightCM == nil || *species.HeightCM != height {
t.Fatalf("deprecated height alias = %v, want %d", species.HeightCM, height)
}
}
+4
View File
@@ -83,6 +83,10 @@ func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r
app.failedValidationResponse(w, r, v.Errors)
return
}
if app.isProtectedDemoAccount(input.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
+4
View File
@@ -177,6 +177,10 @@ func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http
}
return
}
if app.isProtectedDemoAccount(user.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
err = user.Password.Set(input.Password)
if err != nil {
@@ -0,0 +1,6 @@
ALTER TABLE species
DROP COLUMN width_cm_to,
DROP COLUMN width_cm_from,
DROP COLUMN height_cm_from;
ALTER TABLE species RENAME COLUMN height_cm_to TO height_cm;
@@ -0,0 +1,6 @@
ALTER TABLE species RENAME COLUMN height_cm TO height_cm_to;
ALTER TABLE species
ADD COLUMN height_cm_from integer,
ADD COLUMN width_cm_from integer,
ADD COLUMN width_cm_to integer;
+24 -12
View File
@@ -13,7 +13,8 @@ type SpeciesModel struct{ DB *sql.DB }
const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name,
s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction,
s.winter_protection, s.spacing_cm, s.height_cm,
s.winter_protection, s.spacing_cm, s.height_cm_from, s.height_cm_to,
s.width_cm_from, s.width_cm_to,
s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to,
s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
@@ -26,19 +27,26 @@ func scanSpecies(s scanner) (storage.Species, error) {
&species.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
&species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
&species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
&species.HeightCMFrom, &species.HeightCMTo, &species.WidthCMFrom, &species.WidthCMTo,
&species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo,
&species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom,
&species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
)
species.HeightCM = species.HeightCMTo
return species, err
}
func speciesArgs(species storage.Species) []any {
heightTo := species.HeightCMTo
if heightTo == nil {
heightTo = species.HeightCM
}
return []any{
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
species.WinterProtection, species.SpacingCM, species.HeightCM,
species.WinterProtection, species.SpacingCM, species.HeightCMFrom, heightTo,
species.WidthCMFrom, species.WidthCMTo,
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
@@ -53,13 +61,15 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
query := `
INSERT INTO species (
garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm,
soil_condition, soil_reaction, winter_protection, spacing_cm,
height_cm_from, height_cm_to, width_cm_from, width_cm_to,
sow_month_from, sow_day_from, sow_month_to, sow_day_to,
planting_month_from, planting_day_from, planting_month_to, planting_day_to,
harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
notes, attributes, image_data, image_id, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28)
$13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, '', $29, $30, $31)
RETURNING id, created_at, updated_at, version`
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
&species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
@@ -67,6 +77,7 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
if err != nil {
return storage.Species{}, recordError(err)
}
species.HeightCM = species.HeightCMTo
return species, nil
}
@@ -78,6 +89,7 @@ func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
if err != nil {
return storage.Species{}, recordError(err)
}
species.HeightCM = species.HeightCMTo
return species, nil
}
@@ -113,18 +125,18 @@ func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Spe
// created_by is immutable and is intentionally not part of the UPDATE.
// Remove it from the shared insert argument list so PostgreSQL does not
// receive an unused, untyped parameter between image_id and updated_by.
args = append(args[:26], args[27])
args = append(args[:29], args[30])
args = append(args, gardenID, species.ID, species.Version)
err := m.DB.QueryRowContext(ctx, `
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
height_cm = $11, sow_month_from = $12, sow_day_from = $13,
sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30
height_cm_from = $11, height_cm_to = $12, width_cm_from = $13, width_cm_to = $14,
sow_month_from = $15, sow_day_from = $16, sow_month_to = $17, sow_day_to = $18,
planting_month_from=$19, planting_day_from=$20, planting_month_to=$21, planting_day_to=$22,
harvest_month_from = $23, harvest_day_from = $24, harvest_month_to = $25, harvest_day_to = $26,
notes = $27, attributes = $28, image_data = '', image_id = $29, updated_by = $30, updated_at = CURRENT_TIMESTAMP, version = version + 1
WHERE garden_id IS NOT DISTINCT FROM NULLIF($31, 0) AND id = $32 AND version = $33
RETURNING updated_at, version`, args...,
).Scan(&species.UpdatedAt, &species.Version)
if err != nil {
+31 -13
View File
@@ -19,19 +19,24 @@ type SpeciesModelInterface interface {
// Species contains reusable botanical and cultivation master data.
type Species struct {
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
// HeightCM is retained as a deprecated API alias for HeightCMTo.
HeightCM *int `json:"height_cm,omitempty"`
HeightCMFrom *int `json:"height_cm_from,omitempty"`
HeightCMTo *int `json:"height_cm_to,omitempty"`
WidthCMFrom *int `json:"width_cm_from,omitempty"`
WidthCMTo *int `json:"width_cm_to,omitempty"`
SowMonthFrom *int `json:"sow_month_from,omitempty"`
SowDayFrom *int `json:"sow_day_from,omitempty"`
SowMonthTo *int `json:"sow_month_to,omitempty"`
@@ -72,7 +77,12 @@ func ValidateSpecies(v *validate.Validator, species Species) {
validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy")
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
validateOptionalPositive(v, "height_cm", species.HeightCM)
heightTo := species.HeightCMTo
if heightTo == nil {
heightTo = species.HeightCM
}
validateOptionalRange(v, "height_cm", species.HeightCMFrom, heightTo)
validateOptionalRange(v, "width_cm", species.WidthCMFrom, species.WidthCMTo)
validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12)
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
@@ -99,6 +109,14 @@ func validateOptionalPositive(v *validate.Validator, field string, value *int) {
}
}
func validateOptionalRange(v *validate.Validator, field string, from, to *int) {
validateOptionalPositive(v, field+"_from", from)
validateOptionalPositive(v, field+"_to", to)
if from != nil && to != nil {
v.Check(*from <= *to, field+"_from", "must not be greater than "+field+"_to")
}
}
func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
if value != nil {
v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
+39
View File
@@ -0,0 +1,39 @@
package storage
import (
"testing"
"gardomatic.kleiax.de/internal/platform/validate"
)
func TestValidateSpeciesDimensions(t *testing.T) {
tests := []struct {
name string
species Species
wantField string
}{
{name: "valid ranges", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(40), HeightCMTo: intPointer(80), WidthCMFrom: intPointer(30), WidthCMTo: intPointer(60)}},
{name: "height starts above end", species: Species{CommonName: "Rose", HeightCMFrom: intPointer(81), HeightCMTo: intPointer(80)}, wantField: "height_cm_from"},
{name: "width starts above end", species: Species{CommonName: "Rose", WidthCMFrom: intPointer(61), WidthCMTo: intPointer(60)}, wantField: "width_cm_from"},
{name: "non-positive height", species: Species{CommonName: "Rose", HeightCMTo: intPointer(0)}, wantField: "height_cm_to"},
{name: "deprecated height alias", species: Species{CommonName: "Rose", HeightCM: intPointer(80)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := validate.New()
ValidateSpecies(v, tt.species)
_, hasError := v.Errors[tt.wantField]
if tt.wantField == "" && !v.Valid() {
t.Fatalf("unexpected validation errors: %v", v.Errors)
}
if tt.wantField != "" && !hasError {
t.Fatalf("expected validation error for %s, got %v", tt.wantField, v.Errors)
}
})
}
}
func intPointer(value int) *int {
return &value
}
+10 -5
View File
@@ -141,15 +141,20 @@ func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.R
func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
data := app.newTemplateData(r)
data.Form = form
if user, ok := userFromContext(r.Context()); ok {
data.AccountProtected = app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(user.Email), app.config.DemoAccountEmail)
}
if !app.loadOptionalGarden(w, r, data) {
return
}
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
if !data.AccountProtected {
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.AccountSessions = sessions
}
data.AccountSessions = sessions
app.render(w, status, "account.tmpl", data)
}
func (app *application) copyAccountError(form *accountForm, err error) {
+1
View File
@@ -204,6 +204,7 @@ func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
{Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
{Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
{Component: "Web", Name: "GARDOMATIC_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
{Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
}
+12 -1
View File
@@ -15,6 +15,7 @@ type signInForm struct {
Email string `form:"email"`
Password string `form:"password"`
RememberEmail bool `form:"remember_email"`
ReturnTo string `form:"return_to"`
Errors map[string]string
Message string
}
@@ -30,12 +31,17 @@ type activationForm struct {
}
func (app *application) signIn(w http.ResponseWriter, r *http.Request) {
returnTo := safeReturnPath(r.URL.Query().Get("return_to"))
if app.isAuthenticated(r) {
if returnTo != "" {
http.Redirect(w, r, returnTo, http.StatusSeeOther)
return
}
http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther)
return
}
data := app.newTemplateData(r)
form := signInForm{Errors: make(map[string]string)}
form := signInForm{ReturnTo: returnTo, Errors: make(map[string]string)}
if cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil {
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil {
form.Email, form.RememberEmail = string(decoded), true
@@ -52,6 +58,7 @@ func (app *application) signInPost(w http.ResponseWriter, r *http.Request) {
return
}
form.Email = strings.TrimSpace(form.Email)
form.ReturnTo = safeReturnPath(form.ReturnTo)
form.Errors = make(map[string]string)
if form.Email == "" {
form.Errors["email"] = "E-Mail-Adresse ist erforderlich."
@@ -99,6 +106,10 @@ func (app *application) signInPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
return
}
if form.ReturnTo != "" {
http.Redirect(w, r, form.ReturnTo, http.StatusSeeOther)
return
}
http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther)
}
+7 -1
View File
@@ -94,7 +94,7 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" {
t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput)
}
speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
speciesForm := url.Values{"common_name": {"Neue Rose"}, "height_cm_from": {"40"}, "height_cm_to": {"80"}, "width_cm_to": {"60"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}}
speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode()))
speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}})
@@ -103,6 +103,12 @@ func TestGardenAndSpeciesCanBeEdited(t *testing.T) {
if speciesResponse.Code != http.StatusSeeOther || speciesInput.CommonName == nil || *speciesInput.CommonName != "Neue Rose" || !speciesInput.ClearSowRange {
t.Fatalf("species edit: status=%d input=%+v", speciesResponse.Code, speciesInput)
}
if speciesInput.HeightCMFrom == nil || *speciesInput.HeightCMFrom != 40 || speciesInput.HeightCMTo == nil || *speciesInput.HeightCMTo != 80 || speciesInput.WidthCMTo == nil || *speciesInput.WidthCMTo != 60 {
t.Fatalf("species dimensions were not submitted: %+v", speciesInput)
}
if speciesInput.ClearHeightCMFrom || speciesInput.ClearHeightCMTo || !speciesInput.ClearWidthCMFrom || speciesInput.ClearWidthCMTo {
t.Fatalf("species dimension clear flags are incorrect: %+v", speciesInput)
}
}
func TestGardenCanBeDeleted(t *testing.T) {
+53 -3
View File
@@ -272,8 +272,32 @@ func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Errorf("Location: got %q, want %q", location, "/login")
if location := response.Header().Get("Location"); location != "/login?return_to=%2Fgardens" {
t.Errorf("Location: got %q, want login with return path", location)
}
}
func TestProtectedInvitationRedirectPreservesToken(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/session" {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"you must be authenticated"}`))
return
}
http.NotFound(w, r)
})
app := newAPIBackedTestApplication(t, apiHandler)
request := httptest.NewRequest(http.MethodGet, "/invite?token=garden-invite-token", nil)
response := httptest.NewRecorder()
app.routes().ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
}
want := "/login?return_to=%2Finvite%3Ftoken%3Dgarden-invite-token"
if location := response.Header().Get("Location"); location != want {
t.Errorf("Location: got %q, want %q", location, want)
}
}
@@ -288,7 +312,7 @@ func TestSignInForwardsAPISessionCookie(t *testing.T) {
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}}
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}, "return_to": {"/invite?token=garden-invite-token"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
@@ -302,6 +326,32 @@ func TestSignInForwardsAPISessionCookie(t *testing.T) {
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "new-session" {
t.Fatalf("forwarded cookies: got %+v", cookies)
}
if location := response.Header().Get("Location"); location != "/invite?token=garden-invite-token" {
t.Errorf("Location: got %q, want invitation URL", location)
}
}
func TestSignInRejectsExternalReturnURL(t *testing.T) {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/session" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
})
app := newAPIBackedTestApplication(t, apiHandler)
form := url.Values{"email": {"alice@example.com"}, "password": {"correct horse battery staple"}, "return_to": {"https://example.com/phishing"}}
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
response := httptest.NewRecorder()
app.signInPost(response, request)
if location := response.Header().Get("Location"); location != "/gardens?auto=1" {
t.Errorf("Location: got %q, want default landing page", location)
}
}
func TestInactiveSignInRedirectsToActivation(t *testing.T) {
+1 -1
View File
@@ -184,7 +184,7 @@ func (app *application) handleAPIError(w http.ResponseWriter, r *http.Request, e
if errors.As(err, &apiError) {
switch apiError.StatusCode {
case http.StatusUnauthorized:
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
http.Redirect(w, r, loginPathForRequest(r), http.StatusSeeOther)
return
case http.StatusForbidden:
if user, ok := userFromContext(r.Context()); ok && !user.Activated {
+13 -1
View File
@@ -43,7 +43,7 @@ func (app *application) recoverPanic(next http.Handler) http.Handler {
func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.isAuthenticated(r) {
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
http.Redirect(w, r, loginPathForRequest(r), http.StatusSeeOther)
return
}
w.Header().Set("Cache-Control", "no-store")
@@ -51,6 +51,18 @@ func (app *application) requireAuthentication(next http.Handler) http.Handler {
})
}
func loginPathForRequest(r *http.Request) string {
loginPath := webPath("login")
if r.Method != http.MethodGet {
return loginPath
}
returnTo := safeReturnPath(r.URL.RequestURI())
if returnTo == "" {
return loginPath
}
return pathWithQuery(loginPath, "return_to", returnTo)
}
func (app *application) requireActivatedUser(next http.Handler) http.Handler {
return app.requireAuthentication(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
+12
View File
@@ -141,3 +141,15 @@ func pathWithQuery(path string, pairs ...any) string {
}
return path + "?" + values.Encode()
}
func safeReturnPath(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
target, err := url.Parse(value)
if err != nil || target.IsAbs() || target.Host != "" || !strings.HasPrefix(target.Path, "/") || strings.HasPrefix(target.Path, "//") || strings.Contains(target.Path, `\`) {
return ""
}
return target.RequestURI()
}
+23
View File
@@ -33,6 +33,29 @@ func TestPathWithQueryEncodesValues(t *testing.T) {
}
}
func TestSafeReturnPath(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "local path", value: "/invite?token=abc", want: "/invite?token=abc"},
{name: "absolute URL", value: "https://example.com/phishing"},
{name: "scheme relative URL", value: "//example.com/phishing"},
{name: "backslash", value: `/\\example.com/phishing`},
{name: "encoded backslash", value: `/%5C%5Cexample.com/phishing`},
{name: "encoded leading slashes", value: `/%2F%2Fexample.com/phishing`},
{name: "relative path", value: "invite?token=abc"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := safeReturnPath(test.value); got != test.want {
t.Errorf("safeReturnPath(%q): got %q, want %q", test.value, got, test.want)
}
})
}
}
func TestGardenAwareAdminPaths(t *testing.T) {
garden := &client.Garden{ID: 3}
if got := gardenAwarePath(webPath("admin.role.new"), garden); got != "/admin/roles/new?garden=3" {
+25 -14
View File
@@ -22,7 +22,10 @@ type speciesForm struct {
SoilReaction string `form:"soil_reaction"`
WinterProtection string `form:"winter_protection"`
SpacingCM int `form:"spacing_cm"`
HeightCM int `form:"height_cm"`
HeightCMFrom int `form:"height_cm_from"`
HeightCMTo int `form:"height_cm_to"`
WidthCMFrom int `form:"width_cm_from"`
WidthCMTo int `form:"width_cm_to"`
Notes string `form:"notes"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
@@ -133,7 +136,13 @@ func speciesFormFromSpecies(value client.Species) speciesForm {
form.WinterProtection = *value.WinterProtection
}
copyOptionalInt(value.SpacingCM, &form.SpacingCM)
copyOptionalInt(value.HeightCM, &form.HeightCM)
copyOptionalInt(value.HeightCMFrom, &form.HeightCMFrom)
copyOptionalInt(value.HeightCMTo, &form.HeightCMTo)
if value.HeightCMTo == nil {
copyOptionalInt(value.HeightCM, &form.HeightCMTo)
}
copyOptionalInt(value.WidthCMFrom, &form.WidthCMFrom)
copyOptionalInt(value.WidthCMTo, &form.WidthCMTo)
copyOptionalInt(value.CategoryID, &form.CategoryID)
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
@@ -219,9 +228,10 @@ func speciesInput(form speciesForm) client.SpeciesInput {
if form.SpacingCM > 0 {
input.SpacingCM = &form.SpacingCM
}
if form.HeightCM > 0 {
input.HeightCM = &form.HeightCM
}
input.HeightCMFrom, input.HeightCMTo = optionalPositive(form.HeightCMFrom), optionalPositive(form.HeightCMTo)
input.WidthCMFrom, input.WidthCMTo = optionalPositive(form.WidthCMFrom), optionalPositive(form.WidthCMTo)
input.ClearHeightCMFrom, input.ClearHeightCMTo = form.HeightCMFrom == 0, form.HeightCMTo == 0
input.ClearWidthCMFrom, input.ClearWidthCMTo = form.WidthCMFrom == 0, form.WidthCMTo == 0
if json.Valid([]byte(form.Attributes)) {
input.Attributes = json.RawMessage(form.Attributes)
}
@@ -230,18 +240,12 @@ func speciesInput(form speciesForm) client.SpeciesInput {
} else {
input.ClearCategoryID = true
}
assignPositive := func(value int) *int {
if value > 0 {
return &value
}
return nil
}
form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit)
form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit)
form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit)
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = assignPositive(form.SowMonthFrom), assignPositive(form.SowDayFrom), assignPositive(form.SowMonthTo), assignPositive(form.SowDayTo)
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo)
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo)
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = optionalPositive(form.SowMonthFrom), optionalPositive(form.SowDayFrom), optionalPositive(form.SowMonthTo), optionalPositive(form.SowDayTo)
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = optionalPositive(form.PlantingMonthFrom), optionalPositive(form.PlantingDayFrom), optionalPositive(form.PlantingMonthTo), optionalPositive(form.PlantingDayTo)
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = optionalPositive(form.HarvestMonthFrom), optionalPositive(form.HarvestDayFrom), optionalPositive(form.HarvestMonthTo), optionalPositive(form.HarvestDayTo)
input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
@@ -251,6 +255,13 @@ func speciesInput(form speciesForm) client.SpeciesInput {
return input
}
func optionalPositive(value int) *int {
if value > 0 {
return &value
}
return nil
}
func rangeEnd(month, day, duration int, unit string) (int, int) {
if month < 1 {
return 0, 0
+4
View File
@@ -165,6 +165,10 @@ button, .button { display: inline-block; width: auto; padding: .75rem 1rem; colo
.danger:hover, .template-remove:hover { background: #7f291f; }
.species-template-add { width: 2.5rem; height: 2.5rem; margin-top: .75rem; margin-left: auto; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; }
.dimension-range { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: .55rem; }
.dimension-range:focus-within { border-color: var(--leaf); box-shadow: 0 0 0 .15rem var(--leaf-light); }
.dimension-range input { min-width: 0; border: 0; border-radius: 0; outline: 0; }
.dimension-range > span { color: var(--muted); font-weight: 400; }
.filter-bar { width: 100%; max-width: none; margin-bottom: 2rem; }
.image-library-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; }
.image-library-item { margin:0; overflow:hidden; padding:0; }
+1
View File
@@ -409,6 +409,7 @@ function initializeTagEditors(root = document) {
if (!input || !bubbles || !suggestions) return;
let tags = input.value.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean);
tags = [...new Set(tags)];
input.value = "";
const render = () => {
bubbles.replaceChildren(...tags.map((tag) => {
const bubble = document.createElement("span");
+4 -1
View File
@@ -47,7 +47,10 @@ type CommonTemplateData struct {
}
// AccountTemplateData contains account-management page data.
type AccountTemplateData struct{ AccountSessions []client.AccountSession }
type AccountTemplateData struct {
AccountSessions []client.AccountSession
AccountProtected bool
}
// AdminTemplateData contains application-administration page data.
type AdminTemplateData struct {
@@ -1,8 +1,10 @@
{{define "title"}}Benutzerkonto{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Persönlich</p><h2>Benutzerkonto</h2></div></header>{{$form:=.Form}}{{with $form.Message}}<p class='form-message'>{{.}}</p>{{end}}
{{if .AccountProtected}}<section class='panel'><h3>Gemeinsames Demokonto</h3><p>E-Mail-Adresse, Passwort, Profil und aktive Sitzungen sind geschützt, damit alle Besucher weiterhin Zugang zur Demo haben. Die Garteninhalte kannst du frei ausprobieren; sie werden jede Nacht zurückgesetzt.</p></section>{{else}}
<div class='account-grid'><section class='panel'><h3>Profil</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.profile") "garden" .ID}}{{else}}{{webPath "account.profile"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='name'>Name</label><input id='name' name='name' value='{{$form.Name}}' required>{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}<label for='color'>Farbe für deine Einträge</label><input id='color' name='color' type='color' value='{{$form.Color}}' required><p class='muted'>Diese Farbe wird verwendet, um deine Tagebucheinträge schnell zu erkennen.</p>{{with index $form.Errors "color"}}<p class='field-error'>{{.}}</p>{{end}}<button>Profil speichern</button></form></section>
<section class='panel'><h3>E-Mail-Adresse</h3><p>Aktuell: {{.CurrentUser.Email}}</p><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.email") "garden" .ID}}{{else}}{{webPath "account.email"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='email'>Neue E-Mail-Adresse</label><input id='email' type='email' name='email' value='{{$form.Email}}' required><label for='email-password'>Aktuelles Passwort</label><input id='email-password' type='password' name='current_password' required>{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Bestätigung senden</button></form></section>
<section class='panel'><h3>Passwort</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.password") "garden" .ID}}{{else}}{{webPath "account.password"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='current-password'>Aktuelles Passwort</label><input id='current-password' type='password' name='current_password' required><label for='new-password'>Neues Passwort</label><input id='new-password' type='password' name='new_password' minlength='8' required>{{with index $form.Errors "new_password"}}<p class='field-error'>{{.}}</p>{{end}}<label for='confirm-password'>Neues Passwort bestätigen</label><input id='confirm-password' type='password' name='new_password_confirmation' required>{{with index $form.Errors "new_password_confirmation"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Passwort ändern</button></form></section>
<section class='panel'><h3>Aktive Sitzungen</h3>{{if .AccountSessions}}{{range .AccountSessions}}{{$session := .}}<div class='member-row'><div><strong>{{if .Current}}Dieses Gerät{{else}}Weitere Sitzung{{end}}</strong><br><span class='muted'>Angemeldet: {{humanDate .CreatedAt}} · gültig bis {{humanDate .ExpiresAt}}</span></div><form action='{{with $.Garden}}{{pathWithQuery (webPath "account.session.delete" $session.ID) "garden" .ID}}{{else}}{{webPath "account.session.delete" .ID}}{{end}}' method='POST'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='danger'>Widerrufen</button></form></div>{{end}}{{else}}<p>Keine aktiven Sitzungen gefunden.</p>{{end}}</section></div>
{{end}}
{{end}}
+1
View File
@@ -7,6 +7,7 @@
{{with $form.Message}}<p class='form-message error'>{{.}}</p>{{end}}
<form action='{{webPath "login"}}' method='POST'>
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
{{with $form.ReturnTo}}<input type='hidden' name='return_to' value='{{.}}'>{{end}}
<label for='email'>E-Mail-Adresse</label>
<input id='email' name='email' type='email' value='{{$form.Email}}' autocomplete='email' required>
{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}
@@ -30,7 +30,7 @@
{{range .SpeciesCategories}}{{if or .Active (eqInt $form.CategoryID .ID)}}<option value='{{.ID}}' {{if eqInt $form.CategoryID .ID}}selected{{end}}>{{.Name}}{{with lifecycleName .Lifecycle}} ({{.}}){{end}}{{if not .Active}} (inaktiv){{end}}</option>{{end}}{{end}}
</select>
{{with index $form.Errors "category_id"}}<p class='field-error'>{{.}}</p>{{end}}
<div class='form-grid'><label>Licht<select name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select></label><label>Bodenbeschaffenheit<select name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select></label><label>Bodenreaktion<select name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select></label><label>Winterschutz<input name='winter_protection' value='{{$form.WinterProtection}}'></label><label>Pflanzabstand in cm<input type='number' min='1' name='spacing_cm' value='{{if $form.SpacingCM}}{{$form.SpacingCM}}{{end}}'></label><label>Höhe in cm<input type='number' min='1' name='height_cm' value='{{if $form.HeightCM}}{{$form.HeightCM}}{{end}}'></label></div>
<div class='form-grid'><label>Licht<select name='sun_exposure'><option value=''>Nicht angegeben</option><option value='sunny' {{if eqString $form.SunExposure "sunny"}}selected{{end}}>Sonnig</option><option value='partial_shade' {{if eqString $form.SunExposure "partial_shade"}}selected{{end}}>Halbschattig</option><option value='shade' {{if eqString $form.SunExposure "shade"}}selected{{end}}>Schattig</option></select></label><label>Bodenbeschaffenheit<select name='soil_condition'><option value=''>Nicht angegeben</option><option value='dry' {{if eqString $form.SoilCondition "dry"}}selected{{end}}>Trocken</option><option value='moist' {{if eqString $form.SoilCondition "moist"}}selected{{end}}>Feucht</option><option value='boggy' {{if eqString $form.SoilCondition "boggy"}}selected{{end}}>Sumpfig</option></select></label><label>Bodenreaktion<select name='soil_reaction'><option value=''>Nicht angegeben</option><option value='alkaline' {{if eqString $form.SoilReaction "alkaline"}}selected{{end}}>Basisch</option><option value='acidic' {{if eqString $form.SoilReaction "acidic"}}selected{{end}}>Sauer</option><option value='neutral' {{if eqString $form.SoilReaction "neutral"}}selected{{end}}>Neutral</option></select></label><label>Winterschutz<input name='winter_protection' value='{{$form.WinterProtection}}'></label><label>Pflanzabstand in cm<input type='number' min='1' name='spacing_cm' value='{{if $form.SpacingCM}}{{$form.SpacingCM}}{{end}}'></label><label>Höhe in cm<span class='dimension-range'><input type='number' min='1' name='height_cm_from' aria-label='Höhe von' placeholder='von' value='{{if $form.HeightCMFrom}}{{$form.HeightCMFrom}}{{end}}'><span aria-hidden='true'></span><input type='number' min='1' name='height_cm_to' aria-label='Höhe bis' placeholder='bis' value='{{if $form.HeightCMTo}}{{$form.HeightCMTo}}{{end}}'></span>{{with index $form.Errors "height_cm_from"}}<span class='field-error'>{{.}}</span>{{end}}{{with index $form.Errors "height_cm_to"}}<span class='field-error'>{{.}}</span>{{end}}</label><label>Breite in cm<span class='dimension-range'><input type='number' min='1' name='width_cm_from' aria-label='Breite von' placeholder='von' value='{{if $form.WidthCMFrom}}{{$form.WidthCMFrom}}{{end}}'><span aria-hidden='true'></span><input type='number' min='1' name='width_cm_to' aria-label='Breite bis' placeholder='bis' value='{{if $form.WidthCMTo}}{{$form.WidthCMTo}}{{end}}'></span>{{with index $form.Errors "width_cm_from"}}<span class='field-error'>{{.}}</span>{{end}}{{with index $form.Errors "width_cm_to"}}<span class='field-error'>{{.}}</span>{{end}}</label></div>
{{template "season_range" (dict "Legend" "Aussaat" "Prefix" "sow" "Month" $form.SowMonthFrom "Day" $form.SowDayFrom "Duration" $form.SowDuration "Unit" $form.SowDurationUnit)}}
{{template "season_range" (dict "Legend" "Pflanzzeit" "Prefix" "planting" "Month" $form.PlantingMonthFrom "Day" $form.PlantingDayFrom "Duration" $form.PlantingDuration "Unit" $form.PlantingDurationUnit)}}
{{template "season_range" (dict "Legend" "Ernte" "Prefix" "harvest" "Month" $form.HarvestMonthFrom "Day" $form.HarvestDayFrom "Duration" $form.HarvestDuration "Unit" $form.HarvestDurationUnit)}}
+31 -2
View File
@@ -128,6 +128,27 @@ func TestGardenContextIsKeptInAccountAndSettingsLinks(t *testing.T) {
}
}
func TestProtectedDemoAccountHidesAccountMutationForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Demo", Email: "demo@example.com", Activated: true}
data := &templateData{
commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Form: accountForm{Errors: map[string]string{}}},
accountTemplateData: accountTemplateData{AccountProtected: true},
}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "account.tmpl", data)
body := response.Body.String()
if !strings.Contains(body, "Gemeinsames Demokonto") {
t.Fatalf("protected account notice is missing: %s", body)
}
for _, forbidden := range []string{"action='/account/profile'", "action='/account/password'", "action='/account/email'", "Widerrufen"} {
if strings.Contains(body, forbidden) {
t.Errorf("protected account still offers %q: %s", forbidden, body)
}
}
}
func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
@@ -350,11 +371,19 @@ func TestEmptyOptionalSpeciesDimensionsDoNotBlockSubmit(t *testing.T) {
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "species_form.tmpl", data)
body := response.Body.String()
for _, field := range []string{"spacing_cm", "height_cm"} {
if !strings.Contains(body, "name='"+field+"' value=''") {
for _, field := range []string{"spacing_cm", "height_cm_from", "height_cm_to", "width_cm_from", "width_cm_to"} {
fieldStart := strings.Index(body, "name='"+field+"'")
fieldEnd := -1
if fieldStart >= 0 {
fieldEnd = strings.Index(body[fieldStart:], ">")
}
if fieldStart < 0 || fieldEnd < 0 || !strings.Contains(body[fieldStart:fieldStart+fieldEnd], "value=''") {
t.Errorf("optional field %s should render empty: %s", field, body)
}
}
if got := strings.Count(body, "class='dimension-range'"); got != 2 {
t.Errorf("dimension ranges: got %d composite controls, want 2: %s", got, body)
}
}
func TestTaskDueFormatsWindows(t *testing.T) {
+2 -1
View File
@@ -15,8 +15,8 @@ import (
"syscall"
"time"
"gardomatic.kleiax.de/lib/client"
"gardomatic.kleiax.de/internal/vcs"
"gardomatic.kleiax.de/lib/client"
"github.com/go-playground/form/v4"
)
@@ -30,6 +30,7 @@ type Config struct {
Port int
Env string
APIBaseURL string
DemoAccountEmail string
SessionCookieName string
CookieSecure bool
}
+38 -24
View File
@@ -177,19 +177,24 @@ type UpdateGardenInput struct {
// Species describes global or garden-specific plant master data.
type Species struct {
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
ID int `json:"id"`
GardenID *int `json:"garden_id,omitempty"`
CommonName string `json:"common_name"`
Cultivar string `json:"cultivar"`
BotanicalName string `json:"botanical_name"`
CategoryID *int `json:"category_id,omitempty"`
Category string `json:"category"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
// HeightCM is retained as a deprecated API alias for HeightCMTo.
HeightCM *int `json:"height_cm,omitempty"`
HeightCMFrom *int `json:"height_cm_from,omitempty"`
HeightCMTo *int `json:"height_cm_to,omitempty"`
WidthCMFrom *int `json:"width_cm_from,omitempty"`
WidthCMTo *int `json:"width_cm_to,omitempty"`
SowMonthFrom *int `json:"sow_month_from,omitempty"`
SowDayFrom *int `json:"sow_day_from,omitempty"`
SowMonthTo *int `json:"sow_month_to,omitempty"`
@@ -216,19 +221,28 @@ type Species struct {
// SpeciesInput contains optional species fields used for creation and updates.
type SpeciesInput struct {
Global bool `json:"global,omitempty"`
Tags []string `json:"tags,omitempty"`
CommonName *string `json:"common_name,omitempty"`
Cultivar *string `json:"cultivar,omitempty"`
BotanicalName *string `json:"botanical_name,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
ClearCategoryID bool `json:"clear_category_id,omitempty"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
Global bool `json:"global,omitempty"`
Tags []string `json:"tags,omitempty"`
CommonName *string `json:"common_name,omitempty"`
Cultivar *string `json:"cultivar,omitempty"`
BotanicalName *string `json:"botanical_name,omitempty"`
CategoryID *int `json:"category_id,omitempty"`
ClearCategoryID bool `json:"clear_category_id,omitempty"`
SunExposure *string `json:"sun_exposure,omitempty"`
SoilCondition *string `json:"soil_condition,omitempty"`
SoilReaction *string `json:"soil_reaction,omitempty"`
WinterProtection *string `json:"winter_protection,omitempty"`
SpacingCM *int `json:"spacing_cm,omitempty"`
// HeightCM is retained for compatibility; new code should use HeightCMTo.
HeightCM *int `json:"height_cm,omitempty"`
HeightCMFrom *int `json:"height_cm_from,omitempty"`
HeightCMTo *int `json:"height_cm_to,omitempty"`
WidthCMFrom *int `json:"width_cm_from,omitempty"`
WidthCMTo *int `json:"width_cm_to,omitempty"`
ClearHeightCMFrom bool `json:"clear_height_cm_from,omitempty"`
ClearHeightCMTo bool `json:"clear_height_cm_to,omitempty"`
ClearWidthCMFrom bool `json:"clear_width_cm_from,omitempty"`
ClearWidthCMTo bool `json:"clear_width_cm_to,omitempty"`
SowMonthFrom *int `json:"sow_month_from,omitempty"`
SowDayFrom *int `json:"sow_day_from,omitempty"`
ClearSowDayFrom bool `json:"clear_sow_day_from,omitempty"`
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Reset Gardomatic demo data
After=postgresql.service
ConditionPathExists=/etc/gardomatic/demo-password
[Service]
Type=oneshot
User=gardomatic
Group=gardomatic
EnvironmentFile=/etc/gardomatic/gardomatic.env
WorkingDirectory=/var/lib/gardomatic
StandardInput=file:/etc/gardomatic/demo-password
ExecStart=/usr/local/bin/gardomatic-cli --yes demo reset --email ${GARDOMATIC_DEMO_ACCOUNT_EMAIL} --password-stdin
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Reset Gardomatic demo data every night
[Timer]
OnCalendar=*-*-* 04:00:00 Europe/Berlin
Persistent=true
Unit=gardomatic-demo-reset.service
[Install]
WantedBy=timers.target
+23 -3
View File
@@ -1,7 +1,7 @@
#!/bin/bash
set -Eeuo pipefail
# Installs artifacts uploaded by `make production/deploy`. Run as root or as an
# Installs artifacts uploaded by a Make deployment target. Run as root or as an
# administrator with passwordless sudo.
if [[ $(id -u) -ne 0 ]]; then
exec sudo -n "$0" "$@"
@@ -18,6 +18,8 @@ required_files=(
cli
api.service
web.service
demo-reset.service
demo-reset.timer
create-admin.sh
)
for file in "${required_files[@]}"; do
@@ -31,11 +33,11 @@ done
exit 1
}
[[ -f "$ENV_FILE" ]] || {
printf 'Missing runtime configuration: %s; run production/provision first.\n' "$ENV_FILE" >&2
printf 'Missing runtime configuration: %s; provision this deployment target first.\n' "$ENV_FILE" >&2
exit 1
}
id gardomatic >/dev/null 2>&1 || {
printf 'Missing service user gardomatic; run production/provision first.\n' >&2
printf 'Missing service user gardomatic; provision this deployment target first.\n' >&2
exit 1
}
@@ -49,6 +51,8 @@ rsync --archive --delete "$SCRIPT_DIR/migrations/" "$APPLICATION_DIR/migrations/
install -m 0644 -o root -g root "$SCRIPT_DIR/api.service" /etc/systemd/system/api.service
install -m 0644 -o root -g root "$SCRIPT_DIR/web.service" /etc/systemd/system/web.service
install -m 0644 -o root -g root "$SCRIPT_DIR/demo-reset.service" /etc/systemd/system/gardomatic-demo-reset.service
install -m 0644 -o root -g root "$SCRIPT_DIR/demo-reset.timer" /etc/systemd/system/gardomatic-demo-reset.timer
install -m 0755 -o root -g root "$SCRIPT_DIR/create-admin.sh" /usr/local/sbin/gardomatic-create-admin
# provision-server.sh writes a file compatible with systemd and Bash. Loading it
@@ -61,6 +65,22 @@ migrate -path "$APPLICATION_DIR/migrations" -database "$GARDOMATIC_DB_DSN" up
systemctl daemon-reload
systemctl enable api web
if [[ "${GARDOMATIC_DEMO_RESET_ENABLED:-false}" == true ]]; then
[[ -n "${GARDOMATIC_DEMO_ACCOUNT_EMAIL:-}" ]] || {
printf 'Demo reset is enabled, but GARDOMATIC_DEMO_ACCOUNT_EMAIL is empty.\n' >&2
exit 1
}
[[ -f /etc/gardomatic/demo-password ]] || {
printf 'Demo reset is enabled, but /etc/gardomatic/demo-password is missing.\n' >&2
exit 1
}
chown root:gardomatic /etc/gardomatic/demo-password
chmod 0640 /etc/gardomatic/demo-password
systemctl enable --now gardomatic-demo-reset.timer
systemctl start gardomatic-demo-reset.service
else
systemctl disable --now gardomatic-demo-reset.timer >/dev/null 2>&1 || true
fi
systemctl restart api web
printf 'Gardomatic deployment complete.\n'
+11 -4
View File
@@ -1,7 +1,7 @@
# Copy this file to .env next to provision-server.sh and restrict it to the
# administrator. The file is sourced as Bash configuration and must be trusted.
# cp .env.example .env
# chmod 600 .env
# Copy this template to .env, .env.testserver, and .env.demo as needed. Keep
# separate database credentials and URLs in every file. The files are sourced
# as Bash configuration and must be trusted and restricted to the administrator.
# `make config/init` creates all three with mode 0600.
# Server provisioning ---------------------------------------------------------
@@ -70,6 +70,13 @@ GARDOMATIC_RATE_LIMIT_BURST='40'
# https://garden.example.com,https://admin.example.com
GARDOMATIC_CORS_TRUSTED_ORIGINS=''
# Destructive demo reset. Leave false on every non-demo database. When true,
# deployments require /etc/gardomatic/demo-password and enable the nightly timer.
GARDOMATIC_DEMO_RESET_ENABLED='false'
# Required when the demo reset is enabled. The API protects this shared account's
# profile, email address, password and sessions from changes by visitors.
GARDOMATIC_DEMO_ACCOUNT_EMAIL=''
# Delivery mode. Allowed exactly: file or smtp. Production normally uses smtp.
GARDOMATIC_SMTP_MODE='smtp'
# Required in smtp mode: resolvable SMTP hostname and TCP port.
+8
View File
@@ -74,6 +74,12 @@ require_variable GARDOMATIC_WEB_BASE_URL
require_identifier GARDOMATIC_DB_NAME
require_identifier GARDOMATIC_DB_USER
GARDOMATIC_DEMO_RESET_ENABLED=${GARDOMATIC_DEMO_RESET_ENABLED:-false}
[[ "$GARDOMATIC_DEMO_RESET_ENABLED" == true || "$GARDOMATIC_DEMO_RESET_ENABLED" == false ]] || die "GARDOMATIC_DEMO_RESET_ENABLED must be true or false"
if [[ "$GARDOMATIC_DEMO_RESET_ENABLED" == true ]]; then
require_variable GARDOMATIC_DEMO_ACCOUNT_EMAIL
fi
case "${GARDOMATIC_SMTP_MODE:-file}" in
smtp)
require_variable GARDOMATIC_SMTP_HOST
@@ -218,6 +224,8 @@ runtime_variables=(
GARDOMATIC_RATE_LIMIT_RPS
GARDOMATIC_RATE_LIMIT_BURST
GARDOMATIC_CORS_TRUSTED_ORIGINS
GARDOMATIC_DEMO_RESET_ENABLED
GARDOMATIC_DEMO_ACCOUNT_EMAIL
GARDOMATIC_SMTP_MODE
GARDOMATIC_SMTP_HOST
GARDOMATIC_SMTP_PORT