Compare commits
8
Commits
904d14b64c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6670100248 | ||
|
|
ef608551ad | ||
|
|
87809b2344 | ||
|
|
b6d264c9ec | ||
|
|
df8544e04b | ||
|
|
d06ff4a94a | ||
|
|
b87aa0aa17 | ||
|
|
64f11e4960 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ type config struct {
|
||||
dsn string
|
||||
environment string
|
||||
webBaseURL string
|
||||
demoResetEnabled bool
|
||||
json bool
|
||||
yes bool
|
||||
mail mailer.Config
|
||||
@@ -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"),
|
||||
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 |
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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()},
|
||||
|
||||
@@ -32,6 +32,7 @@ type Config struct {
|
||||
Port int
|
||||
Env string
|
||||
WebBaseURL string
|
||||
DemoAccountEmail string
|
||||
DB DatabaseConfig
|
||||
Limiter LimiterConfig
|
||||
Session SessionConfig
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,7 +31,12 @@ type Species struct {
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
if !data.AccountProtected {
|
||||
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
|
||||
if err != nil {
|
||||
app.handleAPIError(w, r, err)
|
||||
return
|
||||
}
|
||||
data.AccountSessions = sessions
|
||||
}
|
||||
app.render(w, status, "account.tmpl", data)
|
||||
}
|
||||
func (app *application) copyAccountError(form *accountForm, err error) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -189,7 +189,12 @@ type Species struct {
|
||||
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"`
|
||||
@@ -228,7 +233,16 @@ type SpeciesInput struct {
|
||||
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"`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user