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
|
# https://garden.example.com,https://admin.example.com
|
||||||
GARDOMATIC_CORS_TRUSTED_ORIGINS=http://localhost:4040
|
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.
|
# Delivery mode. Allowed: file or smtp.
|
||||||
GARDOMATIC_SMTP_MODE=file
|
GARDOMATIC_SMTP_MODE=file
|
||||||
# SMTP hostname and TCP port. Used only in smtp mode.
|
# SMTP hostname and TCP port. Used only in smtp mode.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ SHELL := /bin/bash
|
|||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.DELETE_ON_ERROR:
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
# Non-secret deployment settings. Runtime secrets remain in the shell-compatible
|
# Non-secret deployment settings. Runtime secrets remain in separate shell-compatible
|
||||||
# .envrc and remote/setup/.env files instead of being parsed by Make.
|
# files below remote/setup instead of being parsed by Make.
|
||||||
-include config.mk
|
-include config.mk
|
||||||
|
|
||||||
define load_envrc
|
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 ./.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 ./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 ]]; 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
|
# DEVELOPMENT
|
||||||
@@ -104,7 +106,7 @@ test/integration:
|
|||||||
|
|
||||||
.PHONY: build/dirs
|
.PHONY: build/dirs
|
||||||
build/dirs:
|
build/dirs:
|
||||||
mkdir -p ./bin ${PRODUCTION_BUILD_DIR}
|
mkdir -p ./bin
|
||||||
|
|
||||||
## build/api: build the cmd/api application
|
## build/api: build the cmd/api application
|
||||||
.PHONY: build/api
|
.PHONY: build/api
|
||||||
@@ -121,12 +123,21 @@ build/web: build/dirs
|
|||||||
build/cli: build/dirs
|
build/cli: build/dirs
|
||||||
go build -ldflags='-s' -o=./bin/cli ./cmd/cli
|
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
|
.PHONY: build/production
|
||||||
build/production: build/dirs
|
build/production:
|
||||||
GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/api ./cmd/api
|
@$(MAKE) --no-print-directory build/deployment DEPLOYMENT_GOOS=${PRODUCTION_GOOS} DEPLOYMENT_GOARCH=${PRODUCTION_GOARCH}
|
||||||
GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/web ./cmd/web
|
|
||||||
GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/cli ./cmd/cli
|
|
||||||
|
|
||||||
## code/stats: print source lines and their estimated number of book pages
|
## code/stats: print source lines and their estimated number of book pages
|
||||||
.PHONY: code/stats
|
.PHONY: code/stats
|
||||||
@@ -143,7 +154,7 @@ build/all: build/api build/web build/cli
|
|||||||
@$(MAKE) --no-print-directory code/stats
|
@$(MAKE) --no-print-directory code/stats
|
||||||
|
|
||||||
# ==================================================================================== #
|
# ==================================================================================== #
|
||||||
# PRODUCTION
|
# REMOTE DEPLOYMENTS
|
||||||
# ==================================================================================== #
|
# ==================================================================================== #
|
||||||
|
|
||||||
PRODUCTION_HOST ?=
|
PRODUCTION_HOST ?=
|
||||||
@@ -152,46 +163,72 @@ PRODUCTION_SSH_PORT ?= 22
|
|||||||
PRODUCTION_SSH_IDENTITY_FILE ?=
|
PRODUCTION_SSH_IDENTITY_FILE ?=
|
||||||
PRODUCTION_GOOS ?= linux
|
PRODUCTION_GOOS ?= linux
|
||||||
PRODUCTION_GOARCH ?= amd64
|
PRODUCTION_GOARCH ?= amd64
|
||||||
PRODUCTION_BUILD_DIR = ./bin/${PRODUCTION_GOOS}_${PRODUCTION_GOARCH}
|
|
||||||
|
|
||||||
production_target = ${PRODUCTION_SSH_USER}@${PRODUCTION_HOST}
|
TESTSERVER_HOST ?=
|
||||||
production_ssh_options = -p ${PRODUCTION_SSH_PORT}
|
TESTSERVER_SSH_USER ?= root
|
||||||
ifneq ($(strip ${PRODUCTION_SSH_IDENTITY_FILE}),)
|
TESTSERVER_SSH_PORT ?= 22
|
||||||
production_ssh_options += -i ${PRODUCTION_SSH_IDENTITY_FILE}
|
TESTSERVER_SSH_IDENTITY_FILE ?=
|
||||||
endif
|
TESTSERVER_GOOS ?= linux
|
||||||
|
TESTSERVER_GOARCH ?= amd64
|
||||||
|
|
||||||
.PHONY: production/check-config
|
DEMO_HOST ?=
|
||||||
production/check-config:
|
DEMO_SSH_USER ?= root
|
||||||
@test -n "${PRODUCTION_HOST}" || { echo 'PRODUCTION_HOST is required; configure it in config.mk' >&2; exit 1; }
|
DEMO_SSH_PORT ?= 22
|
||||||
@test -n "${PRODUCTION_SSH_USER}" || { echo 'PRODUCTION_SSH_USER is required' >&2; exit 1; }
|
DEMO_SSH_IDENTITY_FILE ?=
|
||||||
@[[ "${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; }
|
DEMO_GOOS ?= linux
|
||||||
@[[ "${PRODUCTION_GOOS}" == linux ]] || { echo 'PRODUCTION_GOOS must be linux' >&2; exit 1; }
|
DEMO_GOARCH ?= amd64
|
||||||
@[[ "${PRODUCTION_GOARCH}" == amd64 || "${PRODUCTION_GOARCH}" == arm64 ]] || { echo 'PRODUCTION_GOARCH must be amd64 or arm64' >&2; exit 1; }
|
|
||||||
@if [[ -n "${PRODUCTION_SSH_IDENTITY_FILE}" && ! -f "${PRODUCTION_SSH_IDENTITY_FILE}" ]]; then echo 'PRODUCTION_SSH_IDENTITY_FILE does not exist' >&2; exit 1; fi
|
|
||||||
|
|
||||||
## production/connect: connect to the production server
|
deployments := production testserver demo
|
||||||
.PHONY: production/connect
|
deployment_prefix.production := PRODUCTION
|
||||||
production/connect: production/check-config
|
deployment_prefix.testserver := TESTSERVER
|
||||||
ssh ${production_ssh_options} ${production_target}
|
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)
|
deployment_setting = $($(deployment_prefix.$1)_$2)
|
||||||
.PHONY: production/provision
|
deployment_target = $(call deployment_setting,$1,SSH_USER)@$(call deployment_setting,$1,HOST)
|
||||||
production/provision: production/check-config
|
deployment_identity_option = $(if $(strip $(call deployment_setting,$1,SSH_IDENTITY_FILE)),-i $(call deployment_setting,$1,SSH_IDENTITY_FILE))
|
||||||
@test -f ./remote/setup/.env || { echo 'Copy remote/setup/.env.example to remote/setup/.env and configure it first' >&2; exit 1; }
|
deployment_ssh_options = -p $(call deployment_setting,$1,SSH_PORT) $(call deployment_identity_option,$1)
|
||||||
@permissions=$$(stat -c '%a' ./remote/setup/.env); (( (8#$$permissions & 077) == 0 )) || { echo 'remote/setup/.env must have mode 0600' >&2; exit 1; }
|
deployment_scp_options = -P $(call deployment_setting,$1,SSH_PORT) $(call deployment_identity_option,$1)
|
||||||
rsync -P -e "ssh ${production_ssh_options}" ./remote/setup/provision-server.sh ${production_target}:/tmp/gardomatic-provision-server.sh
|
deployment_build_dir = ./bin/$(call deployment_setting,$1,GOOS)_$(call deployment_setting,$1,GOARCH)
|
||||||
ssh ${production_ssh_options} ${production_target} 'status=0; chmod 700 /tmp/gardomatic-provision-server.sh && /tmp/gardomatic-provision-server.sh --env-file - || status=$$?; rm -f /tmp/gardomatic-provision-server.sh; exit $$status' < ./remote/setup/.env
|
|
||||||
|
|
||||||
## production/deploy: deploy API and web to production
|
deployment_check_targets := $(addsuffix /check-config,$(deployments))
|
||||||
.PHONY: production/deploy
|
deployment_connect_targets := $(addsuffix /connect,$(deployments))
|
||||||
production/deploy: production/check-config build/production
|
deployment_provision_targets := $(addsuffix /provision,$(deployments))
|
||||||
ssh ${production_ssh_options} ${production_target} 'mkdir -p "$$HOME/gardomatic-deploy/migrations"'
|
deployment_deploy_targets := $(addsuffix /deploy,$(deployments))
|
||||||
rsync -P -e "ssh ${production_ssh_options}" ${PRODUCTION_BUILD_DIR}/api ${PRODUCTION_BUILD_DIR}/web ${PRODUCTION_BUILD_DIR}/cli ${production_target}:gardomatic-deploy/
|
deployment_admin_targets := $(addsuffix /create-admin,$(deployments))
|
||||||
rsync -rP --delete -e "ssh ${production_ssh_options}" ./internal/storage/postgres/migrations/ ${production_target}:gardomatic-deploy/migrations/
|
|
||||||
rsync -P -e "ssh ${production_ssh_options}" ./remote/production/api.service ./remote/production/web.service ./remote/production/deploy-server.sh ./remote/setup/create-admin.sh ${production_target}:gardomatic-deploy/
|
|
||||||
ssh -t ${production_ssh_options} ${production_target} 'chmod 700 "$$HOME/gardomatic-deploy/deploy-server.sh" && "$$HOME/gardomatic-deploy/deploy-server.sh"'
|
|
||||||
|
|
||||||
## production/create-admin: interactively create an active application administrator
|
.PHONY: $(deployment_check_targets) $(deployment_connect_targets) $(deployment_provision_targets) $(deployment_deploy_targets) $(deployment_admin_targets)
|
||||||
.PHONY: production/create-admin
|
|
||||||
production/create-admin: production/check-config
|
$(deployment_check_targets): %/check-config:
|
||||||
ssh -t ${production_ssh_options} ${production_target} /usr/local/sbin/gardomatic-create-admin
|
@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/
|
lib/
|
||||||
client/ typisierter Go-Client für die JSON-API
|
client/ typisierter Go-Client für die JSON-API
|
||||||
doc/ Planung und weiterführende Dokumentation
|
doc/ Planung und weiterführende Dokumentation
|
||||||
remote/ Produktionsbeispiele für systemd und Caddy
|
remote/ Produktionsbeispiele für systemd
|
||||||
request/ manuelle HTTP-Beispielanfragen
|
request/ manuelle HTTP-Beispielanfragen
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -383,28 +383,44 @@ verwenden. Beiträge im Namen eines Unternehmens müssen vorab abgestimmt werden
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Der `Dockerfile` kann Images für API und Web erzeugen. Unter `remote/production`
|
Der `Dockerfile` kann Images für API und Web erzeugen. Unter `remote/production`
|
||||||
liegen außerdem Beispielkonfigurationen für systemd und Caddy. Die
|
liegen außerdem Beispielkonfigurationen für systemd. Die
|
||||||
`production/*`-Ziele im `Makefile` sind auf die vorhandene Gardomatic-Infrastruktur
|
Präfixe `production/*`, `testserver/*` und `demo/*` verwenden denselben
|
||||||
zugeschnitten, enthalten einen fest konfigurierten Zielhost und führen Migrationen
|
Deployment-Ablauf, aber getrennte Zielhosts und Laufzeitkonfigurationen. Sie sind
|
||||||
sowie Dienstneustarts aus. Sie sind keine allgemeine Deployment-Anleitung und
|
auf die vorhandene Gardomatic-Infrastruktur zugeschnitten, führen Migrationen
|
||||||
sollten vor jeder Verwendung geprüft werden.
|
sowie Dienstneustarts aus und sollten vor jeder Verwendung geprüft werden.
|
||||||
|
|
||||||
Die lokale Produktionsverbindung wird in `config.mk` konfiguriert. Eine kommentierte
|
Die drei SSH-Verbindungen werden gemeinsam in `config.mk` konfiguriert. Eine
|
||||||
Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel und
|
kommentierte Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel
|
||||||
Zielarchitektur steht in `config.mk.example`. Der SSH-Admin ist der vom Hoster oder
|
und Zielarchitektur steht in `config.mk.example`. Die Präfixe der Variablen sind
|
||||||
bei der LXC-Erstellung bereitgestellte Benutzer (`root`, `ubuntu` oder ähnlich)
|
`PRODUCTION_`, `TESTSERVER_` und `DEMO_`. Der SSH-Admin ist der vom Hoster oder bei
|
||||||
und benötigt Root-Rechte oder passwortloses `sudo`. Private SSH-Schlüssel bleiben
|
der LXC-Erstellung bereitgestellte Benutzer (`root`, `ubuntu` oder ähnlich) und
|
||||||
ausschließlich auf dem lokalen Rechner; auf dem Server muss vorab nur der
|
benötigt Root-Rechte oder passwortloses `sudo`.
|
||||||
zugehörige öffentliche Schlüssel für diesen Admin hinterlegt sein.
|
|
||||||
|
|
||||||
Das Server-Setup unter `remote/setup/provision-server.sh` liest seine Konfiguration
|
Das Server-Setup unter `remote/setup/provision-server.sh` liest seine Konfiguration
|
||||||
aus `remote/setup/.env`. Als Ausgangspunkt dient `remote/setup/.env.example`; die
|
aus einer zum Ziel gehörenden Datei. Alle drei Dateien werden von `make config/init`
|
||||||
echte Datei muss auf Modus `0600` gesetzt werden und bleibt von Git ausgeschlossen.
|
mit Modus `0600` angelegt und bleiben 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
|
| Make-Präfix | SSH-Einstellungen | Laufzeitkonfiguration |
|
||||||
den gesperrten Servicebenutzer `gardomatic` ohne Login, SSH-Schlüssel oder
|
| --- | --- | --- |
|
||||||
sudo-Rechte an und installiert die Laufzeitwerte als
|
| `production/` | `PRODUCTION_*` in `config.mk` | `remote/setup/.env` |
|
||||||
`/etc/gardomatic/gardomatic.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:
|
Ein vollständiger Erstbetrieb besteht aus:
|
||||||
|
|
||||||
@@ -414,13 +430,21 @@ make config/init
|
|||||||
make production/provision
|
make production/provision
|
||||||
make production/deploy
|
make production/deploy
|
||||||
make production/create-admin
|
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
|
Jedes Präfix bietet `connect`, `provision`, `deploy` und `create-admin`.
|
||||||
Admin-Hilfe über denselben SSH-Admin, wendet Migrationen an und startet die Dienste.
|
`*/deploy` überträgt Binärdateien, Migrationen, systemd-Units und die Admin-Hilfe,
|
||||||
`production/create-admin` fragt interaktiv nach Name und E-Mail und erstellt den
|
wendet Migrationen an und startet die Dienste. `*/create-admin` erstellt
|
||||||
Benutzer aktiviert und mit der Rolle `application:admin`; das sichere generierte
|
interaktiv einen aktivierten Anwendungsadministrator und gibt das generierte
|
||||||
Passwort wird einmalig ausgegeben.
|
Passwort einmalig aus.
|
||||||
|
|
||||||
Für Produktion gelten mindestens folgende Anforderungen:
|
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
|
- korrekte öffentliche Web-URL und vertrauenswürdige CORS-Origins
|
||||||
- SMTP statt dateibasiertem Mailversand, sofern E-Mails zugestellt werden sollen
|
- 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
|
## Weiterführende Dokumentation
|
||||||
|
|
||||||
- [`doc/planung.md`](doc/planung.md) – Produktbeschreibung, Leitplanken und Fahrplan
|
- [`doc/planung.md`](doc/planung.md) – Produktbeschreibung, Leitplanken und Fahrplan
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"gardomatic.kleiax.de/internal/api"
|
"gardomatic.kleiax.de/internal/api"
|
||||||
"gardomatic.kleiax.de/internal/mailer"
|
"gardomatic.kleiax.de/internal/mailer"
|
||||||
"gardomatic.kleiax.de/internal/platform/environment"
|
"gardomatic.kleiax.de/internal/platform/environment"
|
||||||
|
"gardomatic.kleiax.de/internal/platform/validate"
|
||||||
|
"gardomatic.kleiax.de/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func configFromEnvironment() (api.Config, error) {
|
func configFromEnvironment() (api.Config, error) {
|
||||||
@@ -55,6 +57,9 @@ func configFromEnvironment() (api.Config, error) {
|
|||||||
Port: integer("GARDOMATIC_API_PORT", 4000),
|
Port: integer("GARDOMATIC_API_PORT", 4000),
|
||||||
Env: environment.String("GARDOMATIC_ENV", "development"),
|
Env: environment.String("GARDOMATIC_ENV", "development"),
|
||||||
WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||||
|
DemoAccountEmail: strings.ToLower(strings.TrimSpace(
|
||||||
|
environment.String("GARDOMATIC_DEMO_ACCOUNT_EMAIL", ""),
|
||||||
|
)),
|
||||||
DB: api.DatabaseConfig{
|
DB: api.DatabaseConfig{
|
||||||
Dsn: required("GARDOMATIC_DB_DSN"), MaxOpenConns: integer("GARDOMATIC_DB_MAX_OPEN_CONNS", 25),
|
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),
|
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 {
|
if cfg.Env == "production" && !cfg.Session.CookieSecure {
|
||||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
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 {
|
if _, err := mailer.New(cfg.Mail); err != nil {
|
||||||
errs = append(errs, fmt.Errorf("mail configuration: %w", err))
|
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_HOST", "127.0.0.1")
|
||||||
t.Setenv("GARDOMATIC_API_PORT", "4100")
|
t.Setenv("GARDOMATIC_API_PORT", "4100")
|
||||||
t.Setenv("GARDOMATIC_CORS_TRUSTED_ORIGINS", "https://example.com, https://app.example.com")
|
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()
|
cfg, err := configFromEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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) {
|
func TestConfigRequiresDSN(t *testing.T) {
|
||||||
t.Setenv("GARDOMATIC_DB_DSN", "")
|
t.Setenv("GARDOMATIC_DB_DSN", "")
|
||||||
if _, err := configFromEnvironment(); err == nil {
|
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:])
|
return app.runGardens(ctx, cfg, store, remaining[1:])
|
||||||
case "db":
|
case "db":
|
||||||
return app.runDB(ctx, cfg, store, remaining[1:])
|
return app.runDB(ctx, cfg, store, remaining[1:])
|
||||||
|
case "demo":
|
||||||
|
return app.runDemo(ctx, cfg, store, remaining[1:])
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0])
|
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 {
|
func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role")
|
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] users set-role --email EMAIL --role ROLE
|
||||||
gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE]
|
gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE]
|
||||||
gardomatic [global options] db ping
|
gardomatic [global options] db ping
|
||||||
|
gardomatic --yes demo reset --password-stdin [--name NAME] [--email EMAIL]
|
||||||
gardomatic [global options] version
|
gardomatic [global options] version
|
||||||
|
|
||||||
Global options:`)
|
Global options:`)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type fakeAdminStore struct {
|
|||||||
setRoleCalls int
|
setRoleCalls int
|
||||||
addMemberCalls int
|
addMemberCalls int
|
||||||
lastRole string
|
lastRole string
|
||||||
|
demoResetCalls int
|
||||||
|
demoResetInput demoResetInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fakeAdminStore) Ping(context.Context) error { return nil }
|
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
|
s.lastRole = role
|
||||||
return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil
|
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) {
|
func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) {
|
||||||
t.Helper()
|
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) {
|
func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) {
|
||||||
store := new(fakeAdminStore)
|
store := new(fakeAdminStore)
|
||||||
app, _, _ := newTestApplication(t, "", store)
|
app, _, _ := newTestApplication(t, "", store)
|
||||||
|
|||||||
+27
-9
@@ -10,12 +10,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
dsn string
|
dsn string
|
||||||
environment string
|
environment string
|
||||||
webBaseURL string
|
webBaseURL string
|
||||||
json bool
|
demoResetEnabled bool
|
||||||
yes bool
|
json bool
|
||||||
mail mailer.Config
|
yes bool
|
||||||
|
mail mailer.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func configFromEnvironment() (config, error) {
|
func configFromEnvironment() (config, error) {
|
||||||
@@ -23,11 +24,16 @@ func configFromEnvironment() (config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return config{}, err
|
return config{}, err
|
||||||
}
|
}
|
||||||
|
demoResetEnabled, err := envBool("GARDOMATIC_DEMO_RESET_ENABLED", false)
|
||||||
|
if err != nil {
|
||||||
|
return config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return config{
|
return config{
|
||||||
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
|
dsn: os.Getenv("GARDOMATIC_DB_DSN"),
|
||||||
environment: envString("GARDOMATIC_ENV", "development"),
|
environment: envString("GARDOMATIC_ENV", "development"),
|
||||||
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"),
|
||||||
|
demoResetEnabled: demoResetEnabled,
|
||||||
mail: mailer.Config{
|
mail: mailer.Config{
|
||||||
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
|
Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))),
|
||||||
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
|
Host: os.Getenv("GARDOMATIC_SMTP_HOST"),
|
||||||
@@ -40,6 +46,18 @@ func configFromEnvironment() (config, error) {
|
|||||||
}, nil
|
}, 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 {
|
func envString(name, fallback string) string {
|
||||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||||
return 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
|
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 {
|
type adminStore interface {
|
||||||
Ping(context.Context) error
|
Ping(context.Context) error
|
||||||
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
CreateUser(context.Context, createUserInput) (createUserResult, error)
|
||||||
@@ -59,6 +77,7 @@ type adminStore interface {
|
|||||||
ResetPassword(context.Context, string, []byte) (userView, error)
|
ResetPassword(context.Context, string, []byte) (userView, error)
|
||||||
SetUserRole(context.Context, string, string) (userView, error)
|
SetUserRole(context.Context, string, string) (userView, error)
|
||||||
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
AddGardenMember(context.Context, int, string, string) (gardenMemberView, error)
|
||||||
|
ResetDemo(context.Context, demoResetInput) (demoResetResult, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -4,8 +4,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gardomatic.kleiax.de/internal/platform/environment"
|
"gardomatic.kleiax.de/internal/platform/environment"
|
||||||
|
"gardomatic.kleiax.de/internal/platform/validate"
|
||||||
|
"gardomatic.kleiax.de/internal/storage"
|
||||||
"gardomatic.kleiax.de/internal/web"
|
"gardomatic.kleiax.de/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ func configFromEnvironment() (web.Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return web.Config{}, err
|
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
|
var errs []error
|
||||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||||
errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 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 {
|
if cfg.Env == "production" && !cfg.CookieSecure {
|
||||||
errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production"))
|
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...)
|
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_HOST", "0.0.0.0")
|
||||||
t.Setenv("GARDOMATIC_WEB_PORT", "4444")
|
t.Setenv("GARDOMATIC_WEB_PORT", "4444")
|
||||||
t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com")
|
t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com")
|
||||||
|
t.Setenv("GARDOMATIC_DEMO_ACCOUNT_EMAIL", " Demo@Example.com ")
|
||||||
cfg, err := configFromEnvironment()
|
cfg, err := configFromEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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.
|
# Copy this file to config.mk. It contains non-secret Make configuration only.
|
||||||
|
|
||||||
# Remote SSH administrator supplied by the hoster or LXC configuration. Use
|
# Each deployment has its own host configuration. The SSH administrator is
|
||||||
# root, ubuntu, or another account with passwordless sudo. This is deliberately
|
# supplied by the hoster or LXC configuration. Use root, ubuntu, or another
|
||||||
# not the unprivileged gardomatic service account.
|
# account with passwordless sudo, not the unprivileged gardomatic service account.
|
||||||
|
|
||||||
|
# Production ------------------------------------------------------------------
|
||||||
PRODUCTION_HOST =
|
PRODUCTION_HOST =
|
||||||
PRODUCTION_SSH_USER = root
|
PRODUCTION_SSH_USER = root
|
||||||
PRODUCTION_SSH_PORT = 22
|
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 =
|
PRODUCTION_SSH_IDENTITY_FILE =
|
||||||
|
|
||||||
# Target operating system and architecture. Supported provisioning architectures
|
|
||||||
# are amd64 and arm64.
|
|
||||||
PRODUCTION_GOOS = linux
|
PRODUCTION_GOOS = linux
|
||||||
PRODUCTION_GOARCH = amd64
|
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_ENV` | `development` | Enables confirmations for risky production operations |
|
||||||
| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | Base URL for activation links |
|
| `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_MODE` | `file` | Mail delivery mode (`file` or `smtp`) |
|
||||||
| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname |
|
| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname |
|
||||||
| `GARDOMATIC_SMTP_PORT` | `25` | SMTP server port |
|
| `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
|
Application roles (`application:user`, `application:admin`) are independent of
|
||||||
garden roles (`owner`, `admin`, `member`, `viewer`, `worker`). `gardens add-user`
|
garden roles (`owner`, `admin`, `member`, `viewer`, `worker`). `gardens add-user`
|
||||||
uses `member` when `--role` is omitted.
|
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
|
# Muss
|
||||||
- Während man in den Admineinstellungen ist, soll der aktuelle Garten beibehalten werden
|
|
||||||
|
|
||||||
# Demnächst und konkret
|
# Demnächst und konkret
|
||||||
- Garten bearbeiten soll auch mit dem Menü Links ausgestattet werden
|
OpenApi einbauen
|
||||||
- Link auf git und kleiax.de
|
|
||||||
- Seite mit Informationen zu Server Version etc Serverzeit
|
|
||||||
- Die Instanzrolle brauchen noch eine Berechtigung, ob Gärten angelegt werden dürfen
|
|
||||||
|
|
||||||
# Vielleicht
|
# Vielleicht
|
||||||
- Detailansicht und Bearbeitenansicht trennen?
|
- Detailansicht und Bearbeitenansicht trennen? Dann in der Ansicht nur gesetzte Sachen anzeigen
|
||||||
- Impressum
|
- 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
|
# Unklar
|
||||||
- pickieren? zwischen aufgabe und in stammdaten entscheiden
|
- pickieren? zwischen aufgabe und in stammdaten entscheiden
|
||||||
@@ -20,9 +19,7 @@
|
|||||||
- Auflösen welche Pflanzen dadurch automatisch bewässert werden
|
- Auflösen welche Pflanzen dadurch automatisch bewässert werden
|
||||||
- Zapfstellen könnten auch ein Ort sein
|
- Zapfstellen könnten auch ein Ort sein
|
||||||
- Datenschutz seite
|
- Datenschutz seite
|
||||||
|
- Finanzielles Ausgaben Einnahmen mit Tags
|
||||||
|
|
||||||
# Refactoring
|
# 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
|
||||||
- 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
|
|
||||||
@@ -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_HOST", Value: app.config.Host},
|
||||||
{Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)},
|
{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_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_COOKIE_NAME", Value: app.config.Session.CookieName},
|
||||||
{Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()},
|
{Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()},
|
||||||
{Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()},
|
{Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()},
|
||||||
|
|||||||
+10
-9
@@ -28,15 +28,16 @@ var (
|
|||||||
|
|
||||||
// Config contains API server, database, session, mail, and security settings.
|
// Config contains API server, database, session, mail, and security settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
Env string
|
Env string
|
||||||
WebBaseURL string
|
WebBaseURL string
|
||||||
DB DatabaseConfig
|
DemoAccountEmail string
|
||||||
Limiter LimiterConfig
|
DB DatabaseConfig
|
||||||
Session SessionConfig
|
Limiter LimiterConfig
|
||||||
Mail mailer.Config
|
Session SessionConfig
|
||||||
Cors CORSConfig
|
Mail mailer.Config
|
||||||
|
Cors CORSConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig controls the PostgreSQL connection pool.
|
// DatabaseConfig controls the PostgreSQL connection pool.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func TestShowAdminEnvironmentMasksSecrets(t *testing.T) {
|
|||||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||||
t.Fatal(err)
|
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
|
found := false
|
||||||
for _, variable := range result.Variables {
|
for _, variable := range result.Variables {
|
||||||
found = found || variable.Name == name
|
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)
|
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) {
|
func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) {
|
||||||
message := "the request origin is not trusted"
|
message := "the request origin is not trusted"
|
||||||
app.errorResponse(w, r, http.StatusForbidden, message)
|
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)
|
app.serverErrorResponse(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
inviteEmail := invite.Email
|
||||||
|
inviteURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token
|
||||||
app.background(func() {
|
app.background(func() {
|
||||||
data := map[string]any{"inviteURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token}
|
data := map[string]any{"inviteURL": inviteURL}
|
||||||
if sendErr := app.mailer.Send(invite.Email, "garden_invite.tmpl", data); sendErr != nil {
|
if sendErr := app.mailer.Send(inviteEmail, "garden_invite.tmpl", data); sendErr != nil {
|
||||||
app.logger.Error(sendErr.Error())
|
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 {
|
func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
|
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.MethodPost, "/v1/users", app.registerUserHandler)
|
||||||
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
|
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
|
||||||
router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)
|
router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)
|
||||||
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler))
|
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountProfileHandler)))
|
||||||
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler))
|
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountPasswordHandler)))
|
||||||
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler))
|
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requireMutableAccount(app.requestAccountEmailChangeHandler)))
|
||||||
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler))
|
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.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.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.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler)))
|
||||||
router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler)))
|
router.HandlerFunc(http.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"`
|
WinterProtection *string `json:"winter_protection"`
|
||||||
SpacingCM *int `json:"spacing_cm"`
|
SpacingCM *int `json:"spacing_cm"`
|
||||||
HeightCM *int `json:"height_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"`
|
SowMonthFrom *int `json:"sow_month_from"`
|
||||||
SowDayFrom *int `json:"sow_day_from"`
|
SowDayFrom *int `json:"sow_day_from"`
|
||||||
ClearSowDayFrom bool `json:"clear_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.SoilReaction, &species.SoilReaction)
|
||||||
assignStringPointer(input.WinterProtection, &species.WinterProtection)
|
assignStringPointer(input.WinterProtection, &species.WinterProtection)
|
||||||
assignIntPointer(input.SpacingCM, &species.SpacingCM)
|
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.SowMonthFrom, &species.SowMonthFrom)
|
||||||
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
|
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
|
||||||
assignIntPointer(input.SowMonthTo, &species.SowMonthTo)
|
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) {
|
func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
gardenID, _ := app.readGardenIDParam(r)
|
gardenID, _ := app.readGardenIDParam(r)
|
||||||
var input speciesInput
|
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)
|
app.failedValidationResponse(w, r, v.Errors)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if app.isProtectedDemoAccount(input.Email) {
|
||||||
|
app.demoAccountProtectedResponse(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := app.models.Users.GetByEmail(input.Email)
|
user, err := app.models.Users.GetByEmail(input.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if app.isProtectedDemoAccount(user.Email) {
|
||||||
|
app.demoAccountProtectedResponse(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err = user.Password.Set(input.Password)
|
err = user.Password.Set(input.Password)
|
||||||
if err != nil {
|
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,
|
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.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.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.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to,
|
||||||
s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to,
|
s.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.ID, &species.GardenID, &species.CommonName, &species.Cultivar,
|
||||||
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
|
&species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition,
|
||||||
&species.SoilReaction, &species.WinterProtection, &species.SpacingCM,
|
&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.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.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID,
|
||||||
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
|
&species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy,
|
||||||
)
|
)
|
||||||
|
species.HeightCM = species.HeightCMTo
|
||||||
return species, err
|
return species, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func speciesArgs(species storage.Species) []any {
|
func speciesArgs(species storage.Species) []any {
|
||||||
|
heightTo := species.HeightCMTo
|
||||||
|
if heightTo == nil {
|
||||||
|
heightTo = species.HeightCM
|
||||||
|
}
|
||||||
return []any{
|
return []any{
|
||||||
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
|
species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName,
|
||||||
species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction,
|
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.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo,
|
||||||
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
|
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo,
|
||||||
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
|
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo,
|
||||||
@@ -53,13 +61,15 @@ func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) {
|
|||||||
query := `
|
query := `
|
||||||
INSERT INTO species (
|
INSERT INTO species (
|
||||||
garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure,
|
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,
|
sow_month_from, sow_day_from, sow_month_to, sow_day_to,
|
||||||
planting_month_from, planting_day_from, planting_month_to, planting_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,
|
harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to,
|
||||||
notes, attributes, image_data, image_id, created_by, updated_by)
|
notes, attributes, image_data, image_id, created_by, updated_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
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`
|
RETURNING id, created_at, updated_at, version`
|
||||||
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
|
err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan(
|
||||||
&species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version,
|
&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 {
|
if err != nil {
|
||||||
return storage.Species{}, recordError(err)
|
return storage.Species{}, recordError(err)
|
||||||
}
|
}
|
||||||
|
species.HeightCM = species.HeightCMTo
|
||||||
return species, nil
|
return species, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +89,7 @@ func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return storage.Species{}, recordError(err)
|
return storage.Species{}, recordError(err)
|
||||||
}
|
}
|
||||||
|
species.HeightCM = species.HeightCMTo
|
||||||
return species, nil
|
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.
|
// created_by is immutable and is intentionally not part of the UPDATE.
|
||||||
// Remove it from the shared insert argument list so PostgreSQL does not
|
// Remove it from the shared insert argument list so PostgreSQL does not
|
||||||
// receive an unused, untyped parameter between image_id and updated_by.
|
// 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)
|
args = append(args, gardenID, species.ID, species.Version)
|
||||||
err := m.DB.QueryRowContext(ctx, `
|
err := m.DB.QueryRowContext(ctx, `
|
||||||
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
|
UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3,
|
||||||
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
|
botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7,
|
||||||
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
|
soil_reaction = $8, winter_protection = $9, spacing_cm = $10,
|
||||||
height_cm = $11, sow_month_from = $12, sow_day_from = $13,
|
height_cm_from = $11, height_cm_to = $12, width_cm_from = $13, width_cm_to = $14,
|
||||||
sow_month_to = $14, sow_day_to = $15, planting_month_from=$16,
|
sow_month_from = $15, sow_day_from = $16, sow_month_to = $17, sow_day_to = $18,
|
||||||
planting_day_from=$17, planting_month_to=$18, planting_day_to=$19,
|
planting_month_from=$19, planting_day_from=$20, planting_month_to=$21, planting_day_to=$22,
|
||||||
harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23,
|
harvest_month_from = $23, harvest_day_from = $24, harvest_month_to = $25, harvest_day_to = $26,
|
||||||
notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1
|
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($28, 0) AND id = $29 AND version = $30
|
WHERE garden_id IS NOT DISTINCT FROM NULLIF($31, 0) AND id = $32 AND version = $33
|
||||||
RETURNING updated_at, version`, args...,
|
RETURNING updated_at, version`, args...,
|
||||||
).Scan(&species.UpdatedAt, &species.Version)
|
).Scan(&species.UpdatedAt, &species.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+31
-13
@@ -19,19 +19,24 @@ type SpeciesModelInterface interface {
|
|||||||
|
|
||||||
// Species contains reusable botanical and cultivation master data.
|
// Species contains reusable botanical and cultivation master data.
|
||||||
type Species struct {
|
type Species struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
GardenID *int `json:"garden_id,omitempty"`
|
GardenID *int `json:"garden_id,omitempty"`
|
||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
Cultivar string `json:"cultivar"`
|
Cultivar string `json:"cultivar"`
|
||||||
BotanicalName string `json:"botanical_name"`
|
BotanicalName string `json:"botanical_name"`
|
||||||
CategoryID *int `json:"category_id,omitempty"`
|
CategoryID *int `json:"category_id,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||||
|
// HeightCM is retained as a deprecated API alias for HeightCMTo.
|
||||||
HeightCM *int `json:"height_cm,omitempty"`
|
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"`
|
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||||
SowMonthTo *int `json:"sow_month_to,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_condition", species.SoilCondition, "dry", "moist", "boggy")
|
||||||
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
|
validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral")
|
||||||
validateOptionalPositive(v, "spacing_cm", species.SpacingCM)
|
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_month_from", species.SowMonthFrom, 12)
|
||||||
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
|
validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31)
|
||||||
validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12)
|
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) {
|
func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) {
|
||||||
if value != nil {
|
if value != nil {
|
||||||
v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range")
|
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
|
||||||
|
}
|
||||||
+10
-5
@@ -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) {
|
func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
|
||||||
data := app.newTemplateData(r)
|
data := app.newTemplateData(r)
|
||||||
data.Form = form
|
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) {
|
if !app.loadOptionalGarden(w, r, data) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
|
if !data.AccountProtected {
|
||||||
if err != nil {
|
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
|
||||||
app.handleAPIError(w, r, err)
|
if err != nil {
|
||||||
return
|
app.handleAPIError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.AccountSessions = sessions
|
||||||
}
|
}
|
||||||
data.AccountSessions = sessions
|
|
||||||
app.render(w, status, "account.tmpl", data)
|
app.render(w, status, "account.tmpl", data)
|
||||||
}
|
}
|
||||||
func (app *application) copyAccountError(form *accountForm, err error) {
|
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_HOST", Value: app.config.Host},
|
||||||
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
|
{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_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_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
|
||||||
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
|
{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"`
|
Email string `form:"email"`
|
||||||
Password string `form:"password"`
|
Password string `form:"password"`
|
||||||
RememberEmail bool `form:"remember_email"`
|
RememberEmail bool `form:"remember_email"`
|
||||||
|
ReturnTo string `form:"return_to"`
|
||||||
Errors map[string]string
|
Errors map[string]string
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
@@ -30,12 +31,17 @@ type activationForm struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) signIn(w http.ResponseWriter, r *http.Request) {
|
func (app *application) signIn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
returnTo := safeReturnPath(r.URL.Query().Get("return_to"))
|
||||||
if app.isAuthenticated(r) {
|
if app.isAuthenticated(r) {
|
||||||
|
if returnTo != "" {
|
||||||
|
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther)
|
http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := app.newTemplateData(r)
|
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 cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil {
|
||||||
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil {
|
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil {
|
||||||
form.Email, form.RememberEmail = string(decoded), true
|
form.Email, form.RememberEmail = string(decoded), true
|
||||||
@@ -52,6 +58,7 @@ func (app *application) signInPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.Email = strings.TrimSpace(form.Email)
|
form.Email = strings.TrimSpace(form.Email)
|
||||||
|
form.ReturnTo = safeReturnPath(form.ReturnTo)
|
||||||
form.Errors = make(map[string]string)
|
form.Errors = make(map[string]string)
|
||||||
if form.Email == "" {
|
if form.Email == "" {
|
||||||
form.Errors["email"] = "E-Mail-Adresse ist erforderlich."
|
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)
|
http.Redirect(w, r, webPath("activate"), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if form.ReturnTo != "" {
|
||||||
|
http.Redirect(w, r, form.ReturnTo, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther)
|
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" {
|
if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" {
|
||||||
t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput)
|
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 := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode()))
|
||||||
speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
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"}})
|
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 {
|
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)
|
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) {
|
func TestGardenCanBeDeleted(t *testing.T) {
|
||||||
|
|||||||
@@ -272,8 +272,32 @@ func TestProtectedPageRedirectsWithoutSession(t *testing.T) {
|
|||||||
if response.Code != http.StatusSeeOther {
|
if response.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
|
t.Fatalf("status: got %d, want %d", response.Code, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
if location := response.Header().Get("Location"); location != "/login" {
|
if location := response.Header().Get("Location"); location != "/login?return_to=%2Fgardens" {
|
||||||
t.Errorf("Location: got %q, want %q", location, "/login")
|
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}}`))
|
_, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","activated":true}}`))
|
||||||
})
|
})
|
||||||
app := newAPIBackedTestApplication(t, apiHandler)
|
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 := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
request = request.WithContext(client.NewContext(request.Context(), app.apiClient))
|
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" {
|
if cookies := response.Result().Cookies(); len(cookies) != 1 || cookies[0].Value != "new-session" {
|
||||||
t.Fatalf("forwarded cookies: got %+v", cookies)
|
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) {
|
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) {
|
if errors.As(err, &apiError) {
|
||||||
switch apiError.StatusCode {
|
switch apiError.StatusCode {
|
||||||
case http.StatusUnauthorized:
|
case http.StatusUnauthorized:
|
||||||
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
|
http.Redirect(w, r, loginPathForRequest(r), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
case http.StatusForbidden:
|
case http.StatusForbidden:
|
||||||
if user, ok := userFromContext(r.Context()); ok && !user.Activated {
|
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 {
|
func (app *application) requireAuthentication(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !app.isAuthenticated(r) {
|
if !app.isAuthenticated(r) {
|
||||||
http.Redirect(w, r, webPath("login"), http.StatusSeeOther)
|
http.Redirect(w, r, loginPathForRequest(r), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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 {
|
func (app *application) requireActivatedUser(next http.Handler) http.Handler {
|
||||||
return app.requireAuthentication(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return app.requireAuthentication(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := userFromContext(r.Context())
|
user, ok := userFromContext(r.Context())
|
||||||
|
|||||||
@@ -141,3 +141,15 @@ func pathWithQuery(path string, pairs ...any) string {
|
|||||||
}
|
}
|
||||||
return path + "?" + values.Encode()
|
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) {
|
func TestGardenAwareAdminPaths(t *testing.T) {
|
||||||
garden := &client.Garden{ID: 3}
|
garden := &client.Garden{ID: 3}
|
||||||
if got := gardenAwarePath(webPath("admin.role.new"), garden); got != "/admin/roles/new?garden=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"`
|
SoilReaction string `form:"soil_reaction"`
|
||||||
WinterProtection string `form:"winter_protection"`
|
WinterProtection string `form:"winter_protection"`
|
||||||
SpacingCM int `form:"spacing_cm"`
|
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"`
|
Notes string `form:"notes"`
|
||||||
ImageData string `form:"image_data"`
|
ImageData string `form:"image_data"`
|
||||||
ImageID int `form:"image_id"`
|
ImageID int `form:"image_id"`
|
||||||
@@ -133,7 +136,13 @@ func speciesFormFromSpecies(value client.Species) speciesForm {
|
|||||||
form.WinterProtection = *value.WinterProtection
|
form.WinterProtection = *value.WinterProtection
|
||||||
}
|
}
|
||||||
copyOptionalInt(value.SpacingCM, &form.SpacingCM)
|
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.CategoryID, &form.CategoryID)
|
||||||
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
|
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
|
||||||
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
|
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
|
||||||
@@ -219,9 +228,10 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
|||||||
if form.SpacingCM > 0 {
|
if form.SpacingCM > 0 {
|
||||||
input.SpacingCM = &form.SpacingCM
|
input.SpacingCM = &form.SpacingCM
|
||||||
}
|
}
|
||||||
if form.HeightCM > 0 {
|
input.HeightCMFrom, input.HeightCMTo = optionalPositive(form.HeightCMFrom), optionalPositive(form.HeightCMTo)
|
||||||
input.HeightCM = &form.HeightCM
|
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)) {
|
if json.Valid([]byte(form.Attributes)) {
|
||||||
input.Attributes = json.RawMessage(form.Attributes)
|
input.Attributes = json.RawMessage(form.Attributes)
|
||||||
}
|
}
|
||||||
@@ -230,18 +240,12 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
|||||||
} else {
|
} else {
|
||||||
input.ClearCategoryID = true
|
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.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.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)
|
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.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 = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo)
|
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 = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo)
|
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.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0
|
||||||
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
|
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
|
||||||
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
|
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
|
||||||
@@ -251,6 +255,13 @@ func speciesInput(form speciesForm) client.SpeciesInput {
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func optionalPositive(value int) *int {
|
||||||
|
if value > 0 {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rangeEnd(month, day, duration int, unit string) (int, int) {
|
func rangeEnd(month, day, duration int, unit string) (int, int) {
|
||||||
if month < 1 {
|
if month < 1 {
|
||||||
return 0, 0
|
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; }
|
.danger:hover, .template-remove:hover { background: #7f291f; }
|
||||||
.species-template-add { width: 2.5rem; height: 2.5rem; margin-top: .75rem; margin-left: auto; }
|
.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; }
|
.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; }
|
.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-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1rem; }
|
||||||
.image-library-item { margin:0; overflow:hidden; padding:0; }
|
.image-library-item { margin:0; overflow:hidden; padding:0; }
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ function initializeTagEditors(root = document) {
|
|||||||
if (!input || !bubbles || !suggestions) return;
|
if (!input || !bubbles || !suggestions) return;
|
||||||
let tags = input.value.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean);
|
let tags = input.value.split(",").map((tag) => tag.trim().toLowerCase()).filter(Boolean);
|
||||||
tags = [...new Set(tags)];
|
tags = [...new Set(tags)];
|
||||||
|
input.value = "";
|
||||||
const render = () => {
|
const render = () => {
|
||||||
bubbles.replaceChildren(...tags.map((tag) => {
|
bubbles.replaceChildren(...tags.map((tag) => {
|
||||||
const bubble = document.createElement("span");
|
const bubble = document.createElement("span");
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ type CommonTemplateData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AccountTemplateData contains account-management page data.
|
// 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.
|
// AdminTemplateData contains application-administration page data.
|
||||||
type AdminTemplateData struct {
|
type AdminTemplateData struct {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
{{define "title"}}Benutzerkonto{{end}}
|
{{define "title"}}Benutzerkonto{{end}}
|
||||||
{{define "main"}}
|
{{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}}
|
<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>
|
<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>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>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>
|
<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}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
{{with $form.Message}}<p class='form-message error'>{{.}}</p>{{end}}
|
{{with $form.Message}}<p class='form-message error'>{{.}}</p>{{end}}
|
||||||
<form action='{{webPath "login"}}' method='POST'>
|
<form action='{{webPath "login"}}' method='POST'>
|
||||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
<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>
|
<label for='email'>E-Mail-Adresse</label>
|
||||||
<input id='email' name='email' type='email' value='{{$form.Email}}' autocomplete='email' required>
|
<input id='email' name='email' type='email' value='{{$form.Email}}' autocomplete='email' required>
|
||||||
{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}
|
{{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}}
|
{{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>
|
</select>
|
||||||
{{with index $form.Errors "category_id"}}<p class='field-error'>{{.}}</p>{{end}}
|
{{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" "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" "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)}}
|
{{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) {
|
func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
|
||||||
app := newTestApplication(t)
|
app := newTestApplication(t)
|
||||||
user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
|
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()
|
response := httptest.NewRecorder()
|
||||||
app.render(response, http.StatusOK, "species_form.tmpl", data)
|
app.render(response, http.StatusOK, "species_form.tmpl", data)
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
for _, field := range []string{"spacing_cm", "height_cm"} {
|
for _, field := range []string{"spacing_cm", "height_cm_from", "height_cm_to", "width_cm_from", "width_cm_to"} {
|
||||||
if !strings.Contains(body, "name='"+field+"' value=''") {
|
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)
|
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) {
|
func TestTaskDueFormatsWindows(t *testing.T) {
|
||||||
|
|||||||
+2
-1
@@ -15,8 +15,8 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gardomatic.kleiax.de/lib/client"
|
|
||||||
"gardomatic.kleiax.de/internal/vcs"
|
"gardomatic.kleiax.de/internal/vcs"
|
||||||
|
"gardomatic.kleiax.de/lib/client"
|
||||||
"github.com/go-playground/form/v4"
|
"github.com/go-playground/form/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ type Config struct {
|
|||||||
Port int
|
Port int
|
||||||
Env string
|
Env string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
|
DemoAccountEmail string
|
||||||
SessionCookieName string
|
SessionCookieName string
|
||||||
CookieSecure bool
|
CookieSecure bool
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-24
@@ -177,19 +177,24 @@ type UpdateGardenInput struct {
|
|||||||
|
|
||||||
// Species describes global or garden-specific plant master data.
|
// Species describes global or garden-specific plant master data.
|
||||||
type Species struct {
|
type Species struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
GardenID *int `json:"garden_id,omitempty"`
|
GardenID *int `json:"garden_id,omitempty"`
|
||||||
CommonName string `json:"common_name"`
|
CommonName string `json:"common_name"`
|
||||||
Cultivar string `json:"cultivar"`
|
Cultivar string `json:"cultivar"`
|
||||||
BotanicalName string `json:"botanical_name"`
|
BotanicalName string `json:"botanical_name"`
|
||||||
CategoryID *int `json:"category_id,omitempty"`
|
CategoryID *int `json:"category_id,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||||
|
// HeightCM is retained as a deprecated API alias for HeightCMTo.
|
||||||
HeightCM *int `json:"height_cm,omitempty"`
|
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"`
|
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||||
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
SowMonthTo *int `json:"sow_month_to,omitempty"`
|
||||||
@@ -216,19 +221,28 @@ type Species struct {
|
|||||||
|
|
||||||
// SpeciesInput contains optional species fields used for creation and updates.
|
// SpeciesInput contains optional species fields used for creation and updates.
|
||||||
type SpeciesInput struct {
|
type SpeciesInput struct {
|
||||||
Global bool `json:"global,omitempty"`
|
Global bool `json:"global,omitempty"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
CommonName *string `json:"common_name,omitempty"`
|
CommonName *string `json:"common_name,omitempty"`
|
||||||
Cultivar *string `json:"cultivar,omitempty"`
|
Cultivar *string `json:"cultivar,omitempty"`
|
||||||
BotanicalName *string `json:"botanical_name,omitempty"`
|
BotanicalName *string `json:"botanical_name,omitempty"`
|
||||||
CategoryID *int `json:"category_id,omitempty"`
|
CategoryID *int `json:"category_id,omitempty"`
|
||||||
ClearCategoryID bool `json:"clear_category_id,omitempty"`
|
ClearCategoryID bool `json:"clear_category_id,omitempty"`
|
||||||
SunExposure *string `json:"sun_exposure,omitempty"`
|
SunExposure *string `json:"sun_exposure,omitempty"`
|
||||||
SoilCondition *string `json:"soil_condition,omitempty"`
|
SoilCondition *string `json:"soil_condition,omitempty"`
|
||||||
SoilReaction *string `json:"soil_reaction,omitempty"`
|
SoilReaction *string `json:"soil_reaction,omitempty"`
|
||||||
WinterProtection *string `json:"winter_protection,omitempty"`
|
WinterProtection *string `json:"winter_protection,omitempty"`
|
||||||
SpacingCM *int `json:"spacing_cm,omitempty"`
|
SpacingCM *int `json:"spacing_cm,omitempty"`
|
||||||
|
// HeightCM is retained for compatibility; new code should use HeightCMTo.
|
||||||
HeightCM *int `json:"height_cm,omitempty"`
|
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"`
|
SowMonthFrom *int `json:"sow_month_from,omitempty"`
|
||||||
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
SowDayFrom *int `json:"sow_day_from,omitempty"`
|
||||||
ClearSowDayFrom bool `json:"clear_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
|
#!/bin/bash
|
||||||
set -Eeuo pipefail
|
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.
|
# administrator with passwordless sudo.
|
||||||
if [[ $(id -u) -ne 0 ]]; then
|
if [[ $(id -u) -ne 0 ]]; then
|
||||||
exec sudo -n "$0" "$@"
|
exec sudo -n "$0" "$@"
|
||||||
@@ -18,6 +18,8 @@ required_files=(
|
|||||||
cli
|
cli
|
||||||
api.service
|
api.service
|
||||||
web.service
|
web.service
|
||||||
|
demo-reset.service
|
||||||
|
demo-reset.timer
|
||||||
create-admin.sh
|
create-admin.sh
|
||||||
)
|
)
|
||||||
for file in "${required_files[@]}"; do
|
for file in "${required_files[@]}"; do
|
||||||
@@ -31,11 +33,11 @@ done
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
[[ -f "$ENV_FILE" ]] || {
|
[[ -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
|
exit 1
|
||||||
}
|
}
|
||||||
id gardomatic >/dev/null 2>&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
|
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/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/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
|
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
|
# 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 daemon-reload
|
||||||
systemctl enable api web
|
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
|
systemctl restart api web
|
||||||
|
|
||||||
printf 'Gardomatic deployment complete.\n'
|
printf 'Gardomatic deployment complete.\n'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Copy this file to .env next to provision-server.sh and restrict it to the
|
# Copy this template to .env, .env.testserver, and .env.demo as needed. Keep
|
||||||
# administrator. The file is sourced as Bash configuration and must be trusted.
|
# separate database credentials and URLs in every file. The files are sourced
|
||||||
# cp .env.example .env
|
# as Bash configuration and must be trusted and restricted to the administrator.
|
||||||
# chmod 600 .env
|
# `make config/init` creates all three with mode 0600.
|
||||||
|
|
||||||
# Server provisioning ---------------------------------------------------------
|
# Server provisioning ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -70,6 +70,13 @@ GARDOMATIC_RATE_LIMIT_BURST='40'
|
|||||||
# https://garden.example.com,https://admin.example.com
|
# https://garden.example.com,https://admin.example.com
|
||||||
GARDOMATIC_CORS_TRUSTED_ORIGINS=''
|
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.
|
# Delivery mode. Allowed exactly: file or smtp. Production normally uses smtp.
|
||||||
GARDOMATIC_SMTP_MODE='smtp'
|
GARDOMATIC_SMTP_MODE='smtp'
|
||||||
# Required in smtp mode: resolvable SMTP hostname and TCP port.
|
# 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_NAME
|
||||||
require_identifier GARDOMATIC_DB_USER
|
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
|
case "${GARDOMATIC_SMTP_MODE:-file}" in
|
||||||
smtp)
|
smtp)
|
||||||
require_variable GARDOMATIC_SMTP_HOST
|
require_variable GARDOMATIC_SMTP_HOST
|
||||||
@@ -218,6 +224,8 @@ runtime_variables=(
|
|||||||
GARDOMATIC_RATE_LIMIT_RPS
|
GARDOMATIC_RATE_LIMIT_RPS
|
||||||
GARDOMATIC_RATE_LIMIT_BURST
|
GARDOMATIC_RATE_LIMIT_BURST
|
||||||
GARDOMATIC_CORS_TRUSTED_ORIGINS
|
GARDOMATIC_CORS_TRUSTED_ORIGINS
|
||||||
|
GARDOMATIC_DEMO_RESET_ENABLED
|
||||||
|
GARDOMATIC_DEMO_ACCOUNT_EMAIL
|
||||||
GARDOMATIC_SMTP_MODE
|
GARDOMATIC_SMTP_MODE
|
||||||
GARDOMATIC_SMTP_HOST
|
GARDOMATIC_SMTP_HOST
|
||||||
GARDOMATIC_SMTP_PORT
|
GARDOMATIC_SMTP_PORT
|
||||||
|
|||||||
Reference in New Issue
Block a user