From 904d14b64c50c51b0937aea84fb8fc2eae85e49e Mon Sep 17 00:00:00 2001 From: Alexander Klein Date: Sat, 12 Sep 2026 22:22:17 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 18 + .env.example | 70 ++ .envrc.example | 7 + .github/pull_request_template.md | 23 + .github/workflows/ci.yml | 44 + .github/workflows/cla.yml | 22 + .gitignore | 36 + .vscode/launch.json | 37 + AGENTS.md | 165 ++++ CLA.md | 182 ++++ CONTRIBUTING.md | 79 ++ Dockerfile | 33 + LICENSE | 136 +++ Makefile | 197 +++++ README.md | 456 ++++++++++ cmd/api/config.go | 99 +++ cmd/api/config_test.go | 24 + cmd/api/doc.go | 2 + cmd/api/main.go | 16 + cmd/cli/application.go | 663 ++++++++++++++ cmd/cli/application_test.go | 272 ++++++ cmd/cli/config.go | 61 ++ cmd/cli/doc.go | 2 + cmd/cli/main.go | 15 + cmd/cli/store.go | 336 ++++++++ cmd/web/config.go | 34 + cmd/web/config_test.go | 16 + cmd/web/doc.go | 2 + cmd/web/main.go | 20 + compose.yaml | 94 ++ config.mk.example | 17 + doc/cli.md | 87 ++ doc/ideen.md | 28 + doc/notes.md | 5 + doc/planung.md | 121 +++ go.mod | 32 + go.sum | 49 ++ internal/api/account.go | 250 ++++++ internal/api/admin.go | 153 ++++ internal/api/admin_environment.go | 78 ++ internal/api/admin_test.go | 166 ++++ internal/api/api.go | 177 ++++ internal/api/application_settings.go | 100 +++ internal/api/application_settings_test.go | 121 +++ internal/api/auth.go | 1 + internal/api/care_instructions.go | 157 ++++ internal/api/context.go | 33 + internal/api/doc.go | 3 + internal/api/errors.go | 99 +++ internal/api/garden_members.go | 194 +++++ internal/api/gardens.go | 187 ++++ internal/api/gardens_test.go | 275 ++++++ internal/api/healthcheck.go | 22 + internal/api/healthcheck_test.go | 30 + internal/api/helpers.go | 175 ++++ internal/api/images.go | 72 ++ internal/api/journal.go | 332 +++++++ internal/api/journal_test.go | 129 +++ internal/api/locations.go | 243 ++++++ internal/api/locations_test.go | 150 ++++ internal/api/middleware.go | 351 ++++++++ internal/api/permissions_test.go | 147 ++++ internal/api/plant_locations.go | 195 +++++ internal/api/plants.go | 257 ++++++ internal/api/resources_test.go | 250 ++++++ internal/api/roles.go | 256 ++++++ internal/api/routes.go | 140 +++ internal/api/season_templates.go | 93 ++ internal/api/season_templates_test.go | 53 ++ internal/api/server.go | 72 ++ internal/api/sessions.go | 97 +++ internal/api/sessions_test.go | 210 +++++ internal/api/species.go | 396 +++++++++ internal/api/species_categories.go | 141 +++ internal/api/species_categories_test.go | 83 ++ internal/api/species_task_templates.go | 300 +++++++ internal/api/species_task_templates_test.go | 36 + internal/api/tags.go | 34 + internal/api/task_generator.go | 142 +++ internal/api/task_generator_test.go | 73 ++ internal/api/task_priorities.go | 153 ++++ internal/api/task_template_opt_outs.go | 44 + internal/api/tasks.go | 371 ++++++++ internal/api/tasks_test.go | 133 +++ internal/api/tokens.go | 189 ++++ internal/api/users.go | 210 +++++ internal/auth/doc.go | 3 + internal/auth/password.go | 71 ++ internal/auth/token.go | 53 ++ internal/daterange/daterange.go | 46 + internal/daterange/daterange_test.go | 36 + internal/mailer/doc.go | 3 + internal/mailer/mailer.go | 163 ++++ internal/mailer/mailer_test.go | 64 ++ internal/mailer/templates/email_change.tmpl | 3 + internal/mailer/templates/garden_invite.tmpl | 3 + internal/mailer/templates/test_mail.tmpl | 25 + .../mailer/templates/token_activation.tmpl | 48 ++ .../templates/token_password_reset.tmpl | 37 + .../mailer/templates/user_invitation.tmpl | 31 + internal/mailer/templates/user_welcome.tmpl | 59 ++ internal/platform/environment/environment.go | 95 ++ .../platform/environment/environment_test.go | 44 + internal/platform/validate/doc.go | 3 + internal/platform/validate/helper.go | 44 + internal/platform/validate/validator.go | 67 ++ internal/storage/application_settings.go | 36 + internal/storage/care_instructions.go | 37 + internal/storage/doc.go | 3 + internal/storage/errors.go | 10 + internal/storage/filters.go | 76 ++ internal/storage/garden_invites.go | 25 + internal/storage/garden_members.go | 202 +++++ internal/storage/gardens.go | 38 + internal/storage/images.go | 44 + internal/storage/journal.go | 77 ++ internal/storage/journal_test.go | 22 + internal/storage/locations.go | 59 ++ internal/storage/models.go | 38 + internal/storage/plant_locations.go | 42 + internal/storage/plants.go | 54 ++ .../storage/postgres/application_settings.go | 118 +++ .../application_settings_integration_test.go | 106 +++ .../storage/postgres/care_instructions.go | 82 ++ internal/storage/postgres/doc.go | 2 + internal/storage/postgres/garden_invites.go | 126 +++ internal/storage/postgres/garden_members.go | 222 +++++ internal/storage/postgres/gardens.go | 116 +++ internal/storage/postgres/helpers.go | 87 ++ internal/storage/postgres/images.go | 90 ++ internal/storage/postgres/journal.go | 175 ++++ .../postgres/journal_integration_test.go | 105 +++ internal/storage/postgres/locations.go | 118 +++ .../postgres/locations_integration_test.go | 75 ++ .../migrations/000001_initial_schema.down.sql | 49 ++ .../migrations/000001_initial_schema.up.sql | 524 +++++++++++ internal/storage/postgres/models.go | 33 + internal/storage/postgres/plant_locations.go | 139 +++ internal/storage/postgres/plants.go | 104 +++ internal/storage/postgres/roles.go | 201 +++++ .../postgres/roles_integration_test.go | 103 +++ internal/storage/postgres/species.go | 142 +++ .../storage/postgres/species_categories.go | 86 ++ .../postgres/species_integration_test.go | 35 + .../postgres/species_task_templates.go | 124 +++ internal/storage/postgres/tags.go | 108 +++ .../task_generation_integration_test.go | 81 ++ internal/storage/postgres/task_priorities.go | 73 ++ .../postgres/task_template_opt_outs.go | 60 ++ internal/storage/postgres/tasks.go | 116 +++ .../postgres/tasks_integration_test.go | 64 ++ internal/storage/postgres/tokens.go | 51 ++ internal/storage/postgres/users.go | 424 +++++++++ .../postgres/users_integration_test.go | 89 ++ internal/storage/roles.go | 47 + internal/storage/species.go | 106 +++ internal/storage/species_categories.go | 35 + internal/storage/species_task_templates.go | 117 +++ internal/storage/tags.go | 50 ++ internal/storage/task_priorities.go | 35 + internal/storage/task_template_opt_outs.go | 9 + internal/storage/tasks.go | 88 ++ internal/storage/tokens.go | 13 + internal/storage/users.go | 141 +++ internal/vcs/doc.go | 2 + internal/vcs/vcs.go | 15 + internal/web/account.go | 165 ++++ internal/web/admin.go | 416 +++++++++ internal/web/auth.go | 191 ++++ internal/web/care_instructions.go | 93 ++ internal/web/collection_filters.go | 219 +++++ internal/web/collection_filters_test.go | 94 ++ internal/web/context.go | 19 + internal/web/dashboard.go | 475 ++++++++++ internal/web/doc.go | 3 + internal/web/edit_flows_test.go | 218 +++++ internal/web/efs.go | 8 + internal/web/flows_test.go | 626 ++++++++++++++ internal/web/gardens.go | 259 ++++++ internal/web/handlers.go | 38 + internal/web/handlers_test.go | 21 + internal/web/helpers.go | 211 +++++ internal/web/images.go | 65 ++ internal/web/journal.go | 352 ++++++++ internal/web/journal_test.go | 99 +++ internal/web/locations.go | 355 ++++++++ internal/web/members.go | 161 ++++ internal/web/middleware.go | 114 +++ internal/web/middleware_test.go | 96 +++ internal/web/page_names_test.go | 71 ++ internal/web/pagination.go | 81 ++ internal/web/pagination_test.go | 38 + internal/web/paths.go | 143 +++ internal/web/paths_test.go | 53 ++ internal/web/plant_locations.go | 179 ++++ internal/web/plant_tasks.go | 244 ++++++ internal/web/plant_ui_test.go | 276 ++++++ internal/web/plants.go | 525 +++++++++++ internal/web/role_editor.go | 62 ++ internal/web/routes.go | 139 +++ internal/web/species.go | 392 +++++++++ internal/web/static/css/cropper-1.6.2.min.css | 9 + internal/web/static/css/main.css | 384 +++++++++ .../static/css/toastui-editor-3.2.2.min.css | 6 + .../web/static/icons/apple-touch-icon.png | Bin 0 -> 49967 bytes internal/web/static/icons/gardomatic-192.png | Bin 0 -> 55783 bytes internal/web/static/icons/gardomatic-512.png | Bin 0 -> 314879 bytes .../web/static/js/TOAST-UI-EDITOR-LICENSE.txt | 21 + internal/web/static/js/cropper-1.6.2.min.js | 10 + internal/web/static/js/htmx-2.0.10.min.js | 1 + internal/web/static/js/main.js | 816 ++++++++++++++++++ .../web/static/js/toastui-editor-3.2.2.min.js | 24 + .../js/toastui-editor-de-de-3.2.2.min.js | 7 + internal/web/static/manifest.manifest | 24 + internal/web/static/offline.html | 14 + internal/web/static/sw.js | 29 + internal/web/task_templates.go | 232 +++++ internal/web/tasks.go | 327 +++++++ internal/web/tasks_test.go | 86 ++ internal/web/template_data.go | 327 +++++++ internal/web/templates.go | 426 +++++++++ .../fragments/care_instructions.tmpl | 1 + .../web/templates/fragments/location.tmpl | 39 + .../web/templates/fragments/plant_tasks.tmpl | 17 + .../templates/fragments/task_template.tmpl | 25 + internal/web/templates/layout/base.tmpl | 32 + internal/web/templates/pages/account.tmpl | 8 + internal/web/templates/pages/activate.tmpl | 28 + internal/web/templates/pages/admin.tmpl | 120 +++ internal/web/templates/pages/dashboard.tmpl | 7 + .../web/templates/pages/email_confirm.tmpl | 1 + internal/web/templates/pages/error.tmpl | 9 + internal/web/templates/pages/garden_form.tmpl | 56 ++ internal/web/templates/pages/garden_new.tmpl | 16 + internal/web/templates/pages/gardens.tmpl | 22 + internal/web/templates/pages/healthcheck.tmpl | 19 + internal/web/templates/pages/home.tmpl | 10 + internal/web/templates/pages/images.tmpl | 7 + internal/web/templates/pages/invite.tmpl | 2 + internal/web/templates/pages/journal.tmpl | 17 + .../web/templates/pages/journal_form.tmpl | 32 + .../web/templates/pages/location_form.tmpl | 42 + internal/web/templates/pages/locations.tmpl | 39 + internal/web/templates/pages/plant.tmpl | 42 + internal/web/templates/pages/plant_form.tmpl | 89 ++ .../templates/pages/plant_location_form.tmpl | 2 + internal/web/templates/pages/privacy.tmpl | 15 + internal/web/templates/pages/search.tmpl | 16 + internal/web/templates/pages/settings.tmpl | 37 + internal/web/templates/pages/signin.tmpl | 23 + internal/web/templates/pages/species.tmpl | 18 + .../web/templates/pages/species_form.tmpl | 72 ++ .../web/templates/pages/task_calendar.tmpl | 6 + internal/web/templates/pages/task_form.tmpl | 13 + .../templates/pages/task_template_form.tmpl | 11 + internal/web/templates/pages/tasks.tmpl | 32 + internal/web/templates/partials/aside.tmpl | 8 + internal/web/templates/partials/footer.tmpl | 11 + .../web/templates/partials/garden_fields.tmpl | 11 + internal/web/templates/partials/header.tmpl | 23 + .../web/templates/partials/image_editor.tmpl | 37 + .../partials/journal_photo_editor.tmpl | 8 + internal/web/templates/partials/nav.tmpl | 27 + .../web/templates/partials/pagination.tmpl | 1 + .../web/templates/partials/role_editor.tmpl | 37 + .../web/templates/partials/season_range.tmpl | 8 + .../web/templates/partials/settings_nav.tmpl | 19 + .../web/templates/partials/tag_editor.tmpl | 9 + .../web/templates/partials/task_fields.tmpl | 11 + .../web/templates/partials/view_toggle.tmpl | 4 + internal/web/templates_test.go | 577 +++++++++++++ internal/web/testutils_test.go | 22 + internal/web/web.go | 131 +++ lib/client/account.go | 48 ++ lib/client/application_settings.go | 38 + lib/client/care_instructions.go | 42 + lib/client/client.go | 262 ++++++ lib/client/client_test.go | 233 +++++ lib/client/context.go | 16 + lib/client/doc.go | 2 + lib/client/errors.go | 49 ++ lib/client/garden_members.go | 67 ++ lib/client/gardens.go | 52 ++ lib/client/gardens_test.go | 89 ++ lib/client/healthcheck.go | 13 + lib/client/images.go | 71 ++ lib/client/journal.go | 188 ++++ lib/client/journal_test.go | 56 ++ lib/client/locations.go | 56 ++ lib/client/locations_test.go | 75 ++ lib/client/models.go | 634 ++++++++++++++ lib/client/plant_locations.go | 47 + lib/client/plants.go | 56 ++ lib/client/resources_test.go | 83 ++ lib/client/roles.go | 72 ++ lib/client/sessions.go | 43 + lib/client/species.go | 56 ++ lib/client/species_categories.go | 48 ++ lib/client/species_categories_test.go | 46 + lib/client/species_task_templates.go | 53 ++ lib/client/species_task_templates_test.go | 45 + lib/client/task_priorities.go | 46 + lib/client/task_template_opt_outs.go | 25 + lib/client/tasks.go | 56 ++ lib/client/tasks_test.go | 45 + lib/client/tokens.go | 33 + lib/client/users.go | 75 ++ lib/client/users_test.go | 49 ++ remote/production/api.service | 34 + remote/production/deploy-server.sh | 66 ++ remote/production/web.service | 17 + remote/setup/.env.example | 84 ++ remote/setup/create-admin.sh | 39 + remote/setup/provision-server.sh | 242 ++++++ 314 files changed, 31884 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .envrc.example create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/cla.yml create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 AGENTS.md create mode 100644 CLA.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api/config.go create mode 100644 cmd/api/config_test.go create mode 100644 cmd/api/doc.go create mode 100644 cmd/api/main.go create mode 100644 cmd/cli/application.go create mode 100644 cmd/cli/application_test.go create mode 100644 cmd/cli/config.go create mode 100644 cmd/cli/doc.go create mode 100644 cmd/cli/main.go create mode 100644 cmd/cli/store.go create mode 100644 cmd/web/config.go create mode 100644 cmd/web/config_test.go create mode 100644 cmd/web/doc.go create mode 100644 cmd/web/main.go create mode 100644 compose.yaml create mode 100644 config.mk.example create mode 100644 doc/cli.md create mode 100644 doc/ideen.md create mode 100644 doc/notes.md create mode 100644 doc/planung.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/account.go create mode 100644 internal/api/admin.go create mode 100644 internal/api/admin_environment.go create mode 100644 internal/api/admin_test.go create mode 100644 internal/api/api.go create mode 100644 internal/api/application_settings.go create mode 100644 internal/api/application_settings_test.go create mode 100644 internal/api/auth.go create mode 100644 internal/api/care_instructions.go create mode 100644 internal/api/context.go create mode 100644 internal/api/doc.go create mode 100644 internal/api/errors.go create mode 100644 internal/api/garden_members.go create mode 100644 internal/api/gardens.go create mode 100644 internal/api/gardens_test.go create mode 100644 internal/api/healthcheck.go create mode 100644 internal/api/healthcheck_test.go create mode 100644 internal/api/helpers.go create mode 100644 internal/api/images.go create mode 100644 internal/api/journal.go create mode 100644 internal/api/journal_test.go create mode 100644 internal/api/locations.go create mode 100644 internal/api/locations_test.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/permissions_test.go create mode 100644 internal/api/plant_locations.go create mode 100644 internal/api/plants.go create mode 100644 internal/api/resources_test.go create mode 100644 internal/api/roles.go create mode 100644 internal/api/routes.go create mode 100644 internal/api/season_templates.go create mode 100644 internal/api/season_templates_test.go create mode 100644 internal/api/server.go create mode 100644 internal/api/sessions.go create mode 100644 internal/api/sessions_test.go create mode 100644 internal/api/species.go create mode 100644 internal/api/species_categories.go create mode 100644 internal/api/species_categories_test.go create mode 100644 internal/api/species_task_templates.go create mode 100644 internal/api/species_task_templates_test.go create mode 100644 internal/api/tags.go create mode 100644 internal/api/task_generator.go create mode 100644 internal/api/task_generator_test.go create mode 100644 internal/api/task_priorities.go create mode 100644 internal/api/task_template_opt_outs.go create mode 100644 internal/api/tasks.go create mode 100644 internal/api/tasks_test.go create mode 100644 internal/api/tokens.go create mode 100644 internal/api/users.go create mode 100644 internal/auth/doc.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/token.go create mode 100644 internal/daterange/daterange.go create mode 100644 internal/daterange/daterange_test.go create mode 100644 internal/mailer/doc.go create mode 100644 internal/mailer/mailer.go create mode 100644 internal/mailer/mailer_test.go create mode 100644 internal/mailer/templates/email_change.tmpl create mode 100644 internal/mailer/templates/garden_invite.tmpl create mode 100644 internal/mailer/templates/test_mail.tmpl create mode 100644 internal/mailer/templates/token_activation.tmpl create mode 100644 internal/mailer/templates/token_password_reset.tmpl create mode 100644 internal/mailer/templates/user_invitation.tmpl create mode 100644 internal/mailer/templates/user_welcome.tmpl create mode 100644 internal/platform/environment/environment.go create mode 100644 internal/platform/environment/environment_test.go create mode 100644 internal/platform/validate/doc.go create mode 100644 internal/platform/validate/helper.go create mode 100644 internal/platform/validate/validator.go create mode 100644 internal/storage/application_settings.go create mode 100644 internal/storage/care_instructions.go create mode 100644 internal/storage/doc.go create mode 100644 internal/storage/errors.go create mode 100644 internal/storage/filters.go create mode 100644 internal/storage/garden_invites.go create mode 100644 internal/storage/garden_members.go create mode 100644 internal/storage/gardens.go create mode 100644 internal/storage/images.go create mode 100644 internal/storage/journal.go create mode 100644 internal/storage/journal_test.go create mode 100644 internal/storage/locations.go create mode 100644 internal/storage/models.go create mode 100644 internal/storage/plant_locations.go create mode 100644 internal/storage/plants.go create mode 100644 internal/storage/postgres/application_settings.go create mode 100644 internal/storage/postgres/application_settings_integration_test.go create mode 100644 internal/storage/postgres/care_instructions.go create mode 100644 internal/storage/postgres/doc.go create mode 100644 internal/storage/postgres/garden_invites.go create mode 100644 internal/storage/postgres/garden_members.go create mode 100644 internal/storage/postgres/gardens.go create mode 100644 internal/storage/postgres/helpers.go create mode 100644 internal/storage/postgres/images.go create mode 100644 internal/storage/postgres/journal.go create mode 100644 internal/storage/postgres/journal_integration_test.go create mode 100644 internal/storage/postgres/locations.go create mode 100644 internal/storage/postgres/locations_integration_test.go create mode 100644 internal/storage/postgres/migrations/000001_initial_schema.down.sql create mode 100644 internal/storage/postgres/migrations/000001_initial_schema.up.sql create mode 100644 internal/storage/postgres/models.go create mode 100644 internal/storage/postgres/plant_locations.go create mode 100644 internal/storage/postgres/plants.go create mode 100644 internal/storage/postgres/roles.go create mode 100644 internal/storage/postgres/roles_integration_test.go create mode 100644 internal/storage/postgres/species.go create mode 100644 internal/storage/postgres/species_categories.go create mode 100644 internal/storage/postgres/species_integration_test.go create mode 100644 internal/storage/postgres/species_task_templates.go create mode 100644 internal/storage/postgres/tags.go create mode 100644 internal/storage/postgres/task_generation_integration_test.go create mode 100644 internal/storage/postgres/task_priorities.go create mode 100644 internal/storage/postgres/task_template_opt_outs.go create mode 100644 internal/storage/postgres/tasks.go create mode 100644 internal/storage/postgres/tasks_integration_test.go create mode 100644 internal/storage/postgres/tokens.go create mode 100644 internal/storage/postgres/users.go create mode 100644 internal/storage/postgres/users_integration_test.go create mode 100644 internal/storage/roles.go create mode 100644 internal/storage/species.go create mode 100644 internal/storage/species_categories.go create mode 100644 internal/storage/species_task_templates.go create mode 100644 internal/storage/tags.go create mode 100644 internal/storage/task_priorities.go create mode 100644 internal/storage/task_template_opt_outs.go create mode 100644 internal/storage/tasks.go create mode 100644 internal/storage/tokens.go create mode 100644 internal/storage/users.go create mode 100644 internal/vcs/doc.go create mode 100644 internal/vcs/vcs.go create mode 100644 internal/web/account.go create mode 100644 internal/web/admin.go create mode 100644 internal/web/auth.go create mode 100644 internal/web/care_instructions.go create mode 100644 internal/web/collection_filters.go create mode 100644 internal/web/collection_filters_test.go create mode 100644 internal/web/context.go create mode 100644 internal/web/dashboard.go create mode 100644 internal/web/doc.go create mode 100644 internal/web/edit_flows_test.go create mode 100644 internal/web/efs.go create mode 100644 internal/web/flows_test.go create mode 100644 internal/web/gardens.go create mode 100644 internal/web/handlers.go create mode 100644 internal/web/handlers_test.go create mode 100644 internal/web/helpers.go create mode 100644 internal/web/images.go create mode 100644 internal/web/journal.go create mode 100644 internal/web/journal_test.go create mode 100644 internal/web/locations.go create mode 100644 internal/web/members.go create mode 100644 internal/web/middleware.go create mode 100644 internal/web/middleware_test.go create mode 100644 internal/web/page_names_test.go create mode 100644 internal/web/pagination.go create mode 100644 internal/web/pagination_test.go create mode 100644 internal/web/paths.go create mode 100644 internal/web/paths_test.go create mode 100644 internal/web/plant_locations.go create mode 100644 internal/web/plant_tasks.go create mode 100644 internal/web/plant_ui_test.go create mode 100644 internal/web/plants.go create mode 100644 internal/web/role_editor.go create mode 100644 internal/web/routes.go create mode 100644 internal/web/species.go create mode 100644 internal/web/static/css/cropper-1.6.2.min.css create mode 100644 internal/web/static/css/main.css create mode 100644 internal/web/static/css/toastui-editor-3.2.2.min.css create mode 100644 internal/web/static/icons/apple-touch-icon.png create mode 100644 internal/web/static/icons/gardomatic-192.png create mode 100644 internal/web/static/icons/gardomatic-512.png create mode 100644 internal/web/static/js/TOAST-UI-EDITOR-LICENSE.txt create mode 100644 internal/web/static/js/cropper-1.6.2.min.js create mode 100644 internal/web/static/js/htmx-2.0.10.min.js create mode 100644 internal/web/static/js/main.js create mode 100644 internal/web/static/js/toastui-editor-3.2.2.min.js create mode 100644 internal/web/static/js/toastui-editor-de-de-3.2.2.min.js create mode 100644 internal/web/static/manifest.manifest create mode 100644 internal/web/static/offline.html create mode 100644 internal/web/static/sw.js create mode 100644 internal/web/task_templates.go create mode 100644 internal/web/tasks.go create mode 100644 internal/web/tasks_test.go create mode 100644 internal/web/template_data.go create mode 100644 internal/web/templates.go create mode 100644 internal/web/templates/fragments/care_instructions.tmpl create mode 100644 internal/web/templates/fragments/location.tmpl create mode 100644 internal/web/templates/fragments/plant_tasks.tmpl create mode 100644 internal/web/templates/fragments/task_template.tmpl create mode 100644 internal/web/templates/layout/base.tmpl create mode 100644 internal/web/templates/pages/account.tmpl create mode 100644 internal/web/templates/pages/activate.tmpl create mode 100644 internal/web/templates/pages/admin.tmpl create mode 100644 internal/web/templates/pages/dashboard.tmpl create mode 100644 internal/web/templates/pages/email_confirm.tmpl create mode 100644 internal/web/templates/pages/error.tmpl create mode 100644 internal/web/templates/pages/garden_form.tmpl create mode 100644 internal/web/templates/pages/garden_new.tmpl create mode 100644 internal/web/templates/pages/gardens.tmpl create mode 100644 internal/web/templates/pages/healthcheck.tmpl create mode 100644 internal/web/templates/pages/home.tmpl create mode 100644 internal/web/templates/pages/images.tmpl create mode 100644 internal/web/templates/pages/invite.tmpl create mode 100644 internal/web/templates/pages/journal.tmpl create mode 100644 internal/web/templates/pages/journal_form.tmpl create mode 100644 internal/web/templates/pages/location_form.tmpl create mode 100644 internal/web/templates/pages/locations.tmpl create mode 100644 internal/web/templates/pages/plant.tmpl create mode 100644 internal/web/templates/pages/plant_form.tmpl create mode 100644 internal/web/templates/pages/plant_location_form.tmpl create mode 100644 internal/web/templates/pages/privacy.tmpl create mode 100644 internal/web/templates/pages/search.tmpl create mode 100644 internal/web/templates/pages/settings.tmpl create mode 100644 internal/web/templates/pages/signin.tmpl create mode 100644 internal/web/templates/pages/species.tmpl create mode 100644 internal/web/templates/pages/species_form.tmpl create mode 100644 internal/web/templates/pages/task_calendar.tmpl create mode 100644 internal/web/templates/pages/task_form.tmpl create mode 100644 internal/web/templates/pages/task_template_form.tmpl create mode 100644 internal/web/templates/pages/tasks.tmpl create mode 100644 internal/web/templates/partials/aside.tmpl create mode 100644 internal/web/templates/partials/footer.tmpl create mode 100644 internal/web/templates/partials/garden_fields.tmpl create mode 100644 internal/web/templates/partials/header.tmpl create mode 100644 internal/web/templates/partials/image_editor.tmpl create mode 100644 internal/web/templates/partials/journal_photo_editor.tmpl create mode 100644 internal/web/templates/partials/nav.tmpl create mode 100644 internal/web/templates/partials/pagination.tmpl create mode 100644 internal/web/templates/partials/role_editor.tmpl create mode 100644 internal/web/templates/partials/season_range.tmpl create mode 100644 internal/web/templates/partials/settings_nav.tmpl create mode 100644 internal/web/templates/partials/tag_editor.tmpl create mode 100644 internal/web/templates/partials/task_fields.tmpl create mode 100644 internal/web/templates/partials/view_toggle.tmpl create mode 100644 internal/web/templates_test.go create mode 100644 internal/web/testutils_test.go create mode 100644 internal/web/web.go create mode 100644 lib/client/account.go create mode 100644 lib/client/application_settings.go create mode 100644 lib/client/care_instructions.go create mode 100644 lib/client/client.go create mode 100644 lib/client/client_test.go create mode 100644 lib/client/context.go create mode 100644 lib/client/doc.go create mode 100644 lib/client/errors.go create mode 100644 lib/client/garden_members.go create mode 100644 lib/client/gardens.go create mode 100644 lib/client/gardens_test.go create mode 100644 lib/client/healthcheck.go create mode 100644 lib/client/images.go create mode 100644 lib/client/journal.go create mode 100644 lib/client/journal_test.go create mode 100644 lib/client/locations.go create mode 100644 lib/client/locations_test.go create mode 100644 lib/client/models.go create mode 100644 lib/client/plant_locations.go create mode 100644 lib/client/plants.go create mode 100644 lib/client/resources_test.go create mode 100644 lib/client/roles.go create mode 100644 lib/client/sessions.go create mode 100644 lib/client/species.go create mode 100644 lib/client/species_categories.go create mode 100644 lib/client/species_categories_test.go create mode 100644 lib/client/species_task_templates.go create mode 100644 lib/client/species_task_templates_test.go create mode 100644 lib/client/task_priorities.go create mode 100644 lib/client/task_template_opt_outs.go create mode 100644 lib/client/tasks.go create mode 100644 lib/client/tasks_test.go create mode 100644 lib/client/tokens.go create mode 100644 lib/client/users.go create mode 100644 lib/client/users_test.go create mode 100644 remote/production/api.service create mode 100755 remote/production/deploy-server.sh create mode 100644 remote/production/web.service create mode 100644 remote/setup/.env.example create mode 100755 remote/setup/create-admin.sh create mode 100755 remote/setup/provision-server.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5021857 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.env* +.envrc +config.mk +/bin +/dist +/remote +/request +*.key +*.pem +*.p12 +*.pfx +*.log +*.out +*.prof +coverage.* +**/__debug_bin* +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4380346 --- /dev/null +++ b/.env.example @@ -0,0 +1,70 @@ +# Copy this file to .env before starting the Compose stack. Do not commit .env. + +# PostgreSQL database and role name. Simple lowercase identifiers are recommended. +POSTGRES_DB=gardomatic +POSTGRES_USER=gardomatic +# Required, non-empty password. If it contains URI-reserved characters, use the +# percent-encoded form of the same password in GARDOMATIC_DB_DSN below. +POSTGRES_PASSWORD=change-me +# Host port published by Compose. Allowed: integer from 1 to 65535. +POSTGRES_PORT=5432 + +# Host ports published for API and web. Allowed: integers from 1 to 65535. +API_PORT=4000 +WEB_PORT=4040 + +# Runtime mode. Allowed: development, test, production. +GARDOMATIC_ENV=development +# Required PostgreSQL connection string. Supported form: +# postgres://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=MODE +GARDOMATIC_DB_DSN=postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable +# Connection-pool limits. Open must be at least 1; idle must be between 0 and open. +GARDOMATIC_DB_MAX_OPEN_CONNS=25 +GARDOMATIC_DB_MAX_IDLE_CONNS=25 +# Go duration, for example 30s, 15m or 1h30m. +GARDOMATIC_DB_MAX_IDLE_TIME=15m + +# Listener ports. Allowed: integers from 1 to 65535. +GARDOMATIC_API_PORT=4000 +GARDOMATIC_WEB_PORT=4040 +# Listener hosts. Empty means all available interfaces; otherwise use an IP address +# or resolvable hostname such as 127.0.0.1 or localhost. +GARDOMATIC_API_HOST= +GARDOMATIC_WEB_HOST= +# Absolute HTTP(S) URLs including scheme and host. These are addresses used by the +# applications, not listener addresses. +GARDOMATIC_API_BASE_URL=http://localhost:4000 +GARDOMATIC_WEB_BASE_URL=http://localhost:4040 + +# Non-empty cookie name shared by API and web. +GARDOMATIC_SESSION_COOKIE_NAME=gardomatic_session +# Go durations, for example 30m, 12h or 168h. Use positive values. +GARDOMATIC_SESSION_LIFETIME=12h +GARDOMATIC_SESSION_IDLE_TIMEOUT=30m +# Boolean. Accepted true values: 1, t, T, TRUE, true, True. Accepted false +# values: 0, f, F, FALSE, false, False. Must be true in production. +GARDOMATIC_COOKIE_SECURE=false + +# Boolean with the same accepted forms as GARDOMATIC_COOKIE_SECURE. +GARDOMATIC_RATE_LIMIT_ENABLED=true +# Positive requests per second; decimal values such as 0.5 are accepted. +GARDOMATIC_RATE_LIMIT_RPS=10 +# Positive integer defining the permitted request burst. +GARDOMATIC_RATE_LIMIT_BURST=40 +# Comma-separated absolute URLs; every entry requires a scheme and host. Empty +# disables cross-origin browser access. Use origins without paths, for example: +# https://garden.example.com,https://admin.example.com +GARDOMATIC_CORS_TRUSTED_ORIGINS=http://localhost:4040 + +# Delivery mode. Allowed: file or smtp. +GARDOMATIC_SMTP_MODE=file +# SMTP hostname and TCP port. Used only in smtp mode. +GARDOMATIC_SMTP_HOST= +GARDOMATIC_SMTP_PORT=25 +# SMTP credentials. Used only in smtp mode; keep the password in .env only. +GARDOMATIC_SMTP_USERNAME= +GARDOMATIC_SMTP_PASSWORD= +# Sender mailbox, for example gardomatic@example.com. +GARDOMATIC_SMTP_SENDER=gardomatic@localhost +# Writable absolute file path. Required only in file mode. +GARDOMATIC_SMTP_FILE_PATH=/tmp/gardomatic-mails.log diff --git a/.envrc.example b/.envrc.example new file mode 100644 index 0000000..ea6b2a5 --- /dev/null +++ b/.envrc.example @@ -0,0 +1,7 @@ +# Copy this file to .envrc. Make targets source it as a shell file; direnv can +# load it as well. Secrets may therefore be quoted normally for Bash. + +# Local development database. +export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' +# Required only for integration tests. Always use a disposable test database. +export GARDOMATIC_TEST_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic_test?sslmode=disable' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..003fc71 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Änderung + + + +## Prüfung + + + +- [ ] Betroffene Tests wurden ergänzt oder der Verzicht wurde begründet. +- [ ] `go test ./...` wurde ausgeführt oder ein Fehlschlag wurde dokumentiert. +- [ ] Dokumentation und Konfigurationsbeispiele wurden bei Bedarf aktualisiert. +- [ ] Der Beitrag enthält kein undeklariertes Fremdmaterial und keine Geheimnisse. + +## Contributor License Agreement + +- [ ] **I have read and agree to the Gardomatic Contributor License Agreement, + Version 1.0 (`CLA.md`), and I have the authority to grant the rights stated + in it for this contribution.** + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8fd29c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: gardomatic + POSTGRES_USER: gardomatic + POSTGRES_PASSWORD: gardomatic + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U gardomatic" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + env: + GARDOMATIC_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable + GARDOMATIC_TEST_DB_DSN: postgres://gardomatic:gardomatic@localhost:5432/gardomatic?sslmode=disable + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install migrate + run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest + - name: Migrate database + run: migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_DB_DSN" up + - name: Verify module and source + run: | + go mod tidy -diff + go vet ./... + - name: Test with race detector + run: go test -race -count=1 ./... + - name: Build all binaries + run: make build/all diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..bfd889a --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,22 @@ +name: CLA + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + acceptance: + name: Verify CLA acceptance + runs-on: ubuntu-latest + steps: + - name: Check pull request declaration + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + declaration='^- \[[xX]\] \*\*I have read and agree to the Gardomatic Contributor License Agreement,' + if ! grep -Eq "$declaration" <<< "$PR_BODY"; then + echo "The pull request author must accept CLA.md using the checkbox in the pull request template." + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97b5c5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Environment files and secrets +.env +.env.* +!.env.example +!.env.*.example +.envrc +/config.mk +*.key +*.pem +*.p12 +*.pfx +remote/production/gardomatic.env + +# Local HTTP requests which may contain credentials +request/*.local.http +request/*.private.http + +# Build and test artifacts +/bin/ +/dist/ +*.test +*.out +coverage.* +*.prof +**/__debug_bin* + +# Runtime files +*.log +/tmp/ + +# Local editor and operating-system files +.idea/ +.vscode/* +!.vscode/launch.json +.DS_Store +Thumbs.db diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b5d8a54 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Gardomatic: API", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/api", + "envFile": "${workspaceFolder}/.env", + "env": { + "GARDOMATIC_API_HOST": "127.0.0.1" + } + }, + { + "name": "Gardomatic: Web", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "${workspaceFolder}/cmd/web", + "envFile": "${workspaceFolder}/.env", + "env": { + "GARDOMATIC_WEB_HOST": "0.0.0.0" + } + } + ], + "compounds": [ + { + "name": "Gardomatic: API + Web", + "configurations": [ + "Gardomatic: API", + "Gardomatic: Web" + ], + "stopAll": true + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cf11b62 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# AGENTS.md + +Diese Anweisungen gelten für das gesamte Repository. Benutzeranweisungen haben +Vorrang. Spezifischere `AGENTS.md`- oder `AGENTS.override.md`-Dateien in +Unterverzeichnissen dürfen diese Regeln für ihren Bereich ergänzen oder +überschreiben. + +## Projektüberblick + +Gardomatic ist eine mobile, mehrbenutzerfähige Gartenverwaltung in Go. Das +Repository enthält drei Programme: + +- `cmd/api`: JSON-API; besitzt Authentifizierung, Autorisierung und Fachlogik. +- `cmd/web`: serverseitig gerenderte Webanwendung; greift ausschließlich über + `lib/client` auf die API zu. +- `cmd/cli`: Administration über direkten PostgreSQL-Zugriff. + +PostgreSQL ist die einzige unterstützte Datenbank. Das Web-Frontend soll in den +wesentlichen Abläufen ohne JavaScript funktionieren; htmx ergänzt diese Abläufe. + +## Arbeitsweise + +- Vor Änderungen zuerst den betroffenen Ablauf über alle Schichten verfolgen. + Relevante Implementierungen, Helfer und Tests mit `rg` suchen. +- Änderungen klein und auf den Auftrag begrenzt halten. Keine beiläufigen + Umbenennungen, Formatierungen oder Refactorings außerhalb des betroffenen + Bereichs durchführen. +- Bestehende, nicht zum Auftrag gehörende Änderungen im Arbeitsverzeichnis + erhalten und nicht überschreiben. +- Fachliche Änderungen als vollständigen Vertical Slice umsetzen, soweit + betroffen: Migration, Storage-Vertrag, PostgreSQL-Adapter, API, Client, Web und + Tests. +- Öffentliche Schnittstellen und persistierte Daten nur bewusst und + rückwärtskompatibel ändern. Unvermeidbare Brüche klar dokumentieren. + +## Struktur und Schichtengrenzen + +- `cmd/*` bleibt schlank und enthält nur Konfiguration, Verdrahtung und Startcode. +- Datenbankzugriffe gehören hinter die Verträge in `internal/storage`; konkrete + PostgreSQL-Implementierungen liegen in `internal/storage/postgres`. +- HTTP- und Berechtigungslogik der JSON-API gehört in `internal/api`. +- Wiederverwendbare API-Aufrufe des Web-Clients gehören in `lib/client`. +- `internal/web` greift nicht direkt auf PostgreSQL oder Storage-Modelle zu. +- Browserpfade werden zentral in `internal/web/paths.go` gepflegt; keine + verstreuten Pfad-Literale einführen. +- Templates, statische Dateien und Mailvorlagen in ihren bestehenden Verzeichnissen + ablegen und die vorhandenen Einbettungsmechanismen beibehalten. +- Neue Pakete nur bilden, wenn sie eine klare Verantwortung besitzen. Keine + Sammelpakete wie `utils`, `common` oder `helpers` neu einführen. + +## Idiomatischer Go-Code + +- Die in `go.mod` festgelegte Go-Version und Standardbibliothek bevorzugen. +- Geänderte Go-Dateien mit `gofmt` formatieren. Code muss `go vet` und + `staticcheck` bestehen. +- Kleine, fokussierte Funktionen, frühe Rückgaben und verständliche Namen + bevorzugen. Kommentare erklären das Warum, nicht den offensichtlichen Ablauf. +- Fehler mit hilfreichem Kontext und `%w` weiterreichen, wenn Aufrufer die Ursache + noch untersuchen sollen. Erwartbare Domänenfehler mit `errors.Is`/`errors.As` + behandeln. +- `context.Context` entlang bestehender Request- und Storage-Grenzen weitergeben; + keine neuen Hintergrundkontexte mitten in einem Request-Ablauf erzeugen. +- Abhängigkeiten explizit verdrahten. Globale veränderliche Zustände vermeiden. +- Neue Produktionsabhängigkeiten nur bei klarem Mehrwert einführen. Vorhandene + Standardbibliotheks- oder Projektlösungen bevorzugen und neue Abhängigkeiten in + der Übergabe begründen. + +## Keine Codeduplizierung + +- Vor dem Anlegen neuer Typen, Validatoren, Abfragen, Handler-Helfer, Clientmethoden + oder Template-Teile nach gleichartigem Code suchen. +- Gemeinsame fachliche Regeln an einer Stelle implementieren und von den + aufrufenden Schichten wiederverwenden. API und Web dürfen dieselbe Fachregel + nicht unabhängig voneinander nachbilden. +- Wiederholte SQL-Fragmente, Filter- und Paginglogik über die bereits vorhandenen + Storage-Helfer konsolidieren, sofern Semantik und Sicherheitsgrenzen identisch + sind. +- Ähnliche, aber fachlich unterschiedliche Abläufe nicht vorschnell abstrahieren. + Eine Abstraktion muss Namen, Verantwortung und Fehlerverhalten klarer machen. +- Beim Entfernen einer Duplikation alle Aufrufer migrieren und das alte Konstrukt + löschen, sobald es nicht mehr benötigt wird. + +## Fachliche und sicherheitsrelevante Regeln + +- Ein Garten ist eine abgeschlossene Daten- und Berechtigungsgrenze. Jede + gartenbezogene Abfrage muss die Garten-ID berücksichtigen. +- Fremde oder nicht sichtbare Garten-IDs liefern `404`; verbotene Aktionen in einem + sichtbaren Garten liefern `403`. +- Authentifizierung und Autorisierung verbleiben in der API. Das Web leitet das + Session-Cookie über einen request-spezifischen API-Client weiter. +- Rollen und Berechtigungen nicht durch reine UI-Prüfungen absichern. +- Artenstammdaten und konkrete Pflanzen getrennt halten; Pflanzen dürfen ohne Art + existieren. +- Automatisch erzeugte Aufgaben müssen idempotent bleiben. Fälligkeitsfenster und + Zeiträume über Jahresgrenzen hinweg korrekt behandeln. +- Nutzereingaben an der zuständigen Systemgrenze validieren. HTML-Ausgabe über + `html/template` escapen und bestehende CSRF-, Cookie- und Security-Header- + Mechanismen nicht umgehen. +- Keine Zugangsdaten, Tokens, echte personenbezogene Daten oder lokale `.env`- und + `.envrc`-Inhalte committen oder in Logs und Tests ausgeben. + +## Datenbank und Migrationen + +- Schemaänderungen ausschließlich als neues, fortlaufend nummeriertes Paar aus + `.up.sql` und `.down.sql` in `internal/storage/postgres/migrations` hinzufügen. +- Bereits eingecheckte Migrationen nicht nachträglich ändern, außer der Benutzer + fordert dies ausdrücklich und die Migration wurde nachweislich noch nirgends + angewendet. +- Migrationen müssen auf einer leeren Datenbank vorwärts laufen. Die Down-Migration + muss die Änderung soweit sinnvoll und sicher rückgängig machen. +- SQL parametrieren, Transaktionsgrenzen bewusst wählen und konkurrierende + Zugriffe berücksichtigen. +- Neue oder geänderte Abfragen durch PostgreSQL-Integrationstests abdecken, wenn + Verhalten nicht sinnvoll mit einem Unit-Test geprüft werden kann. + +## Tests + +- Jede Verhaltensänderung erhält passende Tests, sofern technisch möglich. Fehler- + und Berechtigungspfade gehören ebenso dazu wie der Erfolgsfall. +- Fehlerbehebungen möglichst zuerst mit einem Regressionstest reproduzieren. +- Tests nahe am getesteten Paket ablegen und vorhandene Testhelfer wiederverwenden. + Tabellengetriebene Tests nutzen, wenn mehrere gleichartige Fälle dadurch klarer + werden. +- Tests müssen deterministisch und voneinander unabhängig sein. Keine echten + Netzwerkdienste, Uhrzeit oder Zufallswerte unkontrolliert voraussetzen. +- Bestehende Tests nicht löschen, abschwächen oder überspringen, nur um einen Build + grün zu bekommen. +- Zuerst die direkt betroffenen Pakete testen, anschließend standardmäßig: + + ```sh + go test ./... + ``` + +- Vor Abschluss einer größeren oder sicherheitsrelevanten Änderung zusätzlich + ausführen: + + ```sh + make audit + ``` + +- PostgreSQL-Integrationstests nur mit einer ausdrücklich dafür vorgesehenen + Datenbank ausführen: + + ```sh + GARDOMATIC_TEST_DB_DSN="$TEST_DATABASE_URL" go test -count=1 ./internal/storage/postgres + ``` + + `make test/integration` migriert die in `GARDOMATIC_DB_DSN` konfigurierte + Datenbank. Niemals versehentlich gegen Produktion ausführen. + +## Dokumentation und Abschluss + +- README, Konfigurationsbeispiele und weiterführende Dokumentation aktualisieren, + wenn sich Setup, Befehle, Umgebungsvariablen oder Nutzerverhalten ändern. +- Für Beiträge Dritter gelten `CONTRIBUTING.md` und `CLA.md`. Die CLA-Checkbox oder + Zustimmung niemals stellvertretend für eine beitragende Person setzen. +- Den CLA-Workflow nicht umgehen, abschwächen oder auf einen grünen Status setzen, + wenn die nachweisbare Zustimmung der beitragenden Person fehlt. +- Kein Fremdmaterial übernehmen, dessen Herkunft, Lizenz oder Vereinbarkeit mit + der Projektlizenz und der CLA unklar ist. +- Rechtstexte (`LICENSE` und `CLA.md`) nur auf ausdrücklichen Auftrag ändern. +- Vor der Übergabe den Diff auf unnötige Änderungen, Duplikationen, Geheimnisse und + fehlende Tests prüfen. +- In der Übergabe geänderte Bereiche, ausgeführte Prüfungen und verbleibende Risiken + knapp nennen. Übersprungene Prüfungen mit Grund aufführen. diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..93f0faf --- /dev/null +++ b/CLA.md @@ -0,0 +1,182 @@ +# Gardomatic Contributor License Agreement + +Version 1.0, 10 September 2026 + +Thank you for your interest in contributing to Gardomatic. This Contributor +License Agreement (the "Agreement") documents the rights granted by contributors +to Alexander Klein ("We", "Us", or the "Project Owner"). It is based on the +Harmony Contributor License Agreement Template, Version 1.0. + +This is a legally binding agreement. Please read it carefully before submitting a +Contribution. + +## How to accept this Agreement + +For an individual Contribution, You accept this Agreement by submitting a pull +request and checking the CLA acceptance box included in the pull request template. +The pull request and its history provide the electronic record of acceptance. + +If You contribute on behalf of a Legal Entity, or if Your employer owns or may own +rights in the Contribution, an authorized representative must contact +alex@kleiax.de before the Contribution can be accepted. We may request a separately +signed entity agreement. + +Do not include a home address, identification document, or other unnecessary +personal information in a public pull request. + +## 1. Definitions + +"You" means the individual who Submits a Contribution to Us. When a Contribution +is made on behalf of a Legal Entity, "You" also means that Legal Entity and its +Affiliates where the context requires it. + +"Legal Entity" means an entity that is not a natural person. "Affiliates" means +other Legal Entities that control, are controlled by, or are under common control +with that Legal Entity. "Control" means (i) the direct or indirect power to direct +the management of an entity, whether by contract or otherwise, (ii) ownership of +fifty percent or more of the securities entitled to elect its management, or (iii) +beneficial ownership of the entity. + +"Contribution" means any work of authorship that is Submitted by You to Us and in +which You own or assert ownership of the Copyright. + +"Copyright" means all rights protecting works of authorship owned or controlled by +You or Your Affiliates, including copyright, moral, and neighboring rights, for +their full term and any extensions. + +"Material" means Gardomatic and its associated source code, documentation, media, +and other materials made available by Us to third parties. After You Submit a +Contribution, it may be included in the Material. + +"Submit" means any electronic, verbal, or written communication sent to Us or our +representatives through systems managed by or on behalf of Us for discussing or +improving the Material. This includes pull requests, commits, patches, issue +trackers, and code review comments. Communications conspicuously marked "Not a +Contribution" are excluded. + +"Submission Date" means the date on which You Submit a Contribution to Us. + +"Effective Date" means the date You accept this Agreement or the date You first +Submit a Contribution to Us, whichever is earlier. + +## 2. Grant of Rights + +### 2.1 Copyright License + +You retain ownership of the Copyright in Your Contribution and keep the same rights +to use or license the Contribution that You would have had without entering into +this Agreement. + +To the maximum extent permitted by applicable law, You grant to Us a perpetual, +worldwide, non-exclusive, transferable, royalty-free, and irrevocable license under +the Copyright covering the Contribution. This license includes the right to +sublicense through multiple tiers of sublicensees and to reproduce, modify, +prepare derivative works of, display, perform, distribute, and otherwise use the +Contribution as part of the Material, subject to Section 2.3. + +### 2.2 Patent License + +For patent claims, including method, process, and apparatus claims, that You or Your +Affiliates own, control, or have the right to grant now or in the future, You grant +to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, and +irrevocable patent license, with the right to sublicense through multiple tiers of +sublicensees, to make, have made, use, sell, offer for sale, import, and otherwise +transfer the Contribution and the Contribution in combination with the Material. + +This patent license is granted only to the extent that exercising the rights in the +Contribution would infringe those patent claims and is subject to Section 2.3. + +### 2.3 Outbound License + +If We include Your Contribution in the Material, We may license the Contribution +under any license, including copyleft, permissive, commercial, or proprietary +licenses. + +As a condition of exercising this right, We will also make the Contribution +available under the license or licenses that apply to the Material on the +Submission Date. At the time this Agreement was published, that license is the +PolyForm Noncommercial License 1.0.0. + +### 2.4 Moral Rights + +If moral rights apply to the Contribution, to the maximum extent permitted by law, +You waive and agree not to assert those rights against Us, our successors in +interest, or our direct or indirect licensees in connection with exercising the +rights granted by this Agreement. + +### 2.5 Our Rights + +We are not obligated to use, merge, publish, or continue to distribute any +Contribution. + +### 2.6 Reservation of Rights + +Any rights not expressly licensed under this Section 2 are reserved by You. + +## 3. Your Confirmations + +You confirm that: + +1. You have the legal authority to enter into this Agreement. +2. You or Your Affiliates own the Copyright and patent claims covering the + Contribution that are required to grant the rights in Section 2. +3. The rights granted under Section 2 do not violate rights previously granted to + third parties, including Your employer. +4. If You are an employee and Your employer owns or may own rights in the + Contribution, You have obtained its written approval or an authorized + representative has entered into an entity agreement with Us. +5. You have clearly identified any part of the Contribution that You did not author + and have provided its source and applicable license. You will not Submit + third-party material unless its license permits the Contribution and the rights + granted by this Agreement. +6. If You are under eighteen years old, Your parent or legal guardian has approved + Your acceptance of this Agreement. + +## 4. Disclaimer + +EXCEPT FOR THE EXPRESS CONFIRMATIONS IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS +IS". TO THE MAXIMUM EXTENT PERMITTED BY LAW, YOU DISCLAIM ALL EXPRESS OR IMPLIED +WARRANTIES, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, AND NON-INFRINGEMENT. IF A WARRANTY CANNOT BE DISCLAIMED, IT IS LIMITED TO +THE MINIMUM SCOPE AND DURATION PERMITTED BY LAW. + +## 5. Consequential Damage Waiver + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, NEITHER YOU NOR WE WILL BE LIABLE +TO THE OTHER UNDER THIS AGREEMENT FOR LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, +LOSS OF DATA, OR INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR EXEMPLARY +DAMAGES, REGARDLESS OF THE LEGAL THEORY ON WHICH THE CLAIM IS BASED. + +## 6. Miscellaneous + +1. This Agreement is governed by the laws of the Federal Republic of Germany, + excluding its conflict-of-law rules and the United Nations Convention on + Contracts for the International Sale of Goods. +2. This Agreement is the entire agreement between You and Us concerning Your + Contributions and replaces prior agreements or understandings about those + Contributions. +3. If You or We transfer rights or obligations received under this Agreement to a + third party, that third party must agree in writing to comply with the relevant + rights and obligations of this Agreement. +4. A failure to require performance of a provision once does not waive the right to + require it later. +5. If a provision is found invalid or unenforceable, it will be replaced to the + extent possible by an enforceable provision that most closely reflects its + purpose. The remaining provisions continue in effect. +6. We may publish a new version of this Agreement for future Contributions. A new + version does not retroactively change the terms governing Contributions already + Submitted under an earlier version. + +## Template attribution + +This Agreement is adapted from the Harmony Contributor Agreement Template, +Version 1.0, published by Harmony Agreements under the Creative Commons Attribution +3.0 Unported License: + + + +The template has been customized for Gardomatic by selecting a contributor license +(not a copyright assignment), selecting Harmony outbound license Option Five, +naming the parties and project, defining electronic acceptance, choosing German +law, and removing unused template alternatives and placeholders. Harmony +Agreements does not endorse this adaptation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2bd6762 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Zu Gardomatic beitragen + +Vielen Dank für dein Interesse an Gardomatic. Beiträge sollen klein, +nachvollziehbar und mit der bestehenden Architektur vereinbar sein. + +## Vor dem ersten Beitrag + +1. Lies die Projektbeschreibung und den Entwicklungsablauf in der + [`README.md`](README.md). +2. Beachte die verbindlichen technischen Regeln in [`AGENTS.md`](AGENTS.md). +3. Lies die [`Contributor License Agreement`](CLA.md). Sie ist eine rechtlich + bindende Vereinbarung. + +Mit der CLA behältst du das Copyright an deinem Beitrag. Du erlaubst Alexander +Klein zugleich, den Beitrag innerhalb von Gardomatic unter der öffentlichen +PolyForm-Lizenz sowie unter separaten kommerziellen oder proprietären Lizenzen zu +verwenden. Das ermöglicht das in diesem Projekt vorgesehene Dual-Licensing-Modell. + +### CLA annehmen + +Einzelpersonen nehmen Version 1.0 der CLA an, indem sie einen Pull Request mit der +unveränderten CLA-Erklärung aus der Pull-Request-Vorlage einreichen und die +zugehörige Checkbox selbst markieren. Die Annahme gilt auch für spätere Beiträge +unter derselben CLA-Version. + +Der Workflow `.github/workflows/cla.yml` prüft diese Erklärung bei jedem Pull +Request. Ein fehlgeschlagener CLA-Check darf nicht umgangen oder administrativ als +Ersatz für die Zustimmung der beitragenden Person bestätigt werden. + +Beiträge im Namen eines Unternehmens oder einer anderen Organisation müssen vorab +mit `alex@kleiax.de` abgestimmt werden. Dasselbe gilt, wenn dein Arbeitgeber Rechte +an deinem Beitrag besitzen könnte. Eine Projektperson darf die CLA-Erklärung nicht +stellvertretend für Beitragende markieren. + +## Entwicklungsablauf + +1. Erstelle einen fokussierten Branch und beschreibe das zu lösende Problem. +2. Suche vor der Implementierung nach vorhandenen Helfern und ähnlichen Abläufen. +3. Halte die Schichtengrenzen zwischen API, Storage, Client und Web ein. +4. Ergänze Tests für neues oder korrigiertes Verhalten, soweit technisch möglich. +5. Formatiere geänderten Go-Code mit `gofmt`. +6. Führe zunächst die betroffenen Tests und anschließend `go test ./...` aus. +7. Führe bei größeren Änderungen zusätzlich `make audit` aus. +8. Erläutere im Pull Request Verhalten, Motivation, Tests und mögliche Risiken. + +Für Datenbankänderungen ist ein neues Up-/Down-Migrationspaar erforderlich. +Bereits veröffentlichte Migrationen dürfen nicht nachträglich verändert werden. + +## Pull Requests + +Ein Pull Request sollte: + +- genau ein zusammenhängendes Problem lösen, +- keine unbeabsichtigten oder fachfremden Änderungen enthalten, +- vorhandene Tests nicht abschwächen, +- geändertes Nutzerverhalten und Konfiguration dokumentieren, +- alle verwendeten Quellen und Fremdmaterialien mit ihrer Lizenz offenlegen und +- die CLA-Erklärung enthalten. + +Ein Beitrag kann abgelehnt oder bis zur Klärung zurückgestellt werden. Das gilt +insbesondere bei fehlender CLA-Annahme, unklaren Rechten, Sicherheitsproblemen, +fehlenden Tests oder Änderungen außerhalb des vereinbarten Umfangs. + +## Fremdmaterial und KI-gestützte Beiträge + +Reiche nur Material ein, an dem du die erforderlichen Rechte besitzt. Kopiere +keinen Code, keine Texte, Bilder oder anderen Inhalte aus inkompatibel lizenzierten +Quellen. Kennzeichne Fremdmaterial und nenne Quelle sowie Lizenz. + +Für KI-gestützte Beiträge bleibst du selbst verantwortlich. Prüfe den erzeugten +Inhalt auf Korrektheit, Sicherheit, Herkunft und mögliche Lizenzkonflikte, bevor du +ihn einreichst. + +## Sicherheitsprobleme + +Noch nicht veröffentlichte Sicherheitslücken sollten nicht als öffentlicher Issue +oder Pull Request eingereicht werden. Melde sie zunächst vertraulich an +`alex@kleiax.de` und füge keine echten Zugangsdaten oder personenbezogenen Daten +bei. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9398062 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM docker.io/library/golang:1.26-alpine AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +FROM build AS api-build +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +FROM build AS web-build +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/web ./cmd/web + +FROM docker.io/library/alpine:3.22 AS runtime + +RUN apk add --no-cache ca-certificates \ + && addgroup -S gardomatic \ + && adduser -S -G gardomatic gardomatic + +WORKDIR /app +USER gardomatic + +FROM runtime AS api +COPY --from=api-build /out/api /app/api +EXPOSE 4000 +ENTRYPOINT ["/app/api"] + +FROM runtime AS web +COPY --from=web-build /out/web /app/web +EXPOSE 4040 +ENTRYPOINT ["/app/web"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8e6626 --- /dev/null +++ b/LICENSE @@ -0,0 +1,136 @@ +# PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, +or government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. + +Required Notice: Copyright © 2026 Alexander Klein. + +For permission to use Gardomatic commercially, contact +Alexander Klein at alex@kleiax.de to obtain a separate commercial license. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e95c09 --- /dev/null +++ b/Makefile @@ -0,0 +1,197 @@ +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c +.DEFAULT_GOAL := help +.DELETE_ON_ERROR: + +# Non-secret deployment settings. Runtime secrets remain in the shell-compatible +# .envrc and remote/setup/.env files instead of being parsed by Make. +-include config.mk + +define load_envrc +set -a; [[ ! -f ./.envrc ]] || source ./.envrc; set +a; +endef + +# ==================================================================================== # +# HELPERS +# ==================================================================================== # + +## help: print this help message +.PHONY: help +help: + @echo 'Usage:' + @sed -n 's/^##//p' $(firstword $(MAKEFILE_LIST)) | column -t -s ':' | sed -e 's/^/ /' + +.PHONY: confirm +confirm: + @read -r -p "Are you sure? [y/N] " answer && [[ "$${answer:-N}" == y ]] + +## config/init: create missing local configuration files from their examples +.PHONY: config/init +config/init: + @if [[ -e ./.env ]]; then echo 'keep .env'; else install -m 0600 ./.env.example ./.env; echo 'create .env'; fi + @if [[ -e ./.envrc ]]; then echo 'keep .envrc'; else install -m 0600 ./.envrc.example ./.envrc; echo 'create .envrc'; fi + @if [[ -e ./config.mk ]]; then echo 'keep config.mk'; else install -m 0600 ./config.mk.example ./config.mk; echo 'create config.mk'; fi + @if [[ -e ./remote/setup/.env ]]; then echo 'keep remote/setup/.env'; else install -m 0600 ./remote/setup/.env.example ./remote/setup/.env; echo 'create remote/setup/.env'; fi + +# ==================================================================================== # +# DEVELOPMENT +# ==================================================================================== # + +## run/api: run the cmd/api application +.PHONY: run/api +run/api: + @${load_envrc} go run ./cmd/api + +## run/web: run the cmd/web application +.PHONY: run/web +run/web: + @${load_envrc} go run ./cmd/web + +## run/cli: run the cmd/cli administration tool (use ARGS='...') +.PHONY: run/cli +run/cli: + @${load_envrc} go run ./cmd/cli ${ARGS} + +## db/psql: connect to the database using psql +.PHONY: db/psql +db/psql: + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; psql "$$GARDOMATIC_DB_DSN" + +## db/migrations/new name=$1: create a new database migration +.PHONY: db/migrations/new +db/migrations/new: + @[[ "${name}" =~ ^[a-z0-9_]+$$ ]] || { echo 'name must contain lowercase letters, digits, or underscores' >&2; exit 1; } + migrate create -seq -ext=.sql -dir=./internal/storage/postgres/migrations "${name}" + +## db/migrations/up: apply all up database migrations +.PHONY: db/migrations/up +db/migrations/up: + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" up + +## db/migrations/down: apply all down database migrations +.PHONY: db/migrations/down +db/migrations/down: confirm + @${load_envrc} test -n "$${GARDOMATIC_DB_DSN:-}" || { echo 'GARDOMATIC_DB_DSN is required' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_DB_DSN" down + +# ==================================================================================== # +# QUALITY CONTROL +# ==================================================================================== # + +## tidy: tidy module dependencies and format all Go files +.PHONY: tidy +tidy: + go mod tidy + go mod verify + go fmt ./... + +## audit: run quality control checks +.PHONY: audit +audit: + go mod tidy -diff + go mod verify + go vet ./... + go tool staticcheck ./... + go test -race -vet=off ./... + +## test/integration: migrate the configured PostgreSQL database and run integration tests +.PHONY: test/integration +test/integration: + @${load_envrc} test -n "$${GARDOMATIC_TEST_DB_DSN:-}" || { echo 'GARDOMATIC_TEST_DB_DSN is required and must point to a disposable test database' >&2; exit 1; }; migrate -path ./internal/storage/postgres/migrations -database "$$GARDOMATIC_TEST_DB_DSN" up; go test -count=1 ./internal/storage/postgres + +# ==================================================================================== # +# BUILD +# ==================================================================================== # + +.PHONY: build/dirs +build/dirs: + mkdir -p ./bin ${PRODUCTION_BUILD_DIR} + +## build/api: build the cmd/api application +.PHONY: build/api +build/api: build/dirs + go build -ldflags='-s' -o=./bin/api ./cmd/api + +## build/web: build the cmd/web application +.PHONY: build/web +build/web: build/dirs + go build -ldflags='-s' -o=./bin/web ./cmd/web + +## build/cli: build the cmd/cli administration tool +.PHONY: build/cli +build/cli: build/dirs + go build -ldflags='-s' -o=./bin/cli ./cmd/cli + +## build/production: cross-compile all production binaries +.PHONY: build/production +build/production: build/dirs + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/api ./cmd/api + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/web ./cmd/web + GOOS=${PRODUCTION_GOOS} GOARCH=${PRODUCTION_GOARCH} go build -trimpath -ldflags='-s -w' -o=${PRODUCTION_BUILD_DIR}/cli ./cmd/cli + +## code/stats: print source lines and their estimated number of book pages +.PHONY: code/stats +code/stats: + @lines=$$(git ls-files -z -- '*.go' '*.tmpl' '*.html' '*.css' '*.js' '*.sql' '*.sh' \ + ':(exclude)vendor/**' ':(exclude)**/*.min.css' ':(exclude)**/*.min.js' \ + | xargs -0 awk 'END { print NR }'); \ + pages=$$((($$lines + 49) / 50)); \ + printf 'Codeumfang: %s Zeilen\nBuchumfang: ca. %s Seiten (bei 50 Codezeilen pro Seite)\n' "$$lines" "$$pages" + +## build/all: build API, web, and administration CLI, then print code statistics +.PHONY: build/all +build/all: build/api build/web build/cli + @$(MAKE) --no-print-directory code/stats + +# ==================================================================================== # +# PRODUCTION +# ==================================================================================== # + +PRODUCTION_HOST ?= +PRODUCTION_SSH_USER ?= root +PRODUCTION_SSH_PORT ?= 22 +PRODUCTION_SSH_IDENTITY_FILE ?= +PRODUCTION_GOOS ?= linux +PRODUCTION_GOARCH ?= amd64 +PRODUCTION_BUILD_DIR = ./bin/${PRODUCTION_GOOS}_${PRODUCTION_GOARCH} + +production_target = ${PRODUCTION_SSH_USER}@${PRODUCTION_HOST} +production_ssh_options = -p ${PRODUCTION_SSH_PORT} +ifneq ($(strip ${PRODUCTION_SSH_IDENTITY_FILE}),) +production_ssh_options += -i ${PRODUCTION_SSH_IDENTITY_FILE} +endif + +.PHONY: production/check-config +production/check-config: + @test -n "${PRODUCTION_HOST}" || { echo 'PRODUCTION_HOST is required; configure it in config.mk' >&2; exit 1; } + @test -n "${PRODUCTION_SSH_USER}" || { echo 'PRODUCTION_SSH_USER is required' >&2; exit 1; } + @[[ "${PRODUCTION_SSH_PORT}" =~ ^[0-9]+$$ ]] && (( ${PRODUCTION_SSH_PORT} >= 1 && ${PRODUCTION_SSH_PORT} <= 65535 )) || { echo 'PRODUCTION_SSH_PORT must be between 1 and 65535' >&2; exit 1; } + @[[ "${PRODUCTION_GOOS}" == linux ]] || { echo 'PRODUCTION_GOOS must be linux' >&2; exit 1; } + @[[ "${PRODUCTION_GOARCH}" == amd64 || "${PRODUCTION_GOARCH}" == arm64 ]] || { echo 'PRODUCTION_GOARCH must be amd64 or arm64' >&2; exit 1; } + @if [[ -n "${PRODUCTION_SSH_IDENTITY_FILE}" && ! -f "${PRODUCTION_SSH_IDENTITY_FILE}" ]]; then echo 'PRODUCTION_SSH_IDENTITY_FILE does not exist' >&2; exit 1; fi + +## production/connect: connect to the production server +.PHONY: production/connect +production/connect: production/check-config + ssh ${production_ssh_options} ${production_target} + +## production/provision: provision a fresh Ubuntu server (root or passwordless sudo required) +.PHONY: production/provision +production/provision: production/check-config + @test -f ./remote/setup/.env || { echo 'Copy remote/setup/.env.example to remote/setup/.env and configure it first' >&2; exit 1; } + @permissions=$$(stat -c '%a' ./remote/setup/.env); (( (8#$$permissions & 077) == 0 )) || { echo 'remote/setup/.env must have mode 0600' >&2; exit 1; } + rsync -P -e "ssh ${production_ssh_options}" ./remote/setup/provision-server.sh ${production_target}:/tmp/gardomatic-provision-server.sh + ssh ${production_ssh_options} ${production_target} 'status=0; chmod 700 /tmp/gardomatic-provision-server.sh && /tmp/gardomatic-provision-server.sh --env-file - || status=$$?; rm -f /tmp/gardomatic-provision-server.sh; exit $$status' < ./remote/setup/.env + +## production/deploy: deploy API and web to production +.PHONY: production/deploy +production/deploy: production/check-config build/production + ssh ${production_ssh_options} ${production_target} 'mkdir -p "$$HOME/gardomatic-deploy/migrations"' + rsync -P -e "ssh ${production_ssh_options}" ${PRODUCTION_BUILD_DIR}/api ${PRODUCTION_BUILD_DIR}/web ${PRODUCTION_BUILD_DIR}/cli ${production_target}:gardomatic-deploy/ + rsync -rP --delete -e "ssh ${production_ssh_options}" ./internal/storage/postgres/migrations/ ${production_target}:gardomatic-deploy/migrations/ + rsync -P -e "ssh ${production_ssh_options}" ./remote/production/api.service ./remote/production/web.service ./remote/production/deploy-server.sh ./remote/setup/create-admin.sh ${production_target}:gardomatic-deploy/ + ssh -t ${production_ssh_options} ${production_target} 'chmod 700 "$$HOME/gardomatic-deploy/deploy-server.sh" && "$$HOME/gardomatic-deploy/deploy-server.sh"' + +## production/create-admin: interactively create an active application administrator +.PHONY: production/create-admin +production/create-admin: production/check-config + ssh -t ${production_ssh_options} ${production_target} /usr/local/sbin/gardomatic-create-admin diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aedef0 --- /dev/null +++ b/README.md @@ -0,0 +1,456 @@ +# Gardomatic + +Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur gemeinsamen +Organisation von Gärten. Sie bündelt Pflanzenwissen, Pflanzorte, anstehende +Arbeiten, Bilder und ein Gartentagebuch in einer Anwendung. + +Das Projekt besteht aus einer JSON-API und einer serverseitig gerenderten +Webanwendung in Go. PostgreSQL speichert alle Fachdaten. Das Frontend ist +mobile-first, bleibt in den wesentlichen Abläufen ohne JavaScript nutzbar und +verwendet htmx für komfortable Teilaktualisierungen. Eine installierbare PWA stellt +eine Offline-App-Shell bereit; die eigentlichen Gartendaten bleiben serverseitig. + +> **Projektstatus:** Gardomatic wird aktiv entwickelt. Datenmodell, API und +> Bedienoberfläche können sich noch verändern. + +## Funktionen + +- mehrere voneinander isolierte Gärten pro Benutzer +- gemeinsame Gartenpflege mit Einladungen, Rollen und Berechtigungen +- Verwaltung von Arten, Sorten, konkreten Pflanzen und Pflanzorten +- Pflegehinweise, Kategorien, Tags und Bilder +- Aufgaben mit Fälligkeitsfenstern, Prioritäten und Wiederholungen +- Aufgabenvorlagen pro Art und automatische, idempotente Aufgabenerzeugung +- Aufgabenansicht und Kalenderdarstellung +- Gartentagebuch, Pinnwand und Bildbibliothek +- Benutzerkonto, Aktivierung, Sitzungsverwaltung und Passwortänderung +- administrative Einladung, Anonymisierung und Verwaltung von Benutzern, Rollen und Anwendungseinstellungen inklusive maskierter Laufzeitkonfiguration und Testmailversand +- Kommandozeilenwerkzeug zur Benutzer- und Datenbankadministration + +## Architektur + +```text +Browser + │ + ▼ +Webanwendung :4040 ──► typisierter API-Client + │ + ▼ + JSON-API :4000 + │ + ▼ + Storage-Schnittstellen + │ + ▼ + PostgreSQL 16 +``` + +Die API ist die maßgebliche Sicherheits- und Fachgrenze. Sie verwaltet +Authentifizierung, Sitzungen, Autorisierung, Validierung und Datenzugriff. Die +Webanwendung greift nicht direkt auf die Datenbank zu, sondern leitet das +Session-Cookie eines Requests über `lib/client` an die API weiter. + +Gartenbezogene Browserrouten beginnen mit `/g/{gardenID}/`, die entsprechenden +API-Ressourcen mit `/v1/gardens/{gardenID}/`. Ein Garten bildet eine abgeschlossene +Daten- und Berechtigungsgrenze. + +### Programme + +| Programm | Aufgabe | Standardadresse | +| --- | --- | --- | +| `cmd/api` | JSON-API und Fachlogik | `http://localhost:4000` | +| `cmd/web` | serverseitig gerenderte Weboberfläche | `http://localhost:4040` | +| `cmd/cli` | Administration direkt über PostgreSQL | keine | + +## Voraussetzungen + +Für den vollständigen lokalen Betrieb werden benötigt: + +- Go gemäß der Version in `go.mod` (aktuell Go 1.26 oder neuer) +- PostgreSQL 16 +- optional Docker oder Podman mit Compose-Unterstützung +- [`golang-migrate`](https://github.com/golang-migrate/migrate) für lokale + Migrationen und Integrationstests +- `make` für die bereitgestellten Entwicklungsbefehle + +Die Compose-Variante bringt PostgreSQL und `golang-migrate` bereits als Container +mit. Go wird dort nur benötigt, wenn das Administrations-CLI auf dem Host verwendet +werden soll. + +## Schnellstart mit Compose + +1. Konfiguration anlegen und mindestens das Datenbankpasswort ändern: + + ```sh + cp .env.example .env + ``` + + `POSTGRES_PASSWORD` und das Passwort in `GARDOMATIC_DB_DSN` sollten + übereinstimmen, damit auch lokale CLI- und Testbefehle dieselbe Datenbank + erreichen können. `.env` ist von Git ausgeschlossen. + +2. Datenbank, Migrationen, API und Webanwendung starten: + + ```sh + docker compose up --build + ``` + + Bei Podman kann je nach Installation stattdessen `podman compose` oder + `podman-compose` verwendet werden. + +3. Einen ersten aktiven Benutzer anlegen. Dazu in einem zweiten Terminal die DSN + aus der lokalen `.env` setzen und das CLI starten: + + ```sh + export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' + go run ./cmd/cli users create \ + --name 'Admin' \ + --email 'admin@example.com' \ + --role application:admin \ + --active \ + --generate-password + ``` + + Das generierte Passwort wird einmalig ausgegeben. + +4. [http://localhost:4040](http://localhost:4040) öffnen und anmelden. + +Die API-Gesundheitsprüfung ist unter +[http://localhost:4000/v1/healthcheck](http://localhost:4000/v1/healthcheck), die +Web-Gesundheitsprüfung unter [http://localhost:4040/ping](http://localhost:4040/ping) +erreichbar. Eine lesbare Statusseite mit API- und Serverinformationen steht unter +[http://localhost:4040/healtcheck](http://localhost:4040/healtcheck) bereit. + +Den Stack beendet `docker compose down`. Die PostgreSQL-Daten liegen im benannten +Volume `gardomatic-postgres-data` und bleiben dabei erhalten. `docker compose down +-v` löscht dieses Volume und damit die lokale Datenbank dauerhaft. + +## Lokale Entwicklung ohne Anwendungscontainer + +PostgreSQL und die Migrationen können weiterhin über Compose laufen: + +```sh +cp .env.example .env +docker compose up -d postgres migrate +``` + +Der `Makefile` bindet eine lokale, nicht versionierte `.envrc` ein. Mindestens die +Datenbankverbindung muss darin für API-, CLI- und Datenbankbefehle exportiert sein: + +```sh +export GARDOMATIC_DB_DSN='postgres://gardomatic:change-me@localhost:5432/gardomatic?sslmode=disable' +``` + +Danach API und Webanwendung in getrennten Terminals starten: + +```sh +make run/api +``` + +```sh +make run/web +``` + +Alternativ können beide Programme direkt mit `go run ./cmd/api` und +`go run ./cmd/web` gestartet werden, sofern die benötigten Umgebungsvariablen in +der Shell gesetzt sind. Die API benötigt zwingend `GARDOMATIC_DB_DSN`; alle +anderen Entwicklungswerte besitzen sinnvolle Standardwerte. + +## Konfiguration + +`.env.example` dokumentiert eine vollständige lokale Konfiguration. Geheimnisse +gehören ausschließlich in `.env`, `.envrc`, einen Secret Store oder die +Produktionsumgebung und dürfen nicht eingecheckt werden. + +### Compose-Variablen + +| Variable | Standard/Beispiel | Beschreibung | +| --- | --- | --- | +| `POSTGRES_DB` | `gardomatic` | Datenbankname des PostgreSQL-Containers | +| `POSTGRES_USER` | `gardomatic` | Datenbankbenutzer des Containers | +| `POSTGRES_PASSWORD` | `change-me` | Datenbankpasswort; lokal unbedingt ändern | +| `POSTGRES_PORT` | `5432` | auf dem Host veröffentlichter PostgreSQL-Port | +| `API_PORT` | `4000` | auf dem Host veröffentlichter API-Port | +| `WEB_PORT` | `4040` | auf dem Host veröffentlichter Web-Port | + +### Laufzeitvariablen + +| Variable | Standard | Verwendung | +| --- | --- | --- | +| `GARDOMATIC_ENV` | `development` | `development`, `test` oder `production` | +| `GARDOMATIC_DB_DSN` | erforderlich | PostgreSQL-Verbindungszeichenfolge für API und CLI | +| `GARDOMATIC_DB_MAX_OPEN_CONNS` | `25` | maximale offene DB-Verbindungen | +| `GARDOMATIC_DB_MAX_IDLE_CONNS` | `25` | maximale ungenutzte DB-Verbindungen | +| `GARDOMATIC_DB_MAX_IDLE_TIME` | `15m` | maximale Leerlaufzeit einer DB-Verbindung | +| `GARDOMATIC_API_HOST` | leer | Bind-Adresse der API | +| `GARDOMATIC_API_PORT` | `4000` | Listener-Port der API | +| `GARDOMATIC_WEB_HOST` | leer | Bind-Adresse der Webanwendung | +| `GARDOMATIC_WEB_PORT` | `4040` | Listener-Port der Webanwendung | +| `GARDOMATIC_API_BASE_URL` | `http://localhost:4000` | API-Adresse für den Web-Client | +| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | öffentliche Basis-URL für Links aus API und CLI | +| `GARDOMATIC_SESSION_COOKIE_NAME` | `gardomatic_session` | gemeinsamer Name des Session-Cookies | +| `GARDOMATIC_SESSION_LIFETIME` | `12h` | absolute Lebensdauer einer Sitzung | +| `GARDOMATIC_SESSION_IDLE_TIMEOUT` | `30m` | Ablaufzeit bei Inaktivität | +| `GARDOMATIC_COOKIE_SECURE` | `false` | nur HTTPS-Cookies; in Produktion zwingend `true` | +| `GARDOMATIC_RATE_LIMIT_ENABLED` | `true` | API-Ratenbegrenzung aktivieren | +| `GARDOMATIC_RATE_LIMIT_RPS` | `10` | erlaubte Requests pro Sekunde | +| `GARDOMATIC_RATE_LIMIT_BURST` | `40` | kurzfristig erlaubte Request-Spitze | +| `GARDOMATIC_CORS_TRUSTED_ORIGINS` | leer/lokal gesetzt | kommaseparierte erlaubte Origins | + +### E-Mail-Versand + +| Variable | Standard | Beschreibung | +| --- | --- | --- | +| `GARDOMATIC_SMTP_MODE` | `file` | `file` für Entwicklung oder `smtp` | +| `GARDOMATIC_SMTP_HOST` | leer | SMTP-Server | +| `GARDOMATIC_SMTP_PORT` | `25` | SMTP-Port | +| `GARDOMATIC_SMTP_USERNAME` | leer | SMTP-Benutzername | +| `GARDOMATIC_SMTP_PASSWORD` | leer | SMTP-Passwort | +| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Absenderadresse | +| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Ausgabe im `file`-Modus | + +Im Entwicklungsmodus schreibt der voreingestellte `file`-Mailer E-Mails in die +angegebene Datei. In der Compose-API liegt diese Datei innerhalb des Containers; +sie kann beispielsweise mit `docker compose exec api cat +/tmp/gardomatic-mails.log` gelesen werden. + +## Datenbankmigrationen + +Migrationen liegen als fortlaufend nummerierte Up-/Down-Paare in +`internal/storage/postgres/migrations`. + +Eine neue Migration anlegen: + +```sh +make db/migrations/new name=describe_change +``` + +Alle ausstehenden Migrationen anwenden: + +```sh +make db/migrations/up +``` + +Alle angewendeten Migrationen zurücknehmen: + +```sh +make db/migrations/down +``` + +`migrate down` ohne Schrittzahl setzt das gesamte Schema zurück und kann sämtliche +Anwendungsdaten löschen. Die Make-Ziele verwenden `GARDOMATIC_DB_DSN`; vor Up- und +besonders Down-Befehlen daher immer prüfen, auf welche Datenbank die Variable zeigt. +Bereits veröffentlichte Migrationen sollten nicht verändert werden; +Schemaänderungen erhalten eine neue Migration. + +## Tests und Qualitätsprüfungen + +Alle Unit- und Handler-Tests ausführen: + +```sh +go test ./... +``` + +Die PostgreSQL-Integrationstests werden ohne `GARDOMATIC_TEST_DB_DSN` automatisch +übersprungen. Für einen vollständigen Integrationslauf eine separate Testdatenbank +konfigurieren und zuerst migrieren: + +```sh +export GARDOMATIC_TEST_DB_DSN='postgres://user:password@localhost/gardomatic_test?sslmode=disable' +migrate -path ./internal/storage/postgres/migrations -database "$GARDOMATIC_TEST_DB_DSN" up +go test -count=1 ./internal/storage/postgres +``` + +Alternativ migriert folgendes Ziel die in `GARDOMATIC_DB_DSN` konfigurierte +Datenbank und führt die Integrationstests dagegen aus: + +```sh +make test/integration +``` + +Dieses Ziel ausschließlich mit einer entbehrlichen Testdatenbank verwenden. + +Der vollständige lokale Qualitätslauf umfasst Modulprüfung, `go vet`, +`staticcheck` und Tests mit Race Detector: + +```sh +make audit +``` + +Quelltext modernisieren und formatieren: + +```sh +make tidy +``` + +`make tidy` kann `go.mod`, `go.sum`, den Vendor-Bestand und Go-Quelltext verändern. +Den resultierenden Diff deshalb immer prüfen. + +Die CI unter `.github/workflows/ci.yml` ist dafür konfiguriert, PostgreSQL zu +migrieren, Module und Quelltext zu prüfen, alle Tests mit Race Detector auszuführen +und alle drei Programme zu bauen. + +## Administration mit dem CLI + +Das CLI benötigt für Datenbankbefehle `GARDOMATIC_DB_DSN`. Globale Optionen stehen +vor dem Unterbefehl. Mit `--json` liefert es maschinenlesbare Ausgabe; `--yes` +bestätigt bewusst konfigurierte Produktionsoperationen. + +Häufige Befehle: + +```sh +go run ./cmd/cli users list +go run ./cmd/cli users show --email alice@example.com +go run ./cmd/cli users invite --email alice@example.com --send-email +go run ./cmd/cli users activate --email alice@example.com +go run ./cmd/cli users deactivate --email alice@example.com +go run ./cmd/cli users reset-password --email alice@example.com --generate-password +go run ./cmd/cli db ping +``` + +Passwörter werden absichtlich nicht als Kommandozeilenargument angenommen. Sie +werden sicher abgefragt, generiert oder mit `--password-stdin` von der +Standardeingabe gelesen. Eine ausführliche Referenz mit Beispielen enthält +[`doc/cli.md`](doc/cli.md). + +## Build + +Einzelne Programme für das lokale System und Linux/AMD64 bauen: + +```sh +make build/api +make build/web +make build/cli +``` + +Alle Programme bauen: + +```sh +make build/all +``` + +Artefakte landen unter `bin/` und werden nicht versioniert. Der `Dockerfile` +enthält getrennte, minimale Laufzeit-Targets für API und Web. + +## Projektstruktur + +```text +cmd/ + api/ Start und Konfiguration der JSON-API + web/ Start und Konfiguration der Webanwendung + cli/ Administrations-CLI +internal/ + api/ API-Handler, Middleware und Fachabläufe + auth/ Passwort- und Tokenfunktionen + mailer/ Mailversand und Vorlagen + platform/ kleine technische Basispakete + storage/ Modelle und Storage-Schnittstellen + postgres/ PostgreSQL-Implementierungen und Migrationen + web/ Handler, Templates und statische Assets +lib/ + client/ typisierter Go-Client für die JSON-API +doc/ Planung und weiterführende Dokumentation +remote/ Produktionsbeispiele für systemd und Caddy +request/ manuelle HTTP-Beispielanfragen +``` + +## Entwicklungskonventionen + +Bei Änderungen sind insbesondere folgende Grundsätze verbindlich: + +- idiomatischer, mit `gofmt` formatierter Go-Code +- klare Verantwortlichkeiten und bestehende Schichtengrenzen +- Wiederverwendung vorhandener Helfer und Fachlogik statt Codeduplizierung +- Tests für neues oder korrigiertes Verhalten, soweit technisch möglich +- neue Migrationen statt Änderungen an bereits veröffentlichten Migrationen +- keine Zugangsdaten oder lokalen Umgebungsdateien im Repository + +Ausführliche Arbeitsregeln für Coding Agents und Beitragende stehen in +[`AGENTS.md`](AGENTS.md). + +## Beiträge + +Beiträge sind willkommen. Der vollständige Ablauf, Qualitätsanforderungen und der +Umgang mit Fremdmaterial sind in [`CONTRIBUTING.md`](CONTRIBUTING.md) beschrieben. + +Vor dem ersten Pull Request müssen Beitragende die +[`Contributor License Agreement`](CLA.md) lesen und im Pull Request selbst +akzeptieren. Beitragende behalten ihr Copyright, räumen dem Projektinhaber jedoch +die notwendigen Rechte ein, den Beitrag sowohl unter der öffentlichen +Projektlizenz als auch unter separaten kommerziellen oder proprietären Lizenzen zu +verwenden. Beiträge im Namen eines Unternehmens müssen vorab abgestimmt werden. + +## Deployment + +Der `Dockerfile` kann Images für API und Web erzeugen. Unter `remote/production` +liegen außerdem Beispielkonfigurationen für systemd und Caddy. Die +`production/*`-Ziele im `Makefile` sind auf die vorhandene Gardomatic-Infrastruktur +zugeschnitten, enthalten einen fest konfigurierten Zielhost und führen Migrationen +sowie Dienstneustarts aus. Sie sind keine allgemeine Deployment-Anleitung und +sollten vor jeder Verwendung geprüft werden. + +Die lokale Produktionsverbindung wird in `config.mk` konfiguriert. Eine kommentierte +Vorlage mit Zielhost, SSH-Admin, Port, optionalem privaten Schlüssel und +Zielarchitektur steht in `config.mk.example`. Der SSH-Admin ist der vom Hoster oder +bei der LXC-Erstellung bereitgestellte Benutzer (`root`, `ubuntu` oder ähnlich) +und benötigt Root-Rechte oder passwortloses `sudo`. Private SSH-Schlüssel bleiben +ausschließlich auf dem lokalen Rechner; auf dem Server muss vorab nur der +zugehörige öffentliche Schlüssel für diesen Admin hinterlegt sein. + +Das Server-Setup unter `remote/setup/provision-server.sh` liest seine Konfiguration +aus `remote/setup/.env`. Als Ausgangspunkt dient `remote/setup/.env.example`; die +echte Datei muss auf Modus `0600` gesetzt werden und bleibt von Git ausgeschlossen. +`make production/provision` überträgt das Skript und streamt die Konfiguration über +SSH, ohne die Quelldatei dauerhaft auf dem Server abzulegen. Das Provisioning legt +den gesperrten Servicebenutzer `gardomatic` ohne Login, SSH-Schlüssel oder +sudo-Rechte an und installiert die Laufzeitwerte als +`/etc/gardomatic/gardomatic.env`. + +Ein vollständiger Erstbetrieb besteht aus: + +```sh +make config/init +# Die benötigten lokalen Konfigurationsdateien bearbeiten, dann: +make production/provision +make production/deploy +make production/create-admin +``` + +`production/deploy` überträgt Binärdateien, Migrationen, systemd-Units und die +Admin-Hilfe über denselben SSH-Admin, wendet Migrationen an und startet die Dienste. +`production/create-admin` fragt interaktiv nach Name und E-Mail und erstellt den +Benutzer aktiviert und mit der Rolle `application:admin`; das sichere generierte +Passwort wird einmalig ausgegeben. + +Für Produktion gelten mindestens folgende Anforderungen: + +- `GARDOMATIC_ENV=production` +- HTTPS am Reverse Proxy +- `GARDOMATIC_COOKIE_SECURE=true` +- starke, extern verwaltete Zugangsdaten +- eingeschränkter Datenbankzugriff und regelmäßige Backups +- korrekte öffentliche Web-URL und vertrauenswürdige CORS-Origins +- SMTP statt dateibasiertem Mailversand, sofern E-Mails zugestellt werden sollen + +## Weiterführende Dokumentation + +- [`doc/planung.md`](doc/planung.md) – Produktbeschreibung, Leitplanken und Fahrplan +- [`doc/cli.md`](doc/cli.md) – vollständige Bedienung des Administrations-CLI +- [`doc/issues.md`](doc/issues.md) – bekannte Themen und Arbeitsnotizen +- [`doc/ideen.md`](doc/ideen.md) – mögliche spätere Erweiterungen + +## Lizenz + +Gardomatic steht unter der +[PolyForm Noncommercial License 1.0.0](LICENSE). Sie erlaubt Nutzung, Veränderung +und Weitergabe für nichtkommerzielle Zwecke unter den Bedingungen der Lizenz. + +Kommerzielle Nutzung ist von dieser Lizenz nicht abgedeckt und erfordert eine +separate, kostenpflichtige Lizenzvereinbarung. Anfragen können an +[alex@kleiax.de](mailto:alex@kleiax.de) gerichtet werden. + +Für Beiträge Dritter gilt zusätzlich die +[`Gardomatic Contributor License Agreement`](CLA.md). + +Wegen des Ausschlusses kommerzieller Nutzung ist Gardomatic „source-available“, +aber keine Open-Source-Software nach der Definition der Open Source Initiative. diff --git a/cmd/api/config.go b/cmd/api/config.go new file mode 100644 index 0000000..906c74e --- /dev/null +++ b/cmd/api/config.go @@ -0,0 +1,99 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "gardomatic.kleiax.de/internal/api" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/platform/environment" +) + +func configFromEnvironment() (api.Config, error) { + var errs []error + required := func(name string) string { + value, err := environment.Required(name) + if err != nil { + errs = append(errs, err) + } + return value + } + integer := func(name string, fallback int) int { + value, err := environment.Int(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + boolean := func(name string, fallback bool) bool { + value, err := environment.Bool(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + duration := func(name string, fallback time.Duration) time.Duration { + value, err := environment.Duration(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + floating := func(name string, fallback float64) float64 { + value, err := environment.Float(name, fallback) + if err != nil { + errs = append(errs, err) + } + return value + } + + cfg := api.Config{ + Host: environment.String("GARDOMATIC_API_HOST", ""), + Port: integer("GARDOMATIC_API_PORT", 4000), + Env: environment.String("GARDOMATIC_ENV", "development"), + WebBaseURL: environment.String("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"), + DB: api.DatabaseConfig{ + Dsn: required("GARDOMATIC_DB_DSN"), MaxOpenConns: integer("GARDOMATIC_DB_MAX_OPEN_CONNS", 25), + MaxIdleConns: integer("GARDOMATIC_DB_MAX_IDLE_CONNS", 25), MaxIdleTime: duration("GARDOMATIC_DB_MAX_IDLE_TIME", 15*time.Minute), + }, + Limiter: api.LimiterConfig{Enabled: boolean("GARDOMATIC_RATE_LIMIT_ENABLED", true), Rps: floating("GARDOMATIC_RATE_LIMIT_RPS", 10), Burst: integer("GARDOMATIC_RATE_LIMIT_BURST", 40)}, + Session: api.SessionConfig{Lifetime: duration("GARDOMATIC_SESSION_LIFETIME", 12*time.Hour), IdleTimeout: duration("GARDOMATIC_SESSION_IDLE_TIMEOUT", 30*time.Minute), CookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: boolean("GARDOMATIC_COOKIE_SECURE", false)}, + Mail: mailer.Config{Mode: mailer.Mode(environment.String("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), Host: environment.String("GARDOMATIC_SMTP_HOST", ""), Port: integer("GARDOMATIC_SMTP_PORT", 25), Username: environment.String("GARDOMATIC_SMTP_USERNAME", ""), Password: environment.String("GARDOMATIC_SMTP_PASSWORD", ""), Sender: environment.String("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), FilePath: environment.String("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log")}, + Cors: api.CORSConfig{TrustedOrigins: environment.CSV("GARDOMATIC_CORS_TRUSTED_ORIGINS")}, + } + + if cfg.Port < 1 || cfg.Port > 65535 { + errs = append(errs, errors.New("GARDOMATIC_API_PORT must be between 1 and 65535")) + } + if cfg.Env != "development" && cfg.Env != "test" && cfg.Env != "production" { + errs = append(errs, fmt.Errorf("GARDOMATIC_ENV has unsupported value %q", cfg.Env)) + } + if cfg.DB.MaxOpenConns < 1 || cfg.DB.MaxIdleConns < 0 || cfg.DB.MaxIdleConns > cfg.DB.MaxOpenConns { + errs = append(errs, errors.New("database pool limits are invalid")) + } + if cfg.Limiter.Rps <= 0 || cfg.Limiter.Burst < 1 { + errs = append(errs, errors.New("rate limit values must be positive")) + } + if strings.TrimSpace(cfg.Session.CookieName) == "" { + errs = append(errs, errors.New("GARDOMATIC_SESSION_COOKIE_NAME must not be empty")) + } + for _, origin := range cfg.Cors.TrustedOrigins { + parsed, err := url.ParseRequestURI(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, fmt.Errorf("invalid trusted origin %q", origin)) + } + } + if parsed, err := url.ParseRequestURI(cfg.WebBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, errors.New("GARDOMATIC_WEB_BASE_URL must be an absolute URL")) + } + if cfg.Env == "production" && !cfg.Session.CookieSecure { + errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production")) + } + if _, err := mailer.New(cfg.Mail); err != nil { + errs = append(errs, fmt.Errorf("mail configuration: %w", err)) + } + return cfg, errors.Join(errs...) +} diff --git a/cmd/api/config_test.go b/cmd/api/config_test.go new file mode 100644 index 0000000..ac60c5d --- /dev/null +++ b/cmd/api/config_test.go @@ -0,0 +1,24 @@ +package main + +import "testing" + +func TestConfigFromEnvironment(t *testing.T) { + t.Setenv("GARDOMATIC_DB_DSN", "postgres://example") + t.Setenv("GARDOMATIC_API_HOST", "127.0.0.1") + t.Setenv("GARDOMATIC_API_PORT", "4100") + t.Setenv("GARDOMATIC_CORS_TRUSTED_ORIGINS", "https://example.com, https://app.example.com") + cfg, err := configFromEnvironment() + if err != nil { + t.Fatal(err) + } + if cfg.Host != "127.0.0.1" || cfg.Port != 4100 || len(cfg.Cors.TrustedOrigins) != 2 { + t.Fatalf("unexpected config: %#v", cfg) + } +} + +func TestConfigRequiresDSN(t *testing.T) { + t.Setenv("GARDOMATIC_DB_DSN", "") + if _, err := configFromEnvironment(); err == nil { + t.Fatal("expected missing DSN error") + } +} diff --git a/cmd/api/doc.go b/cmd/api/doc.go new file mode 100644 index 0000000..0d824e6 --- /dev/null +++ b/cmd/api/doc.go @@ -0,0 +1,2 @@ +// Package main starts the Gardomatic JSON API service. +package main diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..f167571 --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "log" + + "gardomatic.kleiax.de/internal/api" +) + +func main() { + cfg, err := configFromEnvironment() + if err != nil { + log.Fatal(err) + } + server := api.New(cfg) + server.Run() +} diff --git a/cmd/cli/application.go b/cmd/cli/application.go new file mode 100644 index 0000000..305012b --- /dev/null +++ b/cmd/cli/application.go @@ -0,0 +1,663 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "slices" + "strings" + "text/tabwriter" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "gardomatic.kleiax.de/internal/vcs" + "golang.org/x/term" +) + +const activationTokenTTL = 3 * 24 * time.Hour + +type application struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + openStore func(string) (adminStore, error) + newMailer func(mailer.Config) (*mailer.Mailer, error) +} + +func newApplication(stdin io.Reader, stdout, stderr io.Writer) *application { + return &application{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + openStore: openPostgresStore, + newMailer: mailer.New, + } +} + +func (app *application) run(ctx context.Context, args []string) error { + cfg, err := configFromEnvironment() + if err != nil { + return err + } + + global := flag.NewFlagSet("gardomatic", flag.ContinueOnError) + global.SetOutput(app.stderr) + global.StringVar(&cfg.dsn, "dsn", cfg.dsn, "PostgreSQL DSN (default: GARDOMATIC_DB_DSN)") + global.StringVar(&cfg.environment, "env", cfg.environment, "environment name") + global.StringVar(&cfg.webBaseURL, "web-base-url", cfg.webBaseURL, "public web base URL") + global.BoolVar(&cfg.json, "json", false, "write JSON output") + global.BoolVar(&cfg.yes, "yes", false, "skip production confirmation") + global.Usage = func() { app.printUsage(global) } + if err = global.Parse(args); err != nil { + return err + } + + remaining := global.Args() + if len(remaining) == 0 { + global.Usage() + return nil + } + if remaining[0] == "help" || remaining[0] == "--help" || remaining[0] == "-h" { + global.Usage() + return nil + } + if slices.Contains(remaining[1:], "--help") || slices.Contains(remaining[1:], "-h") { + global.Usage() + return nil + } + if remaining[0] == "version" { + return app.writeValue(cfg, map[string]string{"version": vcs.Version()}, "Version: %s\n", vcs.Version()) + } + + store, err := app.openStore(cfg.dsn) + if err != nil { + return err + } + defer store.Close() + + switch remaining[0] { + case "users": + return app.runUsers(ctx, cfg, store, remaining[1:]) + case "gardens": + return app.runGardens(ctx, cfg, store, remaining[1:]) + case "db": + return app.runDB(ctx, cfg, store, remaining[1:]) + default: + return fmt.Errorf("unknown command %q; run gardomatic help", remaining[0]) + } +} + +func (app *application) runUsers(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) == 0 { + return errors.New("missing users command: create, list, show, activate, deactivate, invite, reset-password, or set-role") + } + switch args[0] { + case "create": + return app.createUser(ctx, cfg, store, args[1:]) + case "list": + return app.listUsers(ctx, cfg, store, args[1:]) + case "show": + return app.showUser(ctx, cfg, store, args[1:]) + case "activate": + return app.setUserActivated(ctx, cfg, store, args[1:], true) + case "deactivate": + return app.setUserActivated(ctx, cfg, store, args[1:], false) + case "invite": + return app.inviteUser(ctx, cfg, store, args[1:]) + case "reset-password": + return app.resetPassword(ctx, cfg, store, args[1:]) + case "set-role": + return app.setUserRole(ctx, cfg, store, args[1:]) + default: + return fmt.Errorf("unknown users command %q", args[0]) + } +} + +func (app *application) runGardens(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) == 0 { + return errors.New("missing gardens command: add-user") + } + if args[0] != "add-user" { + return fmt.Errorf("unknown gardens command %q", args[0]) + } + return app.addGardenMember(ctx, cfg, store, args[1:]) +} + +func (app *application) setUserRole(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users set-role", app.stderr) + var email, role string + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", "", "application role (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + email, role = strings.TrimSpace(email), strings.TrimSpace(role) + if err := validateEmail(email); err != nil { + return err + } + if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { + return errors.New("role must be application:user or application:admin") + } + user, err := store.SetUserRole(ctx, email, role) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) addGardenMember(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("gardens add-user", app.stderr) + var gardenID int + var email, role string + fs.IntVar(&gardenID, "garden-id", 0, "garden ID (required)") + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", string(storage.GardenRoleMember), "garden role") + if err := parseFlags(fs, args); err != nil { + return err + } + email, role = strings.TrimSpace(email), strings.TrimSpace(role) + if gardenID < 1 { + return errors.New("garden-id must be a positive integer") + } + if err := validateEmail(email); err != nil { + return err + } + validRole := role == string(storage.GardenRoleOwner) || role == string(storage.GardenRoleAdmin) || role == string(storage.GardenRoleMember) || role == string(storage.GardenRoleViewer) || role == string(storage.GardenRoleWorker) + if !validRole { + return errors.New("role must be owner, admin, member, viewer, or worker") + } + member, err := store.AddGardenMember(ctx, gardenID, email, role) + if err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + return fmt.Errorf("garden %d, user %s, or role %s was not found", gardenID, email, role) + } + return err + } + if cfg.json { + return writeJSON(app.stdout, member) + } + _, err = fmt.Fprintf(app.stdout, "User: %s\nGarden: %d\nRole: %s\n", member.Email, member.GardenID, member.Role) + return err +} + +func (app *application) createUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users create", app.stderr) + var name, email, role string + var active, invite, generatePassword, passwordStdin, sendEmail bool + fs.StringVar(&name, "name", "", "display name (required)") + fs.StringVar(&email, "email", "", "email address (required)") + fs.StringVar(&role, "role", string(storage.ApplicationRoleUser), "application role") + fs.BoolVar(&active, "active", false, "create an activated account") + fs.BoolVar(&invite, "invite", false, "create an activation token") + fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") + fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") + fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") + if err := parseFlags(fs, args); err != nil { + return err + } + name = strings.TrimSpace(name) + email = strings.TrimSpace(email) + role = strings.TrimSpace(role) + if active == invite { + return errors.New("exactly one of --active or --invite is required") + } + if sendEmail && !invite { + return errors.New("--send-email requires --invite") + } + if generatePassword && passwordStdin { + return errors.New("--generate-password and --password-stdin are mutually exclusive") + } + if err := validateIdentity(name, email); err != nil { + return err + } + if role != string(storage.ApplicationRoleUser) && role != string(storage.ApplicationRoleAdmin) { + return errors.New("role must be application:user or application:admin") + } + password, generated, err := app.obtainPassword(generatePassword, passwordStdin) + if err != nil { + return err + } + passwordHash, err := hashPassword(password) + if err != nil { + return err + } + + result, err := store.CreateUser(ctx, createUserInput{ + Name: name, + Email: email, + PasswordHash: passwordHash, + Activated: active, + Role: role, + Invite: invite, + TokenTTL: activationTokenTTL, + }) + if err != nil { + if errors.Is(err, storage.ErrDuplicateEmail) { + return fmt.Errorf("a user with email %s already exists", email) + } + return err + } + + output := userMutationOutput{User: result.User} + if generated { + output.GeneratedPassword = password + } + if result.Token != nil { + output.ActivationToken = result.Token.Plaintext + output.ActivationURL, err = activationURL(cfg.webBaseURL, result.Token.Plaintext) + if err != nil { + return err + } + output.TokenExpiry = &result.Token.Expiry + if sendEmail { + if err = app.sendInvitation(cfg, result.User, *result.Token, output.ActivationURL, true); err != nil { + return fmt.Errorf("user was created, but sending invitation failed: %w", err) + } + output.EmailSent = true + } + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) listUsers(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users list", app.stderr) + if err := parseFlags(fs, args); err != nil { + return err + } + users, err := store.ListUsers(ctx) + if err != nil { + return err + } + if cfg.json { + return writeJSON(app.stdout, users) + } + tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tNAME\tEMAIL\tACTIVE\tROLE\tCREATED") + for _, user := range users { + fmt.Fprintf(tw, "%d\t%s\t%s\t%t\t%s\t%s\n", user.ID, user.Name, user.Email, user.Activated, user.Role, user.CreatedAt.Format(time.RFC3339)) + } + return tw.Flush() +} + +func (app *application) showUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users show", app.stderr) + var email string + fs.StringVar(&email, "email", "", "email address (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + if err := validateEmail(email); err != nil { + return err + } + user, err := store.GetUser(ctx, strings.TrimSpace(email)) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) setUserActivated(ctx context.Context, cfg config, store adminStore, args []string, active bool) error { + command := "activate" + if !active { + command = "deactivate" + } + fs := newFlagSet("users "+command, app.stderr) + var email string + fs.StringVar(&email, "email", "", "email address (required)") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + if !active { + if err := app.confirmProduction(cfg, "deactivate user "+email); err != nil { + return err + } + } + user, err := store.SetActivated(ctx, email, active) + if err != nil { + return userError(email, err) + } + return app.writeUser(cfg, user) +} + +func (app *application) inviteUser(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users invite", app.stderr) + var email string + var sendEmail bool + fs.StringVar(&email, "email", "", "email address (required)") + fs.BoolVar(&sendEmail, "send-email", false, "send the invitation using configured mail settings") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + user, token, err := store.IssueInvitation(ctx, email, activationTokenTTL) + if err != nil { + return userError(email, err) + } + link, err := activationURL(cfg.webBaseURL, token.Plaintext) + if err != nil { + return err + } + output := userMutationOutput{ + User: user, + ActivationToken: token.Plaintext, + ActivationURL: link, + TokenExpiry: &token.Expiry, + } + if sendEmail { + if err = app.sendInvitation(cfg, user, token, link, false); err != nil { + return fmt.Errorf("invitation token was created, but sending email failed: %w", err) + } + output.EmailSent = true + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) resetPassword(ctx context.Context, cfg config, store adminStore, args []string) error { + fs := newFlagSet("users reset-password", app.stderr) + var email string + var generatePassword, passwordStdin bool + fs.StringVar(&email, "email", "", "email address (required)") + fs.BoolVar(&generatePassword, "generate-password", false, "generate a secure password") + fs.BoolVar(&passwordStdin, "password-stdin", false, "read the password from standard input") + if err := parseFlags(fs, args); err != nil { + return err + } + email = strings.TrimSpace(email) + if err := validateEmail(email); err != nil { + return err + } + if generatePassword && passwordStdin { + return errors.New("--generate-password and --password-stdin are mutually exclusive") + } + if err := app.confirmProduction(cfg, "reset password for "+email); err != nil { + return err + } + password, generated, err := app.obtainPassword(generatePassword, passwordStdin) + if err != nil { + return err + } + hash, err := hashPassword(password) + if err != nil { + return err + } + user, err := store.ResetPassword(ctx, email, hash) + if err != nil { + return userError(email, err) + } + output := userMutationOutput{User: user} + if generated { + output.GeneratedPassword = password + } + return app.writeUserMutation(cfg, output) +} + +func (app *application) runDB(ctx context.Context, cfg config, store adminStore, args []string) error { + if len(args) != 1 || args[0] != "ping" { + return errors.New("usage: gardomatic db ping") + } + if err := store.Ping(ctx); err != nil { + return err + } + return app.writeValue(cfg, map[string]string{"status": "ok"}, "database: ok\n") +} + +type userMutationOutput struct { + User userView `json:"user"` + ActivationToken string `json:"activation_token,omitempty"` + ActivationURL string `json:"activation_url,omitempty"` + TokenExpiry *time.Time `json:"token_expiry,omitempty"` + GeneratedPassword string `json:"generated_password,omitempty"` + EmailSent bool `json:"email_sent,omitempty"` +} + +func (app *application) writeUserMutation(cfg config, output userMutationOutput) error { + if cfg.json { + return writeJSON(app.stdout, output) + } + if err := app.writeUser(cfg, output.User); err != nil { + return err + } + if output.GeneratedPassword != "" { + fmt.Fprintf(app.stdout, "Generated password: %s\n", output.GeneratedPassword) + } + if output.ActivationToken != "" { + fmt.Fprintf(app.stdout, "Activation token: %s\n", output.ActivationToken) + fmt.Fprintf(app.stdout, "Activation URL: %s\n", output.ActivationURL) + fmt.Fprintf(app.stdout, "Token expires: %s\n", output.TokenExpiry.Format(time.RFC3339)) + } + if output.EmailSent { + fmt.Fprintln(app.stdout, "Invitation email sent.") + } + return nil +} + +func (app *application) writeUser(cfg config, user userView) error { + if cfg.json { + return writeJSON(app.stdout, user) + } + tw := tabwriter.NewWriter(app.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(tw, "ID:\t%d\n", user.ID) + fmt.Fprintf(tw, "Name:\t%s\n", user.Name) + fmt.Fprintf(tw, "Email:\t%s\n", user.Email) + fmt.Fprintf(tw, "Active:\t%t\n", user.Activated) + fmt.Fprintf(tw, "Role:\t%s\n", user.Role) + return tw.Flush() +} + +func (app *application) writeValue(cfg config, value any, format string, args ...any) error { + if cfg.json { + return writeJSON(app.stdout, value) + } + _, err := fmt.Fprintf(app.stdout, format, args...) + return err +} + +func (app *application) obtainPassword(generate, fromStdin bool) (string, bool, error) { + if generate { + return rand.Text(), true, nil + } + if fromStdin { + password, err := bufio.NewReader(app.stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", false, err + } + password = strings.TrimRight(password, "\r\n") + if err = validatePassword(password); err != nil { + return "", false, err + } + return password, false, nil + } + + input, ok := app.stdin.(*os.File) + if !ok || !term.IsTerminal(int(input.Fd())) { + return "", false, errors.New("standard input is not a terminal; use --password-stdin or --generate-password") + } + fmt.Fprint(app.stderr, "Password: ") + first, err := term.ReadPassword(int(input.Fd())) + fmt.Fprintln(app.stderr) + if err != nil { + return "", false, err + } + fmt.Fprint(app.stderr, "Repeat password: ") + second, err := term.ReadPassword(int(input.Fd())) + fmt.Fprintln(app.stderr) + if err != nil { + return "", false, err + } + if string(first) != string(second) { + return "", false, errors.New("passwords do not match") + } + if err = validatePassword(string(first)); err != nil { + return "", false, err + } + return string(first), false, nil +} + +func (app *application) confirmProduction(cfg config, action string) error { + if !strings.EqualFold(cfg.environment, "production") || cfg.yes { + return nil + } + fmt.Fprintf(app.stderr, "Production: %s. Continue? [y/N] ", action) + answer, err := bufio.NewReader(app.stdin).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return err + } + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + return errors.New("operation cancelled") + } + return nil +} + +func (app *application) sendInvitation(cfg config, user userView, token auth.Token, link string, welcome bool) error { + m, err := app.newMailer(cfg.mail) + if err != nil { + return err + } + template := "token_activation.tmpl" + if welcome { + template = "user_welcome.tmpl" + } + return m.Send(user.Email, template, map[string]any{ + "activationToken": token.Plaintext, + "activationURL": link, + "userID": user.ID, + }) +} + +func (app *application) printUsage(fs *flag.FlagSet) { + fmt.Fprintln(app.stderr, `Gardomatic administration CLI + +Usage: + gardomatic [global options] users create --name NAME --email EMAIL (--active|--invite) [--role ROLE] [options] + gardomatic [global options] users list + gardomatic [global options] users show --email EMAIL + gardomatic [global options] users activate|deactivate --email EMAIL + gardomatic [global options] users invite --email EMAIL [--send-email] + gardomatic [global options] users reset-password --email EMAIL [options] + gardomatic [global options] users set-role --email EMAIL --role ROLE + gardomatic [global options] gardens add-user --garden-id ID --email EMAIL [--role ROLE] + gardomatic [global options] db ping + gardomatic [global options] version + +Global options:`) + fs.PrintDefaults() +} + +func activationURL(baseURL, token string) (string, error) { + u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/activate") + if err != nil { + return "", fmt.Errorf("invalid web base URL: %w", err) + } + if u.Scheme == "" || u.Host == "" { + return "", errors.New("web base URL must include scheme and host") + } + query := u.Query() + query.Set("token", token) + u.RawQuery = query.Encode() + return u.String(), nil +} + +func validateIdentity(name, email string) error { + password := auth.NewPassword([]byte("placeholder")) + user := storage.User{Name: name, Email: email, Password: *password} + v := validate.New() + storage.ValidateUser(v, user) + delete(v.Errors, "password") + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func validateEmail(email string) error { + v := validate.New() + storage.ValidateEmail(v, strings.TrimSpace(email)) + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func validatePassword(password string) error { + v := validate.New() + auth.ValidatePasswordPlaintext(v, password) + if !v.Valid() { + return validationError(v.Errors) + } + return nil +} + +func hashPassword(plaintext string) ([]byte, error) { + if err := validatePassword(plaintext); err != nil { + return nil, err + } + var password auth.Password + if err := password.Set(plaintext); err != nil { + return nil, err + } + return password.Get(), nil +} + +func validationError(fields map[string]string) error { + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + slices.Sort(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+" "+fields[key]) + } + return errors.New(strings.Join(parts, "; ")) +} + +func userError(email string, err error) error { + switch { + case errors.Is(err, errUserNotFound): + return fmt.Errorf("no user found with email %s", email) + case errors.Is(err, errUserAlreadyActive): + return fmt.Errorf("user %s is already active", email) + default: + return err + } +} + +func newFlagSet(name string, output io.Writer) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(output) + return fs +} + +func parseFlags(fs *flag.FlagSet, args []string) error { + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + return nil +} + +func writeJSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + return encoder.Encode(value) +} diff --git a/cmd/cli/application_test.go b/cmd/cli/application_test.go new file mode 100644 index 0000000..c360c3c --- /dev/null +++ b/cmd/cli/application_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "golang.org/x/crypto/bcrypt" +) + +type fakeAdminStore struct { + createInput createUserInput + createCalls int + setActivatedCalls int + inviteCalls int + setRoleCalls int + addMemberCalls int + lastRole string +} + +func (s *fakeAdminStore) Ping(context.Context) error { return nil } +func (s *fakeAdminStore) Close() error { return nil } + +func (s *fakeAdminStore) CreateUser(_ context.Context, input createUserInput) (createUserResult, error) { + s.createCalls++ + s.createInput = input + result := createUserResult{User: userView{ + ID: 42, + Name: input.Name, + Email: input.Email, + Activated: input.Activated, + Role: input.Role, + CreatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, time.August, 31, 10, 0, 0, 0, time.UTC), + }} + if input.Invite { + token := auth.NewToken(42, input.TokenTTL, auth.ScopeActivation) + result.Token = &token + } + return result, nil +} + +func (s *fakeAdminStore) ListUsers(context.Context) ([]userView, error) { return nil, nil } +func (s *fakeAdminStore) GetUser(context.Context, string) (userView, error) { + return userView{}, errUserNotFound +} +func (s *fakeAdminStore) SetActivated(_ context.Context, email string, active bool) (userView, error) { + s.setActivatedCalls++ + return userView{ID: 42, Email: email, Activated: active}, nil +} +func (s *fakeAdminStore) IssueInvitation(_ context.Context, email string, ttl time.Duration) (userView, auth.Token, error) { + s.inviteCalls++ + return userView{ID: 42, Name: "Alice", Email: email}, auth.NewToken(42, ttl, auth.ScopeActivation), nil +} +func (s *fakeAdminStore) ResetPassword(context.Context, string, []byte) (userView, error) { + return userView{}, nil +} +func (s *fakeAdminStore) SetUserRole(_ context.Context, email, role string) (userView, error) { + s.setRoleCalls++ + s.lastRole = role + return userView{ID: 42, Email: email, Role: role}, nil +} +func (s *fakeAdminStore) AddGardenMember(_ context.Context, gardenID int, email, role string) (gardenMemberView, error) { + s.addMemberCalls++ + s.lastRole = role + return gardenMemberView{GardenID: gardenID, UserID: 42, Email: email, Role: role}, nil +} + +func newTestApplication(t *testing.T, stdin string, store adminStore) (*application, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + t.Setenv("GARDOMATIC_DB_DSN", "postgres://unused") + t.Setenv("GARDOMATIC_ENV", "development") + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + app := newApplication(strings.NewReader(stdin), stdout, stderr) + app.openStore = func(string) (adminStore, error) { return store, nil } + return app, stdout, stderr +} + +func TestCreateInvitedUserGeneratesCredentialsAndJSON(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "--json", + "--web-base-url", "https://gardomatic.example/app", + "users", "create", + "--name", " Alice ", + "--email", "alice@example.com", + "--invite", + "--generate-password", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + if store.createCalls != 1 { + t.Fatalf("CreateUser calls = %d, want 1", store.createCalls) + } + if store.createInput.Name != "Alice" || !store.createInput.Invite || store.createInput.Activated { + t.Errorf("CreateUser input = %+v", store.createInput) + } + + var output userMutationOutput + if err = json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decoding output: %v; output: %s", err, stdout.String()) + } + if output.GeneratedPassword == "" { + t.Fatal("generated password is missing") + } + if err = bcrypt.CompareHashAndPassword(store.createInput.PasswordHash, []byte(output.GeneratedPassword)); err != nil { + t.Errorf("stored password hash does not match generated password: %v", err) + } + if !strings.HasPrefix(output.ActivationURL, "https://gardomatic.example/app/activate?token=") { + t.Errorf("activation URL = %q", output.ActivationURL) + } + if output.ActivationToken == "" || !strings.Contains(output.ActivationURL, output.ActivationToken) { + t.Errorf("activation token and URL do not match: %+v", output) + } +} + +func TestCreateUserRequiresExactlyOneAccountMode(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Alice", + "--email", "alice@example.com", + "--generate-password", + }) + if err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("run() error = %v, want account mode error", err) + } + if store.createCalls != 0 { + t.Errorf("CreateUser calls = %d, want 0", store.createCalls) + } +} + +func TestCreateUserCanAtomicallyCreateApplicationAdmin(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Initial Admin", + "--email", "admin@example.com", + "--role", "application:admin", + "--active", + "--generate-password", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + if store.createInput.Role != "application:admin" { + t.Fatalf("created role = %q, want application:admin", store.createInput.Role) + } + if !strings.Contains(stdout.String(), "application:admin") { + t.Fatalf("output does not contain admin role: %s", stdout.String()) + } +} + +func TestCreateUserRejectsUnknownApplicationRole(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{ + "users", "create", + "--name", "Alice", + "--email", "alice@example.com", + "--role", "superadmin", + "--active", + "--generate-password", + }) + if err == nil || !strings.Contains(err.Error(), "role must be") { + t.Fatalf("run() error = %v, want role validation error", err) + } + if store.createCalls != 0 { + t.Fatalf("CreateUser calls = %d, want 0", store.createCalls) + } +} + +func TestProductionDeactivationRequiresConfirmation(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "n\n", store) + + err := app.run(context.Background(), []string{ + "--env", "production", + "users", "deactivate", "--email", "alice@example.com", + }) + if err == nil || err.Error() != "operation cancelled" { + t.Fatalf("run() error = %v, want operation cancelled", err) + } + if store.setActivatedCalls != 0 { + t.Errorf("SetActivated calls = %d, want 0", store.setActivatedCalls) + } +} + +func TestInviteCanWriteEmailWithActivationLink(t *testing.T) { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + mailPath := filepath.Join(t.TempDir(), "mail.log") + t.Setenv("GARDOMATIC_SMTP_MODE", "file") + t.Setenv("GARDOMATIC_SMTP_FILE_PATH", mailPath) + + err := app.run(context.Background(), []string{ + "--web-base-url", "https://gardomatic.example", + "users", "invite", "--email", "alice@example.com", "--send-email", + }) + if err != nil { + t.Fatalf("run() returned an error: %v", err) + } + content, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "https://gardomatic.example/activate?token=") { + t.Errorf("mail does not contain activation URL: %s", content) + } +} + +func TestActivationURLRejectsRelativeBase(t *testing.T) { + _, err := activationURL("localhost:4040", "token") + if err == nil { + t.Fatal("activationURL() returned no error") + } +} + +func TestSetUserRoleCanMakeApplicationAdmin(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{"users", "set-role", "--email", "alice@example.com", "--role", "application:admin"}) + if err != nil { + t.Fatal(err) + } + if store.setRoleCalls != 1 || store.lastRole != "application:admin" || !strings.Contains(stdout.String(), "application:admin") { + t.Fatalf("role update missing: calls=%d role=%q output=%q", store.setRoleCalls, store.lastRole, stdout.String()) + } +} + +func TestAddUserToGardenWithAdminRole(t *testing.T) { + store := new(fakeAdminStore) + app, stdout, _ := newTestApplication(t, "", store) + err := app.run(context.Background(), []string{"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "admin"}) + if err != nil { + t.Fatal(err) + } + if store.addMemberCalls != 1 || store.lastRole != "admin" || !strings.Contains(stdout.String(), "Garden: 7") { + t.Fatalf("garden membership missing: calls=%d role=%q output=%q", store.addMemberCalls, store.lastRole, stdout.String()) + } +} + +func TestRoleCommandsRejectUnknownRoles(t *testing.T) { + tests := [][]string{ + {"users", "set-role", "--email", "alice@example.com", "--role", "superadmin"}, + {"gardens", "add-user", "--garden-id", "7", "--email", "alice@example.com", "--role", "superadmin"}, + } + for _, args := range tests { + store := new(fakeAdminStore) + app, _, _ := newTestApplication(t, "", store) + if err := app.run(context.Background(), args); err == nil || !strings.Contains(err.Error(), "role must be") { + t.Errorf("run(%v) error = %v, want role validation error", args, err) + } + if store.setRoleCalls != 0 || store.addMemberCalls != 0 { + t.Errorf("run(%v) reached store", args) + } + } +} diff --git a/cmd/cli/config.go b/cmd/cli/config.go new file mode 100644 index 0000000..7c4349b --- /dev/null +++ b/cmd/cli/config.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/mailer" +) + +type config struct { + dsn string + environment string + webBaseURL string + json bool + yes bool + mail mailer.Config +} + +func configFromEnvironment() (config, error) { + port, err := envInt("GARDOMATIC_SMTP_PORT", 25) + if err != nil { + return config{}, err + } + + return config{ + dsn: os.Getenv("GARDOMATIC_DB_DSN"), + environment: envString("GARDOMATIC_ENV", "development"), + webBaseURL: envString("GARDOMATIC_WEB_BASE_URL", "http://localhost:4040"), + mail: mailer.Config{ + Mode: mailer.Mode(envString("GARDOMATIC_SMTP_MODE", string(mailer.ModeFile))), + Host: os.Getenv("GARDOMATIC_SMTP_HOST"), + Port: port, + Username: os.Getenv("GARDOMATIC_SMTP_USERNAME"), + Password: os.Getenv("GARDOMATIC_SMTP_PASSWORD"), + Sender: envString("GARDOMATIC_SMTP_SENDER", "gardomatic@localhost"), + FilePath: envString("GARDOMATIC_SMTP_FILE_PATH", "/tmp/gardomatic-mails.log"), + }, + }, nil +} + +func envString(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envInt(name string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + + n, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", name, err) + } + return n, nil +} diff --git a/cmd/cli/doc.go b/cmd/cli/doc.go new file mode 100644 index 0000000..03a6af3 --- /dev/null +++ b/cmd/cli/doc.go @@ -0,0 +1,2 @@ +// Package main provides the Gardomatic administration command-line tool. +package main diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..f0be839 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "context" + "fmt" + "os" +) + +func main() { + app := newApplication(os.Stdin, os.Stdout, os.Stderr) + if err := app.run(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/cli/store.go b/cmd/cli/store.go new file mode 100644 index 0000000..318c8a4 --- /dev/null +++ b/cmd/cli/store.go @@ -0,0 +1,336 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +var ( + errUserNotFound = errors.New("user not found") + errUserAlreadyActive = errors.New("user is already active") +) + +type userView struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Activated bool `json:"activated"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type gardenMemberView struct { + GardenID int `json:"garden_id"` + UserID int `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} + +type createUserInput struct { + Name string + Email string + PasswordHash []byte + Activated bool + Role string + Invite bool + TokenTTL time.Duration +} + +type createUserResult struct { + User userView + Token *auth.Token +} + +type adminStore interface { + Ping(context.Context) error + CreateUser(context.Context, createUserInput) (createUserResult, error) + ListUsers(context.Context) ([]userView, error) + GetUser(context.Context, string) (userView, error) + SetActivated(context.Context, string, bool) (userView, error) + IssueInvitation(context.Context, string, time.Duration) (userView, auth.Token, error) + ResetPassword(context.Context, string, []byte) (userView, error) + SetUserRole(context.Context, string, string) (userView, error) + AddGardenMember(context.Context, int, string, string) (gardenMemberView, error) + Close() error +} + +type postgresStore struct { + db *sql.DB +} + +func openPostgresStore(dsn string) (adminStore, error) { + if dsn == "" { + return nil, errors.New("database DSN is missing; set GARDOMATIC_DB_DSN or use --dsn") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(5) + db.SetMaxIdleConns(2) + db.SetConnMaxIdleTime(5 * time.Minute) + return &postgresStore{db: db}, nil +} + +// Close releases the CLI database pool. +func (s *postgresStore) Close() error { return s.db.Close() } + +// Ping verifies that the CLI can reach PostgreSQL within a bounded timeout. +func (s *postgresStore) Ping(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return s.db.PingContext(ctx) +} + +// CreateUser creates an account and optionally an invitation token atomically. +func (s *postgresStore) CreateUser(ctx context.Context, input createUserInput) (createUserResult, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return createUserResult{}, err + } + defer tx.Rollback() + + var result createUserResult + err = tx.QueryRowContext(ctx, ` + INSERT INTO users (name, email, password_hash, activated, application_role) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, email, activated, application_role, created_at, updated_at`, + input.Name, input.Email, input.PasswordHash, input.Activated, input.Role, + ).Scan( + &result.User.ID, + &result.User.Name, + &result.User.Email, + &result.User.Activated, + &result.User.Role, + &result.User.CreatedAt, + &result.User.UpdatedAt, + ) + if err != nil { + return createUserResult{}, mapPostgresError(err) + } + + if input.Invite { + token := auth.NewToken(result.User.ID, input.TokenTTL, auth.ScopeActivation) + if _, err = tx.ExecContext(ctx, ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil { + return createUserResult{}, err + } + result.Token = &token + } + + if err = tx.Commit(); err != nil { + return createUserResult{}, err + } + return result, nil +} + +// ListUsers returns all non-deleted accounts for CLI administration. +func (s *postgresStore) ListUsers(ctx context.Context) ([]userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, email, activated, application_role, created_at, updated_at + FROM users + WHERE deleted_at IS NULL + ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]userView, 0) + for rows.Next() { + var user userView + if err = rows.Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt); err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +// GetUser returns a non-deleted account by email. +func (s *postgresStore) GetUser(ctx context.Context, email string) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return getUser(ctx, s.db, email) +} + +// SetActivated changes whether an account may authenticate. +func (s *postgresStore) SetActivated(ctx context.Context, email string, activated bool) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, err + } + defer tx.Rollback() + + var user userView + err = tx.QueryRowContext(ctx, ` + UPDATE users + SET activated = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE email = $1 AND deleted_at IS NULL + RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, activated, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + if activated { + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil { + return userView{}, err + } + } + if err = tx.Commit(); err != nil { + return userView{}, err + } + return user, nil +} + +// IssueInvitation replaces an account's activation token. +func (s *postgresStore) IssueInvitation(ctx context.Context, email string, ttl time.Duration) (userView, auth.Token, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, auth.Token{}, err + } + defer tx.Rollback() + + user, err := getUser(ctx, tx, email) + if err != nil { + return userView{}, auth.Token{}, err + } + if user.Activated { + return userView{}, auth.Token{}, errUserAlreadyActive + } + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id = $1 AND scope = $2`, user.ID, auth.ScopeActivation); err != nil { + return userView{}, auth.Token{}, err + } + token := auth.NewToken(user.ID, ttl, auth.ScopeActivation) + if _, err = tx.ExecContext(ctx, ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)`, token.Hash, token.UserID, token.Expiry, token.Scope); err != nil { + return userView{}, auth.Token{}, err + } + if err = tx.Commit(); err != nil { + return userView{}, auth.Token{}, err + } + return user, token, nil +} + +// ResetPassword replaces an account password hash and revokes authentication tokens. +func (s *postgresStore) ResetPassword(ctx context.Context, email string, passwordHash []byte) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return userView{}, err + } + defer tx.Rollback() + + var user userView + err = tx.QueryRowContext(ctx, ` + UPDATE users + SET password_hash = $2, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE email = $1 AND deleted_at IS NULL + RETURNING id, name, email, activated, application_role, created_at, updated_at`, email, passwordHash, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + if _, err = tx.ExecContext(ctx, ` + DELETE FROM tokens + WHERE user_id = $1 AND scope = ANY($2)`, user.ID, pq.Array([]string{auth.ScopeAuthentication, auth.ScopePasswordReset})); err != nil { + return userView{}, err + } + if err = tx.Commit(); err != nil { + return userView{}, err + } + return user, nil +} + +// SetUserRole changes an account's application role. +func (s *postgresStore) SetUserRole(ctx context.Context, email, role string) (userView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var user userView + err := s.db.QueryRowContext(ctx, ` + UPDATE users SET application_role=$2, updated_at=CURRENT_TIMESTAMP, version=version+1 + WHERE email=$1 AND deleted_at IS NULL + RETURNING id,name,email,activated,application_role,created_at,updated_at`, email, role, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + return user, err +} + +// AddGardenMember adds or updates a user's membership in a garden. +func (s *postgresStore) AddGardenMember(ctx context.Context, gardenID int, email, role string) (gardenMemberView, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var member gardenMemberView + err := s.db.QueryRowContext(ctx, ` + INSERT INTO garden_members(garden_id,user_id,role) + SELECT $1,u.id,$3 FROM users u + WHERE u.email=$2 AND u.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM gardens WHERE id=$1) + AND EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + RETURNING garden_id,user_id,$2,role,joined_at`, gardenID, email, role, + ).Scan(&member.GardenID, &member.UserID, &member.Email, &member.Role, &member.JoinedAt) + if errors.Is(err, sql.ErrNoRows) { + return gardenMemberView{}, storage.ErrRecordNotFound + } + return member, mapPostgresError(err) +} + +type dbQuerier interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func getUser(ctx context.Context, db dbQuerier, email string) (userView, error) { + var user userView + err := db.QueryRowContext(ctx, ` + SELECT id, name, email, activated, application_role, created_at, updated_at + FROM users + WHERE email = $1 AND deleted_at IS NULL`, email, + ).Scan(&user.ID, &user.Name, &user.Email, &user.Activated, &user.Role, &user.CreatedAt, &user.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return userView{}, errUserNotFound + } + if err != nil { + return userView{}, err + } + return user, nil +} + +func mapPostgresError(err error) error { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key" { + return storage.ErrDuplicateEmail + } + return err +} diff --git a/cmd/web/config.go b/cmd/web/config.go new file mode 100644 index 0000000..fa4cf47 --- /dev/null +++ b/cmd/web/config.go @@ -0,0 +1,34 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + + "gardomatic.kleiax.de/internal/platform/environment" + "gardomatic.kleiax.de/internal/web" +) + +func configFromEnvironment() (web.Config, error) { + port, err := environment.Int("GARDOMATIC_WEB_PORT", 4040) + if err != nil { + return web.Config{}, err + } + cookieSecure, err := environment.Bool("GARDOMATIC_COOKIE_SECURE", false) + if err != nil { + return web.Config{}, err + } + cfg := web.Config{Host: environment.String("GARDOMATIC_WEB_HOST", ""), Port: port, Env: environment.String("GARDOMATIC_ENV", "development"), APIBaseURL: environment.String("GARDOMATIC_API_BASE_URL", "http://localhost:4000"), SessionCookieName: environment.String("GARDOMATIC_SESSION_COOKIE_NAME", "gardomatic_session"), CookieSecure: cookieSecure} + var errs []error + if cfg.Port < 1 || cfg.Port > 65535 { + errs = append(errs, errors.New("GARDOMATIC_WEB_PORT must be between 1 and 65535")) + } + parsed, parseErr := url.ParseRequestURI(cfg.APIBaseURL) + if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" { + errs = append(errs, fmt.Errorf("GARDOMATIC_API_BASE_URL must be an absolute HTTP URL")) + } + if cfg.Env == "production" && !cfg.CookieSecure { + errs = append(errs, errors.New("GARDOMATIC_COOKIE_SECURE must be true in production")) + } + return cfg, errors.Join(errs...) +} diff --git a/cmd/web/config_test.go b/cmd/web/config_test.go new file mode 100644 index 0000000..8f804ab --- /dev/null +++ b/cmd/web/config_test.go @@ -0,0 +1,16 @@ +package main + +import "testing" + +func TestConfigFromEnvironment(t *testing.T) { + t.Setenv("GARDOMATIC_WEB_HOST", "0.0.0.0") + t.Setenv("GARDOMATIC_WEB_PORT", "4444") + t.Setenv("GARDOMATIC_API_BASE_URL", "https://api.example.com") + cfg, err := configFromEnvironment() + if err != nil { + t.Fatal(err) + } + if cfg.Host != "0.0.0.0" || cfg.Port != 4444 || cfg.APIBaseURL != "https://api.example.com" { + t.Fatalf("unexpected config: %#v", cfg) + } +} diff --git a/cmd/web/doc.go b/cmd/web/doc.go new file mode 100644 index 0000000..31596ae --- /dev/null +++ b/cmd/web/doc.go @@ -0,0 +1,2 @@ +// Package main starts the Gardomatic server-rendered web application. +package main diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..99504f0 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "log" + + "gardomatic.kleiax.de/internal/web" +) + +func main() { + cfg, err := configFromEnvironment() + if err != nil { + log.Fatal(err) + } + + server, err := web.New(cfg) + if err != nil { + log.Fatal(err) + } + server.Run() +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4a25191 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,94 @@ +services: + postgres: + image: docker.io/library/postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "${POSTGRES_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 2s + timeout: 5s + retries: 15 + volumes: + - gardomatic-postgres-data:/var/lib/postgresql/data + + migrate: + image: docker.io/migrate/migrate:v4.19.1 + command: + - -path=/migrations + - -database=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + - up + depends_on: + postgres: + condition: service_healthy + volumes: + - ./internal/storage/postgres/migrations:/migrations:ro + restart: "no" + + api: + build: + context: . + target: api + environment: + GARDOMATIC_API_PORT: 4000 + GARDOMATIC_DB_DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + GARDOMATIC_DB_MAX_OPEN_CONNS: ${GARDOMATIC_DB_MAX_OPEN_CONNS:-25} + GARDOMATIC_DB_MAX_IDLE_CONNS: ${GARDOMATIC_DB_MAX_IDLE_CONNS:-25} + GARDOMATIC_DB_MAX_IDLE_TIME: ${GARDOMATIC_DB_MAX_IDLE_TIME:-15m} + GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development} + GARDOMATIC_WEB_BASE_URL: ${GARDOMATIC_WEB_BASE_URL:-http://localhost:4040} + GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session} + GARDOMATIC_SESSION_LIFETIME: ${GARDOMATIC_SESSION_LIFETIME:-12h} + GARDOMATIC_SESSION_IDLE_TIMEOUT: ${GARDOMATIC_SESSION_IDLE_TIMEOUT:-30m} + GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false} + GARDOMATIC_RATE_LIMIT_ENABLED: ${GARDOMATIC_RATE_LIMIT_ENABLED:-true} + GARDOMATIC_RATE_LIMIT_RPS: ${GARDOMATIC_RATE_LIMIT_RPS:-10} + GARDOMATIC_RATE_LIMIT_BURST: ${GARDOMATIC_RATE_LIMIT_BURST:-40} + GARDOMATIC_CORS_TRUSTED_ORIGINS: ${GARDOMATIC_CORS_TRUSTED_ORIGINS:-http://localhost:4040} + GARDOMATIC_SMTP_MODE: ${GARDOMATIC_SMTP_MODE:-file} + GARDOMATIC_SMTP_HOST: ${GARDOMATIC_SMTP_HOST:-} + GARDOMATIC_SMTP_PORT: ${GARDOMATIC_SMTP_PORT:-25} + GARDOMATIC_SMTP_USERNAME: ${GARDOMATIC_SMTP_USERNAME:-} + GARDOMATIC_SMTP_PASSWORD: ${GARDOMATIC_SMTP_PASSWORD:-} + GARDOMATIC_SMTP_SENDER: ${GARDOMATIC_SMTP_SENDER:-gardomatic@localhost} + GARDOMATIC_SMTP_FILE_PATH: ${GARDOMATIC_SMTP_FILE_PATH:-/tmp/gardomatic-mails.log} + ports: + - "${API_PORT:-4000}:4000" + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4000/v1/healthcheck"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + web: + build: + context: . + target: web + environment: + GARDOMATIC_API_BASE_URL: http://api:4000 + GARDOMATIC_ENV: ${GARDOMATIC_ENV:-development} + GARDOMATIC_WEB_PORT: 4040 + GARDOMATIC_SESSION_COOKIE_NAME: ${GARDOMATIC_SESSION_COOKIE_NAME:-gardomatic_session} + GARDOMATIC_COOKIE_SECURE: ${GARDOMATIC_COOKIE_SECURE:-false} + ports: + - "${WEB_PORT:-4040}:4040" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:4040/ping"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + +volumes: + gardomatic-postgres-data: + driver: local diff --git a/config.mk.example b/config.mk.example new file mode 100644 index 0000000..f3c40e3 --- /dev/null +++ b/config.mk.example @@ -0,0 +1,17 @@ +# Copy this file to config.mk. It contains non-secret Make configuration only. + +# Remote SSH administrator supplied by the hoster or LXC configuration. Use +# root, ubuntu, or another account with passwordless sudo. This is deliberately +# not the unprivileged gardomatic service account. +PRODUCTION_HOST = +PRODUCTION_SSH_USER = root +PRODUCTION_SSH_PORT = 22 + +# Optional private key path on the local workstation. Leave empty to use the +# SSH agent and normal ~/.ssh/config resolution. Paths must not contain spaces. +PRODUCTION_SSH_IDENTITY_FILE = + +# Target operating system and architecture. Supported provisioning architectures +# are amd64 and arm64. +PRODUCTION_GOOS = linux +PRODUCTION_GOARCH = amd64 diff --git a/doc/cli.md b/doc/cli.md new file mode 100644 index 0000000..e62f4fd --- /dev/null +++ b/doc/cli.md @@ -0,0 +1,87 @@ +# Gardomatic administration CLI + +The CLI connects directly to PostgreSQL and is implemented in `cmd/cli`. + +## Configuration + +`GARDOMATIC_DB_DSN` is required for every database command. The following +variables are optional: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `GARDOMATIC_ENV` | `development` | Enables confirmations for risky production operations | +| `GARDOMATIC_WEB_BASE_URL` | `http://localhost:4040` | Base URL for activation links | +| `GARDOMATIC_SMTP_MODE` | `file` | Mail delivery mode (`file` or `smtp`) | +| `GARDOMATIC_SMTP_HOST` | empty | SMTP server hostname | +| `GARDOMATIC_SMTP_PORT` | `25` | SMTP server port | +| `GARDOMATIC_SMTP_USERNAME` | empty | SMTP username | +| `GARDOMATIC_SMTP_PASSWORD` | empty | SMTP password | +| `GARDOMATIC_SMTP_SENDER` | `gardomatic@localhost` | Sender address | +| `GARDOMATIC_SMTP_FILE_PATH` | `/tmp/gardomatic-mails.log` | Development mail output | + +Global flags can override the DSN, environment, and public URL. Global flags +must appear before the command. Use `--json` for machine-readable output and +`--yes` to confirm an explicitly configured production operation. + +## Examples + +Create an invited user and print a generated initial password: + +```sh +go run ./cmd/cli users create \ + --name "Alice Example" \ + --email alice@example.com \ + --invite \ + --generate-password +``` + +Create the initial active application administrator atomically: + +```sh +go run ./cmd/cli users create \ + --name "Initial Admin" \ + --email admin@example.com \ + --role application:admin \ + --active \ + --generate-password +``` + +Create an active development user and read the password without exposing it in +the process list: + +```sh +printf '%s\n' 'correct horse battery staple' | \ + go run ./cmd/cli users create \ + --name "Development User" \ + --email dev@example.com \ + --active \ + --password-stdin +``` + +Without `--password-stdin` or `--generate-password`, the CLI securely prompts +for the password twice. An invitation is printed by default; add `--send-email` +to deliver it using the configured mail backend. + +Other common operations: + +```sh +go run ./cmd/cli users list +go run ./cmd/cli users show --email alice@example.com +go run ./cmd/cli users invite --email alice@example.com --send-email +go run ./cmd/cli users activate --email alice@example.com +go run ./cmd/cli users deactivate --email alice@example.com +go run ./cmd/cli users reset-password --email alice@example.com --generate-password +go run ./cmd/cli users set-role --email alice@example.com --role application:admin +go run ./cmd/cli gardens add-user --garden-id 3 --email alice@example.com --role admin +go run ./cmd/cli db ping +``` + +`users create` requires exactly one of `--active` and `--invite` and accepts +`application:user` or `application:admin` as `--role`. User creation, its role, +and its activation token are committed in one database transaction. Password +reset invalidates existing bearer and password reset tokens. The CLI never +accepts passwords as command-line arguments. + +Application roles (`application:user`, `application:admin`) are independent of +garden roles (`owner`, `admin`, `member`, `viewer`, `worker`). `gardens add-user` +uses `member` when `--role` is omitted. diff --git a/doc/ideen.md b/doc/ideen.md new file mode 100644 index 0000000..471b05b --- /dev/null +++ b/doc/ideen.md @@ -0,0 +1,28 @@ +# Muss +- Während man in den Admineinstellungen ist, soll der aktuelle Garten beibehalten werden + +# Demnächst und konkret +- Garten bearbeiten soll auch mit dem Menü Links ausgestattet werden +- Link auf git und kleiax.de +- Seite mit Informationen zu Server Version etc Serverzeit +- Die Instanzrolle brauchen noch eine Berechtigung, ob Gärten angelegt werden dürfen + +# Vielleicht +- Detailansicht und Bearbeitenansicht trennen? +- Impressum + +# Unklar +- pickieren? zwischen aufgabe und in stammdaten entscheiden + +# Später +- Import von Pflanzen-Details z.B Pflanzmich.de und Naturadatenbank +- Bewässerung direkt an die Orte binden + - Auflösen welche Pflanzen dadurch automatisch bewässert werden + - Zapfstellen könnten auch ein Ort sein +- Datenschutz seite + +# Refactoring +- Alle Migrationen für die v1.0 zusammenfassen +- Module prüfen auf logisch Trennung, welche Module wären wiederverwendbar? Mailer ist ein schwieriger Fall, weil spezifische templates drin sind. +- neues Repo für v1.0 +- gesamtes projekt dokumentieren \ No newline at end of file diff --git a/doc/notes.md b/doc/notes.md new file mode 100644 index 0000000..846e8f2 --- /dev/null +++ b/doc/notes.md @@ -0,0 +1,5 @@ +#Migrate +migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable up +migrate -path=./internal/storage/postgres/migrations -database=postgres://gardomatic:pa55word@localhost/gardomatic?sslmode=disable force 1 +authentication -> Wer ist es? +authorization -> Darf er das? diff --git a/doc/planung.md b/doc/planung.md new file mode 100644 index 0000000..ff1428e --- /dev/null +++ b/doc/planung.md @@ -0,0 +1,121 @@ +# Gardomatic — Projektbeschreibung und Fahrplan + +*Planungsstand: 01.09.2026* + +## Projektbeschreibung + +Gardomatic ist eine mobile, mehrbenutzerfähige Webanwendung zur Organisation von +Gärten. Nutzer verwalten Gärten, Arten und Sorten, konkrete Pflanzen, Pflanzorte und +anstehende Arbeiten. Artenbezogene Aufgabenvorlagen erzeugen passend zum Kalender, +zum Pflanzdatum oder zur letzten Erledigung automatisch konkrete Aufgaben. Ein Garten +bildet dabei einen abgeschlossenen Daten- und Berechtigungsraum. + +Die Anwendung besteht aus einer JSON-API und einem serverseitig gerenderten +Web-Client in Go. PostgreSQL ist die einzige unterstützte Datenbank. Das Frontend +ist mobile-first, funktioniert in den wesentlichen Abläufen ohne JavaScript und +nutzt htmx für komfortable Teilaktualisierungen. Die installierbare PWA bietet eine +Offline-App-Shell; die eigentlichen Gartendaten bleiben serverseitig geführt. + +Gardomatic richtet sich an Einzelpersonen und kleine Gruppen, die einen oder mehrere +Gärten gemeinsam pflegen. Zeitfenster statt starrer Einzeltermine bilden +gärtnerische Arbeiten realistisch ab. Rollen, Einladungen und konsequente +Gartenisolierung ermöglichen eine sichere Zusammenarbeit. + +## Verbindliche Leitplanken + +- Go mit `internal/`-Layout und getrennten Binaries für API, Web und Administration. +- PostgreSQL 16 mit `database/sql`, `lib/pq` und versionierten SQL-Migrationen. +- Garten-ID im Pfad (`/v1/gardens/{gardenID}/...` und `/g/{gardenID}/...`). +- Fremde Garten-IDs liefern 404; unzulässige Aktionen innerhalb eines sichtbaren + Gartens liefern 403. +- Gartenrollen sind `owner`, `admin`, `member` und `viewer`. +- Artenstammdaten und konkrete Pflanzen bleiben getrennt; Pflanzen dürfen ohne Art + angelegt werden. +- Aufgaben verwenden Fälligkeitsfenster. Wiederkehrende Zeiträume dürfen den + Jahreswechsel überspannen. +- Aus Vorlagen erzeugte Aufgaben bleiben idempotent. Ein separater Scheduler wird + erst bei nachgewiesenem Bedarf eingeführt. +- Authentifizierung und Autorisierung liegen in der API. Das Web reicht nur das + Session-Cookie über einen request-spezifischen API-Client weiter. +- Neue Facharbeit wird als vollständiger Vertical Slice aus Migration, Storage, + API, Client, Web und Tests umgesetzt. + +## Weiterer Fahrplan + +### 1. Kalenderbereitstellung über CalDAV + +**Ziel:** Anstehende Aufgaben können in vorhandenen Kalender- und +Aufgabenanwendungen abonniert werden. + +Vorgeschlagene Umsetzung: + +1. Zuerst einen technischen Prototyp mit einer nur lesbaren Collection pro Nutzer + und Garten erstellen. Die Collection erhält stabile Ressourcen-IDs, ETags und + separat widerrufbare Zugangsdaten. +2. Aufgaben als `VTODO` abbilden: Titel, Beschreibung, Fälligkeitsfenster, + Priorität, Status sowie Pflanzen- und Ortsbezug. Gartenrollen begrenzen auch hier + die sichtbaren Daten. +3. Optional eine schreibgeschützte `VEVENT`-Ansicht für Clients ergänzen, die + `VTODO` nicht sinnvoll darstellen. Mehrtägige Fälligkeitsfenster bleiben dabei + als Zeiträume erkennbar. +4. Einrichtung und Widerruf im Benutzerkonto ergänzen und mindestens mit DAVx⁵, + Apple Kalender/Erinnerungen und Thunderbird prüfen. +5. Schreibzugriffe bewusst zurückstellen, bis Konfliktauflösung, Optimistic + Locking, Zeitzonen und die Semantik externer Erledigungen festgelegt sind. + +**Abnahmekriterien:** Eine gartenbezogene, nur lesbare Collection lässt sich in +mindestens zwei unterstützten Clients einrichten; Änderungen erscheinen nach der +Synchronisation; widerrufene Zugänge und Zugriffe auf fremde Gärten funktionieren +nicht. + +### 2. Erstes Erweiterungsmodul: Bewässerung + +**Ziel:** Den modularen Ausbau mit einem klar abgegrenzten Anwendungsfall erproben. + +Vorgeschlagener Umfang: + +1. Bewässerungszonen und deren Zuordnung zu vorhandenen Orten modellieren. +2. Manuelle Laufzeiten sowie einfache, ausführbare Zeitprogramme bereitstellen. +3. Tabellen mit `mod_irrigation_` benennen und Routen, Migrationen und + Berechtigungsprüfungen vom Kern abgrenzen. +4. Maximale Laufzeit, konkurrierende Programme und Verhalten bei fehlender + Geräteverbindung definieren, bevor reale Ventile angesteuert werden. +5. Hardware-Anbindung und Wetterautomatik erst nach einer nutzbaren manuellen + Planung auswählen. Ein allgemeines Modul-Interface erst einführen, wenn ein + zweites Modul tatsächlich gemeinsame Hooks benötigt. + +**Abnahmekriterien:** Das Modul kann deaktiviert werden, ohne Kernfunktionen zu +beeinträchtigen; Planung und manuelle Ausführung sind nutzbar; Sicherheitsgrenzen +sind automatisiert getestet. + +### 3. Aufgabenhistorie und Auswertung + +**Ziel:** Wiederkehrende Gartenarbeit später auswertbar machen, ohne den aktuellen +Aufgabenablauf unnötig zu verkomplizieren. + +Vorgeschlagene Umsetzung: + +1. Vorab entscheiden, welche Fragen beantwortet werden sollen, beispielsweise + Erledigungen pro Zeitraum, Pflanze oder Ort. +2. Erst danach ein append-only Ereignismodell für Erledigen, Wiederöffnen und + Terminänderungen entwerfen. +3. Einen kompakten Export und wenige zielgerichtete Auswertungen vor komplexen + Dashboards priorisieren. + +**Abnahmekriterien:** Historische Daten verändern den aktuellen Aufgabenstatus nicht +und bleiben gartenisoliert; die gewählten Auswertungen sind durch reale +Nutzerfragen begründet. + +## Empfohlene Reihenfolge + +1. Als nächstes den lesenden CalDAV-Prototypen umsetzen und früh mit realen Clients + testen. Das reduziert das größte technische Kompatibilitätsrisiko. +2. Danach den CalDAV-Zugang im Benutzerkonto produktionsreif machen und + dokumentieren. +3. Anschließend das Bewässerungsmodul zunächst ohne Hardwareintegration liefern. +4. Aufgabenhistorie und Reporting erst beginnen, wenn konkrete Auswertungsfragen + gesammelt wurden. + +Bidirektionales CalDAV, herstellerspezifische Bewässerungshardware und eine +allgemeine Plugin-Architektur sind ausdrücklich keine Bestandteile der jeweils +ersten Ausbaustufe. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b060461 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module gardomatic.kleiax.de + +go 1.26.0 + +require ( + github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de + github.com/alexedwards/scs/v2 v2.9.0 + github.com/go-playground/form/v4 v4.3.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/justinas/alice v1.2.0 + github.com/justinas/nosurf v1.2.0 + github.com/lib/pq v1.12.3 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce + github.com/wneessen/go-mail v0.8.1 + github.com/yuin/goldmark v1.8.5 + golang.org/x/crypto v0.55.0 + golang.org/x/term v0.45.0 + golang.org/x/time v0.15.0 +) + +require ( + github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect + golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.48.0 // indirect + honnef.co/go/tools v0.8.1 // indirect +) + +tool honnef.co/go/tools/cmd/staticcheck diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f757092 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de h1:LDrMkjj4OCCQsq9SvIPQV1l3leMxqXZTCTxDFwMrqTE= +github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk= +github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= +github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= +github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= +github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= +github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ= +golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +honnef.co/go/tools v0.8.1 h1:+JKf3xJ1ni4CwrhVg4/pqsfPGP6vNAXcKbMXJodYx3w= +honnef.co/go/tools v0.8.1/go.mod h1:XA+OnlRA9EDh/ukGvXMNSZNKGwFQJ+5dER0ioUkOxks= diff --git a/internal/api/account.go b/internal/api/account.go new file mode 100644 index 0000000..21a4d66 --- /dev/null +++ b/internal/api/account.go @@ -0,0 +1,250 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type accountSession struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Current bool `json:"current"` +} + +func (app *application) listAccountSessionsHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + currentID := app.sessions.GetString(r.Context(), accountSessionIDKey) + sessions := make([]accountSession, 0) + err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { + if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) != user.ID { + return nil + } + id := app.sessions.GetString(ctx, accountSessionIDKey) + if id == "" { + return nil + } + sessions = append(sessions, accountSession{ + ID: id, + CreatedAt: time.Unix(app.sessions.GetInt64(ctx, accountSessionCreatedAtKey), 0).UTC(), + ExpiresAt: app.sessions.Deadline(ctx), + Current: id == currentID, + }) + return nil + }) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"sessions": sessions}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAccountSessionHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + targetID := httprouter.ParamsFromContext(r.Context()).ByName("sessionID") + if targetID == app.sessions.GetString(r.Context(), accountSessionIDKey) { + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + found := false + err := app.sessions.Iterate(r.Context(), func(ctx context.Context) error { + if app.sessions.GetInt(ctx, authenticatedUserIDSessionKey) == user.ID && app.sessions.GetString(ctx, accountSessionIDKey) == targetID { + found = true + return app.sessions.Destroy(ctx) + } + return nil + }) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !found { + app.notFoundResponse(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) updateAccountProfileHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Name string `json:"name"` + Color string `json:"color"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user.Name = strings.TrimSpace(input.Name) + if color := strings.TrimSpace(input.Color); color != "" { + user.Color = color + } + v := validate.New() + storage.ValidateUser(v, user) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + updated, err := app.models.Users.Update(user) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAccountPasswordHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + v := validate.New() + auth.ValidatePasswordPlaintext(v, input.CurrentPassword) + auth.ValidatePasswordPlaintext(v, input.NewPassword) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + match, err := user.Password.Matches(input.CurrentPassword) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !match { + app.invalidCredentialsResponse(w, r) + return + } + if err = user.Password.Set(input.NewPassword); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if _, err = app.models.Users.Update(user); err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"message": "password updated"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) requestAccountEmailChangeHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Email string `json:"email"` + CurrentPassword string `json:"current_password"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.CurrentPassword) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + match, err := user.Password.Matches(input.CurrentPassword) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !match { + app.invalidCredentialsResponse(w, r) + return + } + if existing, lookupErr := app.models.Users.GetByEmail(input.Email); lookupErr == nil && existing.ID != user.ID { + v.AddError("email", "email address is already registered") + app.failedValidationResponse(w, r, v.Errors) + return + } else if lookupErr != nil && !errors.Is(lookupErr, storage.ErrRecordNotFound) { + app.serverErrorResponse(w, r, lookupErr) + return + } + token, err := app.models.Users.CreateEmailChange(user.ID, input.Email, 45*time.Minute) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + app.background(func() { + data := map[string]any{"confirmationURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/account/email-confirm?token=" + token} + if sendErr := app.mailer.Send(input.Email, "email_change.tmpl", data); sendErr != nil { + app.logger.Error(sendErr.Error()) + } + }) + if err = app.writeJSON(w, http.StatusAccepted, envelope{"message": "confirmation email sent"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) confirmAccountEmailHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + var input struct { + Token string `json:"token"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + v := validate.New() + auth.ValidateTokenPlaintext(v, input.Token) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + updated, err := app.models.Users.ConfirmEmailChange(input.Token, user.ID) + if err != nil { + app.respondToAccountError(w, r, err) + return + } + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeAuthentication, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"user": updated}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) respondToAccountError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrDuplicateEmail): + app.failedValidationResponse(w, r, map[string]string{"email": "email address is already registered"}) + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 0000000..690e0a9 --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,153 @@ +package api + +import ( + "crypto/rand" + "errors" + "net/http" + "net/url" + "strings" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +const adminInvitationTTL = 3 * 24 * time.Hour + +func (app *application) listAdminUsersHandler(w http.ResponseWriter, r *http.Request) { + users, err := app.models.Users.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"users": users}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) inviteAdminUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Email string `json:"email"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Name = strings.TrimSpace(input.Name) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + candidate := storage.User{Name: input.Name, Email: input.Email, Activated: false} + if passwordErr := candidate.Password.Set(rand.Text()); passwordErr != nil { + app.serverErrorResponse(w, r, passwordErr) + return + } + v := validate.New() + if storage.ValidateUser(v, candidate); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + switch { + case err == nil && user.Activated: + app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) + return + case err == nil: + // Re-sending for an inactive account lets an administrator recover from + // an earlier delivery failure without creating a duplicate account. + case errors.Is(err, storage.ErrRecordNotFound): + user, err = app.models.Users.Insert(candidate) + if err != nil { + if errors.Is(err, storage.ErrDuplicateEmail) { + app.failedValidationResponse(w, r, map[string]string{"email": "a user with this email address already exists"}) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + default: + app.serverErrorResponse(w, r, err) + return + } + + if err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID); err != nil { + app.serverErrorResponse(w, r, err) + return + } + token, err := app.models.Tokens.New(user.ID, adminInvitationTTL, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + activationURL := strings.TrimRight(app.config.WebBaseURL, "/") + "/activate?token=" + url.QueryEscape(token.Plaintext) + "&set-password=1" + if err = app.mailer.Send(user.Email, "user_invitation.tmpl", map[string]any{"name": user.Name, "activationURL": activationURL}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminUserRoleHandler(w http.ResponseWriter, r *http.Request) { + userID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetAuthenticatedUser(r) + if actor.ID == userID { + app.permissionDeniedResponse(w, r) + return + } + var input struct { + Role storage.ApplicationRole `json:"role"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + role, err := app.models.Roles.Get(string(input.Role)) + if err != nil || role.Scope != storage.RoleScopeApplication { + app.failedValidationResponse(w, r, map[string]string{"role": "must be an application role"}) + return + } + user, err := app.models.Users.UpdateRole(userID, input.Role) + if err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + app.notFoundResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAdminUserHandler(w http.ResponseWriter, r *http.Request) { + userID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetAuthenticatedUser(r) + if actor.ID == userID { + app.permissionDeniedResponse(w, r) + return + } + if err = app.models.Users.Delete(userID); err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/admin_environment.go b/internal/api/admin_environment.go new file mode 100644 index 0000000..04e9a33 --- /dev/null +++ b/internal/api/admin_environment.go @@ -0,0 +1,78 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type environmentVariable struct { + Component string `json:"component"` + Name string `json:"name"` + Value string `json:"value"` +} + +func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *http.Request) { + variables := []environmentVariable{ + {Component: "API", Name: "GARDOMATIC_ENV", Value: app.config.Env}, + {Component: "API", Name: "GARDOMATIC_DB_DSN", Value: maskedValue(app.config.DB.Dsn)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_OPEN_CONNS", Value: fmt.Sprint(app.config.DB.MaxOpenConns)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_CONNS", Value: fmt.Sprint(app.config.DB.MaxIdleConns)}, + {Component: "API", Name: "GARDOMATIC_DB_MAX_IDLE_TIME", Value: app.config.DB.MaxIdleTime.String()}, + {Component: "API", Name: "GARDOMATIC_API_HOST", Value: app.config.Host}, + {Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)}, + {Component: "API", Name: "GARDOMATIC_WEB_BASE_URL", Value: app.config.WebBaseURL}, + {Component: "API", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.Session.CookieName}, + {Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()}, + {Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()}, + {Component: "API", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.Session.CookieSecure)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_ENABLED", Value: fmt.Sprint(app.config.Limiter.Enabled)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_RPS", Value: fmt.Sprint(app.config.Limiter.Rps)}, + {Component: "API", Name: "GARDOMATIC_RATE_LIMIT_BURST", Value: fmt.Sprint(app.config.Limiter.Burst)}, + {Component: "API", Name: "GARDOMATIC_CORS_TRUSTED_ORIGINS", Value: strings.Join(app.config.Cors.TrustedOrigins, ",")}, + {Component: "API", Name: "GARDOMATIC_SMTP_MODE", Value: string(app.config.Mail.Mode)}, + {Component: "API", Name: "GARDOMATIC_SMTP_HOST", Value: app.config.Mail.Host}, + {Component: "API", Name: "GARDOMATIC_SMTP_PORT", Value: fmt.Sprint(app.config.Mail.Port)}, + {Component: "API", Name: "GARDOMATIC_SMTP_USERNAME", Value: app.config.Mail.Username}, + {Component: "API", Name: "GARDOMATIC_SMTP_PASSWORD", Value: maskedValue(app.config.Mail.Password)}, + {Component: "API", Name: "GARDOMATIC_SMTP_SENDER", Value: app.config.Mail.Sender}, + {Component: "API", Name: "GARDOMATIC_SMTP_FILE_PATH", Value: app.config.Mail.FilePath}, + } + if err := app.writeJSON(w, http.StatusOK, envelope{"variables": variables}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func maskedValue(value string) string { + if value == "" { + return "(nicht gesetzt)" + } + return "•••••••• (gesetzt)" +} + +func (app *application) sendAdminTestMailHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + if err := app.mailer.Send(input.Email, "test_mail.tmpl", nil); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusAccepted, envelope{"message": "test mail sent"}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go new file mode 100644 index 0000000..89e9d42 --- /dev/null +++ b/internal/api/admin_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type adminInviteUserModel struct { + sessionTestUserModel + stored storage.User + deletedID int + deleteErr error +} + +func (m *adminInviteUserModel) Insert(user storage.User) (storage.User, error) { + user.ID = 42 + m.stored = user + return user, nil +} + +func (m *adminInviteUserModel) GetByEmail(email string) (storage.User, error) { + if m.stored.ID == 0 || m.stored.Email != email { + return storage.User{}, storage.ErrRecordNotFound + } + return m.stored, nil +} + +func (m *adminInviteUserModel) GetForToken(scope, token string) (storage.User, error) { + if scope != auth.ScopeActivation || token == "" || m.stored.ID == 0 { + return storage.User{}, storage.ErrRecordNotFound + } + return m.stored, nil +} + +func (m *adminInviteUserModel) Update(user storage.User) (storage.User, error) { + m.stored = user + return user, nil +} + +func (m *adminInviteUserModel) Delete(userID int) error { + m.deletedID = userID + return m.deleteErr +} + +type adminInviteTokenModel struct { + token auth.Token + deleted bool +} + +func (m *adminInviteTokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) { + m.token = auth.NewToken(userID, ttl, scope) + return m.token, nil +} + +func (m *adminInviteTokenModel) DeleteAllForUser(scope string, userID int) error { + m.deleted = scope == auth.ScopeActivation && userID == 42 + return nil +} + +func TestAdminInvitationCreatesAccountAndAllowsInitialPassword(t *testing.T) { + users := new(adminInviteUserModel) + tokens := new(adminInviteTokenModel) + mailPath := filepath.Join(t.TempDir(), "mail.log") + configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath}) + if err != nil { + t.Fatal(err) + } + app, _, _ := newGardenTestApplication() + app.config.WebBaseURL = "https://garden.example.com" + app.models.Users = users + app.models.Tokens = tokens + app.mailer = configuredMailer + + inviteResponse := httptest.NewRecorder() + inviteRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":" Ada ","email":" ADA@EXAMPLE.COM "}`)) + app.inviteAdminUserHandler(inviteResponse, inviteRequest) + if inviteResponse.Code != http.StatusAccepted { + t.Fatalf("invite status: got %d, want %d; body: %s", inviteResponse.Code, http.StatusAccepted, inviteResponse.Body.String()) + } + if users.stored.Name != "Ada" || users.stored.Email != "ada@example.com" || users.stored.Activated { + t.Fatalf("unexpected invited user: %+v", users.stored) + } + if !tokens.deleted || tokens.token.UserID != users.stored.ID || tokens.token.Scope != auth.ScopeActivation { + t.Fatalf("unexpected invitation token: %+v", tokens.token) + } + firstToken := tokens.token.Plaintext + resendResponse := httptest.NewRecorder() + resendRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) + app.inviteAdminUserHandler(resendResponse, resendRequest) + if resendResponse.Code != http.StatusAccepted { + t.Fatalf("resend status: got %d, want %d; body: %s", resendResponse.Code, http.StatusAccepted, resendResponse.Body.String()) + } + if tokens.token.Plaintext == firstToken { + t.Fatal("resending did not replace the activation token") + } + mailContent, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"ada@example.com", "Einladung zu Gardomatic", "set-password=3D1", tokens.token.Plaintext} { + if !strings.Contains(string(mailContent), want) { + t.Errorf("invitation is missing %q: %s", want, mailContent) + } + } + + activateResponse := httptest.NewRecorder() + activateBody := `{"token":"` + tokens.token.Plaintext + `","password":"correct horse battery staple"}` + activateRequest := httptest.NewRequest(http.MethodPut, "/v1/users/activated", strings.NewReader(activateBody)) + app.activateUserHandler(activateResponse, activateRequest) + if activateResponse.Code != http.StatusOK { + t.Fatalf("activation status: got %d, want %d; body: %s", activateResponse.Code, http.StatusOK, activateResponse.Body.String()) + } + if !users.stored.Activated { + t.Fatal("invited user was not activated") + } + matches, err := users.stored.Password.Matches("correct horse battery staple") + if err != nil || !matches { + t.Fatalf("initial password was not stored: matches=%t err=%v", matches, err) + } + + duplicateResponse := httptest.NewRecorder() + duplicateRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/users", strings.NewReader(`{"name":"Ada","email":"ada@example.com"}`)) + app.inviteAdminUserHandler(duplicateResponse, duplicateRequest) + if duplicateResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("active duplicate status: got %d, want %d; body: %s", duplicateResponse.Code, http.StatusUnprocessableEntity, duplicateResponse.Body.String()) + } +} + +func TestDeleteAdminUserProtectsSelfAndReportsOwnerConflict(t *testing.T) { + users := new(adminInviteUserModel) + app, _, _ := newGardenTestApplication() + app.models.Users = users + router := httprouter.New() + router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.deleteAdminUserHandler) + + serve := func(actorID, targetID int) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodDelete, "/v1/admin/users/"+strconv.Itoa(targetID), nil) + request = app.contextSetAuthenticatedUser(request, storage.User{ID: actorID}) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response + } + + if response := serve(7, 7); response.Code != http.StatusForbidden || users.deletedID != 0 { + t.Fatalf("self deletion: status=%d deleted=%d", response.Code, users.deletedID) + } + users.deleteErr = storage.ErrConflict + if response := serve(7, 8); response.Code != http.StatusConflict || users.deletedID != 8 { + t.Fatalf("owner deletion: status=%d deleted=%d", response.Code, users.deletedID) + } + users.deleteErr = nil + if response := serve(7, 9); response.Code != http.StatusNoContent || users.deletedID != 9 { + t.Fatalf("deletion: status=%d deleted=%d", response.Code, users.deletedID) + } +} diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..f8374fc --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,177 @@ +package api + +import ( + "context" + "database/sql" + "expvar" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + "sync" + "time" + + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" + "gardomatic.kleiax.de/internal/storage/postgres" + "gardomatic.kleiax.de/internal/vcs" + + "github.com/alexedwards/scs/postgresstore" + "github.com/alexedwards/scs/v2" + _ "github.com/lib/pq" +) + +var ( + version = vcs.Version() +) + +// Config contains API server, database, session, mail, and security settings. +type Config struct { + Host string + Port int + Env string + WebBaseURL string + DB DatabaseConfig + Limiter LimiterConfig + Session SessionConfig + Mail mailer.Config + Cors CORSConfig +} + +// DatabaseConfig controls the PostgreSQL connection pool. +type DatabaseConfig struct { + Dsn string + MaxOpenConns int + MaxIdleConns int + MaxIdleTime time.Duration +} + +// LimiterConfig controls per-client HTTP rate limiting. +type LimiterConfig struct { + Enabled bool + Rps float64 + Burst int +} + +// SessionConfig controls the session cookie and server-side session lifetime. +type SessionConfig struct { + Lifetime time.Duration + IdleTimeout time.Duration + CookieName string + CookieSecure bool +} + +// CORSConfig lists origins allowed to make cross-origin API requests. +type CORSConfig struct { + TrustedOrigins []string +} + +type application struct { + config Config + logger *slog.Logger + models storage.Models + mailer *mailer.Mailer + sessions *scs.SessionManager + wg sync.WaitGroup + // db is retained by the application so Run can close the connection pool. + db *sql.DB +} + +// New initializes the API application and its database-backed dependencies. +func New(cfg Config) *application { + var handler slog.Handler = slog.NewTextHandler(os.Stdout, nil) + if cfg.Env == "production" { + handler = slog.NewJSONHandler(os.Stdout, nil) + } + logger := slog.New(handler) + + db, err := openDB(cfg) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + logger.Info("database connection pool established") + + expvar.NewString("version").Set(version) + + expvar.Publish("goroutines", expvar.Func(func() any { + return runtime.NumGoroutine() + })) + + expvar.Publish("database", expvar.Func(func() any { + return db.Stats() + })) + + expvar.Publish("timestamp", expvar.Func(func() any { + return time.Now().Unix() + })) + + mailer, err := mailer.New(cfg.Mail) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + sessions := scs.New() + sessions.Store = postgresstore.New(db) + sessions.Lifetime = cfg.Session.Lifetime + sessions.IdleTimeout = cfg.Session.IdleTimeout + sessions.HashTokenInStore = true + sessions.Cookie.Name = cfg.Session.CookieName + sessions.Cookie.HttpOnly = true + sessions.Cookie.SameSite = http.SameSiteLaxMode + sessions.Cookie.Secure = cfg.Session.CookieSecure + + app := &application{ + config: cfg, + logger: logger, + models: postgres.New(db), + mailer: mailer, + sessions: sessions, + db: db, + } + + sessions.ErrorFunc = app.serverErrorResponse + + return app +} + +// Run serves API requests until shutdown and then releases application resources. +func (app *application) Run() { + defer app.db.Close() + + err := app.serve() + if err != nil { + app.logger.Error(err.Error()) + os.Exit(1) + } +} + +// Version returns the version embedded in the API binary. +func (app *application) Version() string { + return fmt.Sprintf("Version:\t%s\n", version) +} + +func openDB(cfg Config) (*sql.DB, error) { + db, err := sql.Open("postgres", cfg.DB.Dsn) + if err != nil { + return nil, err + } + + db.SetMaxOpenConns(cfg.DB.MaxOpenConns) + db.SetMaxIdleConns(cfg.DB.MaxIdleConns) + db.SetConnMaxIdleTime(cfg.DB.MaxIdleTime) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.PingContext(ctx) + if err != nil { + db.Close() + return nil, err + } + + return db, nil +} diff --git a/internal/api/application_settings.go b/internal/api/application_settings.go new file mode 100644 index 0000000..4b63575 --- /dev/null +++ b/internal/api/application_settings.go @@ -0,0 +1,100 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type applicationSettingsInput struct { + LifecycleStatusEnabled *bool `json:"lifecycle_status_enabled"` + LifecycleRemovalMonth *int `json:"lifecycle_removal_month"` + LifecycleRemovalDay *int `json:"lifecycle_removal_day"` + Timezone *string `json:"timezone"` +} + +func (input applicationSettingsInput) apply(settings *storage.ApplicationSettings) { + if input.LifecycleStatusEnabled != nil { + settings.LifecycleStatusEnabled = *input.LifecycleStatusEnabled + } + if input.LifecycleRemovalMonth != nil { + settings.LifecycleRemovalMonth = *input.LifecycleRemovalMonth + } + if input.LifecycleRemovalDay != nil { + settings.LifecycleRemovalDay = *input.LifecycleRemovalDay + } + if input.Timezone != nil { + settings.Timezone = strings.TrimSpace(*input.Timezone) + } +} + +func (app *application) showAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) { + settings, err := app.models.ApplicationSettings.Get() + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminApplicationSettingsHandler(w http.ResponseWriter, r *http.Request) { + settings, err := app.models.ApplicationSettings.Get() + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input applicationSettingsInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&settings) + v := validate.New() + storage.ValidateApplicationSettings(v, settings) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + settings, err = app.models.ApplicationSettings.Update(settings) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"settings": settings}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) runLifecycleMaintenance(now time.Time) error { + if app.models.ApplicationSettings == nil { + return nil + } + settings, err := app.models.ApplicationSettings.Get() + if err != nil || !settings.LifecycleStatusEnabled { + return err + } + location, err := time.LoadLocation(settings.Timezone) + if err != nil { + return err + } + localNow := now.In(location) + lastDay := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth)+1, 0, 0, 0, 0, 0, location).Day() + day := settings.LifecycleRemovalDay + if day > lastDay { + day = lastDay + } + cutoff := time.Date(localNow.Year(), time.Month(settings.LifecycleRemovalMonth), day, 0, 0, 0, 0, location) + if localNow.Before(cutoff) { + return nil + } + count, err := app.models.ApplicationSettings.RemoveExpiredPlants(cutoff, settings.LifecycleRemovalMonth, day) + if err == nil && count > 0 { + app.logger.Info("removed plants which reached their configured lifecycle", "count", count, "cutoff", cutoff.Format("2006-01-02")) + } + return err +} diff --git a/internal/api/application_settings_test.go b/internal/api/application_settings_test.go new file mode 100644 index 0000000..26f43b9 --- /dev/null +++ b/internal/api/application_settings_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/mailer" + "gardomatic.kleiax.de/internal/storage" +) + +type applicationSettingsTestModel struct { + settings storage.ApplicationSettings + calls int + asOf time.Time +} + +func (m *applicationSettingsTestModel) Get() (storage.ApplicationSettings, error) { + return m.settings, nil +} +func (m *applicationSettingsTestModel) Update(value storage.ApplicationSettings) (storage.ApplicationSettings, error) { + m.settings = value + return value, nil +} +func (m *applicationSettingsTestModel) RemoveExpiredPlants(asOf time.Time, _, _ int) (int, error) { + m.calls++ + m.asOf = asOf + return 2, nil +} + +func TestLifecycleMaintenanceHonorsEnabledSettingAndCutoff(t *testing.T) { + app, _, _ := newGardenTestApplication() + model := &applicationSettingsTestModel{settings: storage.ApplicationSettings{LifecycleStatusEnabled: true, LifecycleRemovalMonth: 12, LifecycleRemovalDay: 1, Timezone: "Europe/Berlin"}} + app.models.ApplicationSettings = model + + if err := app.runLifecycleMaintenance(time.Date(2026, 11, 30, 12, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if model.calls != 0 { + t.Fatalf("maintenance ran before cutoff") + } + if err := app.runLifecycleMaintenance(time.Date(2026, 12, 1, 12, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if model.calls != 1 || model.asOf.Format("2006-01-02") != "2026-12-01" { + t.Fatalf("maintenance calls=%d cutoff=%v", model.calls, model.asOf) + } +} + +func TestShowAdminEnvironmentMasksSecrets(t *testing.T) { + app, _, _ := newGardenTestApplication() + app.config = Config{ + DB: DatabaseConfig{Dsn: "postgres://user:secret@db/gardomatic"}, + Mail: mailer.Config{Password: "smtp-secret"}, + } + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/admin/environment", nil) + + app.showAdminEnvironmentHandler(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", response.Code, http.StatusOK) + } + body := response.Body.String() + if strings.Contains(body, "secret") { + t.Fatalf("environment response exposes a secret: %s", body) + } + var result struct { + Variables []environmentVariable `json:"variables"` + } + if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} { + found := false + for _, variable := range result.Variables { + found = found || variable.Name == name + } + if !found { + t.Errorf("missing environment variable %s", name) + } + } +} + +func TestSendAdminTestMailValidatesAndDelivers(t *testing.T) { + app, _, _ := newGardenTestApplication() + mailPath := filepath.Join(t.TempDir(), "mail.log") + configuredMailer, err := mailer.New(mailer.Config{Mode: mailer.ModeFile, Sender: "gardomatic@example.com", FilePath: mailPath}) + if err != nil { + t.Fatal(err) + } + app.mailer = configuredMailer + + invalidResponse := httptest.NewRecorder() + invalidRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":"not-an-email"}`)) + app.sendAdminTestMailHandler(invalidResponse, invalidRequest) + if invalidResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("invalid status: got %d, want %d; body: %s", invalidResponse.Code, http.StatusUnprocessableEntity, invalidResponse.Body.String()) + } + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/admin/test-mail", strings.NewReader(`{"email":" Test@Example.com "}`)) + app.sendAdminTestMailHandler(response, request) + if response.Code != http.StatusAccepted { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusAccepted, response.Body.String()) + } + content, err := os.ReadFile(mailPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"test@example.com", "Gardomatic Testmail", "Gardomatic funktion"} { + if !strings.Contains(string(content), want) { + t.Errorf("test mail is missing %q: %s", want, content) + } + } +} diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000..778f64e --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1 @@ +package api diff --git a/internal/api/care_instructions.go b/internal/api/care_instructions.go new file mode 100644 index 0000000..3ec13ce --- /dev/null +++ b/internal/api/care_instructions.go @@ -0,0 +1,157 @@ +package api + +import ( + "fmt" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "net/http" + "strings" +) + +type careInstructionInput struct { + Text *string `json:"text"` + Status *string `json:"status"` +} + +func (i careInstructionInput) apply(v *storage.CareInstruction) { + if i.Text != nil { + v.Text = strings.TrimSpace(*i.Text) + } + if i.Status != nil { + v.Status = *i.Status + } +} +func (app *application) validateCareInstruction(w http.ResponseWriter, r *http.Request, item storage.CareInstruction) bool { + v := validate.New() + storage.ValidateCareInstruction(v, item) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} +func (app *application) listCareInstructionsHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + items, e := app.models.CareInstructions.GetAllForSpecies(g, s) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + if e = app.writeJSON(w, http.StatusOK, envelope{"care_instructions": items}, nil); e != nil { + app.serverErrorResponse(w, r, e) + } +} +func (app *application) createCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + var input careInstructionInput + if e = app.readJSON(w, r, &input); e != nil { + app.badRequestResponse(w, r, e) + return + } + u, _ := app.contextGetAuthenticatedUser(r) + item := storage.CareInstruction{SpeciesID: s, Status: "untested", CreatedBy: u.ID, UpdatedBy: u.ID} + input.apply(&item) + if !app.validateCareInstruction(w, r, item) { + return + } + item, e = app.models.CareInstructions.Insert(item) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + h := make(http.Header) + h.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/care-instructions/%d", g, s, item.ID)) + _ = app.writeJSON(w, http.StatusCreated, envelope{"care_instruction": item}, h) +} +func (app *application) updateCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + id, e := app.readNamedIDParam(r, "instructionID") + if e != nil { + app.notFoundResponse(w, r) + return + } + item, e := app.models.CareInstructions.Get(g, s, id) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + var input careInstructionInput + if e = app.readJSON(w, r, &input); e != nil { + app.badRequestResponse(w, r, e) + return + } + input.apply(&item) + u, _ := app.contextGetAuthenticatedUser(r) + item.UpdatedBy = u.ID + if !app.validateCareInstruction(w, r, item) { + return + } + item, e = app.models.CareInstructions.Update(g, item) + if e != nil { + app.respondToEntityModelError(w, r, e) + return + } + _ = app.writeJSON(w, http.StatusOK, envelope{"care_instruction": item}, nil) +} +func (app *application) deleteCareInstructionHandler(w http.ResponseWriter, r *http.Request) { + g, _ := app.readGardenIDParam(r) + s, e := app.readIDParam(r) + if e != nil { + app.notFoundResponse(w, r) + return + } + if !app.canWriteCareInstructions(w, r, g, s) { + return + } + id, e := app.readNamedIDParam(r, "instructionID") + if e != nil { + app.notFoundResponse(w, r) + return + } + if e = app.models.CareInstructions.Delete(g, s, id); e != nil { + app.respondToEntityModelError(w, r, e) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) canWriteCareInstructions(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) bool { + species, err := app.models.Species.Get(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return false + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if species.GardenID == nil { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return false + } + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return false + } + return true +} diff --git a/internal/api/context.go b/internal/api/context.go new file mode 100644 index 0000000..d2033a6 --- /dev/null +++ b/internal/api/context.go @@ -0,0 +1,33 @@ +package api + +import ( + "context" + "net/http" + + "gardomatic.kleiax.de/internal/storage" +) + +type contextKey string + +const authenticatedUserContextKey = contextKey("authenticatedUser") +const gardenMemberContextKey = contextKey("gardenMember") + +func (app *application) contextSetAuthenticatedUser(r *http.Request, user storage.User) *http.Request { + ctx := context.WithValue(r.Context(), authenticatedUserContextKey, user) + return r.WithContext(ctx) +} + +func (app *application) contextGetAuthenticatedUser(r *http.Request) (storage.User, bool) { + user, ok := r.Context().Value(authenticatedUserContextKey).(storage.User) + return user, ok +} + +func (app *application) contextSetGardenMember(r *http.Request, member storage.GardenMember) *http.Request { + ctx := context.WithValue(r.Context(), gardenMemberContextKey, member) + return r.WithContext(ctx) +} + +func (app *application) contextGetGardenMember(r *http.Request) (storage.GardenMember, bool) { + member, ok := r.Context().Value(gardenMemberContextKey).(storage.GardenMember) + return member, ok +} diff --git a/internal/api/doc.go b/internal/api/doc.go new file mode 100644 index 0000000..6c6c354 --- /dev/null +++ b/internal/api/doc.go @@ -0,0 +1,3 @@ +// Package api implements the Gardomatic HTTP API, including authentication, +// authorization middleware, request validation, and JSON response handling. +package api diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..fd94710 --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,99 @@ +package api + +import ( + "fmt" + "net/http" + "runtime/debug" +) + +func (app *application) logError(r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + ) + + app.logger.Error(err.Error(), "method", method, "uri", uri) + fmt.Printf("%v\n", string(debug.Stack())) +} + +func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message any) { + env := envelope{"error": message} + + err := app.writeJSON(w, status, env, nil) + if err != nil { + app.logError(r, err) + w.WriteHeader(500) + } +} + +func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + app.logError(r, err) + + message := "the server encountered a problem and could not process your request" + app.errorResponse(w, r, http.StatusInternalServerError, message) +} + +func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) { + message := "the requested resource could not be found" + app.errorResponse(w, r, http.StatusNotFound, message) +} + +func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + message := fmt.Sprintf("the %s method is not supported for this resource", r.Method) + app.errorResponse(w, r, http.StatusMethodNotAllowed, message) +} + +func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + app.errorResponse(w, r, http.StatusBadRequest, err.Error()) +} + +func (app *application) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} + +func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) { + message := "unable to update the record due to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) conflictResponse(w http.ResponseWriter, r *http.Request) { + message := "a conflicting record already exists" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) rateLimitExceededResponse(w http.ResponseWriter, r *http.Request) { + message := "rate limit exceeded" + app.errorResponse(w, r, http.StatusTooManyRequests, message) +} + +func (app *application) invalidCredentialsResponse(w http.ResponseWriter, r *http.Request) { + message := "invalid authentication credentials" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) invalidAuthenticationTokenResponse(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", "Bearer") + + message := "invalid or missing authentication token" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) authenticationRequiredResponse(w http.ResponseWriter, r *http.Request) { + message := "you must be authenticated to access this resource" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) inactiveAccountResponse(w http.ResponseWriter, r *http.Request) { + message := "your user account must be activated to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.Request) { + message := "you do not have permission to perform this action" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) { + message := "the request origin is not trusted" + app.errorResponse(w, r, http.StatusForbidden, message) +} diff --git a/internal/api/garden_members.go b/internal/api/garden_members.go new file mode 100644 index 0000000..d673c0e --- /dev/null +++ b/internal/api/garden_members.go @@ -0,0 +1,194 @@ +package api + +import ( + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +func (app *application) listGardenMembersHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + members, err := app.models.GardenMembers.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"members": members}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func canManageMember(target, replacement storage.GardenRole) bool { + return target != storage.GardenRoleOwner && replacement != storage.GardenRoleOwner +} + +func (app *application) updateGardenMemberHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + userID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + Role storage.GardenRole `json:"role"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + _, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role)) + if roleErr != nil || input.Role == storage.GardenRoleOwner { + app.failedValidationResponse(w, r, map[string]string{"role": "must be a garden role; ownership must be transferred separately"}) + return + } + target, err := app.models.GardenMembers.Get(gardenID, userID) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if !canManageMember(target.Role, input.Role) { + app.permissionDeniedResponse(w, r) + return + } + target.Role = input.Role + target, err = app.models.GardenMembers.Update(target) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"member": target}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteGardenMemberHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + userID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + target, err := app.models.GardenMembers.Get(gardenID, userID) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if !canManageMember(target.Role, storage.GardenRoleViewer) { + app.permissionDeniedResponse(w, r) + return + } + if err = app.models.GardenMembers.Delete(gardenID, userID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) transferGardenOwnershipHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + toUserID, err := app.readNamedIDParam(r, "userID") + if err != nil { + app.notFoundResponse(w, r) + return + } + actor, _ := app.contextGetGardenMember(r) + if err = app.models.GardenMembers.TransferOwnership(gardenID, actor.UserID, toUserID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) listGardenInvitesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + invites, err := app.models.GardenInvites.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"invites": invites}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + actor, _ := app.contextGetGardenMember(r) + var input struct { + Email string `json:"email"` + Role storage.GardenRole `json:"role"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + v := validate.New() + storage.ValidateEmail(v, input.Email) + _, roleErr := app.models.Roles.GetForGarden(gardenID, string(input.Role)) + if roleErr != nil || input.Role == storage.GardenRoleOwner { + v.AddError("role", "role may not be granted") + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + invite, err := app.models.GardenInvites.Upsert(storage.GardenInvite{GardenID: gardenID, Email: input.Email, Role: input.Role, InvitedBy: actor.UserID, ExpiresAt: time.Now().Add(7 * 24 * time.Hour)}) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.background(func() { + data := map[string]any{"inviteURL": strings.TrimRight(app.config.WebBaseURL, "/") + "/invite?token=" + invite.Token} + if sendErr := app.mailer.Send(invite.Email, "garden_invite.tmpl", data); sendErr != nil { + app.logger.Error(sendErr.Error()) + } + }) + invite.Token = "" + if err = app.writeJSON(w, http.StatusCreated, envelope{"invite": invite}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + inviteID, err := app.readNamedIDParam(r, "inviteID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.GardenInvites.Delete(gardenID, inviteID); err != nil { + app.respondToMemberError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) acceptGardenInviteHandler(w http.ResponseWriter, r *http.Request) { + token := httprouter.ParamsFromContext(r.Context()).ByName("token") + user, _ := app.contextGetAuthenticatedUser(r) + member, err := app.models.GardenInvites.Accept(token, user) + if err != nil { + app.respondToMemberError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"member": member}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) respondToMemberError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/gardens.go b/internal/api/gardens.go new file mode 100644 index 0000000..0786e80 --- /dev/null +++ b/internal/api/gardens.go @@ -0,0 +1,187 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) createGardenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data"` + ImageID *int `json:"image_id"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + + user, _ := app.contextGetAuthenticatedUser(r) + garden := storage.Garden{ + Name: strings.TrimSpace(input.Name), + Description: strings.TrimSpace(input.Description), + ImageData: input.ImageData, + } + v := validate.New() + validateImageData(v, garden.ImageData) + if storage.ValidateGarden(v, garden); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + garden, err := app.models.Gardens.Insert(garden, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.ImageData != "" || input.ImageID != nil { + garden.ImageID, err = app.resolveImage(garden.ID, input.ImageData, input.ImageID, user.ID, "garden") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + garden.ImageData = "" + garden, err = app.models.Gardens.Update(garden) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + garden.Role = storage.GardenRoleOwner + garden.Permissions = storage.GardenRoleOwner.Permissions() + + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d", garden.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"garden": garden}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listGardensHandler(w http.ResponseWriter, r *http.Request) { + user, _ := app.contextGetAuthenticatedUser(r) + gardens, err := app.models.Gardens.GetAllForUser(user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range gardens { + member, memberErr := app.models.GardenMembers.Get(gardens[i].ID, user.ID) + if memberErr != nil { + app.serverErrorResponse(w, r, memberErr) + return + } + applyGardenMembership(&gardens[i], member) + } + if err := app.writeJSON(w, http.StatusOK, envelope{"gardens": gardens}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + garden, err := app.models.Gardens.Get(gardenID) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + applyGardenMembership(&garden, member) + if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + garden, err := app.models.Gardens.Get(gardenID) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + applyGardenMembership(&garden, member) + + var input struct { + Name *string `json:"name"` + Description *string `json:"description"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.Name != nil { + garden.Name = strings.TrimSpace(*input.Name) + } + if input.Description != nil { + garden.Description = strings.TrimSpace(*input.Description) + } + previousImageID := garden.ImageID + previousImageData := garden.ImageData + if input.ImageData != nil { + garden.ImageData = *input.ImageData + } + + v := validate.New() + validateImageData(v, garden.ImageData) + if storage.ValidateGarden(v, garden); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + if input.ImageData != nil && *input.ImageData != previousImageData { + garden.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "garden") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + garden.ImageData = "" + garden, err = app.models.Gardens.Update(garden) + if err != nil { + app.respondToGardenModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "garden", EntityID: garden.ID, PreviousImageID: previousImageID, ImageID: garden.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"garden": garden}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func applyGardenMembership(garden *storage.Garden, member storage.GardenMember) { + garden.Role = member.Role + garden.Permissions = member.Permissions + if garden.Permissions == nil { + garden.Permissions = member.Role.Permissions() + } +} + +func (app *application) deleteGardenHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + if err := app.models.Gardens.Delete(gardenID); err != nil { + app.respondToGardenModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) respondToGardenModelError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/gardens_test.go b/internal/api/gardens_test.go new file mode 100644 index 0000000..052e9a2 --- /dev/null +++ b/internal/api/gardens_test.go @@ -0,0 +1,275 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type gardenTestModel struct { + gardens map[int]storage.Garden + nextID int + insertOwner int + listUserID int + getCallCount int +} + +func (m *gardenTestModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { + m.nextID++ + garden.ID = m.nextID + garden.Version = 1 + garden.CreatedAt = time.Now() + garden.UpdatedAt = garden.CreatedAt + m.gardens[garden.ID] = garden + m.insertOwner = ownerID + return garden, nil +} + +func (m *gardenTestModel) Get(id int) (storage.Garden, error) { + m.getCallCount++ + garden, ok := m.gardens[id] + if !ok { + return storage.Garden{}, storage.ErrRecordNotFound + } + return garden, nil +} + +func (m *gardenTestModel) GetAllForUser(userID int) ([]storage.Garden, error) { + m.listUserID = userID + gardens := make([]storage.Garden, 0, len(m.gardens)) + for _, garden := range m.gardens { + gardens = append(gardens, garden) + } + return gardens, nil +} + +func (m *gardenTestModel) Update(garden storage.Garden) (storage.Garden, error) { + if _, ok := m.gardens[garden.ID]; !ok { + return storage.Garden{}, storage.ErrRecordNotFound + } + garden.Version++ + m.gardens[garden.ID] = garden + return garden, nil +} + +func (m *gardenTestModel) Delete(id int) error { + if _, ok := m.gardens[id]; !ok { + return storage.ErrRecordNotFound + } + delete(m.gardens, id) + return nil +} + +type gardenMemberTestModel struct { + members map[[2]int]storage.GardenMember +} + +func (m *gardenMemberTestModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { + m.members[[2]int{member.GardenID, member.UserID}] = member + return member, nil +} + +func (m *gardenMemberTestModel) Get(gardenID, userID int) (storage.GardenMember, error) { + member, ok := m.members[[2]int{gardenID, userID}] + if !ok { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + return member, nil +} + +func (m *gardenMemberTestModel) GetAllForGarden(int) ([]storage.GardenMember, error) { + return nil, nil +} + +func (m *gardenMemberTestModel) Update(member storage.GardenMember) (storage.GardenMember, error) { + return member, nil +} + +func (m *gardenMemberTestModel) Delete(gardenID, userID int) error { + delete(m.members, [2]int{gardenID, userID}) + return nil +} + +func (m *gardenMemberTestModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { + from := m.members[[2]int{gardenID, fromUserID}] + to := m.members[[2]int{gardenID, toUserID}] + from.Role = storage.GardenRoleAdmin + to.Role = storage.GardenRoleOwner + m.members[[2]int{gardenID, fromUserID}] = from + m.members[[2]int{gardenID, toUserID}] = to + return nil +} + +func newGardenTestApplication() (*application, *gardenTestModel, *gardenMemberTestModel) { + gardens := &gardenTestModel{gardens: make(map[int]storage.Garden), nextID: 40} + members := &gardenMemberTestModel{members: make(map[[2]int]storage.GardenMember)} + app := &application{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + models: storage.Models{Gardens: gardens, GardenMembers: members}, + } + return app, gardens, members +} + +func serveGardenRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.updateGardenHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.deleteGardenHandler))) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = app.contextSetAuthenticatedUser(r, user) + router.ServeHTTP(w, r) + }) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func TestCreateAndListGardens(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}} + + createResponse := serveGardenRequest(app, user, http.MethodPost, "/v1/gardens", []byte(`{"name":" Hinterhof ","description":" Gemüse "}`)) + if createResponse.Code != http.StatusCreated { + t.Fatalf("create status: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) + } + if gardens.insertOwner != user.ID { + t.Errorf("owner: got %d, want %d", gardens.insertOwner, user.ID) + } + if got := createResponse.Header().Get("Location"); got != "/v1/gardens/41" { + t.Errorf("Location: got %q, want %q", got, "/v1/gardens/41") + } + + var createEnvelope struct { + Garden storage.Garden `json:"garden"` + } + if err := json.Unmarshal(createResponse.Body.Bytes(), &createEnvelope); err != nil { + t.Fatal(err) + } + if createEnvelope.Garden.Name != "Hinterhof" || createEnvelope.Garden.Description != "Gemüse" { + t.Errorf("created garden was not normalized: %+v", createEnvelope.Garden) + } + members.members[[2]int{createEnvelope.Garden.ID, user.ID}] = storage.GardenMember{GardenID: createEnvelope.Garden.ID, UserID: user.ID, Role: storage.GardenRoleOwner} + + listResponse := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) + if listResponse.Code != http.StatusOK { + t.Fatalf("list status: got %d, want %d", listResponse.Code, http.StatusOK) + } + if gardens.listUserID != user.ID { + t.Errorf("list user: got %d, want %d", gardens.listUserID, user.ID) + } +} + +func TestCreateGardenRequiresApplicationPermission(t *testing.T) { + app, gardens, _ := newGardenTestApplication() + response := serveGardenRequest(app, storage.User{ID: 7, Activated: true, Permissions: []storage.ApplicationPermission{}}, http.MethodPost, "/v1/gardens", []byte(`{"name":"Hinterhof"}`)) + if response.Code != http.StatusForbidden { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusForbidden, response.Body.String()) + } + if gardens.insertOwner != 0 { + t.Fatal("garden was inserted without gardens:create permission") + } +} + +func TestGardenMembershipHidesForeignGarden(t *testing.T) { + app, gardens, members := newGardenTestApplication() + gardens.gardens[12] = storage.Garden{ID: 12, Name: "Geheim", Version: 1} + members.members[[2]int{12, 1}] = storage.GardenMember{GardenID: 12, UserID: 1, Role: storage.GardenRoleOwner} + + memberResponse := serveGardenRequest(app, storage.User{ID: 1, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) + if memberResponse.Code != http.StatusOK { + t.Fatalf("member status: got %d, want %d", memberResponse.Code, http.StatusOK) + } + + callsBefore := gardens.getCallCount + foreignResponse := serveGardenRequest(app, storage.User{ID: 2, Activated: true}, http.MethodGet, "/v1/gardens/12", nil) + if foreignResponse.Code != http.StatusNotFound { + t.Fatalf("foreign status: got %d, want %d", foreignResponse.Code, http.StatusNotFound) + } + if gardens.getCallCount != callsBefore { + t.Error("garden was loaded even though membership check failed") + } +} + +func TestGardenResponsesIncludeEffectiveMembershipPermissions(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 4, Activated: true} + gardens.gardens[12] = storage.Garden{ID: 12, Name: "Gemeinschaftsgarten", Version: 1} + members.members[[2]int{12, user.ID}] = storage.GardenMember{ + GardenID: 12, + UserID: user.ID, + Role: "garden:custom", + Permissions: []storage.GardenPermission{storage.GardenPermissionPlantCreate, storage.GardenPermissionTaskCompleteOther}, + } + + response := serveGardenRequest(app, user, http.MethodGet, "/v1/gardens/12", nil) + if response.Code != http.StatusOK { + t.Fatalf("show status: got %d, want %d", response.Code, http.StatusOK) + } + var showEnvelope struct { + Garden storage.Garden `json:"garden"` + } + if err := json.Unmarshal(response.Body.Bytes(), &showEnvelope); err != nil { + t.Fatal(err) + } + if got := showEnvelope.Garden.Permissions; len(got) != 2 || got[0] != storage.GardenPermissionPlantCreate || got[1] != storage.GardenPermissionTaskCompleteOther { + t.Fatalf("show permissions: got %v", got) + } + + response = serveGardenRequest(app, user, http.MethodGet, "/v1/gardens", nil) + if response.Code != http.StatusOK { + t.Fatalf("list status: got %d, want %d", response.Code, http.StatusOK) + } + var listEnvelope struct { + Gardens []storage.Garden `json:"gardens"` + } + if err := json.Unmarshal(response.Body.Bytes(), &listEnvelope); err != nil { + t.Fatal(err) + } + if len(listEnvelope.Gardens) != 1 || len(listEnvelope.Gardens[0].Permissions) != 2 { + t.Fatalf("list permissions: got %+v", listEnvelope.Gardens) + } +} + +func TestUpdateAndDeleteGarden(t *testing.T) { + app, gardens, members := newGardenTestApplication() + user := storage.User{ID: 3, Activated: true} + gardens.gardens[9] = storage.Garden{ID: 9, Name: "Alt", Description: "Alt", Version: 1} + members.members[[2]int{9, user.ID}] = storage.GardenMember{GardenID: 9, UserID: user.ID, Role: storage.GardenRoleMember} + + updateResponse := serveGardenRequest(app, user, http.MethodPatch, "/v1/gardens/9", []byte(`{"name":"Neu"}`)) + if updateResponse.Code != http.StatusOK { + t.Fatalf("update status: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) + } + if got := gardens.gardens[9]; got.Name != "Neu" || got.Description != "Alt" || got.Version != 2 { + t.Errorf("updated garden: got %+v", got) + } + + deleteResponse := serveGardenRequest(app, user, http.MethodDelete, "/v1/gardens/9", nil) + if deleteResponse.Code != http.StatusNoContent { + t.Fatalf("delete status: got %d, want %d", deleteResponse.Code, http.StatusNoContent) + } + if _, exists := gardens.gardens[9]; exists { + t.Error("garden still exists after delete") + } +} + +func TestCreateGardenValidation(t *testing.T) { + app, _, _ := newGardenTestApplication() + response := serveGardenRequest(app, storage.User{ID: 1, Activated: true, Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionGardensCreate}}, http.MethodPost, "/v1/gardens", []byte(`{"name":" "}`)) + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusUnprocessableEntity, response.Body.String()) + } +} diff --git a/internal/api/healthcheck.go b/internal/api/healthcheck.go new file mode 100644 index 0000000..3ff4072 --- /dev/null +++ b/internal/api/healthcheck.go @@ -0,0 +1,22 @@ +package api + +import ( + "net/http" + "time" +) + +func (app *application) healthcheckHandler(w http.ResponseWriter, r *http.Request) { + env := envelope{ + "status": "available", + "server_time": time.Now().UTC(), + "system_info": map[string]string{ + "environment": app.config.Env, + "version": version, + }, + } + + err := app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/healthcheck_test.go b/internal/api/healthcheck_test.go new file mode 100644 index 0000000..3eb1ddf --- /dev/null +++ b/internal/api/healthcheck_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" +) + +func TestHealthcheckIncludesServerTimeAndSystemInformation(t *testing.T) { + app := &application{config: Config{Env: "test"}} + request := httptest.NewRequest("GET", "/v1/healthcheck", nil) + response := httptest.NewRecorder() + app.healthcheckHandler(response, request) + + var body struct { + Status string `json:"status"` + ServerTime time.Time `json:"server_time"` + SystemInfo struct { + Environment string `json:"environment"` + Version string `json:"version"` + } `json:"system_info"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Status != "available" || body.ServerTime.IsZero() || body.SystemInfo.Environment != "test" || body.SystemInfo.Version == "" { + t.Fatalf("unexpected health response: %+v", body) + } +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..b1eebf2 --- /dev/null +++ b/internal/api/helpers.go @@ -0,0 +1,175 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "github.com/julienschmidt/httprouter" +) + +func (app *application) readIDParam(r *http.Request) (int, error) { + return app.readNamedIDParam(r, "id") +} + +func (app *application) readNamedIDParam(r *http.Request, name string) (int, error) { + params := httprouter.ParamsFromContext(r.Context()) + id, err := strconv.Atoi(params.ByName(name)) + if err != nil || id < 1 { + return 0, errors.New("invalid id parameter") + } + + return id, nil +} + +func (app *application) readGardenIDParam(r *http.Request) (int, error) { + params := httprouter.ParamsFromContext(r.Context()) + + id, err := strconv.Atoi(params.ByName("gardenID")) + if err != nil || id < 1 { + return 0, errors.New("invalid garden id parameter") + } + + return id, nil +} + +type envelope map[string]any + +func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error { + js, err := json.MarshalIndent(data, "", "\t") + if err != nil { + return err + } + + js = append(js, '\n') + + for key, values := range headers { + for _, value := range values { + w.Header().Add(key, value) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + + return nil +} + +func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst any) error { + r.Body = http.MaxBytesReader(w, r.Body, 2_097_152) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + var maxBytesError *http.MaxBytesError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) + } + return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) + + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("body contains unknown key %s", fieldName) + + case errors.As(err, &maxBytesError): + return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit) + + case errors.As(err, &invalidUnmarshalError): + panic(err) + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + return errors.New("body must only contain a single JSON value") + } + + return nil +} + +func validateImageData(v *validate.Validator, imageData string) { + if imageData == "" { + return + } + v.Check(len(imageData) <= 1_500_000, "image_data", "must not be larger than 1.5 MB") + v.Check(strings.HasPrefix(imageData, "data:image/jpeg;base64,") || strings.HasPrefix(imageData, "data:image/png;base64,") || strings.HasPrefix(imageData, "data:image/webp;base64,"), "image_data", "must be a JPEG, PNG or WebP image") +} + +//lint:ignore U1000 retained for the upcoming list filters +func (app *application) readString(qs url.Values, key string, defaultValue string) string { + s := qs.Get(key) + + if s == "" { + return defaultValue + } + + return s +} + +//lint:ignore U1000 retained for the upcoming list filters +func (app *application) readCSV(qs url.Values, key string, defaultValue []string) []string { + csv := qs.Get(key) + + if csv == "" { + return defaultValue + } + + return strings.Split(csv, ",") +} + +//lint:ignore U1000 retained for the upcoming pagination support +func (app *application) readInt(qs url.Values, key string, defaultValue int, v *validate.Validator) int { + s := qs.Get(key) + + if s == "" { + return defaultValue + } + + i, err := strconv.Atoi(s) + if err != nil { + v.AddError(key, "must be an integer value") + return defaultValue + } + + return i +} + +func (app *application) background(fn func()) { + app.wg.Go(func() { + defer func() { + pv := recover() + if pv != nil { + app.logger.Error(fmt.Sprintf("%v", pv)) + } + }() + + fn() + }) +} diff --git a/internal/api/images.go b/internal/api/images.go new file mode 100644 index 0000000..c547a0e --- /dev/null +++ b/internal/api/images.go @@ -0,0 +1,72 @@ +package api + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) resolveImage(gardenID int, data string, selectedID *int, userID int, source string) (*int, error) { + if strings.TrimSpace(data) == "" { + if selectedID == nil { + return nil, nil + } + if _, err := app.models.Images.Get(gardenID, *selectedID); err != nil { + return nil, err + } + return selectedID, nil + } + comma := strings.IndexByte(data, ',') + if comma < 1 || !strings.HasPrefix(data, "data:image/") || !strings.HasSuffix(data[:comma], ";base64") { + return nil, errors.New("invalid image data") + } + mediaType := strings.TrimSuffix(strings.TrimPrefix(data[:comma], "data:"), ";base64") + decoded, err := base64.StdEncoding.DecodeString(data[comma+1:]) + if err != nil || len(decoded) == 0 || len(decoded) > storage.MaxImageSize { + return nil, errors.New("invalid image data") + } + image, err := app.models.Images.Insert(storage.Image{GardenID: gardenID, MediaType: mediaType, Data: decoded, Source: source, CreatedBy: userID}) + if err != nil { + return nil, err + } + return &image.ID, nil +} + +func (app *application) recordImageAssignment(change storage.ImageAssignment) error { + if app.models.Images == nil { + return nil + } + return app.models.Images.RecordAssignment(change) +} + +func (app *application) listImagesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + images, err := app.models.Images.GetAllForGarden(gardenID, storage.ImageFilter{Source: r.URL.Query().Get("source"), Query: r.URL.Query().Get("q")}) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"images": images}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showImageHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readNamedIDParam(r, "imageID") + if err != nil { + app.notFoundResponse(w, r) + return + } + image, err := app.models.Images.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.Header().Set("Content-Type", image.MediaType) + w.Header().Set("Cache-Control", "private, max-age=86400") + _, _ = w.Write(image.Data) +} diff --git a/internal/api/journal.go b/internal/api/journal.go new file mode 100644 index 0000000..7c142a7 --- /dev/null +++ b/internal/api/journal.go @@ -0,0 +1,332 @@ +package api + +import ( + "bytes" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type journalEntryInput struct { + Title *string `json:"title"` + Body *string `json:"body"` + CreatedAt *time.Time `json:"created_at"` + Tags []string `json:"tags"` + EntryType *storage.JournalEntryType `json:"entry_type"` +} + +func (input journalEntryInput) apply(entry *storage.JournalEntry) { + if input.Title != nil { + entry.Title = strings.TrimSpace(*input.Title) + } + if input.Body != nil { + entry.Body = strings.TrimSpace(*input.Body) + } + if input.CreatedAt != nil { + entry.CreatedAt = input.CreatedAt.UTC() + } + if input.EntryType != nil { + entry.EntryType = *input.EntryType + } +} + +func (app *application) createJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + var input journalEntryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + entry := storage.JournalEntry{GardenID: gardenID, AuthorID: user.ID, AuthorName: user.Name, EntryType: storage.JournalEntryTypeJournal} + entry.CreatedAt = time.Now().UTC() + input.apply(&entry) + entry.Tags = storage.NormalizeTags(input.Tags) + if !app.validateJournalEntry(w, r, entry) { + return + } + var err error + entry, err = app.models.Journal.Insert(entry) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, entry.ID, entry.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/journal/%d", gardenID, entry.ID)) + if err = app.writeJSON(w, http.StatusCreated, envelope{"journal_entry": entry}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listJournalEntriesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryType := storage.JournalEntryType(r.URL.Query().Get("type")) + if entryType == "" { + entryType = storage.JournalEntryTypeJournal + } + if entryType != storage.JournalEntryTypeJournal && entryType != storage.JournalEntryTypePinboard { + app.badRequestResponse(w, r, fmt.Errorf("type must be journal or pinboard")) + return + } + entries, err := app.models.Journal.GetAllForGarden(gardenID, entryType) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range entries { + entries[i].Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entries[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entries": entries}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + entry, err := app.models.Journal.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, entry.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + entry, err := app.models.Journal.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input journalEntryInput + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&entry) + if input.Tags != nil { + entry.Tags = storage.NormalizeTags(input.Tags) + } else { + entry.Tags, err = app.loadTags(gardenID, storage.TagEntityJournal, id) + } + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !app.validateJournalEntry(w, r, entry) { + return + } + entry, err = app.models.Journal.Update(gardenID, entry) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if input.Tags != nil { + entry.Tags, err = app.saveTags(gardenID, storage.TagEntityJournal, id, entry.Tags) + } + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if stored, getErr := app.models.Journal.Get(gardenID, id); getErr == nil { + entry.Attachments = stored.Attachments + } + if err = app.writeJSON(w, http.StatusOK, envelope{"journal_entry": entry}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteJournalEntryHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Journal.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) createJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, storage.MaxJournalAttachmentSize+(1<<20)) + file, header, err := r.FormFile("file") + if err != nil { + app.badRequestResponse(w, r, fmt.Errorf("file must be provided")) + return + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, storage.MaxJournalAttachmentSize+1)) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + if len(data) > storage.MaxJournalAttachmentSize { + app.badRequestResponse(w, r, fmt.Errorf("file must not be larger than 25 MB")) + return + } + mediaType := strings.ToLower(strings.TrimSpace(strings.Split(header.Header.Get("Content-Type"), ";")[0])) + detected := http.DetectContentType(data) + if mediaType == "" || mediaType == "application/octet-stream" { + mediaType = detected + } + if !allowedJournalMediaType(mediaType) { + app.badRequestResponse(w, r, fmt.Errorf("only photos, videos and audio files are supported")) + return + } + name := filepath.Base(strings.ReplaceAll(header.Filename, "\\", "/")) + if name == "." || name == "" { + name = "attachment" + } + if len(name) > 255 { + name = name[:255] + } + attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: mediaType, Data: data}) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createJournalLibraryAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + ImageID int `json:"image_id"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.ImageID < 1 { + app.badRequestResponse(w, r, fmt.Errorf("image_id must be provided")) + return + } + image, err := app.models.Images.Get(gardenID, input.ImageID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + name := image.FileName + if name == "" { + name = "Bild" + } + attachment, err := app.models.Journal.InsertAttachment(gardenID, entryID, storage.JournalAttachment{FileName: name, MediaType: image.MediaType, Size: image.Size, ImageID: &image.ID}) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusCreated, envelope{"attachment": attachment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func allowedJournalMediaType(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/webp", "image/gif", "video/mp4", "video/webm", "video/quicktime", "audio/mpeg", "audio/mp4", "audio/ogg", "audio/webm", "audio/wav", "audio/x-wav": + return true + default: + return false + } +} + +func (app *application) showJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + attachmentID, err := app.readNamedIDParam(r, "attachmentID") + if err != nil { + app.notFoundResponse(w, r) + return + } + a, err := app.models.Journal.GetAttachment(gardenID, entryID, attachmentID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.Header().Set("Content-Type", a.MediaType) + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": a.FileName})) + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeContent(w, r, a.FileName, a.CreatedAt, bytes.NewReader(a.Data)) +} + +func (app *application) deleteJournalAttachmentHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + entryID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + attachmentID, err := app.readNamedIDParam(r, "attachmentID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Journal.DeleteAttachment(gardenID, entryID, attachmentID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateJournalEntry(w http.ResponseWriter, r *http.Request, entry storage.JournalEntry) bool { + v := validate.New() + storage.ValidateJournalEntry(v, entry) + storage.ValidateTags(v, entry.Tags) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/journal_test.go b/internal/api/journal_test.go new file mode 100644 index 0000000..34cfc11 --- /dev/null +++ b/internal/api/journal_test.go @@ -0,0 +1,129 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type journalTestModel struct { + items map[int]storage.JournalEntry + nextID int + lastListType storage.JournalEntryType +} + +func (m *journalTestModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) { + m.nextID++ + entry.ID, entry.Version = m.nextID, 1 + m.items[entry.ID] = entry + return entry, nil +} + +func (m *journalTestModel) Get(gardenID, id int) (storage.JournalEntry, error) { + entry, ok := m.items[id] + if !ok || entry.GardenID != gardenID { + return storage.JournalEntry{}, storage.ErrRecordNotFound + } + return entry, nil +} + +func (m *journalTestModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) { + m.lastListType = entryType + entries := []storage.JournalEntry{} + for _, entry := range m.items { + if entry.GardenID == gardenID && entry.EntryType == entryType { + entries = append(entries, entry) + } + } + return entries, nil +} + +func (m *journalTestModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) { + if _, err := m.Get(gardenID, entry.ID); err != nil { + return storage.JournalEntry{}, err + } + entry.Version++ + m.items[entry.ID] = entry + return entry, nil +} + +func (m *journalTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +func (m *journalTestModel) InsertAttachment(int, int, storage.JournalAttachment) (storage.JournalAttachment, error) { + return storage.JournalAttachment{}, nil +} +func (m *journalTestModel) GetAttachment(int, int, int) (storage.JournalAttachment, error) { + return storage.JournalAttachment{}, storage.ErrRecordNotFound +} +func (m *journalTestModel) DeleteAttachment(int, int, int) error { return nil } + +type journalTagTestModel struct{} + +func (journalTagTestModel) Get(int, storage.TagEntity, int) ([]string, error) { return nil, nil } +func (journalTagTestModel) Set(_ int, _ storage.TagEntity, _ int, tags []string) ([]string, error) { + return tags, nil +} +func (journalTagTestModel) GetAllForGarden(int) ([]string, error) { return nil, nil } + +func serveJournalRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + protect := func(handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(handler)) + } + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", protect(app.createJournalEntryHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", protect(app.listJournalEntriesHandler)) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, app.contextSetAuthenticatedUser(request, user)) + return response +} + +func TestJournalAPISeparatesPinboardEntries(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Name: "Ada", Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + model := &journalTestModel{items: map[int]storage.JournalEntry{ + 1: {ID: 1, GardenID: 3, EntryType: storage.JournalEntryTypeJournal, Title: "Ernte"}, + 2: {ID: 2, GardenID: 3, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"}, + }, nextID: 2} + app.models.Journal = model + app.models.Tags = journalTagTestModel{} + + listed := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=pinboard", nil) + if listed.Code != http.StatusOK || model.lastListType != storage.JournalEntryTypePinboard { + t.Fatalf("list pinboard: status=%d type=%q body=%s", listed.Code, model.lastListType, listed.Body.String()) + } + if body := listed.Body.String(); !bytes.Contains([]byte(body), []byte("Sitzecke")) || bytes.Contains([]byte(body), []byte("Ernte")) { + t.Fatalf("list mixes entry types: %s", body) + } + + created := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"title":"Teichidee","entry_type":"pinboard"}`)) + if created.Code != http.StatusCreated || model.items[3].EntryType != storage.JournalEntryTypePinboard { + t.Fatalf("create pinboard: status=%d entry=%+v body=%s", created.Code, model.items[3], created.Body.String()) + } + + imageOnly := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"pinboard"}`)) + if imageOnly.Code != http.StatusCreated || model.items[4].Title != "" { + t.Fatalf("create titleless pinboard entry: status=%d entry=%+v body=%s", imageOnly.Code, model.items[4], imageOnly.Body.String()) + } + untitledJournal := serveJournalRequest(app, user, http.MethodPost, "/v1/gardens/3/journal", []byte(`{"entry_type":"journal"}`)) + if untitledJournal.Code != http.StatusUnprocessableEntity { + t.Fatalf("untitled journal status: got %d, want %d", untitledJournal.Code, http.StatusUnprocessableEntity) + } + + invalid := serveJournalRequest(app, user, http.MethodGet, "/v1/gardens/3/journal?type=unknown", nil) + if invalid.Code != http.StatusBadRequest { + t.Fatalf("invalid type: got %d, want %d", invalid.Code, http.StatusBadRequest) + } +} diff --git a/internal/api/locations.go b/internal/api/locations.go new file mode 100644 index 0000000..74a0266 --- /dev/null +++ b/internal/api/locations.go @@ -0,0 +1,243 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type locationInput struct { + ParentID *int `json:"parent_id"` + ClearParentID bool `json:"clear_parent_id"` + Name *string `json:"name"` + Description *string `json:"description"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + Kind *string `json:"kind"` + AreaSQM *float64 `json:"area_sqm"` + SunExposure *string `json:"sun_exposure"` + SoilCondition *string `json:"soil_condition"` + SoilReaction *string `json:"soil_reaction"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input locationInput) apply(location *storage.Location) { + if input.ParentID != nil { + location.ParentID = input.ParentID + } + if input.ClearParentID { + location.ParentID = nil + } + if input.Name != nil { + location.Name = strings.TrimSpace(*input.Name) + } + if input.Description != nil { + location.Description = strings.TrimSpace(*input.Description) + } + if input.ImageData != nil { + location.ImageData = *input.ImageData + } + if input.Kind != nil { + location.Kind = strings.TrimSpace(*input.Kind) + } + if input.AreaSQM != nil { + location.AreaSQM = input.AreaSQM + } + if input.SunExposure != nil { + assignStringPointer(input.SunExposure, &location.SunExposure) + } + assignStringPointer(input.SoilCondition, &location.SoilCondition) + assignStringPointer(input.SoilReaction, &location.SoilReaction) + if len(input.Attributes) != 0 { + location.Attributes = input.Attributes + } +} + +func (app *application) createLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input locationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + location := storage.Location{GardenID: gardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID} + input.apply(&location) + if !app.validateLocationForGarden(w, r, gardenID, location) { + return + } + imageID, err := app.resolveImage(gardenID, location.ImageData, input.ImageID, user.ID, "location") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + location.ImageID = imageID + location.ImageData = "" + location, err = app.models.Locations.Insert(location) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/locations/%d", gardenID, location.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"location": location}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listLocationsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + locations, err := app.models.Locations.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + member, _ := app.contextGetGardenMember(r) + visible := locations[:0] + for _, location := range locations { + if location.CreatedBy == user.ID && member.Can(storage.GardenPermissionLocationReadOwn) || location.CreatedBy != user.ID && member.Can(storage.GardenPermissionLocationReadOther) { + visible = append(visible, location) + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"locations": visible}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationReadOwn, storage.GardenPermissionLocationReadOther) { + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationUpdateOwn, storage.GardenPermissionLocationUpdateOther) { + return + } + var input locationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousImageID := location.ImageID + previousImageData := location.ImageData + input.apply(&location) + user, _ := app.contextGetAuthenticatedUser(r) + location.UpdatedBy = user.ID + if !app.validateLocationForGarden(w, r, gardenID, location) { + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + location.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "location") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + location.ImageData = "" + location, err = app.models.Locations.Update(gardenID, location) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "location", EntityID: location.ID, PreviousImageID: previousImageID, ImageID: location.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"location": location}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + location, err := app.models.Locations.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, location.CreatedBy, storage.GardenPermissionLocationDeleteOwn, storage.GardenPermissionLocationDeleteOther) { + return + } + if err := app.models.Locations.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, location storage.Location) bool { + v := validate.New() + validateImageData(v, location.ImageData) + storage.ValidateLocation(v, location) + if location.ParentID != nil && *location.ParentID > 0 && *location.ParentID != location.ID { + if _, err := app.models.Locations.Get(gardenID, *location.ParentID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("parent_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if v.Valid() && location.ParentID != nil && location.ID > 0 { + locations, err := app.models.Locations.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return false + } + parents := make(map[int]*int, len(locations)) + for i := range locations { + parents[locations[i].ID] = locations[i].ParentID + } + seen := map[int]bool{} + for current := location.ParentID; current != nil; current = parents[*current] { + if *current == location.ID || seen[*current] { + v.AddError("parent_id", "must not create a cycle") + break + } + seen[*current] = true + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/locations_test.go b/internal/api/locations_test.go new file mode 100644 index 0000000..eb0897f --- /dev/null +++ b/internal/api/locations_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "net/http" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +type locationTestModel struct { + items map[int]storage.Location + nextID int +} + +func (m *locationTestModel) Insert(value storage.Location) (storage.Location, error) { + m.nextID++ + value.ID = m.nextID + value.Version = 1 + m.items[value.ID] = value + return value, nil +} +func (m *locationTestModel) Get(gardenID, id int) (storage.Location, error) { + value, ok := m.items[id] + if !ok || value.GardenID != gardenID { + return storage.Location{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *locationTestModel) GetAllForGarden(gardenID int) ([]storage.Location, error) { + result := []storage.Location{} + for _, value := range m.items { + if value.GardenID == gardenID { + result = append(result, value) + } + } + return result, nil +} +func (m *locationTestModel) Update(gardenID int, value storage.Location) (storage.Location, error) { + if _, err := m.Get(gardenID, value.ID); err != nil { + return storage.Location{}, err + } + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *locationTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +type plantLocationTestModel struct { + items map[int]storage.PlantLocation + nextID int +} + +func (m *plantLocationTestModel) Insert(_ int, value storage.PlantLocation) (storage.PlantLocation, error) { + m.nextID++ + value.ID = m.nextID + value.Version = 1 + m.items[value.ID] = value + return value, nil +} +func (m *plantLocationTestModel) Get(_ int, id int) (storage.PlantLocation, error) { + value, ok := m.items[id] + if !ok { + return storage.PlantLocation{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *plantLocationTestModel) GetAllForPlant(_ int, plantID int) ([]storage.PlantLocation, error) { + result := []storage.PlantLocation{} + for _, value := range m.items { + if value.PlantID == plantID { + result = append(result, value) + } + } + return result, nil +} +func (m *plantLocationTestModel) GetAllForLocation(_ int, locationID int) ([]storage.PlantLocation, error) { + result := []storage.PlantLocation{} + for _, value := range m.items { + if value.LocationID == locationID { + result = append(result, value) + } + } + return result, nil +} +func (m *plantLocationTestModel) Update(_ int, value storage.PlantLocation) (storage.PlantLocation, error) { + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *plantLocationTestModel) Delete(_ int, id int) error { + if _, ok := m.items[id]; !ok { + return storage.ErrRecordNotFound + } + delete(m.items, id) + return nil +} + +func TestLocationsRejectForeignParentsAndCycles(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + rootID := 1 + locations := &locationTestModel{items: map[int]storage.Location{1: {ID: 1, GardenID: 3, Name: "Beet", CreatedBy: user.ID, Version: 1}, 2: {ID: 2, GardenID: 3, ParentID: &rootID, Name: "Reihe", CreatedBy: user.ID, Version: 1}, 9: {ID: 9, GardenID: 4, Name: "Fremd", Version: 1}}, nextID: 9} + app.models.Locations = locations + + foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/locations", []byte(`{"name":"Topf","parent_id":9}`)) + if foreign.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign parent: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String()) + } + cycle := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/1", []byte(`{"parent_id":2}`)) + if cycle.Code != http.StatusUnprocessableEntity { + t.Fatalf("cycle: got %d, want %d; %s", cycle.Code, http.StatusUnprocessableEntity, cycle.Body.String()) + } + foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/locations/9", nil) + if foreignRead.Code != http.StatusNotFound { + t.Fatalf("foreign read: got %d, want %d", foreignRead.Code, http.StatusNotFound) + } + cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/locations/2", []byte(`{"clear_parent_id":true}`)) + if cleared.Code != http.StatusOK || locations.items[2].ParentID != nil { + t.Fatalf("clear parent: status=%d body=%s", cleared.Code, cleared.Body.String()) + } +} + +func TestPlantLocationIsScopedToGarden(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 8, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate", Status: "active"}}} + app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}} + assignments := &plantLocationTestModel{items: map[int]storage.PlantLocation{}, nextID: 10} + app.models.PlantLocations = assignments + + foreign := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":9,"quantity":1}`)) + if foreign.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign assignment: got %d, want %d; %s", foreign.Code, http.StatusUnprocessableEntity, foreign.Body.String()) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants/5/locations", []byte(`{"location_id":6,"quantity":3}`)) + if created.Code != http.StatusCreated { + t.Fatalf("assignment: got %d, want %d; %s", created.Code, http.StatusCreated, created.Body.String()) + } + if got := assignments.items[11].Quantity; got != 3 { + t.Errorf("quantity: got %d, want 3", got) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..7b3f975 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,351 @@ +package api + +import ( + "errors" + "expvar" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/tomasen/realip" + "golang.org/x/time/rate" +) + +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + pv := recover() + if pv != nil { + w.Header().Set("Connection", "close") + app.serverErrorResponse(w, r, fmt.Errorf("%v", pv)) + } + }() + + next.ServeHTTP(w, r) + }) +} + +func (app *application) rateLimit(next http.Handler) http.Handler { + if !app.config.Limiter.Enabled { + return next + } + + type client struct { + limiter *rate.Limiter + lastSeen time.Time + } + + var ( + mu sync.Mutex + clients = make(map[string]*client) + ) + + go func() { + for { + time.Sleep(time.Minute) + + mu.Lock() + + for ip, client := range clients { + if time.Since(client.lastSeen) > 3*time.Minute { + delete(clients, ip) + } + } + + mu.Unlock() + } + }() + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := realip.FromRequest(r) + + mu.Lock() + + if _, found := clients[ip]; !found { + clients[ip] = &client{ + limiter: rate.NewLimiter(rate.Limit(app.config.Limiter.Rps), app.config.Limiter.Burst), + } + } + + clients[ip].lastSeen = time.Now() + + if !clients[ip].limiter.Allow() { + mu.Unlock() + app.rateLimitExceededResponse(w, r) + return + } + + mu.Unlock() + + next.ServeHTTP(w, r) + }) +} + +func (app *application) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Authorization") + + authorizationHeader := r.Header.Get("Authorization") + + if authorizationHeader != "" { + headerParts := strings.Split(authorizationHeader, " ") + if len(headerParts) != 2 || headerParts[0] != "Bearer" { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + token := headerParts[1] + v := validate.New() + + if auth.ValidateTokenPlaintext(v, token); !v.Valid() { + app.invalidAuthenticationTokenResponse(w, r) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopeAuthentication, token) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidAuthenticationTokenResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + r = app.contextSetAuthenticatedUser(r, user) + next.ServeHTTP(w, r) + return + } + + userID := app.sessions.GetInt(r.Context(), authenticatedUserIDSessionKey) + if userID != 0 { + user, err := app.models.Users.GetByID(userID) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + default: + app.serverErrorResponse(w, r, err) + return + } + } else { + r = app.contextSetAuthenticatedUser(r, user) + } + } + + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireActivatedUser(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticatedUser, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + if !authenticatedUser.Activated { + app.inactiveAccountResponse(w, r) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authenticatedUser, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + gardenID, err := app.readGardenIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + + member, err := app.models.GardenMembers.Get(gardenID, authenticatedUser.ID) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + r = app.contextSetGardenMember(r, member) + next.ServeHTTP(w, r) + }) +} + +func (app *application) requireGardenPermission(permission storage.GardenPermission, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + member, found := app.contextGetGardenMember(r) + if !found { + app.serverErrorResponse(w, r, errors.New("garden permission check without membership context")) + return + } + if !member.Can(permission) { + app.permissionDeniedResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +// authorizeGardenResource checks an object permission after the object was loaded. +// Objects created by the caller use the "own" permission; every other object uses +// the corresponding "other" permission. +func (app *application) authorizeGardenResource(w http.ResponseWriter, r *http.Request, createdBy int, own, other storage.GardenPermission) bool { + member, found := app.contextGetGardenMember(r) + if !found { + app.serverErrorResponse(w, r, errors.New("resource permission check without membership context")) + return false + } + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return false + } + permission := other + if createdBy == user.ID { + permission = own + } + if !member.Can(permission) { + app.permissionDeniedResponse(w, r) + return false + } + return true +} + +func (app *application) requireApplicationPermission(permission storage.ApplicationPermission, next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + if !user.Can(permission) { + app.permissionDeniedResponse(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Vary", "Origin") + w.Header().Add("Vary", "Access-Control-Request-Method") + w.Header().Add("Vary", "Access-Control-Request-Headers") + + origin := r.Header.Get("Origin") + + if origin != "" { + trustedOrigin := false + for i := range app.config.Cors.TrustedOrigins { + if origin == app.config.Cors.TrustedOrigins[i] { + trustedOrigin = true + break + } + } + + if !trustedOrigin { + app.untrustedOriginResponse(w, r) + return + } + + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.WriteHeader(http.StatusNoContent) + return + } + } + + next.ServeHTTP(w, r) + }) +} + +type metricsResponseWriter struct { + wrapped http.ResponseWriter + statusCode int + headerWritten bool +} + +func newMetricsResponseWriter(w http.ResponseWriter) *metricsResponseWriter { + return &metricsResponseWriter{ + wrapped: w, + statusCode: http.StatusOK, + } +} + +// Header implements http.ResponseWriter. +func (mw *metricsResponseWriter) Header() http.Header { + return mw.wrapped.Header() +} + +// WriteHeader implements http.ResponseWriter while retaining the first status +// code for metrics. +func (mw *metricsResponseWriter) WriteHeader(statusCode int) { + mw.wrapped.WriteHeader(statusCode) + + if !mw.headerWritten { + mw.statusCode = statusCode + mw.headerWritten = true + } +} + +// Write implements http.ResponseWriter. +func (mw *metricsResponseWriter) Write(b []byte) (int, error) { + mw.headerWritten = true + return mw.wrapped.Write(b) +} + +// Unwrap exposes the underlying writer to net/http response-controller logic. +func (mw *metricsResponseWriter) Unwrap() http.ResponseWriter { + return mw.wrapped +} + +func (app *application) metrics(next http.Handler) http.Handler { + var ( + totalRequestsReceived = expvar.NewInt("total_requests_received") + totalResponsesSent = expvar.NewInt("total_responses_sent") + totalProcessingTimeMicroseconds = expvar.NewInt("total_processing_time_μs") + totalResponsesSentByStatus = expvar.NewMap("total_responses_sent_by_status") + ) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + totalRequestsReceived.Add(1) + + mw := newMetricsResponseWriter(w) + next.ServeHTTP(mw, r) + + totalResponsesSent.Add(1) + totalResponsesSentByStatus.Add(strconv.Itoa(mw.statusCode), 1) + + duration := time.Since(start).Microseconds() + totalProcessingTimeMicroseconds.Add(duration) + }) +} diff --git a/internal/api/permissions_test.go b/internal/api/permissions_test.go new file mode 100644 index 0000000..589766b --- /dev/null +++ b/internal/api/permissions_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestGardenRolePermissions(t *testing.T) { + tests := []struct { + role storage.GardenRole + permission storage.GardenPermission + want int + }{ + {storage.GardenRoleOwner, storage.GardenPermissionGardenDelete, http.StatusNoContent}, + {storage.GardenRoleAdmin, storage.GardenPermissionMembersWrite, http.StatusNoContent}, + {storage.GardenRoleAdmin, storage.GardenPermissionGardenDelete, http.StatusForbidden}, + {storage.GardenRoleMember, storage.GardenPermissionPlantUpdateOwn, http.StatusNoContent}, + {storage.GardenRoleMember, storage.GardenPermissionSpeciesWrite, http.StatusForbidden}, + {storage.GardenRoleViewer, storage.GardenPermissionPlantUpdateOwn, http.StatusForbidden}, + {storage.GardenRoleWorker, storage.GardenPermissionTaskCompleteOther, http.StatusNoContent}, + {storage.GardenRoleWorker, storage.GardenPermissionTaskUpdateOther, http.StatusForbidden}, + } + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + for _, tt := range tests { + t.Run(string(tt.role)+"/"+string(tt.permission), func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := app.requireGardenPermission(tt.permission, next) + request := httptest.NewRequest(http.MethodPost, "/", nil) + request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != tt.want { + t.Fatalf("status = %d, want %d", response.Code, tt.want) + } + }) + } +} + +func TestPersistedGardenAdminWildcardDoesNotGrantOwnershipRights(t *testing.T) { + member := storage.GardenMember{Role: storage.GardenRoleAdmin, Permissions: []storage.GardenPermission{"garden:*"}} + if member.Can(storage.GardenPermissionGardenDelete) { + t.Fatal("garden:* must not grant the owner-only garden:delete permission") + } + if !member.Can(storage.GardenPermissionMembersWrite) { + t.Fatal("garden:* must grant ordinary garden administration permissions") + } +} + +func TestGardenPermissionOverridesResolveWildcards(t *testing.T) { + permissions := storage.ResolveGardenPermissions([]string{"garden:*"}, []storage.GardenRolePermissionOverride{{Permission: string(storage.GardenPermissionMembersWrite), Granted: false}, {Permission: string(storage.GardenPermissionGardenDelete), Granted: true}}) + member := storage.GardenMember{Permissions: permissions} + if member.Can(storage.GardenPermissionMembersWrite) || !member.Can(storage.GardenPermissionGardenDelete) { + t.Fatalf("unexpected effective permissions: %v", permissions) + } +} + +func TestGardenRoleBundlesConcretePermissions(t *testing.T) { + member := storage.GardenRoleMember.Permissions() + found := false + for _, permission := range member { + if permission == storage.GardenPermissionPlantUpdateOther { + found = true + } + } + if found { + t.Fatal("member role must not bundle permissions for other users' plants") + } + if len(storage.GardenRoleAdmin.Permissions()) <= len(member) { + t.Fatal("admin role must bundle more permissions than member") + } +} + +func TestGardenResourcePermissionsDistinguishOwnAndOtherObjects(t *testing.T) { + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + tests := []struct { + name string + role storage.GardenRole + createdBy int + want int + }{ + {"member may update own plant", storage.GardenRoleMember, 7, http.StatusNoContent}, + {"member may not update another plant", storage.GardenRoleMember, 8, http.StatusForbidden}, + {"admin may update another plant", storage.GardenRoleAdmin, 8, http.StatusNoContent}, + {"viewer may read another task", storage.GardenRoleViewer, 8, http.StatusNoContent}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPatch, "/", nil) + request = app.contextSetAuthenticatedUser(request, storage.User{ID: 7}) + request = app.contextSetGardenMember(request, storage.GardenMember{Role: tt.role}) + response := httptest.NewRecorder() + own, other := storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther + if tt.role == storage.GardenRoleViewer { + own, other = storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther + } + if app.authorizeGardenResource(response, request, tt.createdBy, own, other) { + response.WriteHeader(http.StatusNoContent) + } + if response.Code != tt.want { + t.Fatalf("status = %d, want %d", response.Code, tt.want) + } + }) + } +} + +func TestApplicationRolePermissions(t *testing.T) { + if !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + t.Fatal("admin role must grant global species write permission") + } + if storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + t.Fatal("user role must not grant global species write permission") + } + if !storage.ApplicationRoleUser.Can(storage.ApplicationPermissionGardensCreate) || !storage.ApplicationRoleAdmin.Can(storage.ApplicationPermissionGardensCreate) { + t.Fatal("built-in application roles must retain permission to create gardens") + } + if !storage.ApplicationRoleAdmin.Valid() || !storage.ApplicationRoleUser.Valid() || storage.ApplicationRole("unknown").Valid() { + t.Fatal("application role validation returned an unexpected result") + } +} + +func TestRequireApplicationPermission(t *testing.T) { + app := &application{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + handler := app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, next) + for _, test := range []struct { + name string + user storage.User + want int + }{ + {"admin role", storage.User{Role: storage.ApplicationRoleAdmin, Permissions: storage.ApplicationRoleAdmin.Permissions()}, http.StatusNoContent}, + {"user role", storage.User{Role: storage.ApplicationRoleUser, Permissions: storage.ApplicationRoleUser.Permissions()}, http.StatusForbidden}, + {"custom server role", storage.User{Role: "application:user-manager", Permissions: []storage.ApplicationPermission{storage.ApplicationPermissionUsersManage}}, http.StatusNoContent}, + } { + request := httptest.NewRequest(http.MethodGet, "/v1/admin/users", nil) + request = app.contextSetAuthenticatedUser(request, test.user) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != test.want { + t.Errorf("%s: got %d, want %d", test.name, response.Code, test.want) + } + } +} diff --git a/internal/api/plant_locations.go b/internal/api/plant_locations.go new file mode 100644 index 0000000..c7a38c1 --- /dev/null +++ b/internal/api/plant_locations.go @@ -0,0 +1,195 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type plantLocationInput struct { + LocationID *int `json:"location_id"` + Quantity *int `json:"quantity"` + PlantedAt *time.Time `json:"planted_at"` + ClearPlantedAt bool `json:"clear_planted_at"` + RemovedAt *time.Time `json:"removed_at"` + Notes *string `json:"notes"` +} + +func (input plantLocationInput) apply(assignment *storage.PlantLocation) { + if input.LocationID != nil { + assignment.LocationID = *input.LocationID + } + if input.Quantity != nil { + assignment.Quantity = *input.Quantity + } + if input.PlantedAt != nil { + assignment.PlantedAt = input.PlantedAt + } + if input.ClearPlantedAt { + assignment.PlantedAt = nil + } + if input.RemovedAt != nil { + assignment.RemovedAt = input.RemovedAt + } + if input.Notes != nil { + assignment.Notes = strings.TrimSpace(*input.Notes) + } +} + +func (app *application) createPlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if _, err := app.models.Plants.Get(gardenID, plantID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input plantLocationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + assignment := storage.PlantLocation{PlantID: plantID, Quantity: 1} + input.apply(&assignment) + if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) { + return + } + assignment, err = app.models.PlantLocations.Insert(gardenID, assignment) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d/locations/%d", gardenID, plantID, assignment.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"plant_location": assignment}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listPlantLocationsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + if _, err := app.models.Plants.Get(gardenID, plantID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + assignments, err := app.models.PlantLocations.GetAllForPlant(gardenID, plantID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant_locations": assignments}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updatePlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, assignmentID, ok := app.readPlantLocationIDs(w, r) + if !ok { + return + } + assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID) + if err != nil || assignment.PlantID != plantID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + var input plantLocationInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&assignment) + if !app.validatePlantLocationForGarden(w, r, gardenID, assignment) { + return + } + assignment, err = app.models.PlantLocations.Update(gardenID, assignment) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant_location": assignment}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deletePlantLocationHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, assignmentID, ok := app.readPlantLocationIDs(w, r) + if !ok { + return + } + assignment, err := app.models.PlantLocations.Get(gardenID, assignmentID) + if err != nil || assignment.PlantID != plantID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + if err := app.models.PlantLocations.Delete(gardenID, assignmentID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) readPlantLocationIDs(w http.ResponseWriter, r *http.Request) (int, int, bool) { + plantID, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return 0, 0, false + } + id, err := app.readPathInt(r, "assignmentID") + if err != nil { + app.notFoundResponse(w, r) + return 0, 0, false + } + return plantID, id, true +} + +func (app *application) readPathInt(r *http.Request, name string) (int, error) { + value := httprouter.ParamsFromContext(r.Context()).ByName(name) + id, err := strconv.Atoi(value) + if err != nil || id < 1 { + return 0, errors.New("invalid path parameter") + } + return id, nil +} + +func (app *application) validatePlantLocationForGarden(w http.ResponseWriter, r *http.Request, gardenID int, assignment storage.PlantLocation) bool { + v := validate.New() + storage.ValidatePlantLocation(v, assignment) + if assignment.LocationID > 0 { + if _, err := app.models.Locations.Get(gardenID, assignment.LocationID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("location_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/plants.go b/internal/api/plants.go new file mode 100644 index 0000000..8f05b68 --- /dev/null +++ b/internal/api/plants.go @@ -0,0 +1,257 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type plantInput struct { + Tags []string `json:"tags"` + SpeciesID *int `json:"species_id"` + ClearSpeciesID bool `json:"clear_species_id"` + Name *string `json:"name"` + Notes *string `json:"notes"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + AcquiredAt *time.Time `json:"acquired_at"` + ClearAcquiredAt bool `json:"clear_acquired_at"` + Status *string `json:"status"` + RemovedAt *time.Time `json:"removed_at"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input plantInput) apply(plant *storage.Plant) { + if input.SpeciesID != nil { + plant.SpeciesID = input.SpeciesID + } + if input.ClearSpeciesID { + plant.SpeciesID = nil + } + if input.Name != nil { + plant.Name = strings.TrimSpace(*input.Name) + } + if input.Notes != nil { + plant.Notes = strings.TrimSpace(*input.Notes) + } + if input.ImageData != nil { + plant.ImageData = *input.ImageData + } + if input.AcquiredAt != nil { + plant.AcquiredAt = input.AcquiredAt + } + if input.ClearAcquiredAt { + plant.AcquiredAt = nil + } + if input.Status != nil { + plant.Status = *input.Status + } + if input.RemovedAt != nil { + plant.RemovedAt = input.RemovedAt + } + if len(input.Attributes) != 0 { + plant.Attributes = input.Attributes + } +} + +func (app *application) createPlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input plantInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + plant := storage.Plant{GardenID: gardenID, Status: "alive", Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID, PlantedBy: user.ID} + input.apply(&plant) + plant.Tags = storage.NormalizeTags(input.Tags) + if !app.validatePlantForGarden(w, r, gardenID, plant) { + return + } + var err error + plant.ImageID, err = app.resolveImage(gardenID, plant.ImageData, input.ImageID, user.ID, "plant") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + plant.ImageData = "" + plant, err = app.models.Plants.Insert(plant) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/plants/%d", gardenID, plant.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"plant": plant}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listPlantsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plants, err := app.models.Plants.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + user, _ := app.contextGetAuthenticatedUser(r) + member, _ := app.contextGetGardenMember(r) + visible := plants[:0] + for i := range plants { + if plants[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionPlantReadOther) { + continue + } + if plants[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionPlantReadOwn) { + continue + } + plants[i].Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plants[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + visible = append(visible, plants[i]) + } + plants = visible + if err := app.writeJSON(w, http.StatusOK, envelope{"plants": plants}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showPlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantReadOwn, storage.GardenPermissionPlantReadOther) { + return + } + plant.Tags, err = app.loadTags(gardenID, storage.TagEntityPlant, plant.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updatePlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantUpdateOwn, storage.GardenPermissionPlantUpdateOther) { + return + } + var input plantInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousImageID := plant.ImageID + previousImageData := plant.ImageData + input.apply(&plant) + user, _ := app.contextGetAuthenticatedUser(r) + plant.UpdatedBy = user.ID + if input.Tags != nil { + plant.Tags = storage.NormalizeTags(input.Tags) + } + if !app.validatePlantForGarden(w, r, gardenID, plant) { + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + plant.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "plant") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + plant.ImageData = "" + plant, err = app.models.Plants.Update(gardenID, plant) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "plant", EntityID: plant.ID, PreviousImageID: previousImageID, ImageID: plant.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.Tags != nil { + plant.Tags, err = app.saveTags(gardenID, storage.TagEntityPlant, plant.ID, plant.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"plant": plant}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deletePlantHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + plant, err := app.models.Plants.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, plant.CreatedBy, storage.GardenPermissionPlantDeleteOwn, storage.GardenPermissionPlantDeleteOther) { + return + } + if err := app.models.Plants.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validatePlantForGarden(w http.ResponseWriter, r *http.Request, gardenID int, plant storage.Plant) bool { + v := validate.New() + validateImageData(v, plant.ImageData) + storage.ValidatePlant(v, plant) + if plant.SpeciesID != nil && *plant.SpeciesID > 0 { + if _, err := app.models.Species.Get(gardenID, *plant.SpeciesID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("species_id", "must refer to an available species") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go new file mode 100644 index 0000000..bab2851 --- /dev/null +++ b/internal/api/resources_test.go @@ -0,0 +1,250 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type speciesTestModel struct { + items map[int]storage.Species + nextID int + lastGardenID int +} + +func (m *speciesTestModel) Insert(species storage.Species) (storage.Species, error) { + m.nextID++ + species.ID = m.nextID + species.Version = 1 + m.items[species.ID] = species + return species, nil +} + +func (m *speciesTestModel) Get(gardenID, id int) (storage.Species, error) { + m.lastGardenID = gardenID + species, ok := m.items[id] + if !ok || (species.GardenID != nil && *species.GardenID != gardenID) { + return storage.Species{}, storage.ErrRecordNotFound + } + return species, nil +} + +func (m *speciesTestModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { + m.lastGardenID = gardenID + result := []storage.Species{} + for _, species := range m.items { + if species.GardenID == nil || *species.GardenID == gardenID { + result = append(result, species) + } + } + return result, nil +} + +func (m *speciesTestModel) Update(gardenID int, species storage.Species) (storage.Species, error) { + m.lastGardenID = gardenID + species.Version++ + m.items[species.ID] = species + return species, nil +} + +func (m *speciesTestModel) Delete(gardenID, id int) error { + species, ok := m.items[id] + if !ok || species.GardenID == nil && gardenID != 0 || species.GardenID != nil && *species.GardenID != gardenID { + return storage.ErrRecordNotFound + } + m.lastGardenID = gardenID + delete(m.items, id) + return nil +} + +type plantTestModel struct { + items map[int]storage.Plant + nextID int + lastGardenID int +} + +func (m *plantTestModel) Insert(plant storage.Plant) (storage.Plant, error) { + m.nextID++ + plant.ID = m.nextID + plant.Version = 1 + m.items[plant.ID] = plant + return plant, nil +} + +func (m *plantTestModel) Get(gardenID, id int) (storage.Plant, error) { + m.lastGardenID = gardenID + plant, ok := m.items[id] + if !ok || plant.GardenID != gardenID { + return storage.Plant{}, storage.ErrRecordNotFound + } + return plant, nil +} + +func (m *plantTestModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) { + m.lastGardenID = gardenID + result := []storage.Plant{} + for _, plant := range m.items { + if plant.GardenID == gardenID { + result = append(result, plant) + } + } + return result, nil +} + +func (m *plantTestModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) { + m.lastGardenID = gardenID + plant.Version++ + m.items[plant.ID] = plant + return plant, nil +} + +func (m *plantTestModel) Delete(gardenID, id int) error { + plant, ok := m.items[id] + if !ok || plant.GardenID != gardenID { + return storage.ErrRecordNotFound + } + delete(m.items, id) + return nil +} + +func serveResourceRequest(app *application, user storage.User, method, path string, body []byte) *httptest.ResponseRecorder { + router := httprouter.New() + protect := func(handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(handler)) + } + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", protect(app.createSpeciesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", protect(app.listSpeciesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", protect(app.showSpeciesHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", protect(app.updateSpeciesHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", protect(app.deleteSpeciesHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.createSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", protect(app.listSpeciesTaskTemplatesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.showSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.updateSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", protect(app.deleteSpeciesTaskTemplateHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", protect(app.createPlantHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", protect(app.listPlantsHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", protect(app.showPlantHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", protect(app.updatePlantHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", protect(app.deletePlantHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", protect(app.createLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", protect(app.listLocationsHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", protect(app.showLocationHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", protect(app.updateLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", protect(app.deleteLocationHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", protect(app.createTaskHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", protect(app.listTasksHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", protect(app.showTaskHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", protect(app.updateTaskHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", protect(app.deleteTaskHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.createPlantLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", protect(app.listPlantLocationsHandler)) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.updatePlantLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", protect(app.deletePlantLocationHandler)) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + router.ServeHTTP(w, app.contextSetAuthenticatedUser(r, user)) + }) + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func TestSpeciesAndPlantsAreScopedToGarden(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 5, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin} + speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} + plantsModel := &plantTestModel{items: make(map[int]storage.Plant), nextID: 20} + app.models.Species = speciesModel + app.models.Plants = plantsModel + + speciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate"}`)) + if speciesResponse.Code != http.StatusCreated { + t.Fatalf("create species status: got %d, want %d; body: %s", speciesResponse.Code, http.StatusCreated, speciesResponse.Body.String()) + } + createdSpecies := speciesModel.items[11] + if createdSpecies.GardenID == nil || *createdSpecies.GardenID != 3 { + t.Errorf("species garden: got %v, want 3", createdSpecies.GardenID) + } + + plantResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":11,"name":"Tomate am Zaun"}`)) + if plantResponse.Code != http.StatusCreated { + t.Fatalf("create plant status: got %d, want %d; body: %s", plantResponse.Code, http.StatusCreated, plantResponse.Body.String()) + } + if got := plantsModel.items[21].GardenID; got != 3 { + t.Errorf("plant garden: got %d, want 3", got) + } + if speciesModel.lastGardenID != 3 { + t.Errorf("species lookup garden: got %d, want 3", speciesModel.lastGardenID) + } + clearSpecies := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/plants/21", []byte(`{"clear_species_id":true,"clear_acquired_at":true}`)) + if clearSpecies.Code != http.StatusOK || plantsModel.items[21].SpeciesID != nil { + t.Fatalf("clear plant species: status=%d body=%s", clearSpecies.Code, clearSpecies.Body.String()) + } + + foreignGardenID := 4 + speciesModel.items[99] = storage.Species{ID: 99, GardenID: &foreignGardenID, CommonName: "Fremd"} + foreignSpeciesResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/plants", []byte(`{"species_id":99,"name":"Nicht erlaubt"}`)) + if foreignSpeciesResponse.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign species status: got %d, want %d; body: %s", foreignSpeciesResponse.Code, http.StatusUnprocessableEntity, foreignSpeciesResponse.Body.String()) + } +} + +func TestGlobalSpeciesRequiresApplicationPermission(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 6, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + speciesModel := &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global", Version: 1}}} + app.models.Species = speciesModel + + showResponse := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/1", nil) + if showResponse.Code != http.StatusOK { + t.Fatalf("show global species: got %d, want %d", showResponse.Code, http.StatusOK) + } + updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/1", []byte(`{"common_name":"Geändert"}`)) + if updateResponse.Code != http.StatusForbidden { + t.Fatalf("update global species: got %d, want %d", updateResponse.Code, http.StatusForbidden) + } + deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/1", nil) + if deleteResponse.Code != http.StatusForbidden { + t.Fatalf("delete global species: got %d, want %d", deleteResponse.Code, http.StatusForbidden) + } +} + +func TestAdminCanCreateAndUpdateGlobalSpeciesWithoutGardenWriteRole(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 7, Activated: true, Role: storage.ApplicationRoleAdmin} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleViewer} + speciesModel := &speciesTestModel{items: make(map[int]storage.Species), nextID: 10} + app.models.Species = speciesModel + + createResponse := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species", []byte(`{"common_name":"Tomate","global":true}`)) + if createResponse.Code != http.StatusCreated { + t.Fatalf("create global species: got %d, want %d; body: %s", createResponse.Code, http.StatusCreated, createResponse.Body.String()) + } + if speciesModel.items[11].GardenID != nil { + t.Fatalf("global species has garden id %v", speciesModel.items[11].GardenID) + } + + updateResponse := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/species/11", []byte(`{"common_name":"Rispentomate"}`)) + if updateResponse.Code != http.StatusOK { + t.Fatalf("update global species: got %d, want %d; body: %s", updateResponse.Code, http.StatusOK, updateResponse.Body.String()) + } + if got := speciesModel.lastGardenID; got != 0 { + t.Fatalf("global update scope: got %d, want 0", got) + } + + deleteResponse := serveResourceRequest(app, user, http.MethodDelete, "/v1/gardens/3/species/11", nil) + if deleteResponse.Code != http.StatusNoContent { + t.Fatalf("delete global species: got %d, want %d; body: %s", deleteResponse.Code, http.StatusNoContent, deleteResponse.Body.String()) + } + if got := speciesModel.lastGardenID; got != 0 { + t.Fatalf("global delete scope: got %d, want 0", got) + } +} diff --git a/internal/api/roles.go b/internal/api/roles.go new file mode 100644 index 0000000..f29e9f3 --- /dev/null +++ b/internal/api/roles.go @@ -0,0 +1,256 @@ +package api + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +var roleNameRX = regexp.MustCompile(`^[a-z][a-z0-9:_-]{1,63}$`) + +func validRolePermissions(scope storage.RoleScope, permissions []string) bool { + for _, permission := range permissions { + if scope == storage.RoleScopeApplication && !storage.ValidApplicationPermission(permission) || + scope == storage.RoleScopeGarden && !storage.ValidGardenPermission(permission) { + return false + } + } + return scope == storage.RoleScopeApplication || scope == storage.RoleScopeGarden +} + +func (app *application) listAdminRolesHandler(w http.ResponseWriter, r *http.Request) { + applicationRoles, err := app.models.Roles.List(storage.RoleScopeApplication) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + gardenRoles, err := app.models.Roles.List(storage.RoleScopeGarden) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"application_roles": applicationRoles, "garden_roles": gardenRoles}, nil) +} + +func (app *application) createAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Scope storage.RoleScope `json:"scope"` + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Name, input.Label = strings.TrimSpace(input.Name), strings.TrimSpace(input.Label) + if input.Scope == storage.RoleScopeApplication && !strings.HasPrefix(input.Name, "application:") { + input.Name = "application:" + input.Name + } + if !roleNameRX.MatchString(input.Name) || input.Label == "" || !validRolePermissions(input.Scope, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "name, scope, label, or permissions are invalid"}) + return + } + role, err := app.models.Roles.Create(storage.Role{Name: input.Name, Scope: input.Scope, Label: input.Label, Permissions: input.Permissions}) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil) +} + +func (app *application) updateAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.Get(name) + if err != nil || role.GardenID != nil { + app.notFoundResponse(w, r) + return + } + var input struct { + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.Label = strings.TrimSpace(input.Label) + if input.Label == "" || !validRolePermissions(role.Scope, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "label or permissions are invalid"}) + return + } + if role.Name == string(storage.GardenRoleOwner) { + app.permissionDeniedResponse(w, r) + return + } + if role.Name == string(storage.ApplicationRoleAdmin) { + hasRoleManagement := false + for _, permission := range input.Permissions { + if permission == string(storage.ApplicationPermissionRolesManage) || permission == "*" { + hasRoleManagement = true + } + } + if !hasRoleManagement { + app.failedValidationResponse(w, r, map[string]string{"permissions": "the administrator role must retain roles:manage"}) + return + } + } + role.Label, role.Permissions = input.Label, input.Permissions + role, err = app.models.Roles.Update(role) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"role": role}, nil) +} + +func (app *application) deleteAdminRoleHandler(w http.ResponseWriter, r *http.Request) { + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.Get(name) + if err != nil || role.GardenID != nil { + app.notFoundResponse(w, r) + return + } + if err := app.models.Roles.Delete(name); err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +type gardenRoleSetting struct { + Role storage.Role `json:"role"` + EffectivePermissions []storage.GardenPermission `json:"effective_permissions"` +} + +func (app *application) listGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + roles, err := app.models.Roles.ListForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + overrides, err := app.models.Roles.ListGardenOverrides(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + settings := make([]gardenRoleSetting, 0, len(roles)) + for _, role := range roles { + selected := []storage.GardenRolePermissionOverride{} + for _, override := range overrides { + if override.RoleName == role.Name { + selected = append(selected, override) + } + } + settings = append(settings, gardenRoleSetting{Role: role, EffectivePermissions: storage.ResolveGardenPermissions(role.Permissions, selected)}) + } + app.writeJSON(w, http.StatusOK, envelope{"roles": settings}, nil) +} + +func (app *application) updateGardenRoleSettingsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.GetForGarden(gardenID, name) + if err != nil { + app.notFoundResponse(w, r) + return + } + if name == string(storage.GardenRoleOwner) { + app.permissionDeniedResponse(w, r) + return + } + var input struct { + Permissions []string `json:"permissions"` + } + if err = app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if !validRolePermissions(storage.RoleScopeGarden, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"permissions": "contains an invalid garden permission"}) + return + } + desired := map[string]bool{} + for _, permission := range storage.ResolveGardenPermissions(input.Permissions, nil) { + desired[string(permission)] = true + } + base := storage.ResolveGardenPermissions(role.Permissions, nil) + baseSet := map[string]bool{} + for _, permission := range base { + baseSet[string(permission)] = true + } + overrides := []storage.GardenRolePermissionOverride{} + for _, permission := range storage.AllGardenPermissions() { + want, has := desired[string(permission)], baseSet[string(permission)] + if want != has { + overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: gardenID, RoleName: name, Permission: string(permission), Granted: want}) + } + } + if err = app.models.Roles.ReplaceGardenOverrides(gardenID, name, overrides); err != nil { + app.serverErrorResponse(w, r, err) + return + } + app.writeJSON(w, http.StatusOK, envelope{"effective_permissions": storage.ResolveGardenPermissions(role.Permissions, overrides)}, nil) +} + +func (app *application) createGardenRoleHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input struct { + Name string `json:"name"` + Label string `json:"label"` + Permissions []string `json:"permissions"` + } + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + slug, label := strings.TrimSpace(input.Name), strings.TrimSpace(input.Label) + name := "garden:" + strconv.Itoa(gardenID) + ":" + slug + if !roleNameRX.MatchString(name) || label == "" || !validRolePermissions(storage.RoleScopeGarden, input.Permissions) { + app.failedValidationResponse(w, r, map[string]string{"role": "name, label, or permissions are invalid"}) + return + } + role, err := app.models.Roles.Create(storage.Role{Name: name, Scope: storage.RoleScopeGarden, GardenID: &gardenID, Label: label, Permissions: input.Permissions}) + if err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + app.writeJSON(w, http.StatusCreated, envelope{"role": role}, nil) +} + +func (app *application) deleteGardenRoleHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + name := httprouter.ParamsFromContext(r.Context()).ByName("roleName") + role, err := app.models.Roles.GetForGarden(gardenID, name) + if err != nil || role.GardenID == nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.Roles.Delete(name); err != nil { + if errors.Is(err, storage.ErrConflict) { + app.conflictResponse(w, r) + } else { + app.serverErrorResponse(w, r, err) + } + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/routes.go b/internal/api/routes.go new file mode 100644 index 0000000..c728157 --- /dev/null +++ b/internal/api/routes.go @@ -0,0 +1,140 @@ +package api + +import ( + "expvar" + "net/http" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +func (app *application) routes() http.Handler { + router := httprouter.New() + + router.NotFound = http.HandlerFunc(app.notFoundResponse) + router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) + + router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler) + + router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler) + router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler) + router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler) + router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler)) + router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler)) + router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler)) + router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler)) + router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler)) + router.HandlerFunc(http.MethodGet, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.listAdminUsersHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.deleteAdminUserHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminApplicationSettingsHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/application-settings", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminApplicationSettingsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/environment", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.showAdminEnvironmentHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/test-mail", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.sendAdminTestMailHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.listAdminSpeciesCategoriesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/species-categories", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.createAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.updateAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/species-categories/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGlobalSpeciesWrite, app.deleteAdminSpeciesCategoryHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.listAdminTaskPrioritiesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/task-priorities", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.createAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.updateAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/task-priorities/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionSettingsWrite, app.deleteAdminTaskPriorityHandler))) + router.HandlerFunc(http.MethodGet, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.listAdminRolesHandler))) + router.HandlerFunc(http.MethodPost, "/v1/admin/roles", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.createAdminRoleHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.updateAdminRoleHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/admin/roles/:roleName", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionRolesManage, app.deleteAdminRoleHandler))) + router.HandlerFunc(http.MethodGet, "/v1/species-categories", app.requireActivatedUser(app.listSpeciesCategoriesHandler)) + router.HandlerFunc(http.MethodGet, "/v1/task-priorities", app.requireActivatedUser(app.listTaskPrioritiesHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler) + router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler) + + router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/activation", app.createActivationTokenHandler) + router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler) + + router.HandlerFunc(http.MethodPost, "/v1/gardens", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionGardensCreate, app.createGardenHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens", app.requireActivatedUser(app.listGardensHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID", app.requireActivatedUser(app.requireGardenMember(app.showGardenHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenUpdate, app.updateGardenHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/members", app.requireActivatedUser(app.requireGardenMember(app.listGardenMembersHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.updateGardenMemberHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/members/:userID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenMemberHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/members/:userID/transfer-ownership", app.protectGarden(storage.GardenPermissionGardenDelete, app.transferGardenOwnershipHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenInvitesHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/invites", app.protectGarden(storage.GardenPermissionMembersWrite, app.createGardenInviteHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/invites/:inviteID", app.protectGarden(storage.GardenPermissionMembersWrite, app.deleteGardenInviteHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionMembersWrite, app.listGardenRoleSettingsHandler)) + router.HandlerFunc(http.MethodPut, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.updateGardenRoleSettingsHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/roles", app.protectGarden(storage.GardenPermissionGardenDelete, app.createGardenRoleHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/roles/:roleName", app.protectGarden(storage.GardenPermissionGardenDelete, app.deleteGardenRoleHandler)) + router.HandlerFunc(http.MethodPost, "/v1/invites/:token/accept", app.requireActivatedUser(app.acceptGardenInviteHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.listCareInstructionsHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/care-instructions", app.requireActivatedUser(app.requireGardenMember(app.createCareInstructionHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.updateCareInstructionHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/care-instructions/:instructionID", app.requireActivatedUser(app.requireGardenMember(app.deleteCareInstructionHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.createSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates", app.requireActivatedUser(app.requireGardenMember(app.listSpeciesTaskTemplatesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.showSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.updateSpeciesTaskTemplateHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/species/:id/task-templates/:templateID", app.requireActivatedUser(app.requireGardenMember(app.deleteSpeciesTaskTemplateHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants", app.protectGarden(storage.GardenPermissionPlantCreate, app.createPlantHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants", app.requireActivatedUser(app.requireGardenMember(app.listPlantsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.showPlantHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.updatePlantHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id", app.requireActivatedUser(app.requireGardenMember(app.deletePlantHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs", app.requireActivatedUser(app.requireGardenMember(app.listTaskTemplateOptOutsHandler))) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.setTaskTemplateOptOutHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/task-template-opt-outs/:templateID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteTaskTemplateOptOutHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/locations", app.protectGarden(storage.GardenPermissionLocationCreate, app.createLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations", app.requireActivatedUser(app.requireGardenMember(app.listLocationsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.showLocationHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.updateLocationHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/locations/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteLocationHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/tasks", app.protectGarden(storage.GardenPermissionTaskCreate, app.createTaskHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks", app.requireActivatedUser(app.requireGardenMember(app.listTasksHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.showTaskHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.updateTaskHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/tasks/:id", app.requireActivatedUser(app.requireGardenMember(app.deleteTaskHandler))) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalEntryHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal", app.requireActivatedUser(app.requireGardenMember(app.listJournalEntriesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/tags", app.requireActivatedUser(app.requireGardenMember(app.listGardenTagsHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images", app.requireActivatedUser(app.requireGardenMember(app.listImagesHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/images/:imageID", app.requireActivatedUser(app.requireGardenMember(app.showImageHandler))) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id", app.requireActivatedUser(app.requireGardenMember(app.showJournalEntryHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.updateJournalEntryHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalEntryHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalAttachmentHandler)) + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/journal/:id/attachments/library", app.protectGarden(storage.GardenPermissionContentWrite, app.createJournalLibraryAttachmentHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.requireActivatedUser(app.requireGardenMember(app.showJournalAttachmentHandler))) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/journal/:id/attachments/:attachmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deleteJournalAttachmentHandler)) + + router.HandlerFunc(http.MethodPost, "/v1/gardens/:gardenID/plants/:id/locations", app.protectGarden(storage.GardenPermissionContentWrite, app.createPlantLocationHandler)) + router.HandlerFunc(http.MethodGet, "/v1/gardens/:gardenID/plants/:id/locations", app.requireActivatedUser(app.requireGardenMember(app.listPlantLocationsHandler))) + router.HandlerFunc(http.MethodPatch, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.updatePlantLocationHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/gardens/:gardenID/plants/:id/locations/:assignmentID", app.protectGarden(storage.GardenPermissionContentWrite, app.deletePlantLocationHandler)) + + if app.config.Env == "development" { + router.HandlerFunc(http.MethodGet, "/debug/vars", app.requireActivatedUser(expvar.Handler().ServeHTTP)) + } + + return app.sessions.LoadAndSave(app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))))) +} + +func (app *application) protectGarden(permission storage.GardenPermission, handler http.HandlerFunc) http.HandlerFunc { + return app.requireActivatedUser(app.requireGardenMember(app.requireGardenPermission(permission, handler))) +} diff --git a/internal/api/season_templates.go b/internal/api/season_templates.go new file mode 100644 index 0000000..3b01b03 --- /dev/null +++ b/internal/api/season_templates.go @@ -0,0 +1,93 @@ +package api + +import ( + "errors" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type seasonTemplateDefinition struct { + origin storage.TaskTemplateOrigin + title string + trigger storage.TaskTriggerType + monthFrom, dayFrom *int + monthTo, dayTo *int +} + +func (app *application) syncSeasonTaskTemplates(gardenID int, species storage.Species) error { + if app.models.SpeciesTaskTemplates == nil { + return nil + } + existing, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, species.ID) + if err != nil { + return err + } + byOrigin := make(map[storage.TaskTemplateOrigin]storage.SpeciesTaskTemplate, len(existing)) + for _, template := range existing { + if template.Origin != storage.TaskTemplateOriginManual { + byOrigin[template.Origin] = template + } + } + definitions := []seasonTemplateDefinition{ + {storage.TaskTemplateOriginSeasonSowing, "Aussaat", storage.TaskTriggerRelativeToSowing, species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo}, + {storage.TaskTemplateOriginSeasonPlanting, "Pflanzen", storage.TaskTriggerRelativeToSpeciesPlanting, species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo}, + {storage.TaskTemplateOriginSeasonHarvest, "Ernten", storage.TaskTriggerRelativeToHarvest, species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo}, + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + for _, definition := range definitions { + template, found := byOrigin[definition.origin] + if definition.monthFrom == nil { + if found && template.Active { + template.Active = false + if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + return err + } + } + continue + } + if found { + // Preserve all user-editable fields. Only repair legacy generated templates. + if template.TriggerType != definition.trigger { + template.TriggerType = definition.trigger + if _, err = app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + return err + } + } + continue + } + duration := seasonDurationDays(definition.monthFrom, definition.dayFrom, definition.monthTo, definition.dayTo) + template = storage.SpeciesTaskTemplate{ + SpeciesID: species.ID, Origin: definition.origin, Title: definition.title, + Description: definition.title + " im vorgesehenen Zeitraum", TriggerType: definition.trigger, + TriggerOffsetUnit: storage.TaskDurationDay, Duration: duration, DurationUnit: storage.TaskDurationDay, + Recurrence: storage.TaskRecurrenceNone, RecurrenceInterval: 1, Active: true, + } + if _, err = app.models.SpeciesTaskTemplates.Insert(template); err != nil && !errors.Is(err, storage.ErrConflict) { + return err + } + } + return nil +} + +func seasonDurationDays(fromMonth, fromDay, toMonth, toDay *int) int { + if fromMonth == nil || toMonth == nil { + return 0 + } + startDay, endDay := 1, 1 + if fromDay != nil { + startDay = *fromDay + } + if toDay != nil { + endDay = *toDay + } + start := time.Date(2024, time.Month(*fromMonth), startDay, 0, 0, 0, 0, time.UTC) + end := time.Date(2024, time.Month(*toMonth), endDay, 0, 0, 0, 0, time.UTC) + if end.Before(start) { + end = end.AddDate(1, 0, 0) + } + return int(end.Sub(start).Hours() / 24) +} diff --git a/internal/api/season_templates_test.go b/internal/api/season_templates_test.go new file mode 100644 index 0000000..77e2de7 --- /dev/null +++ b/internal/api/season_templates_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestSyncSeasonTaskTemplatesCreatesAndPreservesDerivedTemplates(t *testing.T) { + gardenID, march, day, april := 3, 3, 10, 4 + model := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 4} + app, _, _ := newGardenTestApplication() + app.models.SpeciesTaskTemplates = model + species := storage.Species{ID: 2, GardenID: &gardenID, PlantingMonthFrom: &march, PlantingDayFrom: &day, PlantingMonthTo: &april, PlantingDayTo: &day} + + if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil { + t.Fatal(err) + } + if len(model.items) != 1 { + t.Fatalf("templates=%d want=1", len(model.items)) + } + var generated storage.SpeciesTaskTemplate + for _, generated = range model.items { + } + if generated.Origin != storage.TaskTemplateOriginSeasonPlanting || generated.TriggerType != storage.TaskTriggerRelativeToSpeciesPlanting { + t.Fatalf("unexpected generated template: %+v", generated) + } + if generated.Duration != 31 || generated.Recurrence != storage.TaskRecurrenceNone { + t.Fatalf("unexpected generated schedule: %+v", generated) + } + + generated.Title, generated.Active = "Eigener Titel", false + model.items[generated.ID] = generated + if err := app.syncSeasonTaskTemplates(gardenID, species); err != nil { + t.Fatal(err) + } + if got := model.items[generated.ID]; got.Title != "Eigener Titel" || got.Active { + t.Fatalf("user changes were overwritten: %+v", got) + } +} + +func TestGeneratedTaskUsesSpeciesPlantingSeason(t *testing.T) { + acquired := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + month, day := 3, 15 + plant := storage.Plant{ID: 8, AcquiredAt: &acquired} + species := storage.Species{PlantingMonthFrom: &month, PlantingDayFrom: &day} + template := storage.SpeciesTaskTemplate{ID: 9, Title: "Pflanzen", TriggerType: storage.TaskTriggerRelativeToSpeciesPlanting, DurationUnit: storage.TaskDurationDay} + tasks := generatedTasksForTemplate(plant, species, template, nil, 3, 1, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if len(tasks) != 1 || tasks[0].DueAtStart.Month() != time.March || tasks[0].DueAtStart.Day() != 15 || tasks[0].DueAtStart.Year() != 2027 { + t.Fatalf("unexpected planting season task: %+v", tasks) + } +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..5cb0a4e --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "errors" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" +) + +func (app *application) serve() error { + maintenanceContext, stopMaintenance := context.WithCancel(context.Background()) + defer stopMaintenance() + app.background(func() { + if err := app.runLifecycleMaintenance(time.Now()); err != nil { + app.logger.Error("lifecycle maintenance failed", "error", err) + } + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-maintenanceContext.Done(): + return + case now := <-ticker.C: + if err := app.runLifecycleMaintenance(now); err != nil { + app.logger.Error("lifecycle maintenance failed", "error", err) + } + } + } + }) + srv := &http.Server{ + Addr: net.JoinHostPort(app.config.Host, strconv.Itoa(app.config.Port)), + Handler: app.routes(), + IdleTimeout: time.Minute, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 2 * time.Minute, + } + + shutdownError := make(chan error) + + go func() { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + s := <-quit + + app.logger.Info("stopping server", "addr", srv.Addr, "signal", s.String()) + shutdownError <- srv.Shutdown(context.Background()) + }() + + app.logger.Info("starting server", "addr", srv.Addr, "env", app.config.Env) + err := srv.ListenAndServe() + if !errors.Is(err, http.ErrServerClosed) { + return err + } + + err = <-shutdownError + if err != nil { + return err + } + + stopMaintenance() + app.logger.Info("waiting for background tasks") + app.wg.Wait() + + app.logger.Info("shutdown complete") + return nil +} diff --git a/internal/api/sessions.go b/internal/api/sessions.go new file mode 100644 index 0000000..23579ea --- /dev/null +++ b/internal/api/sessions.go @@ -0,0 +1,97 @@ +package api + +import ( + "crypto/rand" + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +const authenticatedUserIDSessionKey = "authenticatedUserID" + +const ( + accountSessionIDKey = "accountSessionID" + accountSessionCreatedAtKey = "accountSessionCreatedAt" +) + +func (app *application) createSessionHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.Password) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidCredentialsResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + match, err := user.Password.Matches(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + if !match { + app.invalidCredentialsResponse(w, r) + return + } + + if err := app.sessions.RenewToken(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.sessions.Put(r.Context(), authenticatedUserIDSessionKey, user.ID) + app.sessions.Put(r.Context(), accountSessionIDKey, rand.Text()) + app.sessions.Put(r.Context(), accountSessionCreatedAtKey, time.Now().UTC().Unix()) + + if err := app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSessionHandler(w http.ResponseWriter, r *http.Request) { + user, found := app.contextGetAuthenticatedUser(r) + if !found { + app.authenticationRequiredResponse(w, r) + return + } + + if err := app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteSessionHandler(w http.ResponseWriter, r *http.Request) { + if err := app.sessions.Destroy(r.Context()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/sessions_test.go b/internal/api/sessions_test.go new file mode 100644 index 0000000..6b69909 --- /dev/null +++ b/internal/api/sessions_test.go @@ -0,0 +1,210 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/alexedwards/scs/v2" + "github.com/julienschmidt/httprouter" + "golang.org/x/crypto/bcrypt" +) + +type sessionTestUserModel struct { + user storage.User +} + +func (m sessionTestUserModel) Insert(user storage.User) (storage.User, error) { + return user, nil +} + +func (m sessionTestUserModel) GetByID(id int) (storage.User, error) { + if id != m.user.ID { + return storage.User{}, storage.ErrRecordNotFound + } + return m.user, nil +} + +func (m sessionTestUserModel) GetByEmail(email string) (storage.User, error) { + if email != m.user.Email { + return storage.User{}, storage.ErrRecordNotFound + } + return m.user, nil +} + +func (m sessionTestUserModel) Update(user storage.User) (storage.User, error) { + return user, nil +} + +func (m sessionTestUserModel) GetForToken(string, string) (storage.User, error) { + return storage.User{}, storage.ErrRecordNotFound +} + +func (m sessionTestUserModel) CreateEmailChange(int, string, time.Duration) (string, error) { + return "", nil +} +func (m sessionTestUserModel) ConfirmEmailChange(string, int) (storage.User, error) { + return storage.User{}, nil +} +func (m sessionTestUserModel) GetAll() ([]storage.User, error) { + return []storage.User{m.user}, nil +} +func (m sessionTestUserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) { + user := m.user + user.Role = role + return user, nil +} + +func (m sessionTestUserModel) Delete(int) error { return nil } + +func newSessionTestApplication(t *testing.T) (*application, http.Handler) { + t.Helper() + + passwordHash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + + user := storage.User{ + ID: 42, + Name: "Alice", + Email: "alice@example.com", + Password: *auth.NewPassword(passwordHash), + Activated: true, + } + + sessions := scs.New() + sessions.Cookie.Name = "gardomatic_session" + + app := &application{ + config: Config{ + Cors: struct{ TrustedOrigins []string }{ + TrustedOrigins: []string{"http://localhost:8080"}, + }, + }, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + models: storage.Models{Users: sessionTestUserModel{user: user}}, + sessions: sessions, + } + + router := httprouter.New() + router.HandlerFunc(http.MethodPost, "/v1/session", app.createSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/session", app.showSessionHandler) + router.HandlerFunc(http.MethodDelete, "/v1/session", app.deleteSessionHandler) + router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler)) + router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler)) + + handler := app.sessions.LoadAndSave(app.enableCORS(app.authenticate(router))) + return app, handler +} + +func TestBrowserSessionLifecycle(t *testing.T) { + _, handler := newSessionTestApplication(t) + + loginBody := []byte(`{"email":"alice@example.com","password":"correct horse battery staple"}`) + loginRequest := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader(loginBody)) + loginRequest.Header.Set("Content-Type", "application/json") + loginRequest.Header.Set("Origin", "http://localhost:8080") + loginResponse := httptest.NewRecorder() + + handler.ServeHTTP(loginResponse, loginRequest) + + if loginResponse.Code != http.StatusCreated { + t.Fatalf("login status: got %d, want %d; body: %s", loginResponse.Code, http.StatusCreated, loginResponse.Body.String()) + } + + var sessionCookie *http.Cookie + for _, cookie := range loginResponse.Result().Cookies() { + if cookie.Name == "gardomatic_session" { + sessionCookie = cookie + break + } + } + if sessionCookie == nil { + t.Fatal("login response did not contain a session cookie") + } + if !sessionCookie.HttpOnly { + t.Error("session cookie is not HttpOnly") + } + + showRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + showRequest.Header.Set("Origin", "http://localhost:8080") + showRequest.AddCookie(sessionCookie) + showResponse := httptest.NewRecorder() + handler.ServeHTTP(showResponse, showRequest) + + if showResponse.Code != http.StatusOK { + t.Fatalf("show session status: got %d, want %d; body: %s", showResponse.Code, http.StatusOK, showResponse.Body.String()) + } + if got := showResponse.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("Access-Control-Allow-Credentials: got %q, want %q", got, "true") + } + + listRequest := httptest.NewRequest(http.MethodGet, "/v1/account/sessions", nil) + listRequest.AddCookie(sessionCookie) + listResponse := httptest.NewRecorder() + handler.ServeHTTP(listResponse, listRequest) + if listResponse.Code != http.StatusOK { + t.Fatalf("list account sessions status: got %d, want %d; body: %s", listResponse.Code, http.StatusOK, listResponse.Body.String()) + } + var listed struct { + Sessions []accountSession `json:"sessions"` + } + if err := json.Unmarshal(listResponse.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if len(listed.Sessions) != 1 || !listed.Sessions[0].Current || listed.Sessions[0].ID == "" { + t.Fatalf("listed sessions = %+v, want one current session", listed.Sessions) + } + + invalidBearerRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + invalidBearerRequest.Header.Set("Origin", "http://localhost:8080") + invalidBearerRequest.Header.Set("Authorization", "Bearer invalid") + invalidBearerRequest.AddCookie(sessionCookie) + invalidBearerResponse := httptest.NewRecorder() + handler.ServeHTTP(invalidBearerResponse, invalidBearerRequest) + + if invalidBearerResponse.Code != http.StatusUnauthorized { + t.Fatalf("invalid bearer status: got %d, want %d", invalidBearerResponse.Code, http.StatusUnauthorized) + } + + logoutRequest := httptest.NewRequest(http.MethodDelete, "/v1/account/sessions/"+listed.Sessions[0].ID, nil) + logoutRequest.Header.Set("Origin", "http://localhost:8080") + logoutRequest.AddCookie(sessionCookie) + logoutResponse := httptest.NewRecorder() + handler.ServeHTTP(logoutResponse, logoutRequest) + + if logoutResponse.Code != http.StatusNoContent { + t.Fatalf("logout status: got %d, want %d", logoutResponse.Code, http.StatusNoContent) + } + + showAfterLogoutRequest := httptest.NewRequest(http.MethodGet, "/v1/session", nil) + showAfterLogoutRequest.Header.Set("Origin", "http://localhost:8080") + showAfterLogoutResponse := httptest.NewRecorder() + handler.ServeHTTP(showAfterLogoutResponse, showAfterLogoutRequest) + + if showAfterLogoutResponse.Code != http.StatusUnauthorized { + t.Fatalf("show after logout status: got %d, want %d", showAfterLogoutResponse.Code, http.StatusUnauthorized) + } +} + +func TestSessionEndpointRejectsUntrustedBrowserOrigin(t *testing.T) { + _, handler := newSessionTestApplication(t) + + request := httptest.NewRequest(http.MethodPost, "/v1/session", bytes.NewReader([]byte(`{}`))) + request.Header.Set("Origin", "https://attacker.example") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status: got %d, want %d", response.Code, http.StatusForbidden) + } +} diff --git a/internal/api/species.go b/internal/api/species.go new file mode 100644 index 0000000..7ca5f94 --- /dev/null +++ b/internal/api/species.go @@ -0,0 +1,396 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesInput struct { + Global bool `json:"global"` + Tags []string `json:"tags"` + CommonName *string `json:"common_name"` + Cultivar *string `json:"cultivar"` + BotanicalName *string `json:"botanical_name"` + CategoryID *int `json:"category_id"` + ClearCategoryID bool `json:"clear_category_id"` + SunExposure *string `json:"sun_exposure"` + SoilCondition *string `json:"soil_condition"` + SoilReaction *string `json:"soil_reaction"` + WinterProtection *string `json:"winter_protection"` + SpacingCM *int `json:"spacing_cm"` + HeightCM *int `json:"height_cm"` + SowMonthFrom *int `json:"sow_month_from"` + SowDayFrom *int `json:"sow_day_from"` + ClearSowDayFrom bool `json:"clear_sow_day_from"` + SowMonthTo *int `json:"sow_month_to"` + SowDayTo *int `json:"sow_day_to"` + ClearSowDayTo bool `json:"clear_sow_day_to"` + PlantingMonthFrom *int `json:"planting_month_from"` + PlantingDayFrom *int `json:"planting_day_from"` + ClearPlantingDayFrom bool `json:"clear_planting_day_from"` + PlantingMonthTo *int `json:"planting_month_to"` + PlantingDayTo *int `json:"planting_day_to"` + ClearPlantingDayTo bool `json:"clear_planting_day_to"` + HarvestMonthFrom *int `json:"harvest_month_from"` + HarvestDayFrom *int `json:"harvest_day_from"` + ClearHarvestDayFrom bool `json:"clear_harvest_day_from"` + HarvestMonthTo *int `json:"harvest_month_to"` + HarvestDayTo *int `json:"harvest_day_to"` + ClearHarvestDayTo bool `json:"clear_harvest_day_to"` + ClearSowRange bool `json:"clear_sow_range"` + ClearPlantingRange bool `json:"clear_planting_range"` + ClearHarvestRange bool `json:"clear_harvest_range"` + Notes *string `json:"notes"` + ImageData *string `json:"image_data"` + ImageID *int `json:"image_id"` + Attributes json.RawMessage `json:"attributes"` +} + +func (input speciesInput) apply(species *storage.Species) { + assignTrimmed(input.CommonName, &species.CommonName) + assignTrimmed(input.Cultivar, &species.Cultivar) + assignTrimmed(input.BotanicalName, &species.BotanicalName) + assignIntPointer(input.CategoryID, &species.CategoryID) + if input.ClearCategoryID { + species.CategoryID = nil + species.Category = "" + } + assignTrimmed(input.Notes, &species.Notes) + if input.ImageData != nil { + species.ImageData = *input.ImageData + } + assignStringPointer(input.SunExposure, &species.SunExposure) + assignStringPointer(input.SoilCondition, &species.SoilCondition) + assignStringPointer(input.SoilReaction, &species.SoilReaction) + assignStringPointer(input.WinterProtection, &species.WinterProtection) + assignIntPointer(input.SpacingCM, &species.SpacingCM) + assignIntPointer(input.HeightCM, &species.HeightCM) + assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom) + assignIntPointer(input.SowDayFrom, &species.SowDayFrom) + assignIntPointer(input.SowMonthTo, &species.SowMonthTo) + assignIntPointer(input.SowDayTo, &species.SowDayTo) + assignIntPointer(input.PlantingMonthFrom, &species.PlantingMonthFrom) + assignIntPointer(input.PlantingDayFrom, &species.PlantingDayFrom) + assignIntPointer(input.PlantingMonthTo, &species.PlantingMonthTo) + assignIntPointer(input.PlantingDayTo, &species.PlantingDayTo) + assignIntPointer(input.HarvestMonthFrom, &species.HarvestMonthFrom) + assignIntPointer(input.HarvestDayFrom, &species.HarvestDayFrom) + assignIntPointer(input.HarvestMonthTo, &species.HarvestMonthTo) + assignIntPointer(input.HarvestDayTo, &species.HarvestDayTo) + if input.ClearSowRange { + species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo = nil, nil, nil, nil + } + if input.ClearHarvestRange { + species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo = nil, nil, nil, nil + } + if input.ClearPlantingRange { + species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo = nil, nil, nil, nil + } + if input.ClearSowDayFrom { + species.SowDayFrom = nil + } + if input.ClearSowDayTo { + species.SowDayTo = nil + } + if input.ClearPlantingDayFrom { + species.PlantingDayFrom = nil + } + if input.ClearPlantingDayTo { + species.PlantingDayTo = nil + } + if input.ClearHarvestDayFrom { + species.HarvestDayFrom = nil + } + if input.ClearHarvestDayTo { + species.HarvestDayTo = nil + } + if len(input.Attributes) != 0 { + species.Attributes = input.Attributes + } +} + +func assignTrimmed(input *string, destination *string) { + if input != nil { + *destination = strings.TrimSpace(*input) + } +} + +func assignStringPointer(input *string, destination **string) { + if input != nil { + value := strings.TrimSpace(*input) + if value == "" { + *destination = nil + } else { + *destination = &value + } + } +} + +func assignIntPointer(input *int, destination **int) { + if input != nil { + *destination = input + } +} + +func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + var input speciesInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if input.Global { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + var ownerGardenID *int + if !input.Global { + ownerGardenID = &gardenID + } + species := storage.Species{GardenID: ownerGardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID} + input.apply(&species) + changedCategoryID := input.CategoryID + if input.ClearCategoryID { + changedCategoryID = nil + } + if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, nil) { + return + } + species.Tags = storage.NormalizeTags(input.Tags) + v := validate.New() + validateImageData(v, species.ImageData) + if storage.ValidateSpecies(v, species); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + imageID, err := app.resolveImage(gardenID, species.ImageData, input.ImageID, user.ID, "species") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + species.ImageID = imageID + species.ImageData = "" + species, err = app.models.Species.Insert(species) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { + app.serverErrorResponse(w, r, err) + return + } + tagScope := gardenID + if species.GardenID == nil { + tagScope = 0 + } + species.Tags, err = app.saveTags(tagScope, storage.TagEntitySpecies, species.ID, species.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d", gardenID, species.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"species": species}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + allSpecies, err := app.models.Species.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + for i := range allSpecies { + tagScope := gardenID + if allSpecies[i].GardenID == nil { + tagScope = 0 + } + allSpecies[i].Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, allSpecies[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": allSpecies}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, err := app.models.Species.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + tagScope := gardenID + if species.GardenID == nil { + tagScope = 0 + } + species.Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, species.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, err := app.models.Species.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + user, _ := app.contextGetAuthenticatedUser(r) + if species.GardenID == nil { + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + } else if *species.GardenID != gardenID { + app.notFoundResponse(w, r) + return + } else if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return + } + var input speciesInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + previousCategoryID := species.CategoryID + previousImageID := species.ImageID + previousImageData := species.ImageData + input.apply(&species) + species.UpdatedBy = user.ID + changedCategoryID := input.CategoryID + if input.ClearCategoryID { + changedCategoryID = nil + } + if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, previousCategoryID) { + return + } + if input.Tags != nil { + species.Tags = storage.NormalizeTags(input.Tags) + } + v := validate.New() + validateImageData(v, species.ImageData) + if storage.ValidateSpecies(v, species); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + if input.ImageData != nil && *input.ImageData != previousImageData { + species.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "species") + if err != nil { + app.badRequestResponse(w, r, err) + return + } + } + species.ImageData = "" + updateScope := gardenID + if species.GardenID == nil { + updateScope = 0 + } + species, err = app.models.Species.Update(updateScope, species) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "species", EntityID: species.ID, PreviousImageID: previousImageID, ImageID: species.ImageID, ChangedBy: user.ID}); err != nil { + app.serverErrorResponse(w, r, err) + return + } + if input.Tags != nil { + species.Tags, err = app.saveTags(updateScope, storage.TagEntitySpecies, species.ID, species.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) resolveSpeciesCategory(w http.ResponseWriter, r *http.Request, species *storage.Species, changedID, existingID *int) bool { + if changedID == nil { + return true + } + category, err := app.models.SpeciesCategories.Get(*changedID) + keepsInactiveCategory := existingID != nil && *existingID == *changedID + if err != nil || !category.Active && !keepsInactiveCategory { + app.failedValidationResponse(w, r, map[string]string{"category_id": "must reference an active category"}) + return false + } + species.Category = category.Name + return true +} + +func (app *application) deleteSpeciesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, id) + if !writable { + return + } + deleteScope := gardenID + if species.GardenID == nil { + deleteScope = 0 + } + if err := app.models.Species.Delete(deleteScope, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) respondToEntityModelError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.notFoundResponse(w, r) + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + case errors.Is(err, storage.ErrConflict): + app.conflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/species_categories.go b/internal/api/species_categories.go new file mode 100644 index 0000000..7a9844a --- /dev/null +++ b/internal/api/species_categories.go @@ -0,0 +1,141 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesCategoryInput struct { + Name *string `json:"name"` + SortOrder *int `json:"sort_order"` + Active *bool `json:"active"` + Lifecycle *string `json:"lifecycle"` +} + +func (input speciesCategoryInput) apply(category *storage.SpeciesCategory) { + if input.Name != nil { + category.Name = strings.TrimSpace(*input.Name) + } + if input.SortOrder != nil { + category.SortOrder = *input.SortOrder + } + if input.Active != nil { + category.Active = *input.Active + } + if input.Lifecycle != nil { + value := strings.TrimSpace(*input.Lifecycle) + if value == "" { + category.Lifecycle = nil + } else { + category.Lifecycle = &value + } + } +} + +func (app *application) listSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) { + categories, err := app.models.SpeciesCategories.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listAdminSpeciesCategoriesHandler(w http.ResponseWriter, r *http.Request) { + categories, err := app.models.SpeciesCategories.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"categories": categories}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + var input speciesCategoryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + category := storage.SpeciesCategory{Active: true} + input.apply(&category) + if !app.validateSpeciesCategory(w, r, category) { + return + } + category, err := app.models.SpeciesCategories.Insert(category) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/admin/species-categories/%d", category.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"category": category}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + category, err := app.models.SpeciesCategories.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input speciesCategoryInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&category) + if !app.validateSpeciesCategory(w, r, category) { + return + } + category, err = app.models.SpeciesCategories.Update(category) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"category": category}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteAdminSpeciesCategoryHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + category, err := app.models.SpeciesCategories.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + category.Active = false + if _, err = app.models.SpeciesCategories.Update(category); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateSpeciesCategory(w http.ResponseWriter, r *http.Request, category storage.SpeciesCategory) bool { + v := validate.New() + storage.ValidateSpeciesCategory(v, category) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/species_categories_test.go b/internal/api/species_categories_test.go new file mode 100644 index 0000000..cd97083 --- /dev/null +++ b/internal/api/species_categories_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gardomatic.kleiax.de/internal/storage" + "github.com/julienschmidt/httprouter" +) + +type speciesCategoryTestModel struct { + items map[int]storage.SpeciesCategory + nextID int +} + +func (m *speciesCategoryTestModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + m.nextID++ + category.ID, category.Version = m.nextID, 1 + m.items[category.ID] = category + return category, nil +} + +func (m *speciesCategoryTestModel) Get(id int) (storage.SpeciesCategory, error) { + category, ok := m.items[id] + if !ok { + return storage.SpeciesCategory{}, storage.ErrRecordNotFound + } + return category, nil +} + +func (m *speciesCategoryTestModel) GetAll() ([]storage.SpeciesCategory, error) { + categories := make([]storage.SpeciesCategory, 0, len(m.items)) + for _, category := range m.items { + categories = append(categories, category) + } + return categories, nil +} + +func (m *speciesCategoryTestModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + if _, ok := m.items[category.ID]; !ok { + return storage.SpeciesCategory{}, storage.ErrRecordNotFound + } + category.Version++ + m.items[category.ID] = category + return category, nil +} + +func TestAdminSpeciesCategoryLifecycle(t *testing.T) { + app, _, _ := newGardenTestApplication() + model := &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{}, nextID: 4} + app.models.SpeciesCategories = model + + createRequest := httptest.NewRequest(http.MethodPost, "/v1/admin/species-categories", strings.NewReader(`{"name":" Gemüse ","sort_order":10}`)) + createResponse := httptest.NewRecorder() + app.createAdminSpeciesCategoryHandler(createResponse, createRequest) + if createResponse.Code != http.StatusCreated || model.items[5].Name != "Gemüse" || !model.items[5].Active { + t.Fatalf("create category: status=%d category=%+v body=%s", createResponse.Code, model.items[5], createResponse.Body.String()) + } + + deleteRequest := httptest.NewRequest(http.MethodDelete, "/v1/admin/species-categories/5", nil) + deleteRequest = deleteRequest.WithContext(context.WithValue(deleteRequest.Context(), httprouter.ParamsKey, httprouter.Params{{Key: "id", Value: "5"}})) + deleteResponse := httptest.NewRecorder() + app.deleteAdminSpeciesCategoryHandler(deleteResponse, deleteRequest) + if deleteResponse.Code != http.StatusNoContent || model.items[5].Active { + t.Fatalf("deactivate category: status=%d category=%+v", deleteResponse.Code, model.items[5]) + } +} + +func TestResolveSpeciesCategoryRejectsInactiveCategory(t *testing.T) { + app, _, _ := newGardenTestApplication() + app.models.SpeciesCategories = &speciesCategoryTestModel{items: map[int]storage.SpeciesCategory{2: {ID: 2, Name: "Alt", Active: false}}} + id := 2 + response := httptest.NewRecorder() + if app.resolveSpeciesCategory(response, httptest.NewRequest(http.MethodPost, "/", nil), &storage.Species{CategoryID: &id}, &id, nil) { + t.Fatal("inactive category was accepted") + } + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } +} diff --git a/internal/api/species_task_templates.go b/internal/api/species_task_templates.go new file mode 100644 index 0000000..cde87a8 --- /dev/null +++ b/internal/api/species_task_templates.go @@ -0,0 +1,300 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type speciesTaskTemplateInput struct { + Title *string `json:"title"` + Description *string `json:"description"` + TriggerType *storage.TaskTriggerType `json:"trigger_type"` + MonthFrom *int `json:"month_from"` + DayFrom *int `json:"day_from"` + ClearDayFrom bool `json:"clear_day_from"` + MonthTo *int `json:"month_to"` + DayTo *int `json:"day_to"` + ClearDayTo bool `json:"clear_day_to"` + OffsetDaysFrom *int `json:"offset_days_from"` + OffsetDaysTo *int `json:"offset_days_to"` + IntervalDays *int `json:"interval_days"` + ClearIntervalDays bool `json:"clear_interval_days"` + TriggerOffset *int `json:"trigger_offset"` + TriggerOffsetUnit *storage.TaskDurationUnit `json:"trigger_offset_unit"` + Duration *int `json:"duration"` + DurationUnit *storage.TaskDurationUnit `json:"duration_unit"` + Recurrence *storage.TaskRecurrence `json:"recurrence"` + RecurrenceInterval *int `json:"recurrence_interval"` + Priority *int `json:"priority"` + Active *bool `json:"active"` +} + +func (input speciesTaskTemplateInput) apply(template *storage.SpeciesTaskTemplate) { + if input.Title != nil { + template.Title = strings.TrimSpace(*input.Title) + } + if input.Description != nil { + template.Description = strings.TrimSpace(*input.Description) + } + if input.TriggerType != nil { + template.TriggerType = *input.TriggerType + } + if input.MonthFrom != nil { + template.MonthFrom = positivePointer(input.MonthFrom) + } + if input.DayFrom != nil { + template.DayFrom = positivePointer(input.DayFrom) + } + if input.ClearDayFrom { + template.DayFrom = nil + } + if input.MonthTo != nil { + template.MonthTo = positivePointer(input.MonthTo) + } + if input.DayTo != nil { + template.DayTo = positivePointer(input.DayTo) + } + if input.ClearDayTo { + template.DayTo = nil + } + if input.OffsetDaysFrom != nil { + template.OffsetDaysFrom = input.OffsetDaysFrom + } + if input.OffsetDaysTo != nil { + template.OffsetDaysTo = input.OffsetDaysTo + } + if input.IntervalDays != nil { + template.IntervalDays = positivePointer(input.IntervalDays) + } + if input.ClearIntervalDays { + template.IntervalDays = nil + } + if input.TriggerOffset != nil { + template.TriggerOffset = *input.TriggerOffset + } + if input.TriggerOffsetUnit != nil { + template.TriggerOffsetUnit = *input.TriggerOffsetUnit + } + if input.Duration != nil { + template.Duration = *input.Duration + } + if input.DurationUnit != nil { + template.DurationUnit = *input.DurationUnit + } + if input.Recurrence != nil { + template.Recurrence = *input.Recurrence + } + if input.RecurrenceInterval != nil { + template.RecurrenceInterval = *input.RecurrenceInterval + } + if input.Priority != nil { + template.Priority = *input.Priority + } + if input.Active != nil { + template.Active = *input.Active + } + if template.TriggerType == storage.TaskTriggerMonthOfYear { + template.OffsetDaysFrom, template.OffsetDaysTo = nil, nil + } else { + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo = nil, nil, nil, nil + } +} + +func positivePointer(value *int) *int { + if value != nil && *value <= 0 { + return nil + } + return value +} + +func (app *application) createSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + if _, ok := app.requireWritableSpecies(w, r, gardenID, speciesID); !ok { + return + } + var input speciesTaskTemplateInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + template := storage.SpeciesTaskTemplate{SpeciesID: speciesID, Origin: storage.TaskTemplateOriginManual, Active: true, TriggerOffsetUnit: storage.TaskDurationDay, DurationUnit: storage.TaskDurationDay, RecurrenceInterval: 1} + input.apply(&template) + if !app.validateSpeciesTaskTemplate(w, r, template) { + return + } + template, err := app.models.SpeciesTaskTemplates.Insert(template) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/task-templates/%d", gardenID, speciesID, template.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"task_template": template}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listSpeciesTaskTemplatesHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + if _, err := app.models.Species.Get(gardenID, speciesID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_templates": templates}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID) + if !writable { + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + var input speciesTaskTemplateInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&template) + if !app.validateSpeciesTaskTemplate(w, r, template) { + return + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + template, err = app.models.SpeciesTaskTemplates.Update(writeScope, template) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) deleteSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + speciesID, _ := app.readIDParam(r) + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID) + if !writable { + return + } + template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID) + if err != nil || template.SpeciesID != speciesID { + if err == nil { + err = storage.ErrRecordNotFound + } + app.respondToEntityModelError(w, r, err) + return + } + writeScope := gardenID + if species.GardenID == nil { + writeScope = 0 + } + if template.Origin != storage.TaskTemplateOriginManual { + template.Active = false + if _, err := app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + if err := app.models.SpeciesTaskTemplates.Delete(writeScope, templateID); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) requireWritableSpecies(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) (storage.Species, bool) { + species, err := app.models.Species.Get(gardenID, speciesID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return storage.Species{}, false + } + if species.GardenID == nil { + user, _ := app.contextGetAuthenticatedUser(r) + if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return storage.Species{}, false + } + return species, true + } + if *species.GardenID != gardenID { + app.respondToEntityModelError(w, r, storage.ErrRecordNotFound) + return storage.Species{}, false + } + member, _ := app.contextGetGardenMember(r) + if !member.Can(storage.GardenPermissionSpeciesWrite) { + app.permissionDeniedResponse(w, r) + return storage.Species{}, false + } + return species, true +} + +func (app *application) validateSpeciesTaskTemplate(w http.ResponseWriter, r *http.Request, template storage.SpeciesTaskTemplate) bool { + v := validate.New() + storage.ValidateSpeciesTaskTemplate(v, template) + if !app.validateConfiguredPriority(w, r, v, template.Priority) { + return false + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/species_task_templates_test.go b/internal/api/species_task_templates_test.go new file mode 100644 index 0000000..448cbf0 --- /dev/null +++ b/internal/api/species_task_templates_test.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestSpeciesTaskTemplatesRequireWritePermission(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 14, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin} + gardenID := 3 + app.models.Species = &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global"}, 2: {ID: 2, GardenID: &gardenID, CommonName: "Rose"}}} + templates := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 10} + app.models.SpeciesTaskTemplates = templates + body := []byte(`{"title":"Schneiden","trigger_type":"month_of_year","month_from":2,"day_from":15,"duration":2,"duration_unit":"week","active":true}`) + global := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body) + if global.Code != http.StatusForbidden { + t.Fatalf("global write status=%d", global.Code) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/2/task-templates", body) + if created.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", created.Code, created.Body.String()) + } + listed := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/2/task-templates", nil) + if listed.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", listed.Code, listed.Body.String()) + } + user.Role = storage.ApplicationRoleAdmin + global = serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body) + if global.Code != http.StatusCreated { + t.Fatalf("global admin write status=%d body=%s", global.Code, global.Body.String()) + } +} diff --git a/internal/api/tags.go b/internal/api/tags.go new file mode 100644 index 0000000..9222944 --- /dev/null +++ b/internal/api/tags.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http" + + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) listGardenTagsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + tags, err := app.models.Tags.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"tags": tags}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) loadTags(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) { + if app.models.Tags == nil { + return nil, nil + } + return app.models.Tags.Get(gardenID, entity, entityID) +} + +func (app *application) saveTags(gardenID int, entity storage.TagEntity, entityID int, values []string) ([]string, error) { + values = storage.NormalizeTags(values) + if app.models.Tags == nil { + return values, nil + } + return app.models.Tags.Set(gardenID, entity, entityID, values) +} diff --git a/internal/api/task_generator.go b/internal/api/task_generator.go new file mode 100644 index 0000000..07d195e --- /dev/null +++ b/internal/api/task_generator.go @@ -0,0 +1,142 @@ +package api + +import ( + "errors" + "time" + + "gardomatic.kleiax.de/internal/daterange" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) generateTasks(gardenID, userID int, now time.Time) error { + plants, err := app.models.Plants.GetAllForGarden(gardenID) + if err != nil { + return err + } + existing, err := app.models.Tasks.GetAllForGarden(gardenID) + if err != nil { + return err + } + for _, plant := range plants { + if plant.SpeciesID == nil || (plant.Status != "alive" && plant.Status != "active") { + continue + } + var species storage.Species + templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, *plant.SpeciesID) + if err != nil { + return err + } + for _, template := range templates { + if !template.Active { + continue + } + if template.TriggerType == storage.TaskTriggerRelativeToSowing || template.TriggerType == storage.TaskTriggerRelativeToHarvest || template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting { + species, err = app.models.Species.Get(gardenID, *plant.SpeciesID) + if err != nil { + return err + } + } + if app.models.TaskTemplateOptOuts != nil { + optedOut, optErr := app.models.TaskTemplateOptOuts.IsOptedOut(gardenID, plant.ID, template.ID) + if optErr != nil { + return optErr + } + if optedOut { + continue + } + } + for _, task := range generatedTasksForTemplate(plant, species, template, existing, gardenID, userID, now) { + if _, err := app.models.Tasks.Insert(task); err != nil && !errors.Is(err, storage.ErrConflict) { + return err + } + } + } + } + return nil +} + +func generatedTasksForTemplate(plant storage.Plant, species storage.Species, template storage.SpeciesTaskTemplate, existing []storage.Task, gardenID, userID int, now time.Time) []storage.Task { + var start, generatedFor time.Time + switch template.TriggerType { + case storage.TaskTriggerMonthOfYear: + if template.MonthFrom == nil || template.DayFrom == nil { + return nil + } + start = nextAnnualStart(now, *template.MonthFrom, *template.DayFrom) + generatedFor = daterange.Date(start) + case storage.TaskTriggerRelativeToPlanting: + if plant.AcquiredAt == nil { + return nil + } + base := daterange.Date(*plant.AcquiredAt) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base + case storage.TaskTriggerRelativeToLastTask: + base, ok := latestCompletion(existing, plant.ID, template.ID) + if !ok { + return nil + } + base = daterange.Date(base) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), base + case storage.TaskTriggerRelativeToSowing, storage.TaskTriggerRelativeToHarvest, storage.TaskTriggerRelativeToSpeciesPlanting: + month, day := species.SowMonthFrom, species.SowDayFrom + if template.TriggerType == storage.TaskTriggerRelativeToHarvest { + month, day = species.HarvestMonthFrom, species.HarvestDayFrom + } else if template.TriggerType == storage.TaskTriggerRelativeToSpeciesPlanting { + month, day = species.PlantingMonthFrom, species.PlantingDayFrom + } + if month == nil { + return nil + } + d := 1 + if day != nil { + d = *day + } + base := nextAnnualStart(now, *month, d) + start, generatedFor = addTemplateDuration(base, template.TriggerOffset, template.TriggerOffsetUnit), daterange.Date(base) + default: + return nil + } + makeTask := func(windowStart, windowEnd, slot time.Time) storage.Task { + plantID, templateID := plant.ID, template.ID + return storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: template.Title, Description: template.Description, DueAtStart: &windowStart, DueAtEnd: &windowEnd, GeneratedFor: &slot, Recurrence: template.Recurrence, RecurrenceInterval: template.RecurrenceInterval, Priority: template.Priority, Active: true, CreatedBy: userID} + } + end := endOfDay(addTemplateDuration(start, template.Duration, template.DurationUnit)) + return []storage.Task{makeTask(start, end, generatedFor)} +} + +func nextAnnualStart(now time.Time, month, day int) time.Time { + lastDay := time.Date(now.Year(), time.Month(month)+1, 0, 0, 0, 0, 0, now.Location()).Day() + if day > lastDay { + day = lastDay + } + result := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, now.Location()) + if result.Before(daterange.Date(now)) { + result = result.AddDate(1, 0, 0) + } + return result +} + +func addTemplateDuration(value time.Time, amount int, unit storage.TaskDurationUnit) time.Time { + switch unit { + case storage.TaskDurationWeek: + return value.AddDate(0, 0, amount*7) + case storage.TaskDurationMonth: + return addClampedDate(value, 0, amount) + default: + return value.AddDate(0, 0, amount) + } +} + +func endOfDay(value time.Time) time.Time { + return daterange.Date(value).AddDate(0, 0, 1).Add(-time.Nanosecond) +} + +func latestCompletion(tasks []storage.Task, plantID, templateID int) (time.Time, bool) { + var latest time.Time + for _, task := range tasks { + if task.PlantID != nil && *task.PlantID == plantID && task.TemplateID != nil && *task.TemplateID == templateID && task.CompletedAt != nil && task.CompletedAt.After(latest) { + latest = *task.CompletedAt + } + } + return latest, !latest.IsZero() +} diff --git a/internal/api/task_generator_test.go b/internal/api/task_generator_test.go new file mode 100644 index 0000000..0d34d11 --- /dev/null +++ b/internal/api/task_generator_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type templateTestModel struct { + items map[int]storage.SpeciesTaskTemplate + nextID int +} + +func (m *templateTestModel) Insert(value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + m.nextID++ + value.ID, value.Version = m.nextID, 1 + m.items[value.ID] = value + return value, nil +} +func (m *templateTestModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) { + value, ok := m.items[id] + if !ok { + return storage.SpeciesTaskTemplate{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *templateTestModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) { + result := []storage.SpeciesTaskTemplate{} + for _, value := range m.items { + if value.SpeciesID == speciesID { + result = append(result, value) + } + } + return result, nil +} +func (m *templateTestModel) Update(gardenID int, value storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *templateTestModel) Delete(gardenID, id int) error { delete(m.items, id); return nil } + +func TestGenerateTasksIsIdempotentAndHandlesWrapAround(t *testing.T) { + app, _, _ := newGardenTestApplication() + speciesID, monthFrom, dayFrom := 2, 11, 1 + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, SpeciesID: &speciesID, Name: "Rose", Status: "active"}}} + app.models.SpeciesTaskTemplates = &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{7: {ID: 7, SpeciesID: speciesID, Title: "Winterschutz prüfen", TriggerType: storage.TaskTriggerMonthOfYear, MonthFrom: &monthFrom, DayFrom: &dayFrom, Duration: 3, DurationUnit: storage.TaskDurationMonth, Recurrence: storage.TaskRecurrenceWeekly, RecurrenceInterval: 2, Active: true}}} + tasks := &taskTestModel{items: map[int]storage.Task{}, nextID: 10} + app.models.Tasks = tasks + now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + if err := app.generateTasks(3, 12, now); err != nil { + t.Fatal(err) + } + firstCount := len(tasks.items) + if firstCount != 1 { + t.Fatalf("generated count=%d", firstCount) + } + if err := app.generateTasks(3, 12, now); err != nil { + t.Fatal(err) + } + if len(tasks.items) != firstCount { + t.Fatalf("second generation count=%d want=%d", len(tasks.items), firstCount) + } + for _, task := range tasks.items { + if task.Recurrence != storage.TaskRecurrenceWeekly || task.RecurrenceInterval != 2 { + t.Errorf("recurrence not copied to generated task: %+v", task) + } + if task.DueAtStart.Before(time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)) || task.DueAtEnd.After(endOfDay(time.Date(2027, 2, 1, 0, 0, 0, 0, time.UTC))) { + t.Errorf("window outside wrap range: %+v", task) + } + } +} diff --git a/internal/api/task_priorities.go b/internal/api/task_priorities.go new file mode 100644 index 0000000..cfa92c2 --- /dev/null +++ b/internal/api/task_priorities.go @@ -0,0 +1,153 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type taskPriorityInput struct { + Name *string `json:"name"` + Value *int `json:"value"` + SortOrder *int `json:"sort_order"` + Active *bool `json:"active"` +} + +func (input taskPriorityInput) apply(value *storage.TaskPriority) { + if input.Name != nil { + value.Name = strings.TrimSpace(*input.Name) + } + if input.Value != nil { + value.Value = *input.Value + } + if input.SortOrder != nil { + value.SortOrder = *input.SortOrder + } + if input.Active != nil { + value.Active = *input.Active + } +} +func (app *application) listTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) { + app.writeTaskPriorities(w, r, false) +} +func (app *application) listAdminTaskPrioritiesHandler(w http.ResponseWriter, r *http.Request) { + app.writeTaskPriorities(w, r, true) +} +func (app *application) writeTaskPriorities(w http.ResponseWriter, r *http.Request, includeInactive bool) { + values, err := app.models.TaskPriorities.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if !includeInactive { + active := values[:0] + for _, value := range values { + if value.Active { + active = append(active, value) + } + } + values = active + } + if err := app.writeJSON(w, http.StatusOK, envelope{"priorities": values}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) createAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + var input taskPriorityInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + value := storage.TaskPriority{Active: true} + input.apply(&value) + if !app.validateTaskPriority(w, r, value) { + return + } + value, err := app.models.TaskPriorities.Insert(value) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/admin/task-priorities/%d", value.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"priority": value}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) updateAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + value, err := app.models.TaskPriorities.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input taskPriorityInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + input.apply(&value) + if !app.validateTaskPriority(w, r, value) { + return + } + value, err = app.models.TaskPriorities.Update(value) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"priority": value}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) deleteAdminTaskPriorityHandler(w http.ResponseWriter, r *http.Request) { + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + value, err := app.models.TaskPriorities.Get(id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + value.Active = false + if _, err = app.models.TaskPriorities.Update(value); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} +func (app *application) validateTaskPriority(w http.ResponseWriter, r *http.Request, value storage.TaskPriority) bool { + v := validate.New() + storage.ValidateTaskPriority(v, value) + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} + +func (app *application) validateConfiguredPriority(w http.ResponseWriter, r *http.Request, v *validate.Validator, value int) bool { + if app.models.TaskPriorities == nil { + return true + } + priorities, err := app.models.TaskPriorities.GetAll() + if err != nil { + app.serverErrorResponse(w, r, err) + return false + } + for _, priority := range priorities { + if priority.Value == value { + return true + } + } + v.AddError("priority", "must refer to a configured task priority") + return true +} diff --git a/internal/api/task_template_opt_outs.go b/internal/api/task_template_opt_outs.go new file mode 100644 index 0000000..03aefc6 --- /dev/null +++ b/internal/api/task_template_opt_outs.go @@ -0,0 +1,44 @@ +package api + +import "net/http" + +func (app *application) listTaskTemplateOptOutsHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readNamedIDParam(r, "id") + if err != nil { + app.notFoundResponse(w, r) + return + } + ids, err := app.models.TaskTemplateOptOuts.GetAllForPlant(gardenID, plantID) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if err = app.writeJSON(w, http.StatusOK, envelope{"template_ids": ids}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} +func (app *application) setTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) { + app.setTaskTemplateOptOut(w, r, true) +} +func (app *application) deleteTaskTemplateOptOutHandler(w http.ResponseWriter, r *http.Request) { + app.setTaskTemplateOptOut(w, r, false) +} +func (app *application) setTaskTemplateOptOut(w http.ResponseWriter, r *http.Request, optedOut bool) { + gardenID, _ := app.readGardenIDParam(r) + plantID, err := app.readNamedIDParam(r, "id") + if err != nil { + app.notFoundResponse(w, r) + return + } + templateID, err := app.readNamedIDParam(r, "templateID") + if err != nil { + app.notFoundResponse(w, r) + return + } + if err = app.models.TaskTemplateOptOuts.Set(gardenID, plantID, templateID, optedOut); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/tasks.go b/internal/api/tasks.go new file mode 100644 index 0000000..80cabda --- /dev/null +++ b/internal/api/tasks.go @@ -0,0 +1,371 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +type taskInput struct { + Tags []string `json:"tags"` + PlantID *int `json:"plant_id"` + LocationID *int `json:"location_id"` + ClearPlantID bool `json:"clear_plant_id"` + ClearLocationID bool `json:"clear_location_id"` + Title *string `json:"title"` + Description *string `json:"description"` + DueAtStart *time.Time `json:"due_at_start"` + DueAtEnd *time.Time `json:"due_at_end"` + ClearDueAtStart bool `json:"clear_due_at_start"` + ClearDueAtEnd bool `json:"clear_due_at_end"` + Recurrence *storage.TaskRecurrence `json:"recurrence"` + RecurrenceInterval *int `json:"recurrence_interval"` + Priority *int `json:"priority"` + Active *bool `json:"active"` + Completed *bool `json:"completed"` + PlantStatusOnCompletion *string `json:"plant_status_on_completion"` +} + +func (input taskInput) isCompletionOnly() bool { + return input.Completed != nil && input.Tags == nil && input.PlantID == nil && input.LocationID == nil && !input.ClearPlantID && !input.ClearLocationID && input.Title == nil && input.Description == nil && input.DueAtStart == nil && input.DueAtEnd == nil && !input.ClearDueAtStart && !input.ClearDueAtEnd && input.Recurrence == nil && input.RecurrenceInterval == nil && input.Priority == nil && input.Active == nil && input.PlantStatusOnCompletion == nil +} + +func (input taskInput) apply(task *storage.Task, userID int) { + if input.PlantID != nil { + task.PlantID = input.PlantID + } + if input.LocationID != nil { + task.LocationID = input.LocationID + } + if input.ClearPlantID { + task.PlantID = nil + } + if input.ClearLocationID { + task.LocationID = nil + } + if input.Title != nil { + task.Title = strings.TrimSpace(*input.Title) + } + if input.Description != nil { + task.Description = strings.TrimSpace(*input.Description) + } + if input.DueAtStart != nil { + task.DueAtStart = input.DueAtStart + } + if input.DueAtEnd != nil { + task.DueAtEnd = input.DueAtEnd + } + if input.ClearDueAtStart { + task.DueAtStart = nil + } + if input.ClearDueAtEnd { + task.DueAtEnd = nil + } + if input.Recurrence != nil { + task.Recurrence = *input.Recurrence + } + if input.RecurrenceInterval != nil { + task.RecurrenceInterval = *input.RecurrenceInterval + } + if input.Priority != nil { + task.Priority = *input.Priority + } + if input.Active != nil { + task.Active = *input.Active + } + if input.Completed != nil { + if *input.Completed { + now := time.Now().UTC() + task.CompletedAt, task.CompletedBy = &now, &userID + } else { + task.CompletedAt, task.CompletedBy = nil, nil + } + } + assignStringPointer(input.PlantStatusOnCompletion, &task.PlantStatusOnCompletion) +} + +func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + var input taskInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + task := storage.Task{GardenID: gardenID, CreatedBy: user.ID, Active: true, RecurrenceInterval: 1} + input.apply(&task, user.ID) + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + task.Tags = storage.NormalizeTags(input.Tags) + if !app.validateTaskForGarden(w, r, gardenID, task) { + return + } + task, err := app.models.Tasks.Insert(task) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags); err != nil { + app.serverErrorResponse(w, r, err) + return + } + headers := make(http.Header) + headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/tasks/%d", gardenID, task.ID)) + if err := app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + user, _ := app.contextGetAuthenticatedUser(r) + if err := app.generateTasks(gardenID, user.ID, time.Now()); err != nil { + app.serverErrorResponse(w, r, err) + return + } + tasks, err := app.models.Tasks.GetAllForGarden(gardenID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + member, _ := app.contextGetGardenMember(r) + visible := tasks[:0] + for i := range tasks { + if tasks[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionTaskReadOther) { + continue + } + if tasks[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionTaskReadOwn) { + continue + } + tasks[i].Tags, err = app.loadTags(gardenID, storage.TagEntityTask, tasks[i].ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + visible = append(visible, tasks[i]) + } + tasks = visible + if err := app.writeJSON(w, http.StatusOK, envelope{"tasks": tasks}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther) { + return + } + task.Tags, err = app.loadTags(gardenID, storage.TagEntityTask, task.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + var input taskInput + if err := app.readJSON(w, r, &input); err != nil { + app.badRequestResponse(w, r, err) + return + } + if input.isCompletionOnly() { + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskCompleteOwn, storage.GardenPermissionTaskCompleteOther) { + return + } + } else if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskUpdateOwn, storage.GardenPermissionTaskUpdateOther) { + return + } + user, _ := app.contextGetAuthenticatedUser(r) + input.apply(&task, user.ID) + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + if input.Tags != nil { + task.Tags = storage.NormalizeTags(input.Tags) + } + if !app.validateTaskForGarden(w, r, gardenID, task) { + return + } + task, err = app.models.Tasks.Update(gardenID, task) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if input.Completed != nil && *input.Completed && task.PlantID != nil && task.PlantStatusOnCompletion != nil { + plant, plantErr := app.models.Plants.Get(gardenID, *task.PlantID) + if plantErr != nil { + app.respondToEntityModelError(w, r, plantErr) + return + } + plant.Status, plant.UpdatedBy = *task.PlantStatusOnCompletion, user.ID + if _, plantErr = app.models.Plants.Update(gardenID, plant); plantErr != nil { + app.respondToEntityModelError(w, r, plantErr) + return + } + } + if input.Tags != nil { + task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if input.Completed != nil && *input.Completed && task.Recurrence != storage.TaskRecurrenceNone { + if err := app.ensureNextRecurringTask(gardenID, user.ID, task); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) ensureNextRecurringTask(gardenID, userID int, task storage.Task) error { + next := task + next.ID, next.Version = 0, 0 + next.GeneratedFor = nil + next.CompletedAt, next.CompletedBy = nil, nil + next.CreatedBy = userID + next.CreatedAt, next.UpdatedAt = time.Time{}, time.Time{} + next.RepeatFromID = &task.ID + next.DueAtStart = advanceRecurringTime(task.DueAtStart, task.Recurrence, task.RecurrenceInterval) + next.DueAtEnd = advanceRecurringTime(task.DueAtEnd, task.Recurrence, task.RecurrenceInterval) + if next.TemplateID != nil && next.DueAtStart != nil { + generatedFor := time.Date(next.DueAtStart.Year(), next.DueAtStart.Month(), next.DueAtStart.Day(), 0, 0, 0, 0, next.DueAtStart.Location()) + next.GeneratedFor = &generatedFor + } + + created, err := app.models.Tasks.Insert(next) + if errors.Is(err, storage.ErrConflict) { + return nil + } + if err != nil { + return err + } + tags, err := app.loadTags(gardenID, storage.TagEntityTask, task.ID) + if err != nil { + return err + } + _, err = app.saveTags(gardenID, storage.TagEntityTask, created.ID, tags) + return err +} + +func advanceRecurringTime(value *time.Time, recurrence storage.TaskRecurrence, interval int) *time.Time { + if value == nil { + return nil + } + result := *value + if interval < 1 { + interval = 1 + } + switch recurrence { + case storage.TaskRecurrenceDaily: + result = result.AddDate(0, 0, interval) + case storage.TaskRecurrenceWeekly: + result = result.AddDate(0, 0, 7*interval) + case storage.TaskRecurrenceMonthly: + result = addClampedDate(result, 0, interval) + case storage.TaskRecurrenceYearly: + result = addClampedDate(result, interval, 0) + } + return &result +} + +func addClampedDate(value time.Time, years, months int) time.Time { + targetMonth := int(value.Month()) + months + targetYear := value.Year() + years + (targetMonth-1)/12 + targetMonth = (targetMonth-1)%12 + 1 + lastDay := time.Date(targetYear, time.Month(targetMonth)+1, 0, 0, 0, 0, 0, value.Location()).Day() + day := value.Day() + if day > lastDay { + day = lastDay + } + return time.Date(targetYear, time.Month(targetMonth), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()) +} + +func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { + gardenID, _ := app.readGardenIDParam(r) + id, err := app.readIDParam(r) + if err != nil { + app.notFoundResponse(w, r) + return + } + task, err := app.models.Tasks.Get(gardenID, id) + if err != nil { + app.respondToEntityModelError(w, r, err) + return + } + if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskDeleteOwn, storage.GardenPermissionTaskDeleteOther) { + return + } + if err := app.models.Tasks.Delete(gardenID, id); err != nil { + app.respondToEntityModelError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (app *application) validateTaskForGarden(w http.ResponseWriter, r *http.Request, gardenID int, task storage.Task) bool { + v := validate.New() + storage.ValidateTask(v, task) + storage.ValidateTags(v, task.Tags) + if !app.validateConfiguredPriority(w, r, v, task.Priority) { + return false + } + if task.PlantID != nil && *task.PlantID > 0 { + if _, err := app.models.Plants.Get(gardenID, *task.PlantID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("plant_id", "must refer to a plant in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if task.LocationID != nil && *task.LocationID > 0 { + if _, err := app.models.Locations.Get(gardenID, *task.LocationID); err != nil { + if errors.Is(err, storage.ErrRecordNotFound) { + v.AddError("location_id", "must refer to a location in this garden") + } else { + app.serverErrorResponse(w, r, err) + return false + } + } + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return false + } + return true +} diff --git a/internal/api/tasks_test.go b/internal/api/tasks_test.go new file mode 100644 index 0000000..97368de --- /dev/null +++ b/internal/api/tasks_test.go @@ -0,0 +1,133 @@ +package api + +import ( + "net/http" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +type taskTestModel struct { + items map[int]storage.Task + nextID int +} + +func (m *taskTestModel) Insert(value storage.Task) (storage.Task, error) { + if value.RepeatFromID != nil { + for _, existing := range m.items { + if existing.RepeatFromID != nil && *existing.RepeatFromID == *value.RepeatFromID { + return storage.Task{}, storage.ErrConflict + } + } + } + if value.TemplateID != nil && value.PlantID != nil && value.GeneratedFor != nil { + for _, existing := range m.items { + if existing.TemplateID != nil && *existing.TemplateID == *value.TemplateID && existing.PlantID != nil && *existing.PlantID == *value.PlantID && existing.GeneratedFor != nil && existing.GeneratedFor.Format("2006-01-02") == value.GeneratedFor.Format("2006-01-02") { + return storage.Task{}, storage.ErrConflict + } + } + } + m.nextID++ + value.ID, value.Version = m.nextID, 1 + m.items[value.ID] = value + return value, nil +} + +func TestCompletingRecurringTaskCreatesNextCalendarOccurrence(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + dueStart := time.Date(2026, time.January, 31, 8, 0, 0, 0, time.UTC) + dueEnd := time.Date(2026, time.January, 31, 18, 0, 0, 0, time.UTC) + app.models.Tasks = &taskTestModel{items: map[int]storage.Task{101: { + ID: 101, GardenID: 3, Title: "Düngen", DueAtStart: &dueStart, DueAtEnd: &dueEnd, + Recurrence: storage.TaskRecurrenceMonthly, RecurrenceInterval: 2, Active: true, CreatedBy: user.ID, Version: 1, + }}, nextID: 101} + + completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completed.Code != http.StatusOK { + t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) + } + next := app.models.Tasks.(*taskTestModel).items[102] + if next.RepeatFromID == nil || *next.RepeatFromID != 101 || next.CompletedAt != nil { + t.Fatalf("next occurrence links: %+v", next) + } + if got := next.DueAtStart.Format(time.RFC3339); got != "2026-03-31T08:00:00Z" { + t.Errorf("next start = %s", got) + } + if got := next.DueAtEnd.Format(time.RFC3339); got != "2026-03-31T18:00:00Z" { + t.Errorf("next end = %s", got) + } + + completedAgain := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completedAgain.Code != http.StatusOK || len(app.models.Tasks.(*taskTestModel).items) != 2 { + t.Fatalf("repeated completion created a duplicate: status=%d tasks=%+v", completedAgain.Code, app.models.Tasks.(*taskTestModel).items) + } +} + +func (m *taskTestModel) Get(gardenID, id int) (storage.Task, error) { + value, ok := m.items[id] + if !ok || value.GardenID != gardenID { + return storage.Task{}, storage.ErrRecordNotFound + } + return value, nil +} +func (m *taskTestModel) GetAllForGarden(gardenID int) ([]storage.Task, error) { + result := []storage.Task{} + for _, value := range m.items { + if value.GardenID == gardenID { + result = append(result, value) + } + } + return result, nil +} +func (m *taskTestModel) Update(gardenID int, value storage.Task) (storage.Task, error) { + if _, err := m.Get(gardenID, value.ID); err != nil { + return storage.Task{}, err + } + value.Version++ + m.items[value.ID] = value + return value, nil +} +func (m *taskTestModel) Delete(gardenID, id int) error { + if _, err := m.Get(gardenID, id); err != nil { + return err + } + delete(m.items, id) + return nil +} + +func TestTasksAreScopedAndValidateReferences(t *testing.T) { + app, _, members := newGardenTestApplication() + user := storage.User{ID: 12, Activated: true} + members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleMember} + app.models.Tasks = &taskTestModel{items: map[int]storage.Task{90: {ID: 90, GardenID: 4, Title: "Fremd", Version: 1}}, nextID: 100} + app.models.Plants = &plantTestModel{items: map[int]storage.Plant{5: {ID: 5, GardenID: 3, Name: "Tomate"}, 9: {ID: 9, GardenID: 4, Name: "Fremd"}}} + app.models.Locations = &locationTestModel{items: map[int]storage.Location{6: {ID: 6, GardenID: 3, Name: "Beet"}, 8: {ID: 8, GardenID: 4, Name: "Fremd"}}} + + foreignReference := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":9,"location_id":8}`)) + if foreignReference.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign references: got %d; %s", foreignReference.Code, foreignReference.Body.String()) + } + created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/tasks", []byte(`{"title":"Gießen","plant_id":5,"location_id":6,"priority":5}`)) + if created.Code != http.StatusCreated { + t.Fatalf("create: got %d; %s", created.Code, created.Body.String()) + } + value := app.models.Tasks.(*taskTestModel).items[101] + if value.CreatedBy != user.ID || value.GardenID != 3 { + t.Errorf("created task scope: %+v", value) + } + completed := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"completed":true}`)) + if completed.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].CompletedAt == nil { + t.Fatalf("complete: got %d; %s", completed.Code, completed.Body.String()) + } + cleared := serveResourceRequest(app, user, http.MethodPatch, "/v1/gardens/3/tasks/101", []byte(`{"clear_plant_id":true,"clear_location_id":true}`)) + if cleared.Code != http.StatusOK || app.models.Tasks.(*taskTestModel).items[101].PlantID != nil || app.models.Tasks.(*taskTestModel).items[101].LocationID != nil { + t.Fatalf("clear references: got %d; %s", cleared.Code, cleared.Body.String()) + } + foreignRead := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/tasks/90", nil) + if foreignRead.Code != http.StatusNotFound { + t.Fatalf("foreign read: got %d", foreignRead.Code) + } +} diff --git a/internal/api/tokens.go b/internal/api/tokens.go new file mode 100644 index 0000000..a55b568 --- /dev/null +++ b/internal/api/tokens.go @@ -0,0 +1,189 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + storage.ValidateEmail(v, input.Email) + auth.ValidatePasswordPlaintext(v, input.Password) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + app.invalidCredentialsResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + match, err := user.Password.Matches(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + if !match { + app.invalidCredentialsResponse(w, r) + return + } + + token, err := app.models.Tokens.New(user.ID, 24*time.Hour, auth.ScopeAuthentication) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": token}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if !user.Activated { + v.AddError("email", "user account must be activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + token, err := app.models.Tokens.New(user.ID, 45*time.Minute, auth.ScopePasswordReset) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "passwordResetToken": token.Plaintext, + } + + err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + env := envelope{"message": "an email will be sent to you containing password reset instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) createActivationTokenHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateEmail(v, input.Email); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetByEmail(input.Email) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("email", "no matching email address found") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + if user.Activated { + v.AddError("email", "user has already been activated") + app.failedValidationResponse(w, r, v.Errors) + return + } + + token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "activationToken": token.Plaintext, + } + + err := app.mailer.Send(user.Email, "token_activation.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + env := envelope{"message": "an email will be sent to you containing activation instructions"} + + err = app.writeJSON(w, http.StatusAccepted, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/api/users.go b/internal/api/users.go new file mode 100644 index 0000000..bbadfd0 --- /dev/null +++ b/internal/api/users.go @@ -0,0 +1,210 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" + "gardomatic.kleiax.de/internal/storage" +) + +func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + user := storage.User{ + Name: input.Name, + Email: input.Email, + Activated: false, + } + + err = user.Password.Set(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + v := validate.New() + + if storage.ValidateUser(v, user); !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err = app.models.Users.Insert(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrDuplicateEmail): + v.AddError("email", "a user with this email address already exists") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, auth.ScopeActivation) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + app.background(func() { + data := map[string]any{ + "activationToken": token.Plaintext, + "userID": user.ID, + } + + err := app.mailer.Send(user.Email, "user_welcome.tmpl", data) + if err != nil { + app.logger.Error(err.Error()) + } + }) + + err = app.writeJSON(w, http.StatusAccepted, envelope{"user": user}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + TokenPlaintext string `json:"token"` + Password string `json:"password"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + auth.ValidateTokenPlaintext(v, input.TokenPlaintext) + if input.Password != "" { + auth.ValidatePasswordPlaintext(v, input.Password) + } + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopeActivation, input.TokenPlaintext) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("token", "invalid or expired activation token") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + user.Activated = true + if input.Password != "" { + if err = user.Password.Set(input.Password); err != nil { + app.serverErrorResponse(w, r, err) + return + } + } + + user, err = app.models.Users.Update(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.models.Tokens.DeleteAllForUser(auth.ScopeActivation, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} + +func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Password string `json:"password"` + TokenPlaintext string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.badRequestResponse(w, r, err) + return + } + + v := validate.New() + + auth.ValidatePasswordPlaintext(v, input.Password) + auth.ValidateTokenPlaintext(v, input.TokenPlaintext) + + if !v.Valid() { + app.failedValidationResponse(w, r, v.Errors) + return + } + + user, err := app.models.Users.GetForToken(auth.ScopePasswordReset, input.TokenPlaintext) + if err != nil { + switch { + case errors.Is(err, storage.ErrRecordNotFound): + v.AddError("token", "invalid or expired password reset token") + app.failedValidationResponse(w, r, v.Errors) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = user.Password.Set(input.Password) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + user, err = app.models.Users.Update(user) + if err != nil { + switch { + case errors.Is(err, storage.ErrEditConflict): + app.editConflictResponse(w, r) + default: + app.serverErrorResponse(w, r, err) + } + return + } + + err = app.models.Tokens.DeleteAllForUser(auth.ScopePasswordReset, user.ID) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + env := envelope{"message": "your password was successfully reset"} + + err = app.writeJSON(w, http.StatusOK, env, nil) + if err != nil { + app.serverErrorResponse(w, r, err) + } +} diff --git a/internal/auth/doc.go b/internal/auth/doc.go new file mode 100644 index 0000000..8c0136c --- /dev/null +++ b/internal/auth/doc.go @@ -0,0 +1,3 @@ +// Package auth provides password hashing and secure token primitives used by +// Gardomatic authentication flows. +package auth diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..6a29336 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,71 @@ +package auth + +import ( + "errors" + + "gardomatic.kleiax.de/internal/platform/validate" + "golang.org/x/crypto/bcrypt" +) + +// Password holds a bcrypt hash and, while constructing a new password, its +// plaintext value for policy validation. Plaintext is never exposed. +type Password struct { + plaintext *string + hash []byte +} + +// NewPassword reconstructs a password value from an existing bcrypt hash. +func NewPassword(hash []byte) *Password { + return &Password{hash: hash} +} + +// Set hashes plaintextPassword and replaces the stored hash. +func (p *Password) Set(plaintextPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) + if err != nil { + return err + } + + p.plaintext = &plaintextPassword + p.hash = hash + + return nil +} + +// Get returns a defensive copy of the bcrypt hash. +func (p *Password) Get() []byte { + return p.hash +} + +// Matches reports whether plaintextPassword matches the stored bcrypt hash. +func (p *Password) Matches(plaintextPassword string) (bool, error) { + err := bcrypt.CompareHashAndPassword(p.hash, []byte(plaintextPassword)) + if err != nil { + switch { + case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword): + return false, nil + default: + return false, err + } + } + + return true, nil +} + +// Validate adds password-hash validation errors to v. +func (p *Password) Validate(v *validate.Validator) { + if p.plaintext != nil { + ValidatePasswordPlaintext(v, *p.plaintext) + } + + if p.hash == nil { + panic("missing password hash for user") + } +} + +// ValidatePasswordPlaintext applies the password policy to plaintext input. +func ValidatePasswordPlaintext(v *validate.Validator, plaintext string) { + v.Check(plaintext != "", "password", "must be provided") + v.Check(len(plaintext) >= 8, "password", "must be at least 8 bytes long") + v.Check(len(plaintext) <= 72, "password", "must not be more than 72 bytes long") +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..48d6d83 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,53 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +const ( + // ScopeActivation identifies account activation tokens. + ScopeActivation = "activation" + // ScopeAuthentication identifies bearer authentication tokens. + ScopeAuthentication = "authentication" + // ScopePasswordReset identifies password reset tokens. + ScopePasswordReset = "password-reset" +) + +// Token carries a one-time plaintext token and the hash persisted by storage. +type Token struct { + Plaintext string `json:"token"` + Hash []byte `json:"-"` + UserID int `json:"-"` + Expiry time.Time `json:"expiry"` + Scope string `json:"-"` +} + +// NewToken creates a cryptographically random token for a user and scope. +func NewToken(userID int, ttl time.Duration, scope string) Token { + token := Token{ + Plaintext: rand.Text(), + UserID: userID, + Expiry: time.Now().Add(ttl), + Scope: scope, + } + + hash := sha256.Sum256([]byte(token.Plaintext)) + token.Hash = hash[:] + + return token +} + +// Validate adds token consistency errors to v. +func (tk Token) Validate(v *validate.Validator) { + ValidateTokenPlaintext(v, tk.Plaintext) +} + +// ValidateTokenPlaintext checks the expected format of a user-supplied token. +func ValidateTokenPlaintext(v *validate.Validator, tokenPlaintext string) { + v.Check(tokenPlaintext != "", "token", "must be provided") + v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long") +} diff --git a/internal/daterange/daterange.go b/internal/daterange/daterange.go new file mode 100644 index 0000000..d492e94 --- /dev/null +++ b/internal/daterange/daterange.go @@ -0,0 +1,46 @@ +// Package daterange resolves recurring calendar windows and normalized dates. +package daterange + +import "time" + +// CalendarWindow resolves a recurring month/day range around now. A range whose +// start month is after its end month crosses the year boundary. +func CalendarWindow(now time.Time, monthFrom int, dayFrom *int, monthTo int, dayTo *int) (time.Time, time.Time) { + location := now.Location() + startYear := now.Year() + if monthFrom > monthTo && int(now.Month()) <= monthTo { + startYear-- + } + endYear := startYear + if monthFrom > monthTo { + endYear++ + } + startDay := 1 + if dayFrom != nil { + startDay = clampDay(startYear, time.Month(monthFrom), *dayFrom) + } + endDay := daysInMonth(endYear, time.Month(monthTo)) + if dayTo != nil { + endDay = clampDay(endYear, time.Month(monthTo), *dayTo) + } + return time.Date(startYear, time.Month(monthFrom), startDay, 0, 0, 0, 0, location), time.Date(endYear, time.Month(monthTo), endDay, 23, 59, 59, 0, location) +} + +// Date returns value at midnight in its original location. +func Date(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +func clampDay(year int, month time.Month, day int) int { + if maximum := daysInMonth(year, month); day > maximum { + return maximum + } + if day < 1 { + return 1 + } + return day +} diff --git a/internal/daterange/daterange_test.go b/internal/daterange/daterange_test.go new file mode 100644 index 0000000..788f0ca --- /dev/null +++ b/internal/daterange/daterange_test.go @@ -0,0 +1,36 @@ +package daterange + +import ( + "testing" + "time" +) + +func intPointer(value int) *int { return &value } + +func TestCalendarWindow(t *testing.T) { + tests := []struct { + name string + now time.Time + from int + fromDay *int + to int + toDay *int + wantStart, wantEnd string + }{ + {"ordinary", time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), 2, nil, 3, nil, "2026-02-01", "2026-03-31"}, + {"wrap before end", time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2025-11-01", "2026-02-28"}, + {"wrap before start", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), 11, nil, 2, nil, "2026-11-01", "2027-02-28"}, + {"clamps invalid month day", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 2, intPointer(31), 2, intPointer(31), "2026-02-28", "2026-02-28"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + start, end := CalendarWindow(test.now, test.from, test.fromDay, test.to, test.toDay) + if got := start.Format("2006-01-02"); got != test.wantStart { + t.Errorf("start=%s", got) + } + if got := end.Format("2006-01-02"); got != test.wantEnd { + t.Errorf("end=%s", got) + } + }) + } +} diff --git a/internal/mailer/doc.go b/internal/mailer/doc.go new file mode 100644 index 0000000..ed56af4 --- /dev/null +++ b/internal/mailer/doc.go @@ -0,0 +1,3 @@ +// Package mailer renders and delivers Gardomatic transactional email through +// SMTP or an append-only development file. +package mailer diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..58cf084 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,163 @@ +package mailer + +import ( + "bytes" + "embed" + "errors" + "fmt" + "os" + "sync" + "time" + + "github.com/wneessen/go-mail" + + ht "html/template" + tt "text/template" +) + +//go:embed "templates" +var templateFS embed.FS + +// Mailer renders embedded templates and delivers the resulting message. +type Mailer struct { + client *mail.Client + mode Mode + filePath string + sender string + fileMu sync.Mutex +} + +// Mode selects the delivery backend used by a Mailer. +type Mode string + +const ( + // ModeSMTP sends messages through an SMTP server. + ModeSMTP Mode = "smtp" + // ModeFile appends rendered messages to a local development file. + ModeFile Mode = "file" +) + +// Config contains SMTP or development-file delivery settings. +type Config struct { + Mode Mode + Host string + Port int + Username string + Password string + Sender string + FilePath string +} + +// New validates config and creates a Mailer. +func New(config Config) (*Mailer, error) { + mailer := &Mailer{ + mode: config.Mode, + filePath: config.FilePath, + sender: config.Sender, + } + + switch config.Mode { + case ModeSMTP: + client, err := mail.NewClient( + config.Host, + mail.WithSMTPAuth(mail.SMTPAuthLogin), + mail.WithPort(config.Port), + mail.WithUsername(config.Username), + mail.WithPassword(config.Password), + mail.WithTimeout(5*time.Second), + ) + if err != nil { + return nil, err + } + mailer.client = client + case ModeFile: + if config.FilePath == "" { + return nil, errors.New("mailer: file path must not be empty in file mode") + } + default: + return nil, fmt.Errorf("mailer: unsupported mode %q", config.Mode) + } + + return mailer, nil +} + +// Send renders templateFile with data and delivers it to recipient. +func (m *Mailer) Send(recipient string, templateFile string, data any) error { + textTmpl, err := tt.New("").ParseFS(templateFS, "templates/"+templateFile) + if err != nil { + return err + } + + subject := new(bytes.Buffer) + err = textTmpl.ExecuteTemplate(subject, "subject", data) + if err != nil { + return err + } + + plainBody := new(bytes.Buffer) + err = textTmpl.ExecuteTemplate(plainBody, "plainBody", data) + if err != nil { + return err + } + + htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile) + if err != nil { + return err + } + + htmlBody := new(bytes.Buffer) + err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data) + if err != nil { + return err + } + + msg := mail.NewMsg() + + err = msg.To(recipient) + if err != nil { + return err + } + + err = msg.From(m.sender) + if err != nil { + return err + } + + msg.Subject(subject.String()) + msg.SetBodyString(mail.TypeTextPlain, plainBody.String()) + msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String()) + + if m.mode == ModeFile { + return m.appendToFile(msg) + } + + return m.client.DialAndSend(msg) +} + +func (m *Mailer) appendToFile(msg *mail.Msg) error { + var content bytes.Buffer + if _, err := msg.WriteTo(&content); err != nil { + return fmt.Errorf("mailer: format message: %w", err) + } + + m.fileMu.Lock() + defer m.fileMu.Unlock() + + file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("mailer: open output file: %w", err) + } + defer file.Close() + + if _, err = file.WriteString("\n=== gardomatic mail ===\n"); err != nil { + return fmt.Errorf("mailer: append separator: %w", err) + } + if _, err = content.WriteTo(file); err != nil { + return fmt.Errorf("mailer: append message: %w", err) + } + if _, err = file.WriteString("\n"); err != nil { + return fmt.Errorf("mailer: finish message: %w", err) + } + + return nil +} diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go new file mode 100644 index 0000000..cf5c4db --- /dev/null +++ b/internal/mailer/mailer_test.go @@ -0,0 +1,64 @@ +package mailer + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFileModeAppendsMessages(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "mails.log") + m, err := New(Config{ + Mode: ModeFile, + Sender: "gardomatic@example.com", + FilePath: filePath, + }) + if err != nil { + t.Fatalf("New() returned an error: %v", err) + } + + data := map[string]any{ + "userID": 42, + "activationToken": "test-token", + } + for _, recipient := range []string{"alice@example.com", "bob@example.com"} { + if err = m.Send(recipient, "user_welcome.tmpl", data); err != nil { + t.Fatalf("Send() returned an error: %v", err) + } + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("reading mail output: %v", err) + } + output := string(content) + + if got := strings.Count(output, "=== gardomatic mail ==="); got != 2 { + t.Errorf("message count = %d, want 2", got) + } + for _, expected := range []string{ + "alice@example.com", + "bob@example.com", + "Subject: Welcome to Gardomatic!", + "test-token", + } { + if !strings.Contains(output, expected) { + t.Errorf("output does not contain %q", expected) + } + } +} + +func TestFileModeRequiresPath(t *testing.T) { + _, err := New(Config{Mode: ModeFile, Sender: "gardomatic@example.com"}) + if err == nil { + t.Fatal("New() returned no error without a file path") + } +} + +func TestNewRejectsUnknownMode(t *testing.T) { + _, err := New(Config{Mode: "unknown", Sender: "gardomatic@example.com"}) + if err == nil { + t.Fatal("New() returned no error for an unknown mode") + } +} diff --git a/internal/mailer/templates/email_change.tmpl b/internal/mailer/templates/email_change.tmpl new file mode 100644 index 0000000..9756789 --- /dev/null +++ b/internal/mailer/templates/email_change.tmpl @@ -0,0 +1,3 @@ +{{define "subject"}}E-Mail-Adresse bei Gardomatic bestätigen{{end}} +{{define "plainBody"}}Bestätige deine neue E-Mail-Adresse: {{.confirmationURL}}{{end}} +{{define "htmlBody"}}

Bestätige deine neue E-Mail-Adresse:

E-Mail-Adresse bestätigen

{{end}} diff --git a/internal/mailer/templates/garden_invite.tmpl b/internal/mailer/templates/garden_invite.tmpl new file mode 100644 index 0000000..2233aa1 --- /dev/null +++ b/internal/mailer/templates/garden_invite.tmpl @@ -0,0 +1,3 @@ +{{define "subject"}}Einladung zu Gardomatic{{end}} +{{define "plainBody"}}Du wurdest zu einem Garten eingeladen. Einladung annehmen: {{.inviteURL}}{{end}} +{{define "htmlBody"}}

Du wurdest zu einem Garten eingeladen.

Einladung annehmen

{{end}} diff --git a/internal/mailer/templates/test_mail.tmpl b/internal/mailer/templates/test_mail.tmpl new file mode 100644 index 0000000..a6123c9 --- /dev/null +++ b/internal/mailer/templates/test_mail.tmpl @@ -0,0 +1,25 @@ +{{define "subject"}}Gardomatic Testmail{{end}} + +{{define "plainBody"}} +Hallo, + +diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert. + +Viele Grüße +Gardomatic +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hallo,

+

diese Testmail bestätigt, dass der Mailversand von Gardomatic funktioniert.

+

Viele Grüße
Gardomatic

+ + +{{end}} diff --git a/internal/mailer/templates/token_activation.tmpl b/internal/mailer/templates/token_activation.tmpl new file mode 100644 index 0000000..9c897f7 --- /dev/null +++ b/internal/mailer/templates/token_activation.tmpl @@ -0,0 +1,48 @@ +{{define "subject"}}Activate your Gardomatic account{{end}} + +{{define "plainBody"}} +Hi, + +{{if .activationURL}}Activate your account using this link: + +{{.activationURL}} + +Alternatively, enter this activation token on the activation page: +{{.activationToken}} +{{else}}Please send a `PUT /v1/users/activated` request with the following JSON body to activate your account: + +{"token": "{{.activationToken}}"} +{{end}} + +Please note that this is a one-time use token and it will expire in 3 days. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hi,

+ {{if .activationURL}} +

Activate your Gardomatic account

+

Alternatively, enter this activation token on the activation page:

+
{{.activationToken}}
+ {{else}} +

Please send a PUT /v1/users/activated request with the following JSON body to activate your account:

+

+    {"token": "{{.activationToken}}"}
+    
+ {{end}} +

Please note that this is a one-time use token and it will expire in 3 days.

+

Thanks,

+

The Gardomatic Team

+ + +{{end}} diff --git a/internal/mailer/templates/token_password_reset.tmpl b/internal/mailer/templates/token_password_reset.tmpl new file mode 100644 index 0000000..685c32a --- /dev/null +++ b/internal/mailer/templates/token_password_reset.tmpl @@ -0,0 +1,37 @@ +{{define "subject"}}Reset your Gardomatic password{{end}} + +{{define "plainBody"}} +Hi, + +Please send a `PUT /v1/users/password` request with the following JSON body to set a new password: + +{"password": "your new password", "token": "{{.passwordResetToken}}"} + +Please note that this is a one-time use token and it will expire in 45 minutes. If you need +another token please make a `POST /v1/tokens/password-reset` request. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hi,

+

Please send a PUT /v1/users/password request with the following JSON body to set a new password:

+

+    {"password": "your new password", "token": "{{.passwordResetToken}}"}
+    
+

Please note that this is a one-time use token and it will expire in 45 minutes. + If you need another token please make a POST /v1/tokens/password-reset request.

+

Thanks,

+

The Gardomatic Team

+ + +{{end}} diff --git a/internal/mailer/templates/user_invitation.tmpl b/internal/mailer/templates/user_invitation.tmpl new file mode 100644 index 0000000..accc1f7 --- /dev/null +++ b/internal/mailer/templates/user_invitation.tmpl @@ -0,0 +1,31 @@ +{{define "subject"}}Einladung zu Gardomatic{{end}} + +{{define "plainBody"}} +Hallo {{.name}}, + +du wurdest zu Gardomatic eingeladen. Öffne den folgenden Link, um deinen Account zu aktivieren und ein Passwort festzulegen: + +{{.activationURL}} + +Der Link ist drei Tage lang gültig. + +Viele Grüße +Gardomatic +{{end}} + +{{define "htmlBody"}} + + + + + + + +

Hallo {{.name}},

+

du wurdest zu Gardomatic eingeladen.

+

Account aktivieren und Passwort festlegen

+

Der Link ist drei Tage lang gültig.

+

Viele Grüße
Gardomatic

+ + +{{end}} diff --git a/internal/mailer/templates/user_welcome.tmpl b/internal/mailer/templates/user_welcome.tmpl new file mode 100644 index 0000000..ef68302 --- /dev/null +++ b/internal/mailer/templates/user_welcome.tmpl @@ -0,0 +1,59 @@ +{{define "subject"}}Welcome to Gardomatic!{{end}} + +{{define "plainBody"}} +Hi, + +Thanks for signing up for a Gardomatic account. We're excited to have you on board! + +For future reference, your user ID number is {{.userID}}. + +{{if .activationURL}}Activate your account using this link: + +{{.activationURL}} + +Alternatively, enter this activation token on the activation page: +{{.activationToken}} +{{else}}Please send a request to the `PUT /v1/users/activated` endpoint with the following JSON +body to activate your account: + +{"token": "{{.activationToken}}"} +{{end}} + +Please note that this is a one-time use token and it will expire in 3 days. + +Thanks, + +The Gardomatic Team +{{end}} + +{{define "htmlBody"}} + + + + + + + + + +

Hi,

+

Thanks for signing up for a Gardomatic account. We're excited to have you on board!

+

For future reference, your user ID number is {{.userID}}.

+ {{if .activationURL}} +

Activate your Gardomatic account

+

Alternatively, enter this activation token on the activation page:

+
{{.activationToken}}
+ {{else}} +

Please send a request to the PUT /v1/users/activated endpoint with the + following JSON body to activate your account:

+

+    {"token": "{{.activationToken}}"}
+    
+ {{end}} +

Please note that this is a one-time use token and it will expire in 3 days.

+

Thanks,

+

The Gardomatic Team

+ + + +{{end}} diff --git a/internal/platform/environment/environment.go b/internal/platform/environment/environment.go new file mode 100644 index 0000000..23ab36d --- /dev/null +++ b/internal/platform/environment/environment.go @@ -0,0 +1,95 @@ +// Package environment provides strict parsing for runtime configuration. +package environment + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// String returns a trimmed environment value or fallback when it is empty. +func String(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +// Required returns a non-empty environment value. +func Required(name string) (string, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return "", fmt.Errorf("%s must be set", name) + } + return value, nil +} + +// Int parses an integer environment value. +func Int(name string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", name, err) + } + return parsed, nil +} + +// Float parses a floating-point environment value. +func Float(name string, fallback float64) (float64, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("%s must be a number: %w", name, err) + } + return parsed, nil +} + +// Bool parses a boolean environment value. +func Bool(name string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be a boolean: %w", name, err) + } + return parsed, nil +} + +// Duration parses a Go duration environment value. +func Duration(name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s must be a duration: %w", name, err) + } + return parsed, nil +} + +// CSV returns non-empty, trimmed comma-separated values. +func CSV(name string) []string { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + return result +} diff --git a/internal/platform/environment/environment_test.go b/internal/platform/environment/environment_test.go new file mode 100644 index 0000000..f7710c6 --- /dev/null +++ b/internal/platform/environment/environment_test.go @@ -0,0 +1,44 @@ +package environment + +import ( + "testing" + "time" +) + +func TestParsers(t *testing.T) { + t.Setenv("TEST_INT", "42") + t.Setenv("TEST_FLOAT", "2.5") + t.Setenv("TEST_BOOL", "true") + t.Setenv("TEST_DURATION", "15m") + t.Setenv("TEST_CSV", " one, two ,,three ") + + if value, err := Int("TEST_INT", 0); err != nil || value != 42 { + t.Fatalf("Int() = %d, %v", value, err) + } + if value, err := Float("TEST_FLOAT", 0); err != nil || value != 2.5 { + t.Fatalf("Float() = %f, %v", value, err) + } + if value, err := Bool("TEST_BOOL", false); err != nil || !value { + t.Fatalf("Bool() = %t, %v", value, err) + } + if value, err := Duration("TEST_DURATION", 0); err != nil || value != 15*time.Minute { + t.Fatalf("Duration() = %s, %v", value, err) + } + values := CSV("TEST_CSV") + if len(values) != 3 || values[1] != "two" { + t.Fatalf("CSV() = %#v", values) + } +} + +func TestInvalidValues(t *testing.T) { + t.Setenv("TEST_VALUE", "not-valid") + if _, err := Int("TEST_VALUE", 0); err == nil { + t.Fatal("Int() did not return an error") + } + if _, err := Bool("TEST_VALUE", false); err == nil { + t.Fatal("Bool() did not return an error") + } + if _, err := Duration("TEST_VALUE", 0); err == nil { + t.Fatal("Duration() did not return an error") + } +} diff --git a/internal/platform/validate/doc.go b/internal/platform/validate/doc.go new file mode 100644 index 0000000..3059083 --- /dev/null +++ b/internal/platform/validate/doc.go @@ -0,0 +1,3 @@ +// Package validate contains reusable validation helpers and an error collector +// for API and form inputs. +package validate diff --git a/internal/platform/validate/helper.go b/internal/platform/validate/helper.go new file mode 100644 index 0000000..36bbba0 --- /dev/null +++ b/internal/platform/validate/helper.go @@ -0,0 +1,44 @@ +package validate + +import ( + "regexp" + "slices" + "strings" + "unicode/utf8" +) + +// PermittedValue reports whether value appears in permittedValues. +func PermittedValue[T comparable](value T, permittedValues ...T) bool { + return slices.Contains(permittedValues, value) +} + +// Matches reports whether value satisfies rx. +func Matches(value string, rx *regexp.Regexp) bool { + return rx.MatchString(value) +} + +// Unique reports whether values contains no duplicate elements. +func Unique[T comparable](values []T) bool { + uniqueValues := make(map[T]bool) + + for _, value := range values { + uniqueValues[value] = true + } + + return len(values) == len(uniqueValues) +} + +// NotBlank reports whether value contains non-whitespace characters. +func NotBlank(value string) bool { + return strings.TrimSpace(value) != "" +} + +// MaxChars reports whether value contains at most n Unicode code points. +func MaxChars(value string, n int) bool { + return utf8.RuneCountInString(value) <= n +} + +// MinChars reports whether value contains at least n Unicode code points. +func MinChars(value string, n int) bool { + return utf8.RuneCountInString(value) >= n +} diff --git a/internal/platform/validate/validator.go b/internal/platform/validate/validator.go new file mode 100644 index 0000000..4e7cefa --- /dev/null +++ b/internal/platform/validate/validator.go @@ -0,0 +1,67 @@ +package validate + +import ( + "regexp" +) + +var ( + // EmailRX matches the email-address format accepted by Gardomatic. + EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + // ColorRX matches the hexadecimal RGB colors accepted for user colors. + ColorRX = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) +) + +// Validator collects validation failures keyed by input field. +type Validator struct { + Errors map[string]string + NonFieldErrors []string + FieldErrors map[string]string +} + +// New returns an empty Validator. +func New() *Validator { + return &Validator{Errors: make(map[string]string)} +} + +// Valid reports whether no validation errors have been recorded. +func (v *Validator) Valid() bool { + return len(v.Errors) == 0 && len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0 +} + +// AddError records message for key unless key already has an error. +func (v *Validator) AddError(key, message string) { + if _, exists := v.Errors[key]; !exists { + v.Errors[key] = message + } +} + +// Check records message for key when ok is false. +func (v *Validator) Check(ok bool, key, message string) { + if !ok { + v.AddError(key, message) + } +} + +// AddNonFieldError records an error that is not associated with one field. +func (v *Validator) AddNonFieldError(message string) { + v.NonFieldErrors = append(v.NonFieldErrors, message) +} + +// AddFieldError records a form-specific field error. +func (v *Validator) AddFieldError(key, message string) { + + if v.FieldErrors == nil { + v.FieldErrors = make(map[string]string) + } + + if _, exists := v.FieldErrors[key]; !exists { + v.FieldErrors[key] = message + } +} + +// CheckField records a form-specific field error when ok is false. +func (v *Validator) CheckField(ok bool, key, message string) { + if !ok { + v.AddFieldError(key, message) + } +} diff --git a/internal/storage/application_settings.go b/internal/storage/application_settings.go new file mode 100644 index 0000000..baa05c7 --- /dev/null +++ b/internal/storage/application_settings.go @@ -0,0 +1,36 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// ApplicationSettings contains application-wide automation settings. +type ApplicationSettings struct { + LifecycleStatusEnabled bool `json:"lifecycle_status_enabled"` + LifecycleRemovalMonth int `json:"lifecycle_removal_month"` + LifecycleRemovalDay int `json:"lifecycle_removal_day"` + Timezone string `json:"timezone"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ApplicationSettingsModelInterface persists global settings and applies lifecycle cleanup atomically. +type ApplicationSettingsModelInterface interface { + Get() (ApplicationSettings, error) + Update(settings ApplicationSettings) (ApplicationSettings, error) + RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) +} + +// ValidateApplicationSettings validates global automation configuration. +func ValidateApplicationSettings(v *validate.Validator, settings ApplicationSettings) { + v.Check(settings.LifecycleRemovalMonth >= 1 && settings.LifecycleRemovalMonth <= 12, "lifecycle_removal_month", "must be between 1 and 12") + v.Check(settings.LifecycleRemovalDay >= 1 && settings.LifecycleRemovalDay <= 31, "lifecycle_removal_day", "must be between 1 and 31") + v.Check(strings.TrimSpace(settings.Timezone) != "", "timezone", "must be provided") + if strings.TrimSpace(settings.Timezone) != "" { + _, err := time.LoadLocation(settings.Timezone) + v.Check(err == nil, "timezone", "must be a valid IANA timezone") + } +} diff --git a/internal/storage/care_instructions.go b/internal/storage/care_instructions.go new file mode 100644 index 0000000..a194a95 --- /dev/null +++ b/internal/storage/care_instructions.go @@ -0,0 +1,37 @@ +package storage + +import ( + "gardomatic.kleiax.de/internal/platform/validate" + "strings" + "time" +) + +// CareInstructionModelInterface persists care instructions within their +// species and garden boundary. +type CareInstructionModelInterface interface { + Insert(CareInstruction) (CareInstruction, error) + Get(gardenID, speciesID, id int) (CareInstruction, error) + GetAllForSpecies(gardenID, speciesID int) ([]CareInstruction, error) + Update(gardenID int, instruction CareInstruction) (CareInstruction, error) + Delete(gardenID, speciesID, id int) error +} + +// CareInstruction records garden-specific cultivation knowledge for a species. +type CareInstruction struct { + ID int `json:"id"` + SpeciesID int `json:"species_id"` + Text string `json:"text"` + Status string `json:"status"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ValidateCareInstruction applies the care-instruction input rules. +func ValidateCareInstruction(v *validate.Validator, item CareInstruction) { + v.Check(strings.TrimSpace(item.Text) != "", "text", "must be provided") + v.Check(len(item.Text) <= 10000, "text", "must not be more than 10000 bytes long") + v.Check(validate.PermittedValue(item.Status, "good", "bad", "untested", "testing", "planned"), "status", "is invalid") +} diff --git a/internal/storage/doc.go b/internal/storage/doc.go new file mode 100644 index 0000000..be772c9 --- /dev/null +++ b/internal/storage/doc.go @@ -0,0 +1,3 @@ +// Package storage defines Gardomatic persistence models and the interfaces +// implemented by database-specific adapters. +package storage diff --git a/internal/storage/errors.go b/internal/storage/errors.go new file mode 100644 index 0000000..65e0bae --- /dev/null +++ b/internal/storage/errors.go @@ -0,0 +1,10 @@ +package storage + +import ( + "errors" +) + +var ( + // ErrDuplicateEmail indicates that a user email is already registered. + ErrDuplicateEmail = errors.New("models: duplicate email") +) diff --git a/internal/storage/filters.go b/internal/storage/filters.go new file mode 100644 index 0000000..686c974 --- /dev/null +++ b/internal/storage/filters.go @@ -0,0 +1,76 @@ +//lint:file-ignore U1000 pagination helpers are retained for the upcoming filtered list endpoints + +package storage + +import ( + "slices" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// Filters contains bounded pagination and safe-list-based sorting parameters. +type Filters struct { + Page int + PageSize int + Sort string + SortSafelist []string +} + +func (f Filters) sortColumn() string { + if slices.Contains(f.SortSafelist, f.Sort) { + return strings.TrimPrefix(f.Sort, "-") + } + + panic("unsafe sort parameter: " + f.Sort) +} + +func (f Filters) sortDirection() string { + if strings.HasPrefix(f.Sort, "-") { + return "DESC" + } + + return "ASC" +} + +func (f Filters) limit() int { + return f.PageSize +} + +func (f Filters) offset() int { + return (f.Page - 1) * f.PageSize +} + +// ValidateFilters checks pagination bounds and the requested sort field. +func ValidateFilters(v *validate.Validator, f Filters) { + v.Check(f.Page > 0, "page", "must be greater than zero") + v.Check(f.Page <= 10_000_000, "page", "must be a maximum of 10 million") + v.Check(f.PageSize > 0, "page_size", "must be greater than zero") + v.Check(f.PageSize <= 100, "page_size", "must be a maximum of 100") + + v.Check(validate.PermittedValue(f.Sort, f.SortSafelist...), "sort", "invalid sort value") +} + +// Metadata describes a page within a filtered result set. +type Metadata struct { + CurrentPage int `json:"current_page,omitzero"` + PageSize int `json:"page_size,omitzero"` + FirstPage int `json:"first_page,omitzero"` + LastPage int `json:"last_page,omitzero"` + TotalRecords int `json:"total_records,omitzero"` +} + +func calculateMetadata(totalRecords, page, pageSize int) Metadata { + if totalRecords == 0 { + + return Metadata{} + } + + return Metadata{ + CurrentPage: page, + PageSize: pageSize, + FirstPage: 1, + LastPage: (totalRecords + pageSize - 1) / pageSize, + TotalRecords: totalRecords, + } +} diff --git a/internal/storage/garden_invites.go b/internal/storage/garden_invites.go new file mode 100644 index 0000000..c4e52f5 --- /dev/null +++ b/internal/storage/garden_invites.go @@ -0,0 +1,25 @@ +package storage + +import "time" + +// GardenInviteModelInterface persists pending garden invitations. +type GardenInviteModelInterface interface { + Upsert(invite GardenInvite) (GardenInvite, error) + GetByToken(tokenPlaintext string) (GardenInvite, error) + GetAllForGarden(gardenID int) ([]GardenInvite, error) + Delete(gardenID, inviteID int) error + Accept(tokenPlaintext string, user User) (GardenMember, error) +} + +// GardenInvite grants a user identified by email a garden role. +type GardenInvite struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + Email string `json:"email"` + Role GardenRole `json:"role"` + Token string `json:"token,omitempty"` + InvitedBy int `json:"invited_by"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/storage/garden_members.go b/internal/storage/garden_members.go new file mode 100644 index 0000000..9655d92 --- /dev/null +++ b/internal/storage/garden_members.go @@ -0,0 +1,202 @@ +package storage + +import ( + "slices" + "time" +) + +// GardenRole identifies a member's authorization level within one garden. +type GardenRole string + +// GardenPermission identifies one garden-scoped capability. +type GardenPermission string + +// Garden permission constants identify capabilities resolved by the API's +// garden authorization middleware. +const ( + GardenPermissionGardenRead GardenPermission = "garden:read" + GardenPermissionGardenUpdate GardenPermission = "garden:update" + GardenPermissionGardenDelete GardenPermission = "garden:delete" + // GardenPermissionContentWrite is retained for garden content which has not + // yet been split into object-specific permissions (journal and assignments). + GardenPermissionContentWrite GardenPermission = "content:write" + GardenPermissionMembersWrite GardenPermission = "members:write" + GardenPermissionSpeciesWrite GardenPermission = "species:write" + + GardenPermissionPlantCreate GardenPermission = "plants:create" + GardenPermissionPlantReadOwn GardenPermission = "plants:read:own" + GardenPermissionPlantReadOther GardenPermission = "plants:read:other" + GardenPermissionPlantUpdateOwn GardenPermission = "plants:update:own" + GardenPermissionPlantUpdateOther GardenPermission = "plants:update:other" + GardenPermissionPlantDeleteOwn GardenPermission = "plants:delete:own" + GardenPermissionPlantDeleteOther GardenPermission = "plants:delete:other" + + GardenPermissionLocationCreate GardenPermission = "locations:create" + GardenPermissionLocationReadOwn GardenPermission = "locations:read:own" + GardenPermissionLocationReadOther GardenPermission = "locations:read:other" + GardenPermissionLocationUpdateOwn GardenPermission = "locations:update:own" + GardenPermissionLocationUpdateOther GardenPermission = "locations:update:other" + GardenPermissionLocationDeleteOwn GardenPermission = "locations:delete:own" + GardenPermissionLocationDeleteOther GardenPermission = "locations:delete:other" + + GardenPermissionTaskCreate GardenPermission = "tasks:create" + GardenPermissionTaskReadOwn GardenPermission = "tasks:read:own" + GardenPermissionTaskReadOther GardenPermission = "tasks:read:other" + GardenPermissionTaskUpdateOwn GardenPermission = "tasks:update:own" + GardenPermissionTaskUpdateOther GardenPermission = "tasks:update:other" + GardenPermissionTaskDeleteOwn GardenPermission = "tasks:delete:own" + GardenPermissionTaskDeleteOther GardenPermission = "tasks:delete:other" + GardenPermissionTaskCompleteOwn GardenPermission = "tasks:complete:own" + GardenPermissionTaskCompleteOther GardenPermission = "tasks:complete:other" +) + +const ( + // GardenRoleOwner grants full control over a garden. + GardenRoleOwner GardenRole = "owner" + // GardenRoleAdmin grants administrative access without ownership. + GardenRoleAdmin GardenRole = "admin" + // GardenRoleMember grants ordinary editing access. + GardenRoleMember GardenRole = "member" + // GardenRoleViewer grants read-only access. + GardenRoleViewer GardenRole = "viewer" + // GardenRoleWorker may read and complete tasks, but cannot otherwise edit content. + GardenRoleWorker GardenRole = "worker" +) + +// Can reports whether a role grants permission. +func (role GardenRole) Can(permission GardenPermission) bool { + switch role { + case GardenRoleOwner: + return validGardenPermission(permission) + case GardenRoleAdmin: + return validGardenPermission(permission) && permission != GardenPermissionGardenDelete + case GardenRoleMember: + switch permission { + case GardenPermissionGardenRead, + GardenPermissionContentWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantDeleteOwn, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationDeleteOwn, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskDeleteOwn, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther: + return true + } + return false + case GardenRoleViewer: + return permission == GardenPermissionGardenRead || + permission == GardenPermissionPlantReadOwn || permission == GardenPermissionPlantReadOther || + permission == GardenPermissionLocationReadOwn || permission == GardenPermissionLocationReadOther || + permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther + case GardenRoleWorker: + return permission == GardenPermissionGardenRead || + permission == GardenPermissionTaskReadOwn || permission == GardenPermissionTaskReadOther || + permission == GardenPermissionTaskCompleteOwn || permission == GardenPermissionTaskCompleteOther + default: + return false + } +} + +func validGardenPermission(permission GardenPermission) bool { + switch permission { + case GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete, + GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther: + return true + } + return false +} + +// ValidGardenPermission reports whether permission is a known garden-scoped +// permission or wildcard. +func ValidGardenPermission(permission string) bool { + return permission == "*" || permission == "garden:*" || validGardenPermission(GardenPermission(permission)) +} + +// ResolveGardenPermissions applies garden-specific grants and revocations to a +// role's base permissions. The result is deduplicated and stable-sorted. +func ResolveGardenPermissions(base []string, overrides []GardenRolePermissionOverride) []GardenPermission { + permissions := make(map[GardenPermission]bool) + apply := func(permission string, granted bool) { + if permission == "*" || permission == "garden:*" { + for _, concrete := range AllGardenPermissions() { + if permission == "garden:*" && concrete == GardenPermissionGardenDelete { + continue + } + permissions[concrete] = granted + } + return + } + permissions[GardenPermission(permission)] = granted + } + for _, permission := range base { + apply(permission, true) + } + for _, override := range overrides { + apply(override.Permission, override.Granted) + } + result := make([]GardenPermission, 0, len(permissions)) + for permission, granted := range permissions { + if granted { + result = append(result, permission) + } + } + slices.Sort(result) + return result +} + +// Permissions returns the concrete capabilities bundled into a garden role. +func (role GardenRole) Permissions() []GardenPermission { + permissions := []GardenPermission{} + for _, permission := range AllGardenPermissions() { + if role.Can(permission) { + permissions = append(permissions, permission) + } + } + return permissions +} + +// AllGardenPermissions returns every concrete garden-scoped permission. +func AllGardenPermissions() []GardenPermission { + return []GardenPermission{ + GardenPermissionGardenRead, GardenPermissionGardenUpdate, GardenPermissionGardenDelete, + GardenPermissionContentWrite, GardenPermissionMembersWrite, GardenPermissionSpeciesWrite, + GardenPermissionPlantCreate, GardenPermissionPlantReadOwn, GardenPermissionPlantReadOther, GardenPermissionPlantUpdateOwn, GardenPermissionPlantUpdateOther, GardenPermissionPlantDeleteOwn, GardenPermissionPlantDeleteOther, + GardenPermissionLocationCreate, GardenPermissionLocationReadOwn, GardenPermissionLocationReadOther, GardenPermissionLocationUpdateOwn, GardenPermissionLocationUpdateOther, GardenPermissionLocationDeleteOwn, GardenPermissionLocationDeleteOther, + GardenPermissionTaskCreate, GardenPermissionTaskReadOwn, GardenPermissionTaskReadOther, GardenPermissionTaskUpdateOwn, GardenPermissionTaskUpdateOther, GardenPermissionTaskDeleteOwn, GardenPermissionTaskDeleteOther, GardenPermissionTaskCompleteOwn, GardenPermissionTaskCompleteOther, + } +} + +// GardenMemberModelInterface persists garden membership and role assignments. +type GardenMemberModelInterface interface { + Insert(member GardenMember) (GardenMember, error) + Get(gardenID, userID int) (GardenMember, error) + GetAllForGarden(gardenID int) ([]GardenMember, error) + Update(member GardenMember) (GardenMember, error) + Delete(gardenID, userID int) error + TransferOwnership(gardenID, fromUserID, toUserID int) error +} + +// GardenMember links a user to a garden with a role. +type GardenMember struct { + GardenID int `json:"garden_id"` + UserID int `json:"user_id"` + Role GardenRole `json:"role"` + JoinedAt time.Time `json:"joined_at"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Permissions []GardenPermission `json:"permissions,omitempty"` +} + +// Can uses persisted permissions when present, retaining built-in roles for +// compatibility with in-memory tests and pre-migration callers. +func (member GardenMember) Can(permission GardenPermission) bool { + if member.Permissions == nil { + return member.Role.Can(permission) + } + for _, granted := range member.Permissions { + if granted == permission || granted == "*" || granted == "garden:*" && permission != GardenPermissionGardenDelete { + return true + } + } + return false +} diff --git a/internal/storage/gardens.go b/internal/storage/gardens.go new file mode 100644 index 0000000..e6d5f0f --- /dev/null +++ b/internal/storage/gardens.go @@ -0,0 +1,38 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// GardenModelInterface persists gardens and their initial owner membership. +type GardenModelInterface interface { + Insert(garden Garden, ownerID int) (Garden, error) + Get(id int) (Garden, error) + GetAllForUser(userID int) ([]Garden, error) + Update(garden Garden) (Garden, error) + Delete(id int) error +} + +// Garden is the tenant boundary for garden-specific resources. +type Garden struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Role GardenRole `json:"role,omitempty"` + Permissions []GardenPermission `json:"permissions"` +} + +// ValidateGarden applies persistence-independent garden validation rules. +func ValidateGarden(v *validate.Validator, garden Garden) { + v.Check(strings.TrimSpace(garden.Name) != "", "name", "must be provided") + v.Check(len(garden.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(garden.Description) <= 5000, "description", "must not be more than 5000 bytes long") +} diff --git a/internal/storage/images.go b/internal/storage/images.go new file mode 100644 index 0000000..9fd6a94 --- /dev/null +++ b/internal/storage/images.go @@ -0,0 +1,44 @@ +package storage + +import "time" + +// MaxImageSize is the largest image payload accepted by storage, in bytes. +const MaxImageSize = 10 << 20 + +// ImageModelInterface persists garden-owned image data and assignment history. +type ImageModelInterface interface { + Insert(image Image) (Image, error) + Get(gardenID, id int) (Image, error) + GetAllForGarden(gardenID int, filter ImageFilter) ([]Image, error) + CountForGarden(gardenID int) (int, error) + RecordAssignment(change ImageAssignment) error +} + +// Image is a binary image stored in a garden's reusable media library. +type Image struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + FileName string `json:"file_name"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + Source string `json:"source"` + CreatedBy int `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + Data []byte `json:"-"` +} + +// ImageFilter restricts image-library queries by filename or media type. +type ImageFilter struct { + Source string + Query string +} + +// ImageAssignment records an image change on a garden entity. +type ImageAssignment struct { + GardenID int + EntityType string + EntityID int + PreviousImageID *int + ImageID *int + ChangedBy int +} diff --git a/internal/storage/journal.go b/internal/storage/journal.go new file mode 100644 index 0000000..d32e332 --- /dev/null +++ b/internal/storage/journal.go @@ -0,0 +1,77 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// MaxJournalAttachmentSize is the largest journal attachment accepted, in bytes. +const MaxJournalAttachmentSize = 25 << 20 + +// JournalModelInterface persists journal entries and their attachments. +type JournalModelInterface interface { + Insert(entry JournalEntry) (JournalEntry, error) + Get(gardenID, id int) (JournalEntry, error) + GetAllForGarden(gardenID int, entryType JournalEntryType) ([]JournalEntry, error) + Update(gardenID int, entry JournalEntry) (JournalEntry, error) + Delete(gardenID, id int) error + InsertAttachment(gardenID, entryID int, attachment JournalAttachment) (JournalAttachment, error) + GetAttachment(gardenID, entryID, attachmentID int) (JournalAttachment, error) + DeleteAttachment(gardenID, entryID, attachmentID int) error +} + +// JournalEntryType distinguishes chronological journal entries from pinboard +// notes while sharing the same persistence model. +type JournalEntryType string + +// Supported journal entry types. +const ( + JournalEntryTypeJournal JournalEntryType = "journal" + JournalEntryTypePinboard JournalEntryType = "pinboard" +) + +// JournalEntry is a garden note with optional tags and attachments. +type JournalEntry struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + AuthorID int `json:"author_id"` + AuthorName string `json:"author_name"` + AuthorColor string `json:"author_color"` + EntryType JournalEntryType `json:"entry_type"` + Title string `json:"title"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + Attachments []JournalAttachment `json:"attachments,omitempty"` +} + +// JournalAttachment contains either inline binary data or a reference to a +// reusable image-library item. +type JournalAttachment struct { + ID int `json:"id"` + EntryID int `json:"entry_id"` + FileName string `json:"file_name"` + MediaType string `json:"media_type"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` + Data []byte `json:"-"` + ImageID *int `json:"image_id,omitempty"` +} + +// ValidateJournalEntry applies the journal-entry input rules. +func ValidateJournalEntry(v *validate.Validator, entry JournalEntry) { + entryType := entry.EntryType + if entryType == "" { + entryType = JournalEntryTypeJournal + } + v.Check(validate.PermittedValue(entryType, JournalEntryTypeJournal, JournalEntryTypePinboard), "entry_type", "must be journal or pinboard") + if entryType == JournalEntryTypeJournal { + v.Check(strings.TrimSpace(entry.Title) != "", "title", "must be provided") + } + v.Check(len(entry.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(entry.Body) <= 100_000, "body", "must not be more than 100000 bytes long") +} diff --git a/internal/storage/journal_test.go b/internal/storage/journal_test.go new file mode 100644 index 0000000..d801383 --- /dev/null +++ b/internal/storage/journal_test.go @@ -0,0 +1,22 @@ +package storage + +import ( + "strings" + "testing" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +func TestValidateJournalEntry(t *testing.T) { + valid := validate.New() + ValidateJournalEntry(valid, JournalEntry{Title: "Erste Ernte", Body: "**Drei** Tomaten geerntet."}) + if !valid.Valid() { + t.Fatalf("valid entry rejected: %#v", valid.Errors) + } + + invalid := validate.New() + ValidateJournalEntry(invalid, JournalEntry{Title: " ", Body: strings.Repeat("x", 100_001)}) + if invalid.Errors["title"] == "" || invalid.Errors["body"] == "" { + t.Fatalf("expected title and body errors, got %#v", invalid.Errors) + } +} diff --git a/internal/storage/locations.go b/internal/storage/locations.go new file mode 100644 index 0000000..41d3697 --- /dev/null +++ b/internal/storage/locations.go @@ -0,0 +1,59 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// LocationModelInterface persists hierarchical locations within a garden. +type LocationModelInterface interface { + Insert(location Location) (Location, error) + Get(gardenID, id int) (Location, error) + GetAllForGarden(gardenID int) ([]Location, error) + Update(gardenID int, location Location) (Location, error) + Delete(gardenID, id int) error +} + +// ValidateLocation applies persistence-independent location validation rules. +func ValidateLocation(v *validate.Validator, location Location) { + v.Check(strings.TrimSpace(location.Name) != "", "name", "must be provided") + v.Check(len(location.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(location.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(len(location.Kind) <= 100, "kind", "must not be more than 100 bytes long") + v.Check(len(location.Attributes) == 0 || json.Valid(location.Attributes), "attributes", "must be valid JSON") + if location.ParentID != nil { + v.Check(*location.ParentID > 0, "parent_id", "must be a positive integer") + v.Check(*location.ParentID != location.ID, "parent_id", "must not refer to the location itself") + } + if location.AreaSQM != nil { + v.Check(*location.AreaSQM >= 0, "area_sqm", "must be zero or greater") + } + validateOptionalEnum(v, "sun_exposure", location.SunExposure, "sunny", "partial_shade", "shade") + validateOptionalEnum(v, "soil_condition", location.SoilCondition, "dry", "moist", "boggy") + validateOptionalEnum(v, "soil_reaction", location.SoilReaction, "alkaline", "acidic", "neutral") +} + +// Location describes a physical place where plants can be assigned. +type Location struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + ParentID *int `json:"parent_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + Kind string `json:"kind"` + AreaSQM *float64 `json:"area_sqm,omitempty"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` +} diff --git a/internal/storage/models.go b/internal/storage/models.go new file mode 100644 index 0000000..a79cd1d --- /dev/null +++ b/internal/storage/models.go @@ -0,0 +1,38 @@ +package storage + +import ( + "errors" +) + +var ( + // ErrRecordNotFound indicates that a scoped query matched no record. + ErrRecordNotFound = errors.New("record not found") + // ErrEditConflict indicates a failed optimistic-lock update. + ErrEditConflict = errors.New("edit conflict") + // ErrConflict indicates a uniqueness or equivalent persistence conflict. + ErrConflict = errors.New("conflict") +) + +// Models groups the persistence interfaces required by the API application. +type Models struct { + ApplicationSettings ApplicationSettingsModelInterface + Roles RoleModelInterface + Gardens GardenModelInterface + GardenMembers GardenMemberModelInterface + GardenInvites GardenInviteModelInterface + Locations LocationModelInterface + Plants PlantModelInterface + PlantLocations PlantLocationModelInterface + Species SpeciesModelInterface + CareInstructions CareInstructionModelInterface + SpeciesCategories SpeciesCategoryModelInterface + TaskPriorities TaskPriorityModelInterface + SpeciesTaskTemplates SpeciesTaskTemplateModelInterface + Tasks TaskModelInterface + Journal JournalModelInterface + Images ImageModelInterface + Tags TagModelInterface + TaskTemplateOptOuts TaskTemplateOptOutModelInterface + Tokens TokenModelInterface + Users UserModelInterface +} diff --git a/internal/storage/plant_locations.go b/internal/storage/plant_locations.go new file mode 100644 index 0000000..3d5bcf2 --- /dev/null +++ b/internal/storage/plant_locations.go @@ -0,0 +1,42 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// PlantLocationModelInterface persists assignments between plants and locations. +type PlantLocationModelInterface interface { + Insert(gardenID int, plantLocation PlantLocation) (PlantLocation, error) + Get(gardenID, id int) (PlantLocation, error) + GetAllForPlant(gardenID, plantID int) ([]PlantLocation, error) + GetAllForLocation(gardenID, locationID int) ([]PlantLocation, error) + Update(gardenID int, plantLocation PlantLocation) (PlantLocation, error) + Delete(gardenID, id int) error +} + +// PlantLocation records where and when a quantity of plants was planted. +type PlantLocation struct { + ID int `json:"id"` + PlantID int `json:"plant_id"` + LocationID int `json:"location_id"` + Quantity int `json:"quantity"` + PlantedAt *time.Time `json:"planted_at,omitempty"` + RemovedAt *time.Time `json:"removed_at,omitempty"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + Version int `json:"version"` +} + +// ValidatePlantLocation applies persistence-independent assignment validation rules. +func ValidatePlantLocation(v *validate.Validator, assignment PlantLocation) { + v.Check(assignment.PlantID > 0, "plant_id", "must be a positive integer") + v.Check(assignment.LocationID > 0, "location_id", "must be a positive integer") + v.Check(assignment.Quantity > 0, "quantity", "must be greater than zero") + v.Check(len(strings.TrimSpace(assignment.Notes)) <= 10_000, "notes", "must not be more than 10000 bytes long") + if assignment.PlantedAt != nil && assignment.RemovedAt != nil { + v.Check(!assignment.RemovedAt.Before(*assignment.PlantedAt), "removed_at", "must not be before planted_at") + } +} diff --git a/internal/storage/plants.go b/internal/storage/plants.go new file mode 100644 index 0000000..4ec12f7 --- /dev/null +++ b/internal/storage/plants.go @@ -0,0 +1,54 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// PlantModelInterface persists plant instances within a garden boundary. +type PlantModelInterface interface { + Insert(plant Plant) (Plant, error) + Get(gardenID, id int) (Plant, error) + GetAllForGarden(gardenID int) ([]Plant, error) + Update(gardenID int, plant Plant) (Plant, error) + Delete(gardenID, id int) error +} + +// Plant represents a named plant instance managed by a garden. +type Plant struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + SpeciesID *int `json:"species_id,omitempty"` + Name string `json:"name"` + Notes string `json:"notes"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + AcquiredAt *time.Time `json:"acquired_at,omitempty"` + Status string `json:"status"` + RemovedAt *time.Time `json:"removed_at,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` + PlantedBy int `json:"planted_by"` + PlantedByName string `json:"planted_by_name,omitempty"` +} + +// ValidatePlant applies persistence-independent plant validation rules. +func ValidatePlant(v *validate.Validator, plant Plant) { + v.Check(strings.TrimSpace(plant.Name) != "", "name", "must be provided") + v.Check(len(plant.Name) <= 500, "name", "must not be more than 500 bytes long") + v.Check(len(plant.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long") + v.Check(validate.PermittedValue(plant.Status, "alive", "dead", "removed", "infested", "harvested"), "status", "must be alive, dead, removed, infested or harvested") + v.Check(len(plant.Attributes) == 0 || json.Valid(plant.Attributes), "attributes", "must be valid JSON") + ValidateTags(v, plant.Tags) + if plant.SpeciesID != nil { + v.Check(*plant.SpeciesID > 0, "species_id", "must be a positive integer") + } +} diff --git a/internal/storage/postgres/application_settings.go b/internal/storage/postgres/application_settings.go new file mode 100644 index 0000000..b80d546 --- /dev/null +++ b/internal/storage/postgres/application_settings.go @@ -0,0 +1,118 @@ +package postgres + +import ( + "database/sql" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// ApplicationSettingsModel stores global automation configuration. +// ApplicationSettingsModel persists the singleton application configuration +// and performs lifecycle cleanup transactionally. +type ApplicationSettingsModel struct{ DB *sql.DB } + +// Get returns the singleton application settings. +func (m ApplicationSettingsModel) Get() (storage.ApplicationSettings, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var settings storage.ApplicationSettings + err := m.DB.QueryRowContext(ctx, ` + SELECT lifecycle_status_enabled, lifecycle_removal_month, lifecycle_removal_day, + timezone, updated_at, version + FROM application_settings WHERE singleton = true`).Scan( + &settings.LifecycleStatusEnabled, &settings.LifecycleRemovalMonth, + &settings.LifecycleRemovalDay, &settings.Timezone, &settings.UpdatedAt, &settings.Version, + ) + if err != nil { + return storage.ApplicationSettings{}, recordError(err) + } + return settings, nil +} + +// Update replaces the singleton application settings using optimistic locking. +func (m ApplicationSettingsModel) Update(settings storage.ApplicationSettings) (storage.ApplicationSettings, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE application_settings + SET lifecycle_status_enabled = $1, lifecycle_removal_month = $2, + lifecycle_removal_day = $3, timezone = $4, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE singleton = true AND version = $5 + RETURNING updated_at, version`, + settings.LifecycleStatusEnabled, settings.LifecycleRemovalMonth, + settings.LifecycleRemovalDay, settings.Timezone, settings.Version, + ).Scan(&settings.UpdatedAt, &settings.Version) + if err == sql.ErrNoRows { + return storage.ApplicationSettings{}, storage.ErrEditConflict + } + if err != nil { + return storage.ApplicationSettings{}, recordError(err) + } + return settings, nil +} + +// RemoveExpiredPlants closes expired annual and biennial plants together with +// their active placements and records each status transition atomically. +func (m ApplicationSettingsModel) RemoveExpiredPlants(asOf time.Time, removalMonth, removalDay int) (int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, ` + UPDATE plants p + SET status = 'removed', removed_at = $1, updated_at = CURRENT_TIMESTAMP, version = p.version + 1 + FROM species s + JOIN species_categories c ON c.id = s.category_id + WHERE p.species_id = s.id + AND p.status = 'alive' + AND p.acquired_at IS NOT NULL + AND c.lifecycle IN ('annual', 'biennial') + AND EXTRACT(YEAR FROM $1::date)::int >= + EXTRACT(YEAR FROM p.acquired_at)::int + + CASE WHEN (EXTRACT(MONTH FROM p.acquired_at)::int, EXTRACT(DAY FROM p.acquired_at)::int) >= ($2, $3) THEN 1 ELSE 0 END + + CASE c.lifecycle WHEN 'biennial' THEN 1 ELSE 0 END + RETURNING p.id`, asOf, removalMonth, removalDay) + if err != nil { + return 0, err + } + ids := []int64{} + for rows.Next() { + var id int64 + if err = rows.Scan(&id); err != nil { + rows.Close() + return 0, err + } + ids = append(ids, id) + } + if err = rows.Close(); err != nil { + return 0, err + } + if len(ids) == 0 { + if err = tx.Commit(); err != nil { + return 0, err + } + return 0, nil + } + if _, err = tx.ExecContext(ctx, ` + INSERT INTO plant_status_history (plant_id, from_status, to_status, reason, effective_at) + SELECT unnest($2::bigint[]), 'alive', 'removed', 'lifecycle_reached', $1`, asOf, pq.Array(ids)); err != nil { + return 0, err + } + if _, err = tx.ExecContext(ctx, `UPDATE plant_locations SET removed_at = $1, version = version + 1 WHERE plant_id = ANY($2) AND removed_at IS NULL`, asOf, pq.Array(ids)); err != nil { + return 0, err + } + if _, err = tx.ExecContext(ctx, `UPDATE tasks SET active = false, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE plant_id = ANY($1) AND template_id IS NOT NULL AND completed_at IS NULL AND active = true`, pq.Array(ids)); err != nil { + return 0, err + } + if err = tx.Commit(); err != nil { + return 0, err + } + return len(ids), nil +} diff --git a/internal/storage/postgres/application_settings_integration_test.go b/internal/storage/postgres/application_settings_integration_test.go new file mode 100644 index 0000000..07d7364 --- /dev/null +++ b/internal/storage/postgres/application_settings_integration_test.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" + "time" + + _ "github.com/lib/pq" +) + +func TestRemoveExpiredPlantsClosesRelatedRecordsAndWritesHistory(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := time.Now().Format("150405.000000000") + var userID, gardenID, categoryID, biennialCategoryID, speciesID, biennialSpeciesID, plantID, biennialPlantID, locationID, templateID, taskID, manualTaskID int + if err = db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Lifecycle integration', $1, 'hash', true) RETURNING id`, "lifecycle-"+stamp+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Lifecycle integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'annual') RETURNING id`, "Annual "+stamp).Scan(&categoryID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_categories (name, lifecycle) VALUES ($1, 'biennial') RETURNING id`, "Biennial "+stamp).Scan(&biennialCategoryID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID) + _, _ = db.Exec(`DELETE FROM species_categories WHERE id IN ($1, $2)`, categoryID, biennialCategoryID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Sommerblume', $2) RETURNING id`, gardenID, categoryID).Scan(&speciesID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species (garden_id, common_name, category_id) VALUES ($1, 'Zweijährige Blume', $2) RETURNING id`, gardenID, biennialCategoryID).Scan(&biennialSpeciesID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, day_from) VALUES ($1, 'Pflegen', 'month_of_year', 3, 10) RETURNING id`, speciesID).Scan(&templateID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Sommerblume', '2026-03-10') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO plants (garden_id, species_id, name, acquired_at) VALUES ($1, $2, 'Zweijährige Blume', '2026-03-10') RETURNING id`, gardenID, biennialSpeciesID).Scan(&biennialPlantID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO locations (garden_id, name) VALUES ($1, 'Beet') RETURNING id`, gardenID).Scan(&locationID); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`INSERT INTO plant_locations (plant_id, location_id, planted_at) VALUES ($1, $2, '2026-03-10')`, plantID, locationID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, template_id, title, created_by) VALUES ($1, $2, $3, 'Pflegen', $4) RETURNING id`, gardenID, plantID, templateID, userID).Scan(&taskID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO tasks (garden_id, plant_id, title, created_by) VALUES ($1, $2, 'Dokumentieren', $3) RETURNING id`, gardenID, plantID, userID).Scan(&manualTaskID); err != nil { + t.Fatal(err) + } + + count, err := (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1) + if err != nil || count != 1 { + t.Fatalf("remove expired plants: count=%d err=%v", count, err) + } + var status string + var removedAt time.Time + if err = db.QueryRow(`SELECT status, removed_at FROM plants WHERE id = $1`, plantID).Scan(&status, &removedAt); err != nil || status != "removed" || removedAt.Format("2006-01-02") != "2026-12-01" { + t.Fatalf("plant status=%q removed_at=%v err=%v", status, removedAt, err) + } + var assignmentClosed, taskActive bool + if err = db.QueryRow(`SELECT removed_at IS NOT NULL FROM plant_locations WHERE plant_id = $1`, plantID).Scan(&assignmentClosed); err != nil || !assignmentClosed { + t.Fatalf("assignment closed=%v err=%v", assignmentClosed, err) + } + if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, taskID).Scan(&taskActive); err != nil || taskActive { + t.Fatalf("task active=%v err=%v", taskActive, err) + } + if err = db.QueryRow(`SELECT active FROM tasks WHERE id = $1`, manualTaskID).Scan(&taskActive); err != nil || !taskActive { + t.Fatalf("manual task active=%v err=%v", taskActive, err) + } + var historyCount int + if err = db.QueryRow(`SELECT count(*) FROM plant_status_history WHERE plant_id = $1 AND reason = 'lifecycle_reached'`, plantID).Scan(&historyCount); err != nil || historyCount != 1 { + t.Fatalf("history count=%d err=%v", historyCount, err) + } + if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "alive" { + t.Fatalf("biennial plant was removed too early: status=%q err=%v", status, err) + } + count, err = (ApplicationSettingsModel{DB: db}).RemoveExpiredPlants(time.Date(2027, 12, 1, 0, 0, 0, 0, time.UTC), 12, 1) + if err != nil || count != 1 { + t.Fatalf("remove biennial plant: count=%d err=%v", count, err) + } + if err = db.QueryRow(`SELECT status FROM plants WHERE id = $1`, biennialPlantID).Scan(&status); err != nil || status != "removed" { + t.Fatalf("biennial plant status=%q err=%v", status, err) + } +} diff --git a/internal/storage/postgres/care_instructions.go b/internal/storage/postgres/care_instructions.go new file mode 100644 index 0000000..d8d2c1e --- /dev/null +++ b/internal/storage/postgres/care_instructions.go @@ -0,0 +1,82 @@ +package postgres + +import ( + "database/sql" + "gardomatic.kleiax.de/internal/storage" +) + +// CareInstructionModel implements storage.CareInstructionModelInterface for +// PostgreSQL and scopes every lookup through the owning garden. +type CareInstructionModel struct{ DB *sql.DB } + +const careInstructionColumns = `ci.id, ci.species_id, ci.text, ci.status, ci.created_by, ci.updated_by, ci.created_at, ci.updated_at, ci.version` + +func scanCareInstruction(s scanner) (storage.CareInstruction, error) { + var item storage.CareInstruction + err := s.Scan(&item.ID, &item.SpeciesID, &item.Text, &item.Status, &item.CreatedBy, &item.UpdatedBy, &item.CreatedAt, &item.UpdatedAt, &item.Version) + return item, err +} + +// Insert creates a care instruction. +func (m CareInstructionModel) Insert(item storage.CareInstruction) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `INSERT INTO care_instructions(species_id,text,status,created_by,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING id,created_at,updated_at,version`, item.SpeciesID, item.Text, item.Status, item.CreatedBy, item.UpdatedBy).Scan(&item.ID, &item.CreatedAt, &item.UpdatedAt, &item.Version) + return item, recordError(err) +} + +// Get returns a care instruction within its garden and species. +func (m CareInstructionModel) Get(gardenID, speciesID, id int) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + return scanCareInstruction(m.DB.QueryRowContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.id=$1 AND ci.species_id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, id, speciesID, gardenID)) +} + +// GetAllForSpecies lists care instructions for a species visible in a garden. +func (m CareInstructionModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+careInstructionColumns+` FROM care_instructions ci JOIN species s ON s.id=ci.species_id WHERE ci.species_id=$1 AND (s.garden_id IS NULL OR s.garden_id=$2) ORDER BY ci.id`, speciesID, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []storage.CareInstruction{} + for rows.Next() { + item, e := scanCareInstruction(rows) + if e != nil { + return nil, e + } + items = append(items, item) + } + return items, rows.Err() +} + +// Update changes a care instruction using optimistic locking. +func (m CareInstructionModel) Update(gardenID int, item storage.CareInstruction) (storage.CareInstruction, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE care_instructions ci SET text=$1,status=$2,updated_by=$3,updated_at=now(),version=ci.version+1 FROM species s WHERE ci.species_id=s.id AND ci.id=$4 AND ci.version=$5 AND (s.garden_id IS NULL OR s.garden_id=$6) RETURNING ci.updated_at,ci.version`, item.Text, item.Status, item.UpdatedBy, item.ID, item.Version, gardenID).Scan(&item.UpdatedAt, &item.Version) + if err == sql.ErrNoRows { + return storage.CareInstruction{}, storage.ErrEditConflict + } + return item, recordError(err) +} + +// Delete removes a care instruction within its garden and species. +func (m CareInstructionModel) Delete(gardenID, speciesID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM care_instructions ci USING species s WHERE ci.species_id=s.id AND ci.species_id=$1 AND ci.id=$2 AND (s.garden_id IS NULL OR s.garden_id=$3)`, speciesID, id, gardenID) + if err != nil { + return err + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/doc.go b/internal/storage/postgres/doc.go new file mode 100644 index 0000000..a979e8b --- /dev/null +++ b/internal/storage/postgres/doc.go @@ -0,0 +1,2 @@ +// Package postgres implements the Gardomatic storage interfaces for PostgreSQL. +package postgres diff --git a/internal/storage/postgres/garden_invites.go b/internal/storage/postgres/garden_invites.go new file mode 100644 index 0000000..b6160c7 --- /dev/null +++ b/internal/storage/postgres/garden_invites.go @@ -0,0 +1,126 @@ +package postgres + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "strings" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenInviteModel stores garden invitations in PostgreSQL. +// GardenInviteModel implements storage.GardenInviteModelInterface for PostgreSQL. +type GardenInviteModel struct{ DB *sql.DB } + +// Upsert creates or replaces a pending invitation for a garden and email. +func (m GardenInviteModel) Upsert(invite storage.GardenInvite) (storage.GardenInvite, error) { + invite.Token = rand.Text() + hash := sha256.Sum256([]byte(invite.Token)) + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO garden_invites (garden_id,email,role,token_hash,invited_by,expires_at) + SELECT $1,$2,$3,$4,$5,$6 + WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + ON CONFLICT (garden_id,email) WHERE accepted_at IS NULL DO UPDATE SET + role=EXCLUDED.role, token_hash=EXCLUDED.token_hash, invited_by=EXCLUDED.invited_by, + expires_at=EXCLUDED.expires_at, created_at=now() + RETURNING id, created_at`, invite.GardenID, invite.Email, invite.Role, hash[:], invite.InvitedBy, invite.ExpiresAt).Scan(&invite.ID, &invite.CreatedAt) + return invite, err +} + +func scanInvite(row scanner) (storage.GardenInvite, error) { + var invite storage.GardenInvite + err := row.Scan(&invite.ID, &invite.GardenID, &invite.Email, &invite.Role, &invite.InvitedBy, &invite.ExpiresAt, &invite.AcceptedAt, &invite.CreatedAt) + return invite, err +} + +// GetByToken returns an unexpired pending invitation by its plaintext token. +func (m GardenInviteModel) GetByToken(tokenPlaintext string) (storage.GardenInvite, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := contextWithTimeout() + defer cancel() + invite, err := scanInvite(m.DB.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 AND expires_at>now() AND accepted_at IS NULL`, hash[:])) + if err != nil { + return storage.GardenInvite{}, recordError(err) + } + return invite, nil +} + +// GetAllForGarden lists pending invitations for a garden. +func (m GardenInviteModel) GetAllForGarden(gardenID int) ([]storage.GardenInvite, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE garden_id=$1 AND accepted_at IS NULL ORDER BY created_at DESC`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.GardenInvite{} + for rows.Next() { + invite, err := scanInvite(rows) + if err != nil { + return nil, err + } + result = append(result, invite) + } + return result, rows.Err() +} + +// Delete revokes an invitation within its garden. +func (m GardenInviteModel) Delete(gardenID, inviteID int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM garden_invites WHERE garden_id=$1 AND id=$2 AND accepted_at IS NULL`, gardenID, inviteID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +// Accept consumes an invitation and creates or updates membership in one +// transaction so a token cannot be accepted twice concurrently. +func (m GardenInviteModel) Accept(tokenPlaintext string, user storage.User) (storage.GardenMember, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.GardenMember{}, err + } + defer tx.Rollback() + invite, err := scanInvite(tx.QueryRowContext(ctx, `SELECT id,garden_id,email,role,invited_by,expires_at,accepted_at,created_at FROM garden_invites WHERE token_hash=$1 FOR UPDATE`, hash[:])) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if invite.AcceptedAt != nil || time.Now().After(invite.ExpiresAt) { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + if !strings.EqualFold(strings.TrimSpace(invite.Email), strings.TrimSpace(user.Email)) { + return storage.GardenMember{}, storage.ErrConflict + } + member := storage.GardenMember{GardenID: invite.GardenID, UserID: user.ID, Role: invite.Role} + err = tx.QueryRowContext(ctx, `INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,$3) ON CONFLICT (garden_id,user_id) DO UPDATE SET role=EXCLUDED.role RETURNING joined_at`, member.GardenID, member.UserID, member.Role).Scan(&member.JoinedAt) + if err != nil { + return storage.GardenMember{}, err + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_invites SET accepted_at=now() WHERE id=$1`, invite.ID); err != nil { + return storage.GardenMember{}, err + } + if err = tx.Commit(); err != nil { + return storage.GardenMember{}, err + } + if err = GardenMemberModel(m).loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} diff --git a/internal/storage/postgres/garden_members.go b/internal/storage/postgres/garden_members.go new file mode 100644 index 0000000..9e88c61 --- /dev/null +++ b/internal/storage/postgres/garden_members.go @@ -0,0 +1,222 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenMemberModel stores garden memberships in PostgreSQL. +// GardenMemberModel implements storage.GardenMemberModelInterface for PostgreSQL. +type GardenMemberModel struct{ DB *sql.DB } + +func (m GardenMemberModel) loadPermissions(member *storage.GardenMember) error { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT permission, true AS granted FROM role_permissions WHERE role_name=$1 + UNION ALL + SELECT permission, granted FROM garden_role_permission_overrides WHERE garden_id=$2 AND role_name=$1 + ORDER BY granted DESC`, member.Role, member.GardenID) + if err != nil { + return err + } + defer rows.Close() + base := []string{} + overrides := []storage.GardenRolePermissionOverride{} + for rows.Next() { + var permission storage.GardenPermission + var granted bool + if err := rows.Scan(&permission, &granted); err != nil { + return err + } + if granted { + base = append(base, string(permission)) + } else { + overrides = append(overrides, storage.GardenRolePermissionOverride{GardenID: member.GardenID, RoleName: string(member.Role), Permission: string(permission), Granted: false}) + } + } + if err := rows.Err(); err != nil { + return err + } + member.Permissions = storage.ResolveGardenPermissions(base, overrides) + return nil +} + +func scanGardenMember(s scanner) (storage.GardenMember, error) { + var member storage.GardenMember + err := s.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt) + return member, err +} + +// Insert adds a user to a garden. +func (m GardenMemberModel) Insert(member storage.GardenMember) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO garden_members (garden_id, user_id, role) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM roles WHERE name=$3 AND scope='garden' AND (garden_id IS NULL OR garden_id=$1)) + RETURNING joined_at`, member.GardenID, member.UserID, member.Role, + ).Scan(&member.JoinedAt) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// Get returns a user's membership and effective permissions in a garden. +func (m GardenMemberModel) Get(gardenID, userID int) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + member, err := scanGardenMember(m.DB.QueryRowContext(ctx, ` + SELECT garden_id, user_id, role, joined_at + FROM garden_members WHERE garden_id = $1 AND user_id = $2`, gardenID, userID)) + if err != nil { + return storage.GardenMember{}, recordError(err) + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// GetAllForGarden lists garden members with effective permissions. +func (m GardenMemberModel) GetAllForGarden(gardenID int) ([]storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT gm.garden_id, gm.user_id, gm.role, gm.joined_at, u.name, u.email + FROM garden_members gm JOIN users u ON u.id = gm.user_id WHERE gm.garden_id = $1 + ORDER BY joined_at, user_id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + members := []storage.GardenMember{} + for rows.Next() { + var member storage.GardenMember + err := rows.Scan(&member.GardenID, &member.UserID, &member.Role, &member.JoinedAt, &member.Name, &member.Email) + if err != nil { + return nil, err + } + members = append(members, member) + if err = m.loadPermissions(&members[len(members)-1]); err != nil { + return nil, err + } + } + return members, rows.Err() +} + +// Update changes a member's garden role. +func (m GardenMemberModel) Update(member storage.GardenMember) (storage.GardenMember, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.GardenMember{}, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, member.GardenID); err != nil { + return storage.GardenMember{}, err + } + var current storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, member.GardenID, member.UserID).Scan(¤t); err != nil { + return storage.GardenMember{}, recordError(err) + } + if current == storage.GardenRoleOwner && member.Role != storage.GardenRoleOwner { + var owners int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, member.GardenID).Scan(&owners); err != nil { + return storage.GardenMember{}, err + } + if owners < 2 { + return storage.GardenMember{}, storage.ErrConflict + } + } + result, err := tx.ExecContext(ctx, `UPDATE garden_members SET role=$1 WHERE garden_id=$2 AND user_id=$3 AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, member.Role, member.GardenID, member.UserID) + if err != nil { + return storage.GardenMember{}, err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return storage.GardenMember{}, countErr + } else if count == 0 { + return storage.GardenMember{}, storage.ErrRecordNotFound + } + if err = tx.Commit(); err != nil { + return storage.GardenMember{}, err + } + if err = m.loadPermissions(&member); err != nil { + return storage.GardenMember{}, err + } + return member, nil +} + +// Delete removes a non-owner membership from a garden. +func (m GardenMemberModel) Delete(gardenID, userID int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + var role storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID).Scan(&role); err != nil { + return recordError(err) + } + if role == storage.GardenRoleOwner { + var owners int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM garden_members WHERE garden_id=$1 AND role='owner'`, gardenID).Scan(&owners); err != nil { + return err + } + if owners < 2 { + return storage.ErrConflict + } + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, userID); err != nil { + return err + } + return tx.Commit() +} + +// TransferOwnership swaps owner and member roles atomically while preserving +// the invariant that a garden always has one owner. +func (m GardenMemberModel) TransferOwnership(gardenID, fromUserID, toUserID int) error { + if fromUserID == toUserID { + return storage.ErrConflict + } + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + var fromRole, toRole storage.GardenRole + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID).Scan(&fromRole); err != nil { + return recordError(err) + } + if err = tx.QueryRowContext(ctx, `SELECT role FROM garden_members WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID).Scan(&toRole); err != nil { + return recordError(err) + } + if fromRole != storage.GardenRoleOwner { + return storage.ErrConflict + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='owner' WHERE garden_id=$1 AND user_id=$2`, gardenID, toUserID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE garden_members SET role='admin' WHERE garden_id=$1 AND user_id=$2`, gardenID, fromUserID); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/storage/postgres/gardens.go b/internal/storage/postgres/gardens.go new file mode 100644 index 0000000..5f82445 --- /dev/null +++ b/internal/storage/postgres/gardens.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// GardenModel stores gardens in PostgreSQL. +// GardenModel implements storage.GardenModelInterface for PostgreSQL. +type GardenModel struct{ DB *sql.DB } + +// Insert creates a garden and its owner membership atomically. +func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.Garden{}, err + } + defer tx.Rollback() + + query := ` + INSERT INTO gardens (name, description, image_data, image_id) + VALUES ($1, $2, '', $3) + RETURNING id, created_at, updated_at, version` + err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan( + &garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, + ) + if err != nil { + return storage.Garden{}, err + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO garden_members (garden_id, user_id, role) + VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner) + if err != nil { + return storage.Garden{}, err + } + if err = tx.Commit(); err != nil { + return storage.Garden{}, err + } + return garden, nil +} + +func scanGarden(s scanner) (storage.Garden, error) { + var garden storage.Garden + err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version) + return garden, err +} + +// Get returns a garden by ID. +func (m GardenModel) Get(id int) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + garden, err := scanGarden(m.DB.QueryRowContext(ctx, ` + SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version + FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id)) + if err != nil { + return storage.Garden{}, recordError(err) + } + return garden, nil +} + +// GetAllForUser lists gardens visible to a user with resolved permissions. +func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role + FROM gardens g + INNER JOIN garden_members gm ON gm.garden_id = g.id + LEFT JOIN images i ON i.id=g.image_id + WHERE gm.user_id = $1 + ORDER BY g.name, g.id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + gardens := []storage.Garden{} + for rows.Next() { + var garden storage.Garden + err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role) + if err != nil { + return nil, err + } + gardens = append(gardens, garden) + } + return gardens, rows.Err() +} + +// Update changes a garden using optimistic locking. +func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE gardens + SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $4 AND version = $5 + RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version, + ).Scan(&garden.UpdatedAt, &garden.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Garden{}, storage.ErrEditConflict + } + return storage.Garden{}, err + } + return garden, nil +} + +// Delete removes a garden and its dependent records. +func (m GardenModel) Delete(id int) error { + return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id) +} diff --git a/internal/storage/postgres/helpers.go b/internal/storage/postgres/helpers.go new file mode 100644 index 0000000..95bd1d2 --- /dev/null +++ b/internal/storage/postgres/helpers.go @@ -0,0 +1,87 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +const queryTimeout = 3 * time.Second + +type scanner interface { + Scan(dest ...any) error +} + +func contextWithTimeout() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), queryTimeout) +} + +func jsonValue(value json.RawMessage) json.RawMessage { + if len(value) == 0 { + return json.RawMessage(`{}`) + } + return value +} + +func deleteByID(db *sql.DB, query string, args ...any) error { + ctx, cancel := contextWithTimeout() + defer cancel() + + result, err := db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +func deleteByGardenID(db *sql.DB, query string, gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + + result, err := db.ExecContext(ctx, query, gardenID, id) + if err != nil { + return err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return storage.ErrRecordNotFound + } + return nil +} + +func recordError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return storage.ErrRecordNotFound + } + var pqError *pq.Error + if errors.As(err, &pqError) && pqError.Code == "23505" { + return storage.ErrConflict + } + if errors.As(err, &pqError) && pqError.Code == "23503" { + return storage.ErrConflict + } + return err +} + +func nullableUserID(id int) any { + if id < 1 { + return nil + } + return id +} diff --git a/internal/storage/postgres/images.go b/internal/storage/postgres/images.go new file mode 100644 index 0000000..88f5648 --- /dev/null +++ b/internal/storage/postgres/images.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "strings" + + "gardomatic.kleiax.de/internal/storage" +) + +// ImageModel implements storage.ImageModelInterface for PostgreSQL. Image +// retrieval is always constrained by the owning garden. +type ImageModel struct{ DB *sql.DB } + +// Insert stores an image in its garden's library. +func (m ImageModel) Insert(image storage.Image) (storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + sum := sha256.Sum256(image.Data) + err := m.DB.QueryRowContext(ctx, `INSERT INTO images(garden_id,file_name,media_type,data,size,checksum,source,created_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id,size,created_at`, image.GardenID, image.FileName, + image.MediaType, image.Data, len(image.Data), hex.EncodeToString(sum[:]), image.Source, nullableUserID(image.CreatedBy)).Scan(&image.ID, &image.Size, &image.CreatedAt) + if err != nil { + return storage.Image{}, recordError(err) + } + image.Data = nil + return image, nil +} + +// Get returns an image within its garden. +func (m ImageModel) Get(gardenID, id int) (storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var image storage.Image + err := m.DB.QueryRowContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at,data FROM images WHERE garden_id=$1 AND id=$2`, gardenID, id).Scan( + &image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt, &image.Data) + if err != nil { + return storage.Image{}, recordError(err) + } + return image, nil +} + +// GetAllForGarden lists image metadata matching filter. +func (m ImageModel) GetAllForGarden(gardenID int, filter storage.ImageFilter) ([]storage.Image, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,garden_id,file_name,media_type,size,source,COALESCE(created_by,0),created_at + FROM images WHERE garden_id=$1 AND ($2='' OR source=$2) AND ($3='' OR LOWER(file_name) LIKE '%'||LOWER($3)||'%') ORDER BY created_at DESC,id DESC`, gardenID, filter.Source, strings.TrimSpace(filter.Query)) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.Image{} + for rows.Next() { + var image storage.Image + if err = rows.Scan(&image.ID, &image.GardenID, &image.FileName, &image.MediaType, &image.Size, &image.Source, &image.CreatedBy, &image.CreatedAt); err != nil { + return nil, err + } + result = append(result, image) + } + return result, rows.Err() +} + +// CountForGarden returns the number of images in a garden library. +func (m ImageModel) CountForGarden(gardenID int) (int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var count int + err := m.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM images WHERE garden_id=$1`, gardenID).Scan(&count) + return count, err +} + +// RecordAssignment appends an entity image-change history record. +func (m ImageModel) RecordAssignment(c storage.ImageAssignment) error { + if sameImage(c.PreviousImageID, c.ImageID) { + return nil + } + ctx, cancel := contextWithTimeout() + defer cancel() + _, err := m.DB.ExecContext(ctx, `INSERT INTO entity_image_history(garden_id,entity_type,entity_id,previous_image_id,image_id,changed_by) VALUES($1,$2,$3,$4,$5,$6)`, c.GardenID, c.EntityType, c.EntityID, c.PreviousImageID, c.ImageID, nullableUserID(c.ChangedBy)) + return err +} + +func sameImage(a, b *int) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/internal/storage/postgres/journal.go b/internal/storage/postgres/journal.go new file mode 100644 index 0000000..29915b3 --- /dev/null +++ b/internal/storage/postgres/journal.go @@ -0,0 +1,175 @@ +package postgres + +import ( + "context" + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// JournalModel implements storage.JournalModelInterface for PostgreSQL and +// enforces the garden boundary on entries and attachments. +type JournalModel struct{ DB *sql.DB } + +const journalColumns = `e.id, e.garden_id, e.author_id, u.name, u.color, e.entry_type, e.title, e.body, e.created_at, e.updated_at, e.version` + +func scanJournalEntry(s scanner) (storage.JournalEntry, error) { + var entry storage.JournalEntry + err := s.Scan(&entry.ID, &entry.GardenID, &entry.AuthorID, &entry.AuthorName, &entry.AuthorColor, &entry.EntryType, &entry.Title, &entry.Body, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + return entry, err +} + +// Insert creates a journal entry and its tags atomically. +func (m JournalModel) Insert(entry storage.JournalEntry) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + if entry.EntryType == "" { + entry.EntryType = storage.JournalEntryTypeJournal + } + err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_entries(garden_id,author_id,entry_type,title,body,created_at) VALUES($1,$2,$3,$4,$5,$6) RETURNING id,created_at,updated_at,version`, entry.GardenID, entry.AuthorID, entry.EntryType, entry.Title, entry.Body, entry.CreatedAt).Scan(&entry.ID, &entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + if err != nil { + return storage.JournalEntry{}, recordError(err) + } + return entry, nil +} + +// Get returns an entry with tags and attachment metadata within its garden. +func (m JournalModel) Get(gardenID, id int) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + entry, err := scanJournalEntry(m.DB.QueryRowContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.id=$2`, gardenID, id)) + if err != nil { + return storage.JournalEntry{}, recordError(err) + } + entry.Attachments, err = m.attachments(ctx, entry.ID) + return entry, err +} + +// GetAllForGarden lists entries of an optional type in a garden. +func (m JournalModel) GetAllForGarden(gardenID int, entryType storage.JournalEntryType) ([]storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + if entryType == "" { + entryType = storage.JournalEntryTypeJournal + } + rows, err := m.DB.QueryContext(ctx, `SELECT `+journalColumns+` FROM journal_entries e JOIN users u ON u.id=e.author_id WHERE e.garden_id=$1 AND e.entry_type=$2 ORDER BY e.created_at DESC,e.id DESC`, gardenID, entryType) + if err != nil { + return nil, err + } + defer rows.Close() + entries := []storage.JournalEntry{} + for rows.Next() { + entry, scanErr := scanJournalEntry(rows) + if scanErr != nil { + return nil, scanErr + } + entries = append(entries, entry) + } + if err = rows.Err(); err != nil { + return nil, err + } + for i := range entries { + entries[i].Attachments, err = m.attachments(ctx, entries[i].ID) + if err != nil { + return nil, err + } + } + return entries, nil +} + +// Update changes an entry and its tags atomically using optimistic locking. +func (m JournalModel) Update(gardenID int, entry storage.JournalEntry) (storage.JournalEntry, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE journal_entries SET title=$1,body=$2,created_at=$3,updated_at=now(),version=version+1 WHERE garden_id=$4 AND id=$5 AND version=$6 RETURNING created_at,updated_at,version`, entry.Title, entry.Body, entry.CreatedAt, gardenID, entry.ID, entry.Version).Scan(&entry.CreatedAt, &entry.UpdatedAt, &entry.Version) + if err == sql.ErrNoRows { + return storage.JournalEntry{}, storage.ErrEditConflict + } + if err != nil { + return storage.JournalEntry{}, err + } + return entry, nil +} + +// Delete removes an entry within its garden. +func (m JournalModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, id) +} + +// InsertAttachment adds inline media or a library-image link to an entry. +func (m JournalModel) InsertAttachment(gardenID, entryID int, attachment storage.JournalAttachment) (storage.JournalAttachment, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var entryType storage.JournalEntryType + if err := m.DB.QueryRowContext(ctx, `SELECT entry_type FROM journal_entries WHERE garden_id=$1 AND id=$2`, gardenID, entryID).Scan(&entryType); err != nil { + return storage.JournalAttachment{}, recordError(err) + } + if attachment.ImageID != nil { + image, err := ImageModel(m).Get(gardenID, *attachment.ImageID) + if err != nil { + return storage.JournalAttachment{}, err + } + attachment.MediaType, attachment.Size = image.MediaType, image.Size + if attachment.FileName == "" { + attachment.FileName = image.FileName + } + if attachment.FileName == "" { + attachment.FileName = "Bild" + } + } else if len(attachment.MediaType) > 6 && attachment.MediaType[:6] == "image/" { + image, err := ImageModel(m).Insert(storage.Image{GardenID: gardenID, FileName: attachment.FileName, MediaType: attachment.MediaType, Data: attachment.Data, Source: string(entryType)}) + if err != nil { + return storage.JournalAttachment{}, err + } + attachment.ImageID = &image.ID + } + data := attachment.Data + if attachment.ImageID != nil { + data = []byte{} + } + size := int64(len(attachment.Data)) + if attachment.ImageID != nil { + size = attachment.Size + } + err := m.DB.QueryRowContext(ctx, `INSERT INTO journal_attachments(journal_entry_id,file_name,media_type,data,size,image_id) SELECT e.id,$1,$2,$3,$4,$5 FROM journal_entries e WHERE e.garden_id=$6 AND e.id=$7 RETURNING id,created_at`, attachment.FileName, attachment.MediaType, data, size, attachment.ImageID, gardenID, entryID).Scan(&attachment.ID, &attachment.CreatedAt) + if err != nil { + return storage.JournalAttachment{}, recordError(err) + } + attachment.EntryID, attachment.Size = entryID, size + attachment.Data = nil + return attachment, nil +} + +// GetAttachment returns attachment metadata and data within its garden and entry. +func (m JournalModel) GetAttachment(gardenID, entryID, attachmentID int) (storage.JournalAttachment, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var a storage.JournalAttachment + err := m.DB.QueryRowContext(ctx, `SELECT a.id,a.journal_entry_id,a.file_name,a.media_type,a.size,a.created_at,COALESCE(i.data,a.data),a.image_id FROM journal_attachments a JOIN journal_entries e ON e.id=a.journal_entry_id LEFT JOIN images i ON i.id=a.image_id WHERE e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID).Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.Data, &a.ImageID) + if err != nil { + return storage.JournalAttachment{}, recordError(err) + } + return a, nil +} + +// DeleteAttachment removes an attachment within its garden and entry. +func (m JournalModel) DeleteAttachment(gardenID, entryID, attachmentID int) error { + return deleteByID(m.DB, `DELETE FROM journal_attachments a USING journal_entries e WHERE a.journal_entry_id=e.id AND e.garden_id=$1 AND e.id=$2 AND a.id=$3`, gardenID, entryID, attachmentID) +} + +func (m JournalModel) attachments(ctx context.Context, entryID int) ([]storage.JournalAttachment, error) { + rows, err := m.DB.QueryContext(ctx, `SELECT id,journal_entry_id,file_name,media_type,size,created_at,image_id FROM journal_attachments WHERE journal_entry_id=$1 ORDER BY id`, entryID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.JournalAttachment{} + for rows.Next() { + var a storage.JournalAttachment + if err = rows.Scan(&a.ID, &a.EntryID, &a.FileName, &a.MediaType, &a.Size, &a.CreatedAt, &a.ImageID); err != nil { + return nil, err + } + result = append(result, a) + } + return result, rows.Err() +} diff --git a/internal/storage/postgres/journal_integration_test.go b/internal/storage/postgres/journal_integration_test.go new file mode 100644 index 0000000..407036d --- /dev/null +++ b/internal/storage/postgres/journal_integration_test.go @@ -0,0 +1,105 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestJournalModelPersistsEntriesTagsAndAttachmentsWithinGarden(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var userID, gardenID, foreignGardenID int + if err = db.QueryRow(`INSERT INTO users(name,email,password_hash,activated) VALUES('Journal integration',$1,'hash',true) RETURNING id`, "journal-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens(name) VALUES('Journal integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1,$2)`, gardenID, foreignGardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, userID) + }) + + model := JournalModel{DB: db} + entry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, Title: "Ernte", Body: "**Drei** Tomaten"}) + if err != nil { + t.Fatalf("insert entry: %v", err) + } + foreign, err := model.Insert(storage.JournalEntry{GardenID: foreignGardenID, AuthorID: userID, Title: "Fremd"}) + if err != nil { + t.Fatalf("insert foreign entry: %v", err) + } + if _, err = model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign lookup: %v", err) + } + pinboardEntry, err := model.Insert(storage.JournalEntry{GardenID: gardenID, AuthorID: userID, EntryType: storage.JournalEntryTypePinboard, Title: "Sitzecke"}) + if err != nil { + t.Fatalf("insert pinboard entry: %v", err) + } + + tags := TagModel{DB: db} + storedTags, err := tags.Set(gardenID, storage.TagEntityJournal, entry.ID, []string{"Tomaten", "ernte"}) + if err != nil || len(storedTags) != 2 { + t.Fatalf("set tags: %#v, %v", storedTags, err) + } + attachment, err := model.InsertAttachment(gardenID, entry.ID, storage.JournalAttachment{FileName: "foto.jpg", MediaType: "image/jpeg", Data: []byte("jpeg")}) + if err != nil { + t.Fatalf("insert attachment: %v", err) + } + pinboardAttachment, err := model.InsertAttachment(gardenID, pinboardEntry.ID, storage.JournalAttachment{FileName: "idee.jpg", MediaType: "image/jpeg", Data: []byte("pinboard-jpeg")}) + if err != nil { + t.Fatalf("insert pinboard attachment: %v", err) + } + if pinboardAttachment.ImageID == nil { + t.Fatal("pinboard image was not added to the shared image library") + } + pinboardImage, err := (ImageModel{DB: db}).Get(gardenID, *pinboardAttachment.ImageID) + if err != nil || pinboardImage.Source != string(storage.JournalEntryTypePinboard) { + t.Fatalf("pinboard image source: %#v, %v", pinboardImage, err) + } + loaded, err := model.GetAttachment(gardenID, entry.ID, attachment.ID) + if err != nil || string(loaded.Data) != "jpeg" { + t.Fatalf("load attachment: %#v, %v", loaded, err) + } + if _, err = model.GetAttachment(foreignGardenID, entry.ID, attachment.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign attachment lookup: %v", err) + } + + entry.Title = "Große Ernte" + updated, err := model.Update(gardenID, entry) + if err != nil || updated.Version != 2 { + t.Fatalf("update entry: %#v, %v", updated, err) + } + listed, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypeJournal) + if err != nil || len(listed) != 1 || len(listed[0].Attachments) != 1 || listed[0].AuthorName != "Journal integration" { + t.Fatalf("list entries: %#v, %v", listed, err) + } + pinboardEntries, err := model.GetAllForGarden(gardenID, storage.JournalEntryTypePinboard) + if err != nil || len(pinboardEntries) != 1 || pinboardEntries[0].ID != pinboardEntry.ID || len(pinboardEntries[0].Attachments) != 1 { + t.Fatalf("list pinboard entries: %#v, %v", pinboardEntries, err) + } + if err = model.DeleteAttachment(gardenID, entry.ID, attachment.ID); err != nil { + t.Fatalf("delete attachment: %v", err) + } + if err = model.Delete(gardenID, entry.ID); err != nil { + t.Fatalf("delete entry: %v", err) + } +} diff --git a/internal/storage/postgres/locations.go b/internal/storage/postgres/locations.go new file mode 100644 index 0000000..96d157a --- /dev/null +++ b/internal/storage/postgres/locations.go @@ -0,0 +1,118 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// LocationModel stores garden locations in PostgreSQL. +// LocationModel implements storage.LocationModelInterface for PostgreSQL and +// scopes hierarchical locations to their garden. +type LocationModel struct{ DB *sql.DB } + +const locationColumns = `l.id, l.garden_id, l.parent_id, l.name, l.description, l.kind, l.area_sqm, + l.sun_exposure, l.soil_condition, l.soil_reaction, l.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), l.image_id, l.created_at, l.updated_at, l.version, + COALESCE(l.created_by, 0), COALESCE(l.updated_by, 0)` + +func scanLocation(s scanner) (storage.Location, error) { + var location storage.Location + err := s.Scan( + &location.ID, &location.GardenID, &location.ParentID, &location.Name, + &location.Description, &location.Kind, &location.AreaSQM, &location.SunExposure, &location.SoilCondition, &location.SoilReaction, + &location.Attributes, &location.ImageData, &location.ImageID, &location.CreatedAt, &location.UpdatedAt, &location.Version, &location.CreatedBy, &location.UpdatedBy, + ) + return location, err +} + +// Insert creates a location in its garden. +func (m LocationModel) Insert(location storage.Location) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO locations + (garden_id, parent_id, name, description, kind, area_sqm, sun_exposure, soil_condition, soil_reaction, attributes, image_data, image_id, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, '', $11, $12, $13) + RETURNING id, created_at, updated_at, version`, + location.GardenID, location.ParentID, location.Name, location.Description, + location.Kind, location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.CreatedBy), nullableUserID(location.UpdatedBy), + ).Scan(&location.ID, &location.CreatedAt, &location.UpdatedAt, &location.Version) + if err != nil { + return storage.Location{}, err + } + return location, nil +} + +// Get returns a location within its garden. +func (m LocationModel) Get(gardenID, id int) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + location, err := scanLocation(m.DB.QueryRowContext(ctx, `SELECT `+locationColumns+` FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 AND l.id = $2`, gardenID, id)) + if err != nil { + return storage.Location{}, recordError(err) + } + return location, nil +} + +// GetAllForGarden lists locations in a garden. +func (m LocationModel) GetAllForGarden(gardenID int) ([]storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+locationColumns+` + FROM locations l LEFT JOIN images i ON i.id=l.image_id WHERE l.garden_id = $1 ORDER BY l.name, l.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + locations := []storage.Location{} + for rows.Next() { + location, err := scanLocation(rows) + if err != nil { + return nil, err + } + locations = append(locations, location) + } + return locations, rows.Err() +} + +// Update changes a location using optimistic locking. +func (m LocationModel) Update(gardenID int, location storage.Location) (storage.Location, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE locations SET parent_id = $1, name = $2, description = $3, kind = $4, + area_sqm = $5, sun_exposure = $6, soil_condition = $7, soil_reaction = $8, attributes = $9, image_data = '', image_id = $10, updated_by = $11, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $12 AND id = $13 AND version = $14 + RETURNING updated_at, version`, + location.ParentID, location.Name, location.Description, location.Kind, + location.AreaSQM, location.SunExposure, location.SoilCondition, location.SoilReaction, jsonValue(location.Attributes), location.ImageID, nullableUserID(location.UpdatedBy), + gardenID, location.ID, location.Version, + ).Scan(&location.UpdatedAt, &location.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Location{}, storage.ErrEditConflict + } + return storage.Location{}, err + } + return location, nil +} + +// Delete removes a location within its garden. +func (m LocationModel) Delete(gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM locations WHERE garden_id = $1 AND id = $2`, gardenID, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/locations_integration_test.go b/internal/storage/postgres/locations_integration_test.go new file mode 100644 index 0000000..1464911 --- /dev/null +++ b/internal/storage/postgres/locations_integration_test.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "database/sql" + "encoding/json" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestLocationAndPlantLocationModelsEnforceGardenBoundary(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var gardenID, foreignGardenID int + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Location integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) }) + + locations := LocationModel{DB: db} + local, err := locations.Insert(storage.Location{GardenID: gardenID, Name: "Beet", Attributes: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("insert local location: %v", err) + } + foreign, err := locations.Insert(storage.Location{GardenID: foreignGardenID, Name: "Fremdes Beet", Attributes: json.RawMessage(`{}`)}) + if err != nil { + t.Fatalf("insert foreign location: %v", err) + } + if _, err := locations.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign location lookup: got %v, want ErrRecordNotFound", err) + } + + var plantID int + if err := db.QueryRow(`INSERT INTO plants (garden_id, name) VALUES ($1, 'Tomate') RETURNING id`, gardenID).Scan(&plantID); err != nil { + t.Fatal(err) + } + plants, err := (PlantModel{DB: db}).GetAllForGarden(gardenID) + if err != nil { + t.Fatalf("list plants with planter join: %v", err) + } + if len(plants) != 1 || plants[0].ID != plantID { + t.Fatalf("listed plants: got %+v, want plant %d", plants, plantID) + } + assignments := PlantLocationModel{DB: db} + created, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: local.ID, Quantity: 2}) + if err != nil { + t.Fatalf("insert local assignment: %v", err) + } + if created.ID == 0 { + t.Fatal("local assignment has no id") + } + if _, err := assignments.Insert(gardenID, storage.PlantLocation{PlantID: plantID, LocationID: foreign.ID, Quantity: 1}); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign assignment: got %v, want ErrRecordNotFound", err) + } + listed, err := assignments.GetAllForPlant(gardenID, plantID) + if err != nil || len(listed) != 1 { + t.Fatalf("list assignments: values=%+v err=%v", listed, err) + } +} diff --git a/internal/storage/postgres/migrations/000001_initial_schema.down.sql b/internal/storage/postgres/migrations/000001_initial_schema.down.sql new file mode 100644 index 0000000..74eba5e --- /dev/null +++ b/internal/storage/postgres/migrations/000001_initial_schema.down.sql @@ -0,0 +1,49 @@ +DROP TABLE plant_status_history; +DROP TABLE application_settings; +DROP TABLE journal_attachments; +DROP TABLE journal_entry_tags; +DROP TABLE journal_entries; +DROP TABLE entity_image_history; + +ALTER TABLE locations DROP COLUMN image_id; +ALTER TABLE plants DROP COLUMN image_id; +ALTER TABLE species DROP COLUMN image_id; +ALTER TABLE gardens DROP COLUMN image_id; +DROP TABLE images; + +DROP TABLE species_tags; +DROP TABLE plant_tags; +DROP TABLE task_tags; +DROP TABLE tags; +DROP TABLE task_priorities; +DROP TABLE task_template_opt_outs; +DROP TABLE tasks; +DROP TABLE plant_locations; +DROP TABLE plants; +DROP TABLE locations; +DROP TABLE species_task_templates; +DROP TABLE care_instructions; +DROP TABLE species; +DROP TABLE species_categories; +DROP TABLE garden_role_permission_overrides; +DROP TABLE garden_invites; +DROP TABLE garden_members; + +ALTER TABLE users DROP CONSTRAINT users_application_role_fkey; +DROP TABLE role_permissions; +DROP TABLE roles; +DROP TABLE gardens; +DROP TABLE user_email_changes; +DROP TABLE tokens; +DROP TABLE sessions; +DROP TABLE users; + +DROP TYPE task_template_origin; +DROP TYPE task_trigger_type; +DROP TYPE care_instruction_status; +DROP TYPE plant_status; +DROP TYPE plant_lifecycle; +DROP TYPE soil_reaction; +DROP TYPE soil_condition; +DROP TYPE sun_exposure; +DROP EXTENSION citext; diff --git a/internal/storage/postgres/migrations/000001_initial_schema.up.sql b/internal/storage/postgres/migrations/000001_initial_schema.up.sql new file mode 100644 index 0000000..a7e1ff2 --- /dev/null +++ b/internal/storage/postgres/migrations/000001_initial_schema.up.sql @@ -0,0 +1,524 @@ +-- Extensions and domain types +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TYPE sun_exposure AS ENUM ('sunny', 'partial_shade', 'shade'); +CREATE TYPE soil_condition AS ENUM ('dry', 'moist', 'boggy'); +CREATE TYPE soil_reaction AS ENUM ('alkaline', 'acidic', 'neutral'); +CREATE TYPE plant_lifecycle AS ENUM ('annual', 'biennial', 'perennial'); +CREATE TYPE plant_status AS ENUM ('alive', 'dead', 'removed', 'infested', 'harvested'); +CREATE TYPE care_instruction_status AS ENUM ('good', 'bad', 'untested', 'testing', 'planned'); +CREATE TYPE task_trigger_type AS ENUM ( + 'month_of_year', + 'relative_to_planting', + 'relative_to_last_task', + 'relative_to_sowing', + 'relative_to_harvest', + 'relative_to_species_planting' +); +CREATE TYPE task_template_origin AS ENUM ( + 'manual', + 'season_sowing', + 'season_planting', + 'season_harvest' +); + +-- Authentication and users +CREATE TABLE users ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + email citext NOT NULL UNIQUE, + password_hash bytea NOT NULL, + activated boolean NOT NULL, + application_role text NOT NULL DEFAULT 'application:user', + color text NOT NULL DEFAULT (ARRAY['#d95f02','#1b9e77','#7570b3','#e7298a','#66a61e','#e6ab02','#a6761d','#1f78b4'])[1 + floor(random() * 8)::int], + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CONSTRAINT users_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') +); + +CREATE TABLE sessions ( + token text PRIMARY KEY, + data bytea NOT NULL, + expiry timestamptz NOT NULL +); +CREATE INDEX sessions_expiry_idx ON sessions (expiry); + +CREATE TABLE tokens ( + hash bytea PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expiry timestamptz NOT NULL, + scope text NOT NULL +); + +CREATE TABLE user_email_changes ( + user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + email citext NOT NULL UNIQUE, + token_hash bytea NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Gardens, roles, and permissions +CREATE TABLE gardens ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL, + description text NOT NULL DEFAULT '', + image_data text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +CREATE TABLE roles ( + name text PRIMARY KEY, + scope text NOT NULL CHECK (scope IN ('application', 'garden')), + label text NOT NULL, + system boolean NOT NULL DEFAULT false, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT roles_scope_garden_check CHECK ( + (scope = 'application' AND garden_id IS NULL) OR scope = 'garden' + ) +); +CREATE INDEX roles_garden_id_idx ON roles(garden_id) WHERE garden_id IS NOT NULL; + +CREATE TABLE role_permissions ( + role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + permission text NOT NULL, + PRIMARY KEY (role_name, permission) +); + +ALTER TABLE users + ADD CONSTRAINT users_application_role_fkey + FOREIGN KEY (application_role) REFERENCES roles(name); + +CREATE TABLE garden_members ( + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'member' REFERENCES roles(name), + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (garden_id, user_id) +); +CREATE INDEX garden_members_user_id_idx ON garden_members (user_id); + +CREATE TABLE garden_invites ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + email citext NOT NULL, + role text NOT NULL DEFAULT 'member' REFERENCES roles(name), + token_hash bytea NOT NULL UNIQUE, + invited_by bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, + accepted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX garden_invites_pending_email_unique + ON garden_invites (garden_id, email) WHERE accepted_at IS NULL; +CREATE INDEX garden_invites_garden_idx ON garden_invites (garden_id, created_at); + +CREATE TABLE garden_role_permission_overrides ( + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + role_name text NOT NULL REFERENCES roles(name) ON DELETE CASCADE, + permission text NOT NULL, + granted boolean NOT NULL, + PRIMARY KEY (garden_id, role_name, permission) +); + +-- Species catalogue and care instructions +CREATE TABLE species_categories ( + id bigserial PRIMARY KEY, + name text NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + lifecycle plant_lifecycle, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX species_categories_name_key ON species_categories (lower(name)); + +CREATE TABLE species ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + category_id bigint REFERENCES species_categories(id) ON DELETE RESTRICT, + common_name text NOT NULL, + cultivar text NOT NULL DEFAULT '', + botanical_name text NOT NULL DEFAULT '', + sun_exposure sun_exposure, + soil_condition soil_condition, + soil_reaction soil_reaction, + winter_protection text, + spacing_cm integer, + height_cm integer, + sow_month_from smallint CHECK (sow_month_from BETWEEN 1 AND 12), + sow_day_from smallint CHECK (sow_day_from BETWEEN 1 AND 31), + sow_month_to smallint CHECK (sow_month_to BETWEEN 1 AND 12), + sow_day_to smallint CHECK (sow_day_to BETWEEN 1 AND 31), + planting_month_from smallint CHECK (planting_month_from BETWEEN 1 AND 12), + planting_day_from smallint CHECK (planting_day_from BETWEEN 1 AND 31), + planting_month_to smallint CHECK (planting_month_to BETWEEN 1 AND 12), + planting_day_to smallint CHECK (planting_day_to BETWEEN 1 AND 31), + harvest_month_from smallint CHECK (harvest_month_from BETWEEN 1 AND 12), + harvest_day_from smallint CHECK (harvest_day_from BETWEEN 1 AND 31), + harvest_month_to smallint CHECK (harvest_month_to BETWEEN 1 AND 12), + harvest_day_to smallint CHECK (harvest_day_to BETWEEN 1 AND 31), + notes text NOT NULL DEFAULT '', + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX species_global_unique + ON species (common_name, cultivar) WHERE garden_id IS NULL; +CREATE UNIQUE INDEX species_garden_unique + ON species (garden_id, common_name, cultivar) WHERE garden_id IS NOT NULL; + +CREATE TABLE care_instructions ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + text text NOT NULL, + status care_instruction_status NOT NULL DEFAULT 'untested', + created_by bigint NOT NULL REFERENCES users(id), + updated_by bigint NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX care_instructions_species ON care_instructions(species_id); + +CREATE TABLE species_task_templates ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + title text NOT NULL, + description text NOT NULL DEFAULT '', + trigger_type task_trigger_type NOT NULL, + month_from smallint CHECK (month_from BETWEEN 1 AND 12), + day_from smallint CHECK (day_from BETWEEN 1 AND 31), + month_to smallint CHECK (month_to BETWEEN 1 AND 12), + day_to smallint CHECK (day_to BETWEEN 1 AND 31), + offset_days_from integer, + offset_days_to integer, + interval_days smallint, + trigger_offset integer NOT NULL DEFAULT 0 CHECK (trigger_offset >= 0), + trigger_offset_unit text NOT NULL DEFAULT 'day' CHECK (trigger_offset_unit IN ('day', 'week', 'month')), + duration integer NOT NULL DEFAULT 0 CHECK (duration >= 0), + duration_unit text NOT NULL DEFAULT 'day' CHECK (duration_unit IN ('day', 'week', 'month')), + recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), + recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), + origin task_template_origin NOT NULL DEFAULT 'manual', + priority smallint NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CHECK (offset_days_from IS NULL OR offset_days_to IS NULL OR offset_days_from <= offset_days_to) +); +CREATE INDEX species_task_templates_species_id_idx + ON species_task_templates (species_id) WHERE active = true; +CREATE UNIQUE INDEX species_task_templates_derived_origin_key + ON species_task_templates (species_id, origin) WHERE origin <> 'manual'; + +-- Locations, plants, and tasks +CREATE TABLE locations ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + parent_id bigint REFERENCES locations(id) ON DELETE SET NULL, + name text NOT NULL, + description text NOT NULL DEFAULT '', + kind text NOT NULL DEFAULT '', + area_sqm numeric(10,2), + sun_exposure sun_exposure, + soil_condition soil_condition, + soil_reaction soil_reaction, + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX locations_garden_id_idx ON locations (garden_id); +CREATE INDEX locations_parent_id_idx ON locations (parent_id); + +CREATE TABLE plants ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + species_id bigint REFERENCES species(id) ON DELETE SET NULL, + name text NOT NULL, + notes text NOT NULL DEFAULT '', + acquired_at date, + status plant_status NOT NULL DEFAULT 'alive', + removed_at date, + attributes jsonb NOT NULL DEFAULT '{}', + image_data text NOT NULL DEFAULT '', + created_by bigint REFERENCES users(id), + updated_by bigint REFERENCES users(id), + planted_by bigint REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX plants_garden_id_idx ON plants (garden_id) WHERE status = 'alive'; + +CREATE TABLE plant_locations ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + location_id bigint NOT NULL REFERENCES locations(id) ON DELETE CASCADE, + quantity integer NOT NULL DEFAULT 1, + planted_at date, + removed_at date, + notes text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE UNIQUE INDEX plant_location_active + ON plant_locations (plant_id, location_id) WHERE removed_at IS NULL; +CREATE INDEX plant_locations_location_id_idx + ON plant_locations (location_id) WHERE removed_at IS NULL; + +CREATE TABLE tasks ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + plant_id bigint REFERENCES plants(id) ON DELETE CASCADE, + location_id bigint REFERENCES locations(id) ON DELETE CASCADE, + template_id bigint REFERENCES species_task_templates(id) ON DELETE SET NULL, + title text NOT NULL, + description text NOT NULL DEFAULT '', + due_at_start timestamptz, + due_at_end timestamptz, + generated_for date, + completed_at timestamptz, + completed_by bigint REFERENCES users(id), + priority smallint NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + recurrence text NOT NULL DEFAULT '' CHECK (recurrence IN ('', 'daily', 'weekly', 'monthly', 'yearly')), + recurrence_interval integer NOT NULL DEFAULT 1 CHECK (recurrence_interval > 0), + repeat_from_id bigint REFERENCES tasks(id) ON DELETE SET NULL, + plant_status_on_completion plant_status, + created_by bigint NOT NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1, + CHECK (due_at_start IS NULL OR due_at_end IS NULL OR due_at_start <= due_at_end), + CONSTRAINT tasks_completion_user_check CHECK ( + (completed_at IS NULL AND completed_by IS NULL) OR + (completed_at IS NOT NULL AND completed_by IS NOT NULL) + ) +); +CREATE INDEX tasks_garden_id_due_at_end_idx + ON tasks (garden_id, due_at_end) WHERE completed_at IS NULL; +CREATE INDEX tasks_garden_id_due_at_start_idx + ON tasks (garden_id, due_at_start) WHERE completed_at IS NULL; +CREATE INDEX tasks_plant_id_idx ON tasks (plant_id) WHERE plant_id IS NOT NULL; +CREATE UNIQUE INDEX tasks_repeat_from_unique + ON tasks (repeat_from_id) WHERE repeat_from_id IS NOT NULL; +CREATE UNIQUE INDEX tasks_template_slot_unique + ON tasks (plant_id, template_id, generated_for) WHERE template_id IS NOT NULL; + +CREATE TABLE task_template_opt_outs ( + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + template_id bigint NOT NULL REFERENCES species_task_templates(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plant_id, template_id) +); + +CREATE TABLE task_priorities ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + name text NOT NULL UNIQUE, + value smallint NOT NULL UNIQUE CHECK (value BETWEEN -100 AND 100), + sort_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +-- Tags +CREATE TABLE tags ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + garden_id bigint REFERENCES gardens(id) ON DELETE CASCADE, + name text NOT NULL, + UNIQUE (garden_id, name) +); +CREATE UNIQUE INDEX tags_global_name_key ON tags(name) WHERE garden_id IS NULL; + +CREATE TABLE task_tags ( + task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, tag_id) +); +CREATE TABLE plant_tags ( + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (plant_id, tag_id) +); +CREATE TABLE species_tags ( + species_id bigint NOT NULL REFERENCES species(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (species_id, tag_id) +); + +-- Image library +CREATE TABLE images ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + file_name text NOT NULL DEFAULT '', + media_type text NOT NULL, + data bytea NOT NULL, + size bigint NOT NULL, + checksum text NOT NULL, + source text NOT NULL DEFAULT 'upload', + created_by bigint REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX images_garden_created_idx ON images(garden_id, created_at DESC, id DESC); +CREATE INDEX images_garden_checksum_idx ON images(garden_id, checksum); + +ALTER TABLE gardens ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE species ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE plants ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; +ALTER TABLE locations ADD COLUMN image_id bigint REFERENCES images(id) ON DELETE SET NULL; + +CREATE TABLE entity_image_history ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + entity_type text NOT NULL CHECK (entity_type IN ('garden', 'species', 'plant', 'location')), + entity_id bigint NOT NULL, + previous_image_id bigint REFERENCES images(id) ON DELETE SET NULL, + image_id bigint REFERENCES images(id) ON DELETE SET NULL, + changed_by bigint REFERENCES users(id) ON DELETE SET NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX entity_image_history_entity_idx + ON entity_image_history(garden_id, entity_type, entity_id, changed_at DESC); + +-- Journal +CREATE TABLE journal_entries ( + id bigserial PRIMARY KEY, + garden_id bigint NOT NULL REFERENCES gardens(id) ON DELETE CASCADE, + author_id bigint NOT NULL REFERENCES users(id), + entry_type text NOT NULL DEFAULT 'journal' CHECK (entry_type IN ('journal', 'pinboard')), + title text NOT NULL, + body text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); +CREATE INDEX journal_entries_garden_created_idx + ON journal_entries(garden_id, created_at DESC, id DESC); +CREATE INDEX journal_entries_garden_type_created_idx + ON journal_entries(garden_id, entry_type, created_at DESC, id DESC); + +CREATE TABLE journal_entry_tags ( + journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, + tag_id bigint NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (journal_entry_id, tag_id) +); + +CREATE TABLE journal_attachments ( + id bigserial PRIMARY KEY, + journal_entry_id bigint NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE, + image_id bigint REFERENCES images(id) ON DELETE RESTRICT, + file_name text NOT NULL, + media_type text NOT NULL, + data bytea NOT NULL, + size bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX journal_attachments_entry_idx ON journal_attachments(journal_entry_id, id); + +-- Application settings and lifecycle history +CREATE TABLE application_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + lifecycle_status_enabled boolean NOT NULL DEFAULT false, + lifecycle_removal_month smallint NOT NULL DEFAULT 12 CHECK (lifecycle_removal_month BETWEEN 1 AND 12), + lifecycle_removal_day smallint NOT NULL DEFAULT 1 CHECK (lifecycle_removal_day BETWEEN 1 AND 31), + timezone text NOT NULL DEFAULT 'Europe/Berlin', + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 1 +); + +CREATE TABLE plant_status_history ( + id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + plant_id bigint NOT NULL REFERENCES plants(id) ON DELETE CASCADE, + from_status plant_status NOT NULL, + to_status plant_status NOT NULL, + reason text NOT NULL, + effective_at date NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX plant_status_history_plant_id_idx + ON plant_status_history (plant_id, created_at DESC); + +-- Initial reference data +INSERT INTO roles (name, scope, label, system) VALUES + ('application:user', 'application', 'Nutzer', true), + ('application:admin', 'application', 'Administrator', true), + ('owner', 'garden', 'Eigentümer', true), + ('admin', 'garden', 'Administrator', true), + ('member', 'garden', 'Mitglied', true), + ('worker', 'garden', 'Mitarbeiter', true), + ('viewer', 'garden', 'Leser', true); + +INSERT INTO role_permissions (role_name, permission) VALUES + ('application:user', 'gardens:create'), + ('application:admin', 'gardens:create'), + ('application:admin', 'global_species:write'), + ('application:admin', 'users:manage'), + ('application:admin', 'application_settings:write'), + ('application:admin', 'roles:manage'), + ('owner', '*'), + ('admin', 'garden:*'), + ('member', 'garden:read'), + ('member', 'content:write'), + ('member', 'plants:create'), + ('member', 'plants:read:own'), + ('member', 'plants:read:other'), + ('member', 'plants:update:own'), + ('member', 'plants:delete:own'), + ('member', 'locations:create'), + ('member', 'locations:read:own'), + ('member', 'locations:read:other'), + ('member', 'locations:update:own'), + ('member', 'locations:delete:own'), + ('member', 'tasks:create'), + ('member', 'tasks:read:own'), + ('member', 'tasks:read:other'), + ('member', 'tasks:update:own'), + ('member', 'tasks:delete:own'), + ('member', 'tasks:complete:own'), + ('member', 'tasks:complete:other'), + ('worker', 'garden:read'), + ('worker', 'tasks:read:own'), + ('worker', 'tasks:read:other'), + ('worker', 'tasks:complete:own'), + ('worker', 'tasks:complete:other'), + ('viewer', 'garden:read'), + ('viewer', 'plants:read:own'), + ('viewer', 'plants:read:other'), + ('viewer', 'locations:read:own'), + ('viewer', 'locations:read:other'), + ('viewer', 'tasks:read:own'), + ('viewer', 'tasks:read:other'); + +INSERT INTO task_priorities (name, value, sort_order) VALUES + ('Niedrig', -5, 10), + ('Normal', 0, 20), + ('Erhöht', 3, 30), + ('Hoch', 5, 40); + +INSERT INTO application_settings (singleton) VALUES (true); + +INSERT INTO species_categories (name, sort_order) VALUES + ('Gehölz', 10), + ('Gemüse', 20), + ('Kraut', 30), + ('Obst', 40), + ('Staude', 50); diff --git a/internal/storage/postgres/models.go b/internal/storage/postgres/models.go new file mode 100644 index 0000000..aa64d21 --- /dev/null +++ b/internal/storage/postgres/models.go @@ -0,0 +1,33 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// New binds all PostgreSQL model implementations to db. +func New(db *sql.DB) storage.Models { + return storage.Models{ + ApplicationSettings: ApplicationSettingsModel{DB: db}, + Roles: RoleModel{DB: db}, + Gardens: GardenModel{DB: db}, + GardenMembers: GardenMemberModel{DB: db}, + GardenInvites: GardenInviteModel{DB: db}, + Locations: LocationModel{DB: db}, + Plants: PlantModel{DB: db}, + PlantLocations: PlantLocationModel{DB: db}, + Species: SpeciesModel{DB: db}, + CareInstructions: CareInstructionModel{DB: db}, + SpeciesCategories: SpeciesCategoryModel{DB: db}, + TaskPriorities: TaskPriorityModel{DB: db}, + SpeciesTaskTemplates: SpeciesTaskTemplateModel{DB: db}, + Tasks: TaskModel{DB: db}, + Journal: JournalModel{DB: db}, + Images: ImageModel{DB: db}, + Tags: TagModel{DB: db}, + TaskTemplateOptOuts: TaskTemplateOptOutModel{DB: db}, + Tokens: TokenModel{DB: db}, + Users: UserModel{DB: db}, + } +} diff --git a/internal/storage/postgres/plant_locations.go b/internal/storage/postgres/plant_locations.go new file mode 100644 index 0000000..5c6ec05 --- /dev/null +++ b/internal/storage/postgres/plant_locations.go @@ -0,0 +1,139 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// PlantLocationModel stores plant-to-location assignments in PostgreSQL. +// PlantLocationModel implements storage.PlantLocationModelInterface for +// PostgreSQL and verifies both plant and location membership in the garden. +type PlantLocationModel struct{ DB *sql.DB } + +const plantLocationQualifiedColumns = `pl.id, pl.plant_id, pl.location_id, pl.quantity, pl.planted_at, + pl.removed_at, pl.notes, pl.created_at, pl.version` + +func scanPlantLocation(s scanner) (storage.PlantLocation, error) { + var plantLocation storage.PlantLocation + err := s.Scan( + &plantLocation.ID, &plantLocation.PlantID, &plantLocation.LocationID, + &plantLocation.Quantity, &plantLocation.PlantedAt, &plantLocation.RemovedAt, + &plantLocation.Notes, &plantLocation.CreatedAt, &plantLocation.Version, + ) + return plantLocation, err +} + +// Insert assigns a plant to a location in the same garden. +func (m PlantLocationModel) Insert(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO plant_locations + (plant_id, location_id, quantity, planted_at, removed_at, notes) + SELECT $2, $3, $4, $5, $6, $7 + FROM plants p, locations l + WHERE p.id = $2 AND p.garden_id = $1 AND l.id = $3 AND l.garden_id = $1 + RETURNING id, created_at, version`, + gardenID, plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity, + plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes, + ).Scan(&plantLocation.ID, &plantLocation.CreatedAt, &plantLocation.Version) + if err != nil { + return storage.PlantLocation{}, recordError(err) + } + return plantLocation, nil +} + +// Get returns a plant assignment within its garden. +func (m PlantLocationModel) Get(gardenID, id int) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + plantLocation, err := scanPlantLocation(m.DB.QueryRowContext(ctx, + `SELECT `+plantLocationQualifiedColumns+` FROM plant_locations pl + JOIN plants p ON p.id = pl.plant_id WHERE p.garden_id = $1 AND pl.id = $2`, gardenID, id)) + if err != nil { + return storage.PlantLocation{}, recordError(err) + } + return plantLocation, nil +} + +func (m PlantLocationModel) getAll(query string, gardenID, id int) ([]storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, query, gardenID, id) + if err != nil { + return nil, err + } + defer rows.Close() + + plantLocations := []storage.PlantLocation{} + for rows.Next() { + plantLocation, err := scanPlantLocation(rows) + if err != nil { + return nil, err + } + plantLocations = append(plantLocations, plantLocation) + } + return plantLocations, rows.Err() +} + +// GetAllForPlant lists a plant's current and historical placements. +func (m PlantLocationModel) GetAllForPlant(gardenID, plantID int) ([]storage.PlantLocation, error) { + return m.getAll(`SELECT `+plantLocationQualifiedColumns+` + FROM plant_locations pl JOIN plants p ON p.id = pl.plant_id + WHERE p.garden_id = $1 AND pl.plant_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, plantID) +} + +// GetAllForLocation lists current and historical plant placements at a location. +func (m PlantLocationModel) GetAllForLocation(gardenID, locationID int) ([]storage.PlantLocation, error) { + return m.getAll(`SELECT `+plantLocationQualifiedColumns+` + FROM plant_locations pl JOIN locations l ON l.id = pl.location_id + WHERE l.garden_id = $1 AND pl.location_id = $2 ORDER BY pl.created_at, pl.id`, gardenID, locationID) +} + +// Update changes a plant assignment using optimistic locking. +func (m PlantLocationModel) Update(gardenID int, plantLocation storage.PlantLocation) (storage.PlantLocation, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, ` + UPDATE plant_locations SET plant_id = $1, location_id = $2, quantity = $3, + planted_at = $4, removed_at = $5, notes = $6, version = version + 1 + WHERE id = $7 AND version = $8 + AND EXISTS (SELECT 1 FROM plants p WHERE p.id = plant_locations.plant_id AND p.garden_id = $9) + AND EXISTS (SELECT 1 FROM plants p WHERE p.id = $1 AND p.garden_id = $9) + AND EXISTS (SELECT 1 FROM locations l WHERE l.id = $2 AND l.garden_id = $9)`, + plantLocation.PlantID, plantLocation.LocationID, plantLocation.Quantity, + plantLocation.PlantedAt, plantLocation.RemovedAt, plantLocation.Notes, + plantLocation.ID, plantLocation.Version, gardenID) + if err != nil { + return storage.PlantLocation{}, err + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return storage.PlantLocation{}, err + } + if rowsAffected == 0 { + return storage.PlantLocation{}, storage.ErrEditConflict + } + plantLocation.Version++ + return plantLocation, nil +} + +// Delete removes a plant assignment within its garden. +func (m PlantLocationModel) Delete(gardenID, id int) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM plant_locations pl USING plants p + WHERE pl.id = $2 AND p.id = pl.plant_id AND p.garden_id = $1`, gardenID, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return storage.ErrRecordNotFound + } + return nil +} diff --git a/internal/storage/postgres/plants.go b/internal/storage/postgres/plants.go new file mode 100644 index 0000000..b703df7 --- /dev/null +++ b/internal/storage/postgres/plants.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// PlantModel stores garden-scoped plant instances in PostgreSQL. +// PlantModel implements storage.PlantModelInterface for PostgreSQL and scopes +// every concrete plant operation to its garden. +type PlantModel struct{ DB *sql.DB } + +const plantColumns = `p.id, p.garden_id, p.species_id, p.name, p.notes, p.acquired_at, p.status, + p.removed_at, p.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), p.image_id, p.created_at, p.updated_at, p.version, + COALESCE(p.created_by, 0), COALESCE(p.updated_by, 0), COALESCE(p.planted_by, 0), COALESCE(u.name, '')` + +func scanPlant(s scanner) (storage.Plant, error) { + var plant storage.Plant + err := s.Scan( + &plant.ID, &plant.GardenID, &plant.SpeciesID, &plant.Name, &plant.Notes, + &plant.AcquiredAt, &plant.Status, &plant.RemovedAt, &plant.Attributes, &plant.ImageData, &plant.ImageID, + &plant.CreatedAt, &plant.UpdatedAt, &plant.Version, &plant.CreatedBy, &plant.UpdatedBy, &plant.PlantedBy, &plant.PlantedByName, + ) + return plant, err +} + +// Insert creates a plant in its garden. +func (m PlantModel) Insert(plant storage.Plant) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO plants + (garden_id, species_id, name, notes, acquired_at, status, removed_at, attributes, image_data, image_id, created_by, updated_by, planted_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', $9, $10, $11, $12) + RETURNING id, created_at, updated_at, version`, + plant.GardenID, plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, + plant.Status, plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.CreatedBy), nullableUserID(plant.UpdatedBy), nullableUserID(plant.PlantedBy), + ).Scan(&plant.ID, &plant.CreatedAt, &plant.UpdatedAt, &plant.Version) + if err != nil { + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// Get returns a plant within its garden. +func (m PlantModel) Get(gardenID, id int) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + plant, err := scanPlant(m.DB.QueryRowContext(ctx, `SELECT `+plantColumns+` FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 AND p.id = $2`, gardenID, id)) + if err != nil { + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// GetAllForGarden lists plants in a garden. +func (m PlantModel) GetAllForGarden(gardenID int) ([]storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+plantColumns+` + FROM plants p LEFT JOIN users u ON u.id=p.planted_by LEFT JOIN images i ON i.id=p.image_id WHERE p.garden_id = $1 ORDER BY p.name, p.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + plants := []storage.Plant{} + for rows.Next() { + plant, err := scanPlant(rows) + if err != nil { + return nil, err + } + plants = append(plants, plant) + } + return plants, rows.Err() +} + +// Update changes a plant using optimistic locking. +func (m PlantModel) Update(gardenID int, plant storage.Plant) (storage.Plant, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE plants SET species_id = $1, name = $2, notes = $3, acquired_at = $4, + status = $5, removed_at = $6, attributes = $7, image_data = '', image_id = $8, updated_by = $9, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $10 AND id = $11 AND version = $12 + RETURNING updated_at, version`, + plant.SpeciesID, plant.Name, plant.Notes, plant.AcquiredAt, plant.Status, + plant.RemovedAt, jsonValue(plant.Attributes), plant.ImageID, nullableUserID(plant.UpdatedBy), gardenID, plant.ID, plant.Version, + ).Scan(&plant.UpdatedAt, &plant.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Plant{}, storage.ErrEditConflict + } + return storage.Plant{}, recordError(err) + } + return plant, nil +} + +// Delete removes a plant within its garden. +func (m PlantModel) Delete(gardenID, id int) error { + return deleteByGardenID(m.DB, `DELETE FROM plants WHERE garden_id = $1 AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/roles.go b/internal/storage/postgres/roles.go new file mode 100644 index 0000000..4165ead --- /dev/null +++ b/internal/storage/postgres/roles.go @@ -0,0 +1,201 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// RoleModel implements storage.RoleModelInterface for shared and garden-owned +// PostgreSQL roles. +type RoleModel struct{ DB *sql.DB } + +// List returns shared roles in a scope. +func (m RoleModel) List(scope storage.RoleScope) ([]storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope=$1 AND r.garden_id IS NULL GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, scope) + return scanRoles(rows, err) +} + +// ListForGarden returns shared and custom roles available in a garden. +func (m RoleModel) ListForGarden(gardenID int) ([]storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.scope='garden' AND (r.garden_id IS NULL OR r.garden_id=$1) GROUP BY r.name ORDER BY r.system DESC,r.label,r.name`, gardenID) + return scanRoles(rows, err) +} + +func scanRoles(rows *sql.Rows, err error) ([]storage.Role, error) { + if err != nil { + return nil, err + } + defer rows.Close() + roles := []storage.Role{} + for rows.Next() { + var role storage.Role + var permissions pq.StringArray + if err := rows.Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions); err != nil { + return nil, err + } + role.Permissions = []string(permissions) + roles = append(roles, role) + } + return roles, rows.Err() +} + +// Get returns a shared role by name. +func (m RoleModel) Get(name string) (storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var role storage.Role + var permissions pq.StringArray + err := m.DB.QueryRowContext(ctx, `SELECT r.name,r.scope,r.garden_id,r.label,r.system,r.created_at,r.updated_at,COALESCE(array_agg(rp.permission) FILTER (WHERE rp.permission IS NOT NULL), '{}') FROM roles r LEFT JOIN role_permissions rp ON rp.role_name=r.name WHERE r.name=$1 GROUP BY r.name`, name).Scan(&role.Name, &role.Scope, &role.GardenID, &role.Label, &role.System, &role.CreatedAt, &role.UpdatedAt, &permissions) + if err != nil { + return storage.Role{}, recordError(err) + } + role.Permissions = []string(permissions) + return role, nil +} + +// GetForGarden returns a shared or custom role available in a garden. +func (m RoleModel) GetForGarden(gardenID int, name string) (storage.Role, error) { + role, err := m.Get(name) + if err != nil { + return storage.Role{}, err + } + if role.Scope != storage.RoleScopeGarden || role.GardenID != nil && *role.GardenID != gardenID { + return storage.Role{}, storage.ErrRecordNotFound + } + return role, nil +} + +// Create adds a role and its base permissions atomically. +func (m RoleModel) Create(role storage.Role) (storage.Role, error) { + if role.Scope != storage.RoleScopeApplication && role.Scope != storage.RoleScopeGarden { + return storage.Role{}, storage.ErrConflict + } + if role.Scope == storage.RoleScopeApplication && role.GardenID != nil { + return storage.Role{}, storage.ErrConflict + } + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return role, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `INSERT INTO roles(name,scope,garden_id,label) VALUES($1,$2,$3,$4)`, role.Name, role.Scope, role.GardenID, role.Label); err != nil { + return role, recordError(err) + } + for _, p := range role.Permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil { + return role, err + } + } + if err = tx.Commit(); err != nil { + return role, err + } + return m.Get(role.Name) +} + +// Update replaces a role's label and base permissions atomically. +func (m RoleModel) Update(role storage.Role) (storage.Role, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return role, err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE roles SET label=$1,updated_at=CURRENT_TIMESTAMP WHERE name=$2`, role.Label, role.Name) + if err != nil { + return role, err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return role, countErr + } else if count == 0 { + return storage.Role{}, storage.ErrRecordNotFound + } + if _, err = tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_name=$1`, role.Name); err != nil { + return role, err + } + for _, p := range role.Permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO role_permissions(role_name,permission) VALUES($1,$2)`, role.Name, p); err != nil { + return role, err + } + } + if err = tx.Commit(); err != nil { + return role, err + } + return m.Get(role.Name) +} + +// Delete removes a non-system shared role that is not assigned. +func (m RoleModel) Delete(name string) error { + ctx, cancel := contextWithTimeout() + defer cancel() + result, err := m.DB.ExecContext(ctx, `DELETE FROM roles WHERE name=$1 AND system=false`, name) + if err != nil { + return recordError(err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return storage.ErrConflict + } + return nil +} + +// ListGardenOverrides lists explicit permission grants and revocations in a garden. +func (m RoleModel) ListGardenOverrides(gardenID int) ([]storage.GardenRolePermissionOverride, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT garden_id,role_name,permission,granted FROM garden_role_permission_overrides WHERE garden_id=$1 ORDER BY role_name,permission`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.GardenRolePermissionOverride{} + for rows.Next() { + var o storage.GardenRolePermissionOverride + if err := rows.Scan(&o.GardenID, &o.RoleName, &o.Permission, &o.Granted); err != nil { + return nil, err + } + result = append(result, o) + } + return result, rows.Err() +} + +// ReplaceGardenOverrides atomically replaces all permission overrides for a +// role in one garden. +func (m RoleModel) ReplaceGardenOverrides(gardenID int, roleName string, overrides []storage.GardenRolePermissionOverride) error { + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='garden' AND (garden_id IS NULL OR garden_id=$2))`, roleName, gardenID).Scan(&exists); err != nil { + return err + } + if !exists { + return storage.ErrRecordNotFound + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_role_permission_overrides WHERE garden_id=$1 AND role_name=$2`, gardenID, roleName); err != nil { + return err + } + for _, o := range overrides { + if _, err = tx.ExecContext(ctx, `INSERT INTO garden_role_permission_overrides(garden_id,role_name,permission,granted) VALUES($1,$2,$3,$4)`, gardenID, roleName, o.Permission, o.Granted); err != nil { + return err + } + } + return tx.Commit() +} + +var _ = sql.ErrNoRows diff --git a/internal/storage/postgres/roles_integration_test.go b/internal/storage/postgres/roles_integration_test.go new file mode 100644 index 0000000..a436f2e --- /dev/null +++ b/internal/storage/postgres/roles_integration_test.go @@ -0,0 +1,103 @@ +package postgres + +import ( + "database/sql" + "os" + "strconv" + "testing" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + _ "github.com/lib/pq" +) + +func TestScopedRolesAndPersistedPermissions(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := time.Now().Format("150405.000000000") + password := auth.NewPassword([]byte("integration-test-hash")) + users := UserModel{DB: db} + user, err := users.Insert(storage.User{Name: "Role integration", Email: "roles-" + stamp + "@example.com", Password: *password, Activated: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, user.ID) }) + if user.Role != storage.ApplicationRoleUser || !user.Can(storage.ApplicationPermissionGardensCreate) { + t.Fatalf("new user role=%q permissions=%v", user.Role, user.Permissions) + } + + user, err = users.UpdateRole(user.ID, storage.ApplicationRoleAdmin) + if err != nil { + t.Fatal(err) + } + if !user.Can(storage.ApplicationPermissionUsersManage) || !user.Can(storage.ApplicationPermissionRolesManage) { + t.Fatalf("admin permissions were not loaded: %v", user.Permissions) + } + + var gardenID int + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Role integration "+stamp).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) }) + members := GardenMemberModel{DB: db} + member, err := members.Insert(storage.GardenMember{GardenID: gardenID, UserID: user.ID, Role: storage.GardenRoleOwner}) + if err != nil { + t.Fatal(err) + } + if !member.Can(storage.GardenPermissionGardenDelete) { + t.Fatal("owner wildcard permission was not expanded") + } + if err = (RoleModel{DB: db}).ReplaceGardenOverrides(gardenID, string(storage.GardenRoleOwner), []storage.GardenRolePermissionOverride{{GardenID: gardenID, RoleName: string(storage.GardenRoleOwner), Permission: string(storage.GardenPermissionGardenDelete), Granted: false}}); err != nil { + t.Fatal(err) + } + member, err = members.Get(gardenID, user.ID) + if err != nil { + t.Fatal(err) + } + if member.Can(storage.GardenPermissionGardenDelete) || !member.Can(storage.GardenPermissionGardenUpdate) { + t.Fatalf("garden override was not applied: %v", member.Permissions) + } + + var otherGardenID int + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ($1) RETURNING id`, "Other role integration "+stamp).Scan(&otherGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, otherGardenID) }) + roleName := "garden:" + strconv.Itoa(gardenID) + ":integration" + role, err := (RoleModel{DB: db}).Create(storage.Role{ + Name: roleName, Scope: storage.RoleScopeGarden, GardenID: &gardenID, + Label: "Integration", Permissions: []string{string(storage.GardenPermissionGardenRead)}, + }) + if err != nil { + t.Fatal(err) + } + if role.GardenID == nil || *role.GardenID != gardenID { + t.Fatalf("garden-specific role has garden_id=%v", role.GardenID) + } + roles, err := (RoleModel{DB: db}).ListForGarden(gardenID) + if err != nil { + t.Fatal(err) + } + found := false + for _, candidate := range roles { + found = found || candidate.Name == roleName + } + if !found { + t.Fatalf("garden-specific role %q is missing", roleName) + } + if _, err = (RoleModel{DB: db}).GetForGarden(otherGardenID, roleName); err != storage.ErrRecordNotFound { + t.Fatalf("role leaked into another garden: %v", err) + } +} diff --git a/internal/storage/postgres/species.go b/internal/storage/postgres/species.go new file mode 100644 index 0000000..cd8b4e1 --- /dev/null +++ b/internal/storage/postgres/species.go @@ -0,0 +1,142 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesModel stores global and garden-specific species data in PostgreSQL. +// SpeciesModel implements storage.SpeciesModelInterface for global and +// garden-owned species records. +type SpeciesModel struct{ DB *sql.DB } + +const speciesColumns = `s.id, s.garden_id, s.common_name, s.cultivar, s.botanical_name, + s.category_id, COALESCE(c.name, ''), s.sun_exposure, s.soil_condition, s.soil_reaction, + s.winter_protection, s.spacing_cm, s.height_cm, + s.sow_month_from, s.sow_day_from, s.sow_month_to, s.sow_day_to, + s.planting_month_from, s.planting_day_from, s.planting_month_to, s.planting_day_to, + s.harvest_month_from, s.harvest_day_from, s.harvest_month_to, s.harvest_day_to, + s.notes, s.attributes, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), s.image_id, s.created_at, s.updated_at, s.version, + COALESCE(s.created_by, 0), COALESCE(s.updated_by, 0)` + +func scanSpecies(s scanner) (storage.Species, error) { + var species storage.Species + err := s.Scan( + &species.ID, &species.GardenID, &species.CommonName, &species.Cultivar, + &species.BotanicalName, &species.CategoryID, &species.Category, &species.SunExposure, &species.SoilCondition, + &species.SoilReaction, &species.WinterProtection, &species.SpacingCM, + &species.HeightCM, &species.SowMonthFrom, &species.SowDayFrom, &species.SowMonthTo, + &species.SowDayTo, &species.PlantingMonthFrom, &species.PlantingDayFrom, &species.PlantingMonthTo, &species.PlantingDayTo, &species.HarvestMonthFrom, &species.HarvestDayFrom, + &species.HarvestMonthTo, &species.HarvestDayTo, &species.Notes, &species.Attributes, &species.ImageData, &species.ImageID, + &species.CreatedAt, &species.UpdatedAt, &species.Version, &species.CreatedBy, &species.UpdatedBy, + ) + return species, err +} + +func speciesArgs(species storage.Species) []any { + return []any{ + species.GardenID, species.CommonName, species.Cultivar, species.BotanicalName, + species.CategoryID, species.SunExposure, species.SoilCondition, species.SoilReaction, + species.WinterProtection, species.SpacingCM, species.HeightCM, + species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo, + species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo, + species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, + species.HarvestDayTo, species.Notes, jsonValue(species.Attributes), species.ImageID, nullableUserID(species.CreatedBy), nullableUserID(species.UpdatedBy), + } +} + +// Insert creates a global or garden-owned species record. +func (m SpeciesModel) Insert(species storage.Species) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + query := ` + INSERT INTO species ( + garden_id, common_name, cultivar, botanical_name, category_id, sun_exposure, + soil_condition, soil_reaction, winter_protection, spacing_cm, height_cm, + sow_month_from, sow_day_from, sow_month_to, sow_day_to, + planting_month_from, planting_day_from, planting_month_to, planting_day_to, + harvest_month_from, harvest_day_from, harvest_month_to, harvest_day_to, + notes, attributes, image_data, image_id, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, + $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, '', $26, $27, $28) + RETURNING id, created_at, updated_at, version` + err := m.DB.QueryRowContext(ctx, query, speciesArgs(species)...).Scan( + &species.ID, &species.CreatedAt, &species.UpdatedAt, &species.Version, + ) + if err != nil { + return storage.Species{}, recordError(err) + } + return species, nil +} + +// Get returns a global or garden-owned species visible in gardenID. +func (m SpeciesModel) Get(gardenID, id int) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + species, err := scanSpecies(m.DB.QueryRowContext(ctx, `SELECT `+speciesColumns+` FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id WHERE s.id = $1 AND (s.garden_id IS NULL OR s.garden_id = $2)`, id, gardenID)) + if err != nil { + return storage.Species{}, recordError(err) + } + return species, nil +} + +// GetAllForGarden lists global species and species owned by a garden. +func (m SpeciesModel) GetAllForGarden(gardenID int) ([]storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesColumns+` + FROM species s LEFT JOIN species_categories c ON c.id = s.category_id LEFT JOIN images i ON i.id=s.image_id + WHERE s.garden_id IS NULL OR s.garden_id = $1 + ORDER BY s.common_name, s.cultivar, s.id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + allSpecies := []storage.Species{} + for rows.Next() { + species, err := scanSpecies(rows) + if err != nil { + return nil, err + } + allSpecies = append(allSpecies, species) + } + return allSpecies, rows.Err() +} + +// Update changes a visible species using optimistic locking. +func (m SpeciesModel) Update(gardenID int, species storage.Species) (storage.Species, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + args := speciesArgs(species) + // created_by is immutable and is intentionally not part of the UPDATE. + // Remove it from the shared insert argument list so PostgreSQL does not + // receive an unused, untyped parameter between image_id and updated_by. + args = append(args[:26], args[27]) + args = append(args, gardenID, species.ID, species.Version) + err := m.DB.QueryRowContext(ctx, ` + UPDATE species SET garden_id = $1, common_name = $2, cultivar = $3, + botanical_name = $4, category_id = $5, sun_exposure = $6, soil_condition = $7, + soil_reaction = $8, winter_protection = $9, spacing_cm = $10, + height_cm = $11, sow_month_from = $12, sow_day_from = $13, + sow_month_to = $14, sow_day_to = $15, planting_month_from=$16, + planting_day_from=$17, planting_month_to=$18, planting_day_to=$19, + harvest_month_from = $20, harvest_day_from = $21, harvest_month_to = $22, harvest_day_to = $23, + notes = $24, attributes = $25, image_data = '', image_id = $26, updated_by = $27, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id IS NOT DISTINCT FROM NULLIF($28, 0) AND id = $29 AND version = $30 + RETURNING updated_at, version`, args..., + ).Scan(&species.UpdatedAt, &species.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Species{}, storage.ErrEditConflict + } + return storage.Species{}, recordError(err) + } + return species, nil +} + +// Delete removes a species owned by the supplied garden. +func (m SpeciesModel) Delete(gardenID, id int) error { + return deleteByGardenID(m.DB, `DELETE FROM species WHERE garden_id IS NOT DISTINCT FROM NULLIF($1, 0) AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/species_categories.go b/internal/storage/postgres/species_categories.go new file mode 100644 index 0000000..39d0a86 --- /dev/null +++ b/internal/storage/postgres/species_categories.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesCategoryModel stores globally configured species categories. +// SpeciesCategoryModel implements storage.SpeciesCategoryModelInterface for +// the application-wide category catalogue. +type SpeciesCategoryModel struct{ DB *sql.DB } + +func scanSpeciesCategory(s scanner) (storage.SpeciesCategory, error) { + var category storage.SpeciesCategory + err := s.Scan(&category.ID, &category.Name, &category.SortOrder, &category.Active, &category.CreatedAt, &category.UpdatedAt, &category.Version, &category.Lifecycle) + return category, err +} + +// Insert creates a species category. +func (m SpeciesCategoryModel) Insert(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO species_categories (name, sort_order, active, lifecycle) + VALUES ($1, $2, $3, $4) + RETURNING id, created_at, updated_at, version`, category.Name, category.SortOrder, category.Active, + category.Lifecycle).Scan(&category.ID, &category.CreatedAt, &category.UpdatedAt, &category.Version) + if err != nil { + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} + +// Get returns a species category by ID. +func (m SpeciesCategoryModel) Get(id int) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + category, err := scanSpeciesCategory(m.DB.QueryRowContext(ctx, ` + SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle + FROM species_categories WHERE id = $1`, id)) + if err != nil { + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} + +// GetAll lists all species categories in display order. +func (m SpeciesCategoryModel) GetAll() ([]storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, ` + SELECT id, name, sort_order, active, created_at, updated_at, version, lifecycle + FROM species_categories ORDER BY sort_order, lower(name), id`) + if err != nil { + return nil, err + } + defer rows.Close() + categories := []storage.SpeciesCategory{} + for rows.Next() { + category, scanErr := scanSpeciesCategory(rows) + if scanErr != nil { + return nil, scanErr + } + categories = append(categories, category) + } + return categories, rows.Err() +} + +// Update changes a species category using optimistic locking. +func (m SpeciesCategoryModel) Update(category storage.SpeciesCategory) (storage.SpeciesCategory, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE species_categories + SET name = $1, sort_order = $2, active = $3, lifecycle = $4, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $5 AND version = $6 + RETURNING updated_at, version`, category.Name, category.SortOrder, category.Active, category.Lifecycle, category.ID, category.Version).Scan(&category.UpdatedAt, &category.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.SpeciesCategory{}, storage.ErrEditConflict + } + return storage.SpeciesCategory{}, recordError(err) + } + return category, nil +} diff --git a/internal/storage/postgres/species_integration_test.go b/internal/storage/postgres/species_integration_test.go new file mode 100644 index 0000000..560764f --- /dev/null +++ b/internal/storage/postgres/species_integration_test.go @@ -0,0 +1,35 @@ +package postgres + +import ( + "database/sql" + "os" + "testing" +) + +func TestSpeciesModelListsSeededGlobalSpecies(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + species, err := (SpeciesModel{DB: db}).GetAllForGarden(2_147_483_647) + if err != nil { + t.Fatalf("list global species: %v", err) + } + + for _, item := range species { + if item.GardenID == nil && item.CommonName == "Tomate" { + return + } + } + t.Fatal("seeded global species Tomate was not returned") +} diff --git a/internal/storage/postgres/species_task_templates.go b/internal/storage/postgres/species_task_templates.go new file mode 100644 index 0000000..31b4a58 --- /dev/null +++ b/internal/storage/postgres/species_task_templates.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// SpeciesTaskTemplateModel stores recurring species task rules in PostgreSQL. +// SpeciesTaskTemplateModel implements storage.SpeciesTaskTemplateModelInterface +// and resolves global species through the requesting garden boundary. +type SpeciesTaskTemplateModel struct{ DB *sql.DB } + +const speciesTaskTemplateColumns = `t.id, t.species_id, t.origin, t.title, t.description, t.trigger_type, + t.month_from, t.day_from, t.month_to, t.day_to, t.offset_days_from, t.offset_days_to, + t.interval_days, t.trigger_offset, t.trigger_offset_unit, t.duration, t.duration_unit, + t.recurrence, t.recurrence_interval, t.priority, t.active, t.created_at, t.updated_at, t.version` + +func scanSpeciesTaskTemplate(s scanner) (storage.SpeciesTaskTemplate, error) { + var template storage.SpeciesTaskTemplate + err := s.Scan( + &template.ID, &template.SpeciesID, &template.Origin, &template.Title, &template.Description, + &template.TriggerType, &template.MonthFrom, &template.DayFrom, &template.MonthTo, + &template.DayTo, &template.OffsetDaysFrom, &template.OffsetDaysTo, + &template.IntervalDays, &template.TriggerOffset, &template.TriggerOffsetUnit, &template.Duration, &template.DurationUnit, + &template.Recurrence, &template.RecurrenceInterval, &template.Priority, &template.Active, &template.CreatedAt, + &template.UpdatedAt, &template.Version, + ) + return template, err +} + +// Insert creates a task template for a species. +func (m SpeciesTaskTemplateModel) Insert(template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO species_task_templates ( + species_id, origin, title, description, trigger_type, month_from, day_from, + month_to, day_to, offset_days_from, offset_days_to, interval_days, + trigger_offset, trigger_offset_unit, duration, duration_unit, recurrence, recurrence_interval, priority, active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING id, created_at, updated_at, version`, + template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType, + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo, + template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays, + template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit, + template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, + ).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt, &template.Version) + if err != nil { + return storage.SpeciesTaskTemplate{}, err + } + return template, nil +} + +// Get returns a task template visible in a garden. +func (m SpeciesTaskTemplateModel) Get(gardenID, id int) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + template, err := scanSpeciesTaskTemplate(m.DB.QueryRowContext(ctx, + `SELECT `+speciesTaskTemplateColumns+` FROM species_task_templates t JOIN species s ON s.id = t.species_id WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.id = $2`, gardenID, id)) + if err != nil { + return storage.SpeciesTaskTemplate{}, recordError(err) + } + return template, nil +} + +// GetAllForSpecies lists task templates for a species visible in a garden. +func (m SpeciesTaskTemplateModel) GetAllForSpecies(gardenID, speciesID int) ([]storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+speciesTaskTemplateColumns+` + FROM species_task_templates t JOIN species s ON s.id = t.species_id + WHERE (s.garden_id IS NULL OR s.garden_id = $1) AND t.species_id = $2 + ORDER BY t.active DESC, t.priority DESC, t.title, t.id`, gardenID, speciesID) + if err != nil { + return nil, err + } + defer rows.Close() + + templates := []storage.SpeciesTaskTemplate{} + for rows.Next() { + template, err := scanSpeciesTaskTemplate(rows) + if err != nil { + return nil, err + } + templates = append(templates, template) + } + return templates, rows.Err() +} + +// Update changes a task template using optimistic locking. +func (m SpeciesTaskTemplateModel) Update(gardenID int, template storage.SpeciesTaskTemplate) (storage.SpeciesTaskTemplate, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE species_task_templates SET species_id = $1, origin = $2, title = $3, description = $4, + trigger_type = $5, month_from = $6, day_from = $7, month_to = $8, + day_to = $9, offset_days_from = $10, offset_days_to = $11, + interval_days = $12, trigger_offset = $13, trigger_offset_unit = $14, + duration = $15, duration_unit = $16, recurrence = $17, recurrence_interval = $18, + priority = $19, active = $20, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $21 AND version = $22 + AND EXISTS (SELECT 1 FROM species s WHERE s.id = species_task_templates.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($23, 0)) + RETURNING updated_at, version`, + template.SpeciesID, template.Origin, template.Title, template.Description, template.TriggerType, + template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo, + template.OffsetDaysFrom, template.OffsetDaysTo, template.IntervalDays, + template.TriggerOffset, template.TriggerOffsetUnit, template.Duration, template.DurationUnit, + template.Recurrence, template.RecurrenceInterval, template.Priority, template.Active, template.ID, template.Version, gardenID, + ).Scan(&template.UpdatedAt, &template.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.SpeciesTaskTemplate{}, storage.ErrEditConflict + } + return storage.SpeciesTaskTemplate{}, err + } + return template, nil +} + +// Delete removes a task template visible in a garden. +func (m SpeciesTaskTemplateModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM species_task_templates t USING species s WHERE t.id = $1 AND s.id = t.species_id AND s.garden_id IS NOT DISTINCT FROM NULLIF($2, 0)`, id, gardenID) +} diff --git a/internal/storage/postgres/tags.go b/internal/storage/postgres/tags.go new file mode 100644 index 0000000..b17cff6 --- /dev/null +++ b/internal/storage/postgres/tags.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "database/sql" + "fmt" + + "gardomatic.kleiax.de/internal/storage" +) + +// TagModel manages global and garden-local tags for supported entity types. +type TagModel struct{ DB *sql.DB } + +// GetAllForGarden lists global and garden-local tag names. +func (m TagModel) GetAllForGarden(gardenID int) ([]string, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT DISTINCT name FROM tags WHERE garden_id=$1 ORDER BY name`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func tagRelation(entity storage.TagEntity) (string, string, error) { + switch entity { + case storage.TagEntityTask: + return "task_tags", "task_id", nil + case storage.TagEntityPlant: + return "plant_tags", "plant_id", nil + case storage.TagEntitySpecies: + return "species_tags", "species_id", nil + case storage.TagEntityJournal: + return "journal_entry_tags", "journal_entry_id", nil + default: + return "", "", fmt.Errorf("unknown tag entity %q", entity) + } +} + +// Get lists tags assigned to an entity within its garden. +func (m TagModel) Get(gardenID int, entity storage.TagEntity, entityID int) ([]string, error) { + table, column, err := tagRelation(entity) + if err != nil { + return nil, err + } + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT t.name FROM tags t JOIN `+table+` et ON et.tag_id=t.id WHERE t.garden_id IS NOT DISTINCT FROM NULLIF($1,0) AND et.`+column+`=$2 ORDER BY t.name`, gardenID, entityID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []string{} + for rows.Next() { + var value string + if err = rows.Scan(&value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +// Set atomically replaces an entity's tags after verifying that the entity +// belongs to the supplied garden. +func (m TagModel) Set(gardenID int, entity storage.TagEntity, entityID int, tags []string) ([]string, error) { + table, column, err := tagRelation(entity) + if err != nil { + return nil, err + } + tags = storage.NormalizeTags(tags) + ctx, cancel := contextWithTimeout() + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE `+column+`=$1`, entityID); err != nil { + return nil, err + } + for _, tag := range tags { + var tagID int + if gardenID == 0 { + err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES(NULL,$1) ON CONFLICT(name) WHERE garden_id IS NULL DO UPDATE SET name=EXCLUDED.name RETURNING id`, tag).Scan(&tagID) + } else { + err = tx.QueryRowContext(ctx, `INSERT INTO tags(garden_id,name) VALUES($1,$2) ON CONFLICT(garden_id,name) DO UPDATE SET name=EXCLUDED.name RETURNING id`, gardenID, tag).Scan(&tagID) + } + if err != nil { + return nil, err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO `+table+`(`+column+`,tag_id) VALUES($1,$2)`, entityID, tagID); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + return tags, nil +} diff --git a/internal/storage/postgres/task_generation_integration_test.go b/internal/storage/postgres/task_generation_integration_test.go new file mode 100644 index 0000000..8e670d8 --- /dev/null +++ b/internal/storage/postgres/task_generation_integration_test.go @@ -0,0 +1,81 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "sync" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestGeneratedTaskInsertIsIdempotentUnderConcurrency(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatal(err) + } + var userID, gardenID, speciesID, plantID, templateID int + if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Generator integration', $1, 'hash', true) RETURNING id`, "generator-"+time.Now().Format("150405.000000000")+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Generator integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id = $1`, gardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + if err := db.QueryRow(`INSERT INTO species (garden_id, common_name) VALUES ($1, 'Parallelrose') RETURNING id`, gardenID).Scan(&speciesID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO plants (garden_id, species_id, name) VALUES ($1, $2, 'Rose') RETURNING id`, gardenID, speciesID).Scan(&plantID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO species_task_templates (species_id, title, trigger_type, month_from, month_to) VALUES ($1, 'Schneiden', 'month_of_year', 2, 3) RETURNING id`, speciesID).Scan(&templateID); err != nil { + t.Fatal(err) + } + + generatedFor := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + dueStart, dueEnd := generatedFor, time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC) + model := TaskModel{DB: db} + const workers = 12 + results := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := model.Insert(storage.Task{GardenID: gardenID, PlantID: &plantID, TemplateID: &templateID, Title: "Schneiden", DueAtStart: &dueStart, DueAtEnd: &dueEnd, GeneratedFor: &generatedFor, CreatedBy: userID}) + results <- err + }() + } + wg.Wait() + close(results) + successes, conflicts := 0, 0 + for err := range results { + if err == nil { + successes++ + } else if errors.Is(err, storage.ErrConflict) { + conflicts++ + } else { + t.Fatalf("insert error: %v", err) + } + } + if successes != 1 || conflicts != workers-1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + var count int + if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE plant_id = $1 AND template_id = $2 AND generated_for = $3`, plantID, templateID, generatedFor).Scan(&count); err != nil || count != 1 { + t.Fatalf("count=%d err=%v", count, err) + } +} diff --git a/internal/storage/postgres/task_priorities.go b/internal/storage/postgres/task_priorities.go new file mode 100644 index 0000000..7dc8875 --- /dev/null +++ b/internal/storage/postgres/task_priorities.go @@ -0,0 +1,73 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskPriorityModel implements storage.TaskPriorityModelInterface for the +// application-wide priority catalogue. +type TaskPriorityModel struct{ DB *sql.DB } + +func scanTaskPriority(s scanner) (storage.TaskPriority, error) { + var priority storage.TaskPriority + err := s.Scan(&priority.ID, &priority.Name, &priority.Value, &priority.SortOrder, &priority.Active, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version) + return priority, err +} + +// Insert creates a priority catalogue entry. +func (m TaskPriorityModel) Insert(priority storage.TaskPriority) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `INSERT INTO task_priorities (name, value, sort_order, active) VALUES ($1,$2,$3,$4) RETURNING id, created_at, updated_at, version`, priority.Name, priority.Value, priority.SortOrder, priority.Active).Scan(&priority.ID, &priority.CreatedAt, &priority.UpdatedAt, &priority.Version) + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} + +// Get returns a priority catalogue entry by ID. +func (m TaskPriorityModel) Get(id int) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + priority, err := scanTaskPriority(m.DB.QueryRowContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities WHERE id=$1`, id)) + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} + +// GetAll lists all priority catalogue entries in display order. +func (m TaskPriorityModel) GetAll() ([]storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id,name,value,sort_order,active,created_at,updated_at,version FROM task_priorities ORDER BY sort_order, value, id`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []storage.TaskPriority{} + for rows.Next() { + value, err := scanTaskPriority(rows) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +// Update changes a priority catalogue entry using optimistic locking. +func (m TaskPriorityModel) Update(priority storage.TaskPriority) (storage.TaskPriority, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, `UPDATE task_priorities SET name=$1,value=$2,sort_order=$3,active=$4,updated_at=CURRENT_TIMESTAMP,version=version+1 WHERE id=$5 AND version=$6 RETURNING updated_at,version`, priority.Name, priority.Value, priority.SortOrder, priority.Active, priority.ID, priority.Version).Scan(&priority.UpdatedAt, &priority.Version) + if err == sql.ErrNoRows { + return storage.TaskPriority{}, storage.ErrEditConflict + } + if err != nil { + return storage.TaskPriority{}, recordError(err) + } + return priority, nil +} diff --git a/internal/storage/postgres/task_template_opt_outs.go b/internal/storage/postgres/task_template_opt_outs.go new file mode 100644 index 0000000..2b9f756 --- /dev/null +++ b/internal/storage/postgres/task_template_opt_outs.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskTemplateOptOutModel stores per-plant task-generation suppression while +// enforcing the garden boundary. +type TaskTemplateOptOutModel struct{ DB *sql.DB } + +// IsOptedOut reports whether task generation is suppressed for a plant-template pair. +func (m TaskTemplateOptOutModel) IsOptedOut(gardenID, plantID, templateID int) (bool, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + var value bool + err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id JOIN species_task_templates st ON st.id=o.template_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3 AND st.species_id=p.species_id)`, gardenID, plantID, templateID).Scan(&value) + return value, err +} + +// GetAllForPlant lists suppressed template IDs for a plant. +func (m TaskTemplateOptOutModel) GetAllForPlant(gardenID, plantID int) ([]int, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT o.template_id FROM task_template_opt_outs o JOIN plants p ON p.id=o.plant_id WHERE p.garden_id=$1 AND p.id=$2 ORDER BY o.template_id`, gardenID, plantID) + if err != nil { + return nil, err + } + defer rows.Close() + result := []int{} + for rows.Next() { + var id int + if err = rows.Scan(&id); err != nil { + return nil, err + } + result = append(result, id) + } + return result, rows.Err() +} + +// Set creates or removes suppression for a plant-template pair. +func (m TaskTemplateOptOutModel) Set(gardenID, plantID, templateID int, optedOut bool) error { + ctx, cancel := contextWithTimeout() + defer cancel() + var valid bool + err := m.DB.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM plants p JOIN species_task_templates st ON st.species_id=p.species_id WHERE p.garden_id=$1 AND p.id=$2 AND st.id=$3)`, gardenID, plantID, templateID).Scan(&valid) + if err != nil { + return err + } + if !valid { + return storage.ErrRecordNotFound + } + if optedOut { + _, err = m.DB.ExecContext(ctx, `INSERT INTO task_template_opt_outs(plant_id,template_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, plantID, templateID) + } else { + _, err = m.DB.ExecContext(ctx, `DELETE FROM task_template_opt_outs WHERE plant_id=$1 AND template_id=$2`, plantID, templateID) + } + return err +} diff --git a/internal/storage/postgres/tasks.go b/internal/storage/postgres/tasks.go new file mode 100644 index 0000000..6a5083c --- /dev/null +++ b/internal/storage/postgres/tasks.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "database/sql" + + "gardomatic.kleiax.de/internal/storage" +) + +// TaskModel stores garden tasks in PostgreSQL. +// TaskModel implements storage.TaskModelInterface for garden-scoped work items. +type TaskModel struct{ DB *sql.DB } + +const taskColumns = `id, garden_id, plant_id, location_id, template_id, title, + description, due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by, + priority, active, created_by, created_at, updated_at, version, plant_status_on_completion` + +func scanTask(s scanner) (storage.Task, error) { + var task storage.Task + err := s.Scan( + &task.ID, &task.GardenID, &task.PlantID, &task.LocationID, &task.TemplateID, + &task.Title, &task.Description, &task.DueAtStart, &task.DueAtEnd, + &task.GeneratedFor, &task.Recurrence, &task.RecurrenceInterval, &task.RepeatFromID, &task.CompletedAt, &task.CompletedBy, &task.Priority, &task.Active, + &task.CreatedBy, &task.CreatedAt, &task.UpdatedAt, &task.Version, &task.PlantStatusOnCompletion, + ) + return task, err +} + +// Insert creates a task; generated task slots remain idempotent under concurrency. +func (m TaskModel) Insert(task storage.Task) (storage.Task, error) { + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + INSERT INTO tasks ( + garden_id, plant_id, location_id, template_id, title, description, + due_at_start, due_at_end, generated_for, recurrence, recurrence_interval, repeat_from_id, completed_at, completed_by, + priority, active, created_by, plant_status_on_completion) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + RETURNING id, created_at, updated_at, version`, + task.GardenID, task.PlantID, task.LocationID, task.TemplateID, task.Title, + task.Description, task.DueAtStart, task.DueAtEnd, task.GeneratedFor, + task.Recurrence, task.RecurrenceInterval, task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.CreatedBy, task.PlantStatusOnCompletion, + ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt, &task.Version) + if err != nil { + return storage.Task{}, recordError(err) + } + return task, nil +} + +// Get returns a task within its garden. +func (m TaskModel) Get(gardenID, id int) (storage.Task, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + task, err := scanTask(m.DB.QueryRowContext(ctx, `SELECT `+taskColumns+` FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id)) + if err != nil { + return storage.Task{}, recordError(err) + } + return task, nil +} + +// GetAllForGarden lists tasks in a garden. +func (m TaskModel) GetAllForGarden(gardenID int) ([]storage.Task, error) { + ctx, cancel := contextWithTimeout() + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT `+taskColumns+` + FROM tasks WHERE garden_id = $1 + ORDER BY completed_at NULLS FIRST, due_at_end NULLS LAST, priority DESC, id`, gardenID) + if err != nil { + return nil, err + } + defer rows.Close() + + tasks := []storage.Task{} + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +// Update changes a task using optimistic locking. +func (m TaskModel) Update(gardenID int, task storage.Task) (storage.Task, error) { + if task.RecurrenceInterval < 1 { + task.RecurrenceInterval = 1 + } + ctx, cancel := contextWithTimeout() + defer cancel() + err := m.DB.QueryRowContext(ctx, ` + UPDATE tasks SET plant_id = $1, location_id = $2, template_id = $3, + title = $4, description = $5, due_at_start = $6, due_at_end = $7, + generated_for = $8, recurrence = $9, recurrence_interval = $10, repeat_from_id = $11, completed_at = $12, completed_by = $13, + priority = $14, active = $15, plant_status_on_completion=$16, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE garden_id = $17 AND id = $18 AND version = $19 + RETURNING updated_at, version`, + task.PlantID, task.LocationID, task.TemplateID, task.Title, task.Description, + task.DueAtStart, task.DueAtEnd, task.GeneratedFor, task.Recurrence, task.RecurrenceInterval, + task.RepeatFromID, task.CompletedAt, task.CompletedBy, task.Priority, task.Active, task.PlantStatusOnCompletion, gardenID, task.ID, task.Version, + ).Scan(&task.UpdatedAt, &task.Version) + if err != nil { + if err == sql.ErrNoRows { + return storage.Task{}, storage.ErrEditConflict + } + return storage.Task{}, err + } + return task, nil +} + +// Delete removes a task within its garden. +func (m TaskModel) Delete(gardenID, id int) error { + return deleteByID(m.DB, `DELETE FROM tasks WHERE garden_id = $1 AND id = $2`, gardenID, id) +} diff --git a/internal/storage/postgres/tasks_integration_test.go b/internal/storage/postgres/tasks_integration_test.go new file mode 100644 index 0000000..bff037e --- /dev/null +++ b/internal/storage/postgres/tasks_integration_test.go @@ -0,0 +1,64 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "testing" + + "gardomatic.kleiax.de/internal/storage" +) + +func TestTaskModelEnforcesGardenBoundary(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := db.Ping(); err != nil { + t.Fatalf("connect to PostgreSQL: %v", err) + } + + var userID, gardenID, foreignGardenID int + if err := db.QueryRow(`INSERT INTO users (name, email, password_hash, activated) VALUES ('Task integration', $1, 'hash', true) RETURNING id`, "task-integration-"+t.Name()+"@example.com").Scan(&userID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration A') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`INSERT INTO gardens (name) VALUES ('Task integration B') RETURNING id`).Scan(&foreignGardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id IN ($1, $2)`, gardenID, foreignGardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) + }) + + model := TaskModel{DB: db} + local, err := model.Insert(storage.Task{GardenID: gardenID, Title: "Gießen", CreatedBy: userID}) + if err != nil { + t.Fatalf("insert task: %v", err) + } + foreign, err := model.Insert(storage.Task{GardenID: foreignGardenID, Title: "Fremd", CreatedBy: userID}) + if err != nil { + t.Fatalf("insert foreign task: %v", err) + } + if _, err := model.Get(gardenID, foreign.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign lookup: got %v", err) + } + local.Title = "Kräftig gießen" + updated, err := model.Update(gardenID, local) + if err != nil || updated.Version != 2 { + t.Fatalf("update: task=%+v err=%v", updated, err) + } + if err := model.Delete(foreignGardenID, local.ID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("foreign delete: got %v", err) + } + if listed, err := model.GetAllForGarden(gardenID); err != nil || len(listed) != 1 || listed[0].ID != local.ID { + t.Fatalf("list: tasks=%+v err=%v", listed, err) + } +} diff --git a/internal/storage/postgres/tokens.go b/internal/storage/postgres/tokens.go new file mode 100644 index 0000000..2e4e563 --- /dev/null +++ b/internal/storage/postgres/tokens.go @@ -0,0 +1,51 @@ +package postgres + +import ( + "context" + "database/sql" + "time" + + "gardomatic.kleiax.de/internal/auth" +) + +// TokenModel stores scoped authentication token hashes in PostgreSQL. +// TokenModel persists only token hashes; plaintext tokens are returned once to +// the caller and never stored. +type TokenModel struct { + DB *sql.DB +} + +// New creates a token and persists only its hash. +func (m TokenModel) New(userID int, ttl time.Duration, scope string) (auth.Token, error) { + token := auth.NewToken(userID, ttl, scope) + + err := m.insert(token) + return token, err +} + +func (m TokenModel) insert(token auth.Token) error { + query := ` + INSERT INTO tokens (hash, user_id, expiry, scope) + VALUES ($1, $2, $3, $4)` + + args := []any{token.Hash, token.UserID, token.Expiry, token.Scope} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, args...) + return err +} + +// DeleteAllForUser revokes every token for one user and scope. +func (m TokenModel) DeleteAllForUser(scope string, userID int) error { + query := ` + DELETE FROM tokens + WHERE scope = $1 AND user_id = $2` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, scope, userID) + return err +} diff --git a/internal/storage/postgres/users.go b/internal/storage/postgres/users.go new file mode 100644 index 0000000..3006cac --- /dev/null +++ b/internal/storage/postgres/users.go @@ -0,0 +1,424 @@ +package postgres + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "errors" + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/storage" + "github.com/lib/pq" +) + +// UserModel stores user accounts in PostgreSQL. +// UserModel implements storage.UserModelInterface for PostgreSQL accounts. +type UserModel struct { + DB *sql.DB +} + +// Insert creates a user account. +func (m UserModel) Insert(user storage.User) (storage.User, error) { + query := ` + INSERT INTO users (name, email, password_hash, activated, application_role) + VALUES ($1, $2, $3, $4, CASE WHEN EXISTS (SELECT 1 FROM users WHERE deleted_at IS NULL) THEN $5 ELSE $6 END) + RETURNING id, created_at, color, application_role, version` + + args := []any{user.Name, user.Email, user.Password.Get(), user.Activated, storage.ApplicationRoleUser, storage.ApplicationRoleAdmin} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.User{}, err + } + defer tx.Rollback() + // Serialize registrations until a first administrator exists. Without this, + // two simultaneous registrations could both observe an empty users table. + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(71432026)`); err != nil { + return storage.User{}, err + } + err = tx.QueryRowContext(ctx, query, args...).Scan(&user.ID, &user.CreatedAt, &user.Color, &user.Role, &user.Version) + if err != nil { + var pqErr *pq.Error + switch { + case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key": + return storage.User{}, storage.ErrDuplicateEmail + default: + return storage.User{}, err + } + } + if err = tx.Commit(); err != nil { + return storage.User{}, err + } + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetByID returns a non-deleted account with resolved application permissions. +func (m UserModel) GetByID(id int) (storage.User, error) { + query := ` + SELECT id, created_at, updated_at, name, email, password_hash, activated, color, application_role, version + FROM users + WHERE id = $1 AND deleted_at IS NULL` + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.CreatedAt, + &user.UpdatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetByEmail returns a non-deleted account by case-insensitive email. +func (m UserModel) GetByEmail(email string) (storage.User, error) { + query := ` + SELECT id, created_at, name, email, password_hash, activated, color, application_role, version + FROM users + WHERE email = $1 AND deleted_at IS NULL` + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.CreatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// Update changes an account using optimistic locking. +func (m UserModel) Update(user storage.User) (storage.User, error) { + query := ` + UPDATE users + SET name = $1, email = $2, password_hash = $3, activated = $4, color = $5, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = $6 AND version = $7 AND deleted_at IS NULL + RETURNING updated_at, version` + + args := []any{ + user.Name, + user.Email, + user.Password.Get(), + user.Activated, + user.Color, + user.ID, + user.Version, + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan(&user.UpdatedAt, &user.Version) + if err != nil { + var pqErr *pq.Error + switch { + case errors.As(err, &pqErr) && pqErr.Code == "23505" && pqErr.Constraint == "users_email_key": + return storage.User{}, storage.ErrDuplicateEmail + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrEditConflict + default: + return storage.User{}, err + } + } + + return user, nil +} + +// GetForToken resolves a non-expired hashed token to its user. +func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (storage.User, error) { + tokenHash := sha256.Sum256([]byte(tokenPlaintext)) + + query := ` + SELECT users.id, users.created_at, users.name, users.email, users.password_hash, users.activated, users.color, users.application_role, users.version + FROM users + INNER JOIN tokens + ON users.id = tokens.user_id + WHERE tokens.hash = $1 + AND tokens.scope = $2 + AND tokens.expiry > $3 + AND users.deleted_at IS NULL` + + args := []any{tokenHash[:], tokenScope, time.Now()} + + var user storage.User + var pwHash []byte + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + err := m.DB.QueryRowContext(ctx, query, args...).Scan( + &user.ID, + &user.CreatedAt, + &user.Name, + &user.Email, + &pwHash, + &user.Activated, + &user.Color, + &user.Role, + &user.Version, + ) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return storage.User{}, storage.ErrRecordNotFound + default: + return storage.User{}, err + } + } + + user.Password = *auth.NewPassword(pwHash) + if err = m.loadPermissions(&user); err != nil { + return storage.User{}, err + } + + return user, nil +} + +// GetAll lists non-deleted accounts with resolved application permissions. +func (m UserModel) GetAll() ([]storage.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT id, created_at, updated_at, name, email, activated, color, application_role, version FROM users WHERE deleted_at IS NULL ORDER BY name, email, id`) + if err != nil { + return nil, err + } + users := []storage.User{} + for rows.Next() { + var user storage.User + if err := rows.Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt, &user.Name, &user.Email, &user.Activated, &user.Color, &user.Role, &user.Version); err != nil { + return nil, err + } + users = append(users, user) + } + if err = rows.Err(); err != nil { + rows.Close() + return nil, err + } + if err = rows.Close(); err != nil { + return nil, err + } + for i := range users { + if err = m.loadPermissions(&users[i]); err != nil { + return nil, err + } + } + return users, nil +} + +// UpdateRole changes an account's application role. +func (m UserModel) UpdateRole(userID int, role storage.ApplicationRole) (storage.User, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := m.DB.ExecContext(ctx, `UPDATE users SET application_role=$1, updated_at=CURRENT_TIMESTAMP, version=version+1 WHERE id=$2 AND deleted_at IS NULL AND EXISTS (SELECT 1 FROM roles WHERE name=$1 AND scope='application')`, role, userID) + if err != nil { + return storage.User{}, err + } + count, err := result.RowsAffected() + if err != nil { + return storage.User{}, err + } + if count == 0 { + return storage.User{}, storage.ErrRecordNotFound + } + return m.GetByID(userID) +} + +// Delete removes access and personal account data while retaining the user row +// as an anonymized author for historical garden content. +// Delete anonymizes an account while preserving authored garden content and +// removes authentication material and memberships transactionally. +func (m UserModel) Delete(userID int) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + var email string + err = tx.QueryRowContext(ctx, `SELECT email FROM users WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&email) + if err != nil { + return recordError(err) + } + rows, err := tx.QueryContext(ctx, `SELECT garden_id FROM garden_members WHERE user_id=$1 ORDER BY garden_id`, userID) + if err != nil { + return err + } + gardenIDs := []int{} + for rows.Next() { + var gardenID int + if err = rows.Scan(&gardenID); err != nil { + rows.Close() + return err + } + gardenIDs = append(gardenIDs, gardenID) + } + if err = rows.Close(); err != nil { + return err + } + if err = rows.Err(); err != nil { + return err + } + for _, gardenID := range gardenIDs { + if _, err = tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, gardenID); err != nil { + return err + } + } + var ownsGarden bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM garden_members WHERE user_id=$1 AND role='owner')`, userID).Scan(&ownsGarden); err != nil { + return err + } + if ownsGarden { + return storage.ErrConflict + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_members WHERE user_id=$1`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM garden_invites WHERE invited_by=$1 OR email=$2`, userID, email); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM tokens WHERE user_id=$1`, userID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil { + return err + } + result, err := tx.ExecContext(ctx, ` + UPDATE users SET name='Gelöschter Nutzer', email='deleted-' || id::text || '@invalid', + activated=false, application_role=$2, deleted_at=now(), updated_at=now(), version=version+1 + WHERE id=$1 AND deleted_at IS NULL`, userID, storage.ApplicationRoleUser) + if err != nil { + return err + } + if count, countErr := result.RowsAffected(); countErr != nil { + return countErr + } else if count == 0 { + return storage.ErrRecordNotFound + } + return tx.Commit() +} + +func (m UserModel) loadPermissions(user *storage.User) error { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rows, err := m.DB.QueryContext(ctx, `SELECT permission FROM role_permissions WHERE role_name=$1 ORDER BY permission`, user.Role) + if err != nil { + return err + } + defer rows.Close() + user.Permissions = []storage.ApplicationPermission{} + for rows.Next() { + var permission storage.ApplicationPermission + if err := rows.Scan(&permission); err != nil { + return err + } + user.Permissions = append(user.Permissions, permission) + } + return rows.Err() +} + +// CreateEmailChange stores a pending address and returns its one-time plaintext token. +func (m UserModel) CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) { + plaintext := rand.Text() + hash := sha256.Sum256([]byte(plaintext)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := m.DB.ExecContext(ctx, `INSERT INTO user_email_changes (user_id,email,token_hash,expires_at) VALUES ($1,$2,$3,$4) ON CONFLICT (user_id) DO UPDATE SET email=EXCLUDED.email,token_hash=EXCLUDED.token_hash,expires_at=EXCLUDED.expires_at,created_at=now()`, userID, email, hash[:], time.Now().Add(ttl)) + if err != nil { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "23505" { + return "", storage.ErrDuplicateEmail + } + return "", err + } + return plaintext, nil +} + +// ConfirmEmailChange consumes a token and applies its pending address atomically. +func (m UserModel) ConfirmEmailChange(tokenPlaintext string, expectedUserID int) (storage.User, error) { + hash := sha256.Sum256([]byte(tokenPlaintext)) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return storage.User{}, err + } + defer tx.Rollback() + var userID int + var email string + err = tx.QueryRowContext(ctx, `SELECT user_id,email FROM user_email_changes WHERE token_hash=$1 AND user_id=$2 AND expires_at>now() FOR UPDATE`, hash[:], expectedUserID).Scan(&userID, &email) + if err != nil { + return storage.User{}, recordError(err) + } + _, err = tx.ExecContext(ctx, `UPDATE users SET email=$1,updated_at=now(),version=version+1 WHERE id=$2 AND deleted_at IS NULL`, email, userID) + if err != nil { + return storage.User{}, err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM user_email_changes WHERE user_id=$1`, userID); err != nil { + return storage.User{}, err + } + if err = tx.Commit(); err != nil { + return storage.User{}, err + } + return m.GetByID(userID) +} diff --git a/internal/storage/postgres/users_integration_test.go b/internal/storage/postgres/users_integration_test.go new file mode 100644 index 0000000..2f3458b --- /dev/null +++ b/internal/storage/postgres/users_integration_test.go @@ -0,0 +1,89 @@ +package postgres + +import ( + "database/sql" + "errors" + "os" + "strings" + "testing" + "time" + + "gardomatic.kleiax.de/internal/storage" + _ "github.com/lib/pq" +) + +func TestDeleteUserAnonymizesAccountAndPreservesAuthoredContent(t *testing.T) { + dsn := os.Getenv("GARDOMATIC_TEST_DB_DSN") + if dsn == "" { + t.Skip("set GARDOMATIC_TEST_DB_DSN to run PostgreSQL integration tests") + } + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err = db.Ping(); err != nil { + t.Fatal(err) + } + + stamp := strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "") + originalEmail := "delete-" + stamp + "@example.com" + var ownerID, targetID, gardenID, entryID int + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Owner',$1,'hash',true) RETURNING id`, "owner-delete-"+stamp+"@example.com").Scan(&ownerID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Personal Name',$1,'hash',true) RETURNING id`, originalEmail).Scan(&targetID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO gardens (name) VALUES ('Deletion integration') RETURNING id`).Scan(&gardenID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM gardens WHERE id=$1`, gardenID) + _, _ = db.Exec(`DELETE FROM users WHERE id IN ($1,$2)`, ownerID, targetID) + }) + if _, err = db.Exec(`INSERT INTO garden_members (garden_id,user_id,role) VALUES ($1,$2,'owner'),($1,$3,'member')`, gardenID, ownerID, targetID); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`INSERT INTO journal_entries (garden_id,author_id,title) VALUES ($1,$2,'Bleibt erhalten') RETURNING id`, gardenID, targetID).Scan(&entryID); err != nil { + t.Fatal(err) + } + + users := UserModel{DB: db} + if err = users.Delete(targetID); err != nil { + t.Fatal(err) + } + if _, err = users.GetByEmail(originalEmail); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("deleted email still resolves: %v", err) + } + if _, err = users.GetByID(targetID); !errors.Is(err, storage.ErrRecordNotFound) { + t.Fatalf("deleted user ID still resolves: %v", err) + } + var name, email string + var activated bool + var deletedAt time.Time + if err = db.QueryRow(`SELECT name,email,activated,deleted_at FROM users WHERE id=$1`, targetID).Scan(&name, &email, &activated, &deletedAt); err != nil { + t.Fatal(err) + } + if name != "Gelöschter Nutzer" || email == originalEmail || activated || deletedAt.IsZero() { + t.Fatalf("account was not anonymized: name=%q email=%q activated=%t deleted_at=%v", name, email, activated, deletedAt) + } + var membershipCount, entryCount int + if err = db.QueryRow(`SELECT count(*) FROM garden_members WHERE user_id=$1`, targetID).Scan(&membershipCount); err != nil { + t.Fatal(err) + } + if err = db.QueryRow(`SELECT count(*) FROM journal_entries WHERE id=$1 AND author_id=$2`, entryID, targetID).Scan(&entryCount); err != nil { + t.Fatal(err) + } + if membershipCount != 0 || entryCount != 1 { + t.Fatalf("membership=%d preserved entries=%d", membershipCount, entryCount) + } + var replacementID int + if err = db.QueryRow(`INSERT INTO users (name,email,password_hash,activated) VALUES ('Replacement',$1,'hash',true) RETURNING id`, originalEmail).Scan(&replacementID); err != nil { + t.Fatalf("original email was not released: %v", err) + } + t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM users WHERE id=$1`, replacementID) }) + if err = users.Delete(ownerID); !errors.Is(err, storage.ErrConflict) { + t.Fatalf("owner deletion error: got %v, want conflict", err) + } +} diff --git a/internal/storage/roles.go b/internal/storage/roles.go new file mode 100644 index 0000000..7dc424f --- /dev/null +++ b/internal/storage/roles.go @@ -0,0 +1,47 @@ +package storage + +import "time" + +// RoleScope separates application-wide privileges from garden membership roles. +type RoleScope string + +// Supported role scopes. +const ( + RoleScopeApplication RoleScope = "application" + RoleScopeGarden RoleScope = "garden" +) + +// Role is a reusable, globally defined bundle of permissions. +type Role struct { + Name string `json:"name"` + Scope RoleScope `json:"scope"` + GardenID *int `json:"garden_id,omitempty"` + Label string `json:"label"` + System bool `json:"system"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GardenRolePermissionOverride grants or revokes one role permission in a +// single garden without modifying the shared role template. +type GardenRolePermissionOverride struct { + GardenID int `json:"garden_id"` + RoleName string `json:"role_name"` + Permission string `json:"permission"` + Granted bool `json:"granted"` +} + +// RoleModelInterface manages application roles, garden role templates, and +// garden-specific permission overrides. +type RoleModelInterface interface { + List(scope RoleScope) ([]Role, error) + ListForGarden(gardenID int) ([]Role, error) + Get(name string) (Role, error) + GetForGarden(gardenID int, name string) (Role, error) + Create(role Role) (Role, error) + Update(role Role) (Role, error) + Delete(name string) error + ListGardenOverrides(gardenID int) ([]GardenRolePermissionOverride, error) + ReplaceGardenOverrides(gardenID int, roleName string, overrides []GardenRolePermissionOverride) error +} diff --git a/internal/storage/species.go b/internal/storage/species.go new file mode 100644 index 0000000..786f12b --- /dev/null +++ b/internal/storage/species.go @@ -0,0 +1,106 @@ +package storage + +import ( + "encoding/json" + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// SpeciesModelInterface persists global and garden-specific species data. +type SpeciesModelInterface interface { + Insert(species Species) (Species, error) + Get(gardenID, id int) (Species, error) + GetAllForGarden(gardenID int) ([]Species, error) + Update(gardenID int, species Species) (Species, error) + Delete(gardenID, id int) error +} + +// Species contains reusable botanical and cultivation master data. +type Species struct { + ID int `json:"id"` + GardenID *int `json:"garden_id,omitempty"` + CommonName string `json:"common_name"` + Cultivar string `json:"cultivar"` + BotanicalName string `json:"botanical_name"` + CategoryID *int `json:"category_id,omitempty"` + Category string `json:"category"` + SunExposure *string `json:"sun_exposure,omitempty"` + SoilCondition *string `json:"soil_condition,omitempty"` + SoilReaction *string `json:"soil_reaction,omitempty"` + WinterProtection *string `json:"winter_protection,omitempty"` + SpacingCM *int `json:"spacing_cm,omitempty"` + HeightCM *int `json:"height_cm,omitempty"` + SowMonthFrom *int `json:"sow_month_from,omitempty"` + SowDayFrom *int `json:"sow_day_from,omitempty"` + SowMonthTo *int `json:"sow_month_to,omitempty"` + SowDayTo *int `json:"sow_day_to,omitempty"` + PlantingMonthFrom *int `json:"planting_month_from,omitempty"` + PlantingDayFrom *int `json:"planting_day_from,omitempty"` + PlantingMonthTo *int `json:"planting_month_to,omitempty"` + PlantingDayTo *int `json:"planting_day_to,omitempty"` + HarvestMonthFrom *int `json:"harvest_month_from,omitempty"` + HarvestDayFrom *int `json:"harvest_day_from,omitempty"` + HarvestMonthTo *int `json:"harvest_month_to,omitempty"` + HarvestDayTo *int `json:"harvest_day_to,omitempty"` + Notes string `json:"notes"` + ImageData string `json:"image_data,omitempty"` + ImageID *int `json:"image_id,omitempty"` + Attributes json.RawMessage `json:"attributes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + CreatedBy int `json:"created_by"` + UpdatedBy int `json:"updated_by"` +} + +// ValidateSpecies applies persistence-independent species validation rules. +func ValidateSpecies(v *validate.Validator, species Species) { + v.Check(strings.TrimSpace(species.CommonName) != "", "common_name", "must be provided") + v.Check(len(species.CommonName) <= 500, "common_name", "must not be more than 500 bytes long") + v.Check(len(species.Cultivar) <= 500, "cultivar", "must not be more than 500 bytes long") + v.Check(len(species.BotanicalName) <= 500, "botanical_name", "must not be more than 500 bytes long") + if species.CategoryID != nil { + v.Check(*species.CategoryID > 0, "category_id", "must be a positive integer") + } + v.Check(len(species.Notes) <= 10_000, "notes", "must not be more than 10000 bytes long") + v.Check(len(species.Attributes) == 0 || json.Valid(species.Attributes), "attributes", "must be valid JSON") + ValidateTags(v, species.Tags) + validateOptionalEnum(v, "sun_exposure", species.SunExposure, "sunny", "partial_shade", "shade") + validateOptionalEnum(v, "soil_condition", species.SoilCondition, "dry", "moist", "boggy") + validateOptionalEnum(v, "soil_reaction", species.SoilReaction, "alkaline", "acidic", "neutral") + validateOptionalPositive(v, "spacing_cm", species.SpacingCM) + validateOptionalPositive(v, "height_cm", species.HeightCM) + validateOptionalCalendarPart(v, "sow_month_from", species.SowMonthFrom, 12) + validateOptionalCalendarPart(v, "sow_day_from", species.SowDayFrom, 31) + validateOptionalCalendarPart(v, "sow_month_to", species.SowMonthTo, 12) + validateOptionalCalendarPart(v, "sow_day_to", species.SowDayTo, 31) + validateOptionalCalendarPart(v, "planting_month_from", species.PlantingMonthFrom, 12) + validateOptionalCalendarPart(v, "planting_day_from", species.PlantingDayFrom, 31) + validateOptionalCalendarPart(v, "planting_month_to", species.PlantingMonthTo, 12) + validateOptionalCalendarPart(v, "planting_day_to", species.PlantingDayTo, 31) + validateOptionalCalendarPart(v, "harvest_month_from", species.HarvestMonthFrom, 12) + validateOptionalCalendarPart(v, "harvest_day_from", species.HarvestDayFrom, 31) + validateOptionalCalendarPart(v, "harvest_month_to", species.HarvestMonthTo, 12) + validateOptionalCalendarPart(v, "harvest_day_to", species.HarvestDayTo, 31) +} + +func validateOptionalEnum(v *validate.Validator, field string, value *string, allowed ...string) { + if value != nil { + v.Check(validate.PermittedValue(*value, allowed...), field, "is invalid") + } +} + +func validateOptionalPositive(v *validate.Validator, field string, value *int) { + if value != nil { + v.Check(*value > 0, field, "must be greater than zero") + } +} + +func validateOptionalCalendarPart(v *validate.Validator, field string, value *int, maximum int) { + if value != nil { + v.Check(*value >= 1 && *value <= maximum, field, "is outside the valid range") + } +} diff --git a/internal/storage/species_categories.go b/internal/storage/species_categories.go new file mode 100644 index 0000000..de6b3fb --- /dev/null +++ b/internal/storage/species_categories.go @@ -0,0 +1,35 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// SpeciesCategoryModelInterface persists the configurable species taxonomy. +type SpeciesCategoryModelInterface interface { + Insert(category SpeciesCategory) (SpeciesCategory, error) + Get(id int) (SpeciesCategory, error) + GetAll() ([]SpeciesCategory, error) + Update(category SpeciesCategory) (SpeciesCategory, error) +} + +// SpeciesCategory is a globally managed option for classifying species. +type SpeciesCategory struct { + ID int `json:"id"` + Name string `json:"name"` + SortOrder int `json:"sort_order"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Lifecycle *string `json:"lifecycle,omitempty"` +} + +// ValidateSpeciesCategory applies validation rules independent of persistence. +func ValidateSpeciesCategory(v *validate.Validator, category SpeciesCategory) { + v.Check(strings.TrimSpace(category.Name) != "", "name", "must be provided") + v.Check(len(category.Name) <= 200, "name", "must not be more than 200 bytes long") + validateOptionalEnum(v, "lifecycle", category.Lifecycle, "annual", "biennial", "perennial") +} diff --git a/internal/storage/species_task_templates.go b/internal/storage/species_task_templates.go new file mode 100644 index 0000000..e8524c3 --- /dev/null +++ b/internal/storage/species_task_templates.go @@ -0,0 +1,117 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskTriggerType identifies how a species task template calculates its due window. +type TaskTriggerType string + +// TaskDurationUnit is the calendar unit used for offsets and durations. +type TaskDurationUnit string + +// TaskTemplateOrigin identifies whether a template was entered manually or derived from species data. +type TaskTemplateOrigin string + +// Supported task duration units. +const ( + TaskDurationDay TaskDurationUnit = "day" + TaskDurationWeek TaskDurationUnit = "week" + TaskDurationMonth TaskDurationUnit = "month" +) + +// Supported task trigger types. +const ( + // TaskTriggerMonthOfYear repeats within a calendar-year date window. + TaskTriggerMonthOfYear TaskTriggerType = "month_of_year" + // TaskTriggerRelativeToPlanting is offset from the planting date. + TaskTriggerRelativeToPlanting TaskTriggerType = "relative_to_planting" + // TaskTriggerRelativeToLastTask is offset from the last completion. + TaskTriggerRelativeToLastTask TaskTriggerType = "relative_to_last_task" + TaskTriggerRelativeToSowing TaskTriggerType = "relative_to_sowing" + TaskTriggerRelativeToHarvest TaskTriggerType = "relative_to_harvest" + TaskTriggerRelativeToSpeciesPlanting TaskTriggerType = "relative_to_species_planting" +) + +// Supported template origins. +const ( + TaskTemplateOriginManual TaskTemplateOrigin = "manual" + TaskTemplateOriginSeasonSowing TaskTemplateOrigin = "season_sowing" + TaskTemplateOriginSeasonPlanting TaskTemplateOrigin = "season_planting" + TaskTemplateOriginSeasonHarvest TaskTemplateOrigin = "season_harvest" +) + +// SpeciesTaskTemplateModelInterface persists recurring rules for species tasks. +type SpeciesTaskTemplateModelInterface interface { + Insert(template SpeciesTaskTemplate) (SpeciesTaskTemplate, error) + Get(gardenID, id int) (SpeciesTaskTemplate, error) + GetAllForSpecies(gardenID, speciesID int) ([]SpeciesTaskTemplate, error) + Update(gardenID int, template SpeciesTaskTemplate) (SpeciesTaskTemplate, error) + Delete(gardenID, id int) error +} + +// ValidateSpeciesTaskTemplate applies scheduling and recurrence rules to a +// species task template. +func ValidateSpeciesTaskTemplate(v *validate.Validator, template SpeciesTaskTemplate) { + v.Check(strings.TrimSpace(template.Title) != "", "title", "must be provided") + v.Check(len(template.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(template.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(template.Priority >= -100 && template.Priority <= 100, "priority", "must be between -100 and 100") + v.Check(template.TriggerType == TaskTriggerMonthOfYear || template.TriggerType == TaskTriggerRelativeToPlanting || template.TriggerType == TaskTriggerRelativeToLastTask || template.TriggerType == TaskTriggerRelativeToSowing || template.TriggerType == TaskTriggerRelativeToHarvest || template.TriggerType == TaskTriggerRelativeToSpeciesPlanting, "trigger_type", "is invalid") + v.Check(template.Origin == TaskTemplateOriginManual || template.Origin == TaskTemplateOriginSeasonSowing || template.Origin == TaskTemplateOriginSeasonPlanting || template.Origin == TaskTemplateOriginSeasonHarvest, "origin", "is invalid") + if template.TriggerType == TaskTriggerMonthOfYear { + v.Check(template.MonthFrom != nil && *template.MonthFrom >= 1 && *template.MonthFrom <= 12, "month_from", "must be between 1 and 12") + validateTemplateDay(v, "day_from", template.DayFrom) + v.Check(template.DayFrom != nil, "day_from", "must be provided") + } else { + v.Check(template.TriggerOffset >= 0, "trigger_offset", "must not be negative") + } + v.Check(template.Duration >= 0, "duration", "must not be negative") + v.Check(validTaskDurationUnit(template.DurationUnit), "duration_unit", "is invalid") + v.Check(validTaskDurationUnit(template.TriggerOffsetUnit), "trigger_offset_unit", "is invalid") + v.Check(template.Recurrence == TaskRecurrenceNone || template.Recurrence == TaskRecurrenceDaily || template.Recurrence == TaskRecurrenceWeekly || template.Recurrence == TaskRecurrenceMonthly || template.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid") + if template.Recurrence != TaskRecurrenceNone { + v.Check(template.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero") + } +} + +func validTaskDurationUnit(unit TaskDurationUnit) bool { + return unit == TaskDurationDay || unit == TaskDurationWeek || unit == TaskDurationMonth +} + +func validateTemplateDay(v *validate.Validator, field string, value *int) { + if value != nil { + v.Check(*value >= 1 && *value <= 31, field, "must be between 1 and 31") + } +} + +// SpeciesTaskTemplate defines a recurring task rule for a species. +type SpeciesTaskTemplate struct { + ID int `json:"id"` + SpeciesID int `json:"species_id"` + Origin TaskTemplateOrigin `json:"origin"` + Title string `json:"title"` + Description string `json:"description"` + TriggerType TaskTriggerType `json:"trigger_type"` + MonthFrom *int `json:"month_from,omitempty"` + DayFrom *int `json:"day_from,omitempty"` + MonthTo *int `json:"month_to,omitempty"` + DayTo *int `json:"day_to,omitempty"` + OffsetDaysFrom *int `json:"offset_days_from,omitempty"` + OffsetDaysTo *int `json:"offset_days_to,omitempty"` + IntervalDays *int `json:"interval_days,omitempty"` + TriggerOffset int `json:"trigger_offset"` + TriggerOffsetUnit TaskDurationUnit `json:"trigger_offset_unit"` + Duration int `json:"duration"` + DurationUnit TaskDurationUnit `json:"duration_unit"` + Recurrence TaskRecurrence `json:"recurrence,omitempty"` + RecurrenceInterval int `json:"recurrence_interval"` + Priority int `json:"priority"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} diff --git a/internal/storage/tags.go b/internal/storage/tags.go new file mode 100644 index 0000000..b6de087 --- /dev/null +++ b/internal/storage/tags.go @@ -0,0 +1,50 @@ +package storage + +import ( + "sort" + "strings" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TagEntity identifies a resource type that can be tagged. +type TagEntity string + +// Supported taggable entity types. +const ( + TagEntityTask TagEntity = "task" + TagEntityPlant TagEntity = "plant" + TagEntitySpecies TagEntity = "species" + TagEntityJournal TagEntity = "journal_entry" +) + +// TagModelInterface lists tags and atomically replaces an entity's tag set. +type TagModelInterface interface { + Get(gardenID int, entity TagEntity, entityID int) ([]string, error) + Set(gardenID int, entity TagEntity, entityID int, tags []string) ([]string, error) + GetAllForGarden(gardenID int) ([]string, error) +} + +// NormalizeTags trims, removes empty values, and deduplicates tags while +// preserving their first occurrence. +func NormalizeTags(values []string) []string { + seen := map[string]bool{} + result := []string{} + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value != "" && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + sort.Strings(result) + return result +} + +// ValidateTags applies count and length limits to a normalized tag list. +func ValidateTags(v *validate.Validator, tags []string) { + v.Check(len(tags) <= 20, "tags", "must contain at most 20 tags") + for _, tag := range tags { + v.Check(len(tag) <= 50, "tags", "each tag must contain at most 50 bytes") + } +} diff --git a/internal/storage/task_priorities.go b/internal/storage/task_priorities.go new file mode 100644 index 0000000..3c405d5 --- /dev/null +++ b/internal/storage/task_priorities.go @@ -0,0 +1,35 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskPriorityModelInterface manages the application-wide priority catalogue. +type TaskPriorityModelInterface interface { + Insert(priority TaskPriority) (TaskPriority, error) + Get(id int) (TaskPriority, error) + GetAll() ([]TaskPriority, error) + Update(priority TaskPriority) (TaskPriority, error) +} + +// TaskPriority maps a user-facing label to the numeric value stored on tasks. +type TaskPriority struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + SortOrder int `json:"sort_order"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` +} + +// ValidateTaskPriority applies catalogue-entry validation rules. +func ValidateTaskPriority(v *validate.Validator, priority TaskPriority) { + v.Check(strings.TrimSpace(priority.Name) != "", "name", "must be provided") + v.Check(len(priority.Name) <= 100, "name", "must not be more than 100 bytes long") + v.Check(priority.Value >= -100 && priority.Value <= 100, "value", "must be between -100 and 100") +} diff --git a/internal/storage/task_template_opt_outs.go b/internal/storage/task_template_opt_outs.go new file mode 100644 index 0000000..26fdb25 --- /dev/null +++ b/internal/storage/task_template_opt_outs.go @@ -0,0 +1,9 @@ +package storage + +// TaskTemplateOptOutModelInterface records per-plant suppression of automatic +// task generation from a species template. +type TaskTemplateOptOutModelInterface interface { + IsOptedOut(gardenID, plantID, templateID int) (bool, error) + GetAllForPlant(gardenID, plantID int) ([]int, error) + Set(gardenID, plantID, templateID int, optedOut bool) error +} diff --git a/internal/storage/tasks.go b/internal/storage/tasks.go new file mode 100644 index 0000000..67d71c2 --- /dev/null +++ b/internal/storage/tasks.go @@ -0,0 +1,88 @@ +package storage + +import ( + "strings" + "time" + + "gardomatic.kleiax.de/internal/platform/validate" +) + +// TaskRecurrence identifies the calendar interval of a manual task. +type TaskRecurrence string + +// Supported task recurrence values. An empty value disables recurrence. +const ( + TaskRecurrenceNone TaskRecurrence = "" + TaskRecurrenceDaily TaskRecurrence = "daily" + TaskRecurrenceWeekly TaskRecurrence = "weekly" + TaskRecurrenceMonthly TaskRecurrence = "monthly" + TaskRecurrenceYearly TaskRecurrence = "yearly" +) + +// TaskModelInterface persists garden work items and generated task instances. +type TaskModelInterface interface { + Insert(task Task) (Task, error) + Get(gardenID, id int) (Task, error) + GetAllForGarden(gardenID int) ([]Task, error) + Update(gardenID int, task Task) (Task, error) + Delete(gardenID, id int) error +} + +// ValidateTask applies persistence-independent rules for manual garden tasks. +func ValidateTask(v *validate.Validator, task Task) { + v.Check(strings.TrimSpace(task.Title) != "", "title", "must be provided") + v.Check(len(task.Title) <= 500, "title", "must not be more than 500 bytes long") + v.Check(len(task.Description) <= 10_000, "description", "must not be more than 10000 bytes long") + v.Check(task.Priority >= -100 && task.Priority <= 100, "priority", "must be between -100 and 100") + if task.PlantID != nil { + v.Check(*task.PlantID > 0, "plant_id", "must be a positive integer") + } + if task.LocationID != nil { + v.Check(*task.LocationID > 0, "location_id", "must be a positive integer") + } + if task.DueAtStart != nil && task.DueAtEnd != nil { + v.Check(!task.DueAtStart.After(*task.DueAtEnd), "due_at_end", "must not be before due_at_start") + } + v.Check(task.Recurrence == TaskRecurrenceNone || task.Recurrence == TaskRecurrenceDaily || task.Recurrence == TaskRecurrenceWeekly || task.Recurrence == TaskRecurrenceMonthly || task.Recurrence == TaskRecurrenceYearly, "recurrence", "is invalid") + if task.Recurrence != TaskRecurrenceNone { + v.Check(task.DueAtStart != nil || task.DueAtEnd != nil, "recurrence", "requires a due date") + v.Check(task.RecurrenceInterval > 0, "recurrence_interval", "must be greater than zero") + } + validateOptionalEnum(v, "plant_status_on_completion", task.PlantStatusOnCompletion, "alive", "dead", "removed", "infested", "harvested") + if task.PlantStatusOnCompletion != nil { + v.Check(task.PlantID != nil, "plant_status_on_completion", "requires a plant") + } + if task.CompletedAt != nil { + v.Check(task.CompletedBy != nil && *task.CompletedBy > 0, "completed_by", "must be set for a completed task") + } + if task.CompletedBy != nil { + v.Check(task.CompletedAt != nil, "completed_at", "must be set when completed_by is set") + } +} + +// Task represents a manual or template-generated garden work item. +type Task struct { + ID int `json:"id"` + GardenID int `json:"garden_id"` + PlantID *int `json:"plant_id,omitempty"` + LocationID *int `json:"location_id,omitempty"` + TemplateID *int `json:"template_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + DueAtStart *time.Time `json:"due_at_start,omitempty"` + DueAtEnd *time.Time `json:"due_at_end,omitempty"` + GeneratedFor *time.Time `json:"generated_for,omitempty"` + Recurrence TaskRecurrence `json:"recurrence,omitempty"` + RecurrenceInterval int `json:"recurrence_interval"` + RepeatFromID *int `json:"repeat_from_id,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CompletedBy *int `json:"completed_by,omitempty"` + Priority int `json:"priority"` + Active bool `json:"active"` + CreatedBy int `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Version int `json:"version"` + Tags []string `json:"tags,omitempty"` + PlantStatusOnCompletion *string `json:"plant_status_on_completion,omitempty"` +} diff --git a/internal/storage/tokens.go b/internal/storage/tokens.go new file mode 100644 index 0000000..eb65dc8 --- /dev/null +++ b/internal/storage/tokens.go @@ -0,0 +1,13 @@ +package storage + +import ( + "time" + + "gardomatic.kleiax.de/internal/auth" +) + +// TokenModelInterface persists scoped, expiring authentication token hashes. +type TokenModelInterface interface { + New(userID int, ttl time.Duration, scope string) (auth.Token, error) + DeleteAllForUser(scope string, userID int) error +} diff --git a/internal/storage/users.go b/internal/storage/users.go new file mode 100644 index 0000000..294d7dd --- /dev/null +++ b/internal/storage/users.go @@ -0,0 +1,141 @@ +package storage + +import ( + "time" + + "gardomatic.kleiax.de/internal/auth" + "gardomatic.kleiax.de/internal/platform/validate" +) + +// UserModelInterface persists accounts and resolves token ownership. +type UserModelInterface interface { + Insert(user User) (User, error) + GetByID(id int) (User, error) + GetByEmail(email string) (User, error) + Update(user User) (User, error) + GetForToken(tokenScope, tokenPlaintext string) (User, error) + CreateEmailChange(userID int, email string, ttl time.Duration) (string, error) + ConfirmEmailChange(tokenPlaintext string, userID int) (User, error) + GetAll() ([]User, error) + UpdateRole(userID int, role ApplicationRole) (User, error) + Delete(userID int) error +} + +// ApplicationRole groups garden-independent permissions for an account. +type ApplicationRole string + +// ApplicationPermission identifies one garden-independent capability. +type ApplicationPermission string + +// Built-in application roles and permissions. +const ( + ApplicationRoleUser ApplicationRole = "application:user" + ApplicationRoleAdmin ApplicationRole = "application:admin" + + ApplicationPermissionGlobalSpeciesWrite ApplicationPermission = "global_species:write" + ApplicationPermissionUsersManage ApplicationPermission = "users:manage" + ApplicationPermissionSettingsWrite ApplicationPermission = "application_settings:write" + ApplicationPermissionRolesManage ApplicationPermission = "roles:manage" + ApplicationPermissionGardensCreate ApplicationPermission = "gardens:create" +) + +// Valid reports whether role is assignable to an account. +func (role ApplicationRole) Valid() bool { + return role == ApplicationRoleUser || role == ApplicationRoleAdmin +} + +// Can reports whether an application role grants a permission. +func (role ApplicationRole) Can(permission ApplicationPermission) bool { + if permission == ApplicationPermissionGardensCreate { + return role == ApplicationRoleUser || role == ApplicationRoleAdmin + } + return role == ApplicationRoleAdmin && (permission == ApplicationPermissionGlobalSpeciesWrite || + permission == ApplicationPermissionUsersManage || + permission == ApplicationPermissionSettingsWrite || + permission == ApplicationPermissionRolesManage) +} + +// Permissions returns the capabilities bundled into the role. +func (role ApplicationRole) Permissions() []ApplicationPermission { + permissions := []ApplicationPermission{} + for _, permission := range []ApplicationPermission{ + ApplicationPermissionGlobalSpeciesWrite, + ApplicationPermissionUsersManage, + ApplicationPermissionSettingsWrite, + ApplicationPermissionRolesManage, + ApplicationPermissionGardensCreate, + } { + if role.Can(permission) { + permissions = append(permissions, permission) + } + } + return permissions +} + +// AllApplicationPermissions returns a copy of the supported application-level +// permissions. +func AllApplicationPermissions() []ApplicationPermission { + return []ApplicationPermission{ + ApplicationPermissionGlobalSpeciesWrite, + ApplicationPermissionUsersManage, + ApplicationPermissionSettingsWrite, + ApplicationPermissionRolesManage, + ApplicationPermissionGardensCreate, + } +} + +// ValidApplicationPermission reports whether permission is application-scoped +// or the global wildcard. +func ValidApplicationPermission(permission string) bool { + for _, candidate := range AllApplicationPermissions() { + if string(candidate) == permission { + return true + } + } + return permission == "*" +} + +// User is an account that can own or join gardens. +type User struct { + ID int `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Email string `json:"email"` + Color string `json:"color"` + Password auth.Password `json:"-"` + Activated bool `json:"activated"` + Role ApplicationRole `json:"role"` + Permissions []ApplicationPermission `json:"permissions"` + Version int `json:"-"` +} + +// Can reports whether the user's resolved permissions grant permission. It +// falls back to the built-in role only when no resolved list is present. +func (user User) Can(permission ApplicationPermission) bool { + if user.Permissions != nil { + for _, granted := range user.Permissions { + if granted == permission || granted == "*" { + return true + } + } + return false + } + return user.Role.Can(permission) +} + +// ValidateEmail applies the accepted email-address rules. +func ValidateEmail(v *validate.Validator, email string) { + v.Check(email != "", "email", "must be provided") + v.Check(validate.Matches(email, validate.EmailRX), "email", "must be a valid email address") +} + +// ValidateUser applies account and password validation rules. +func ValidateUser(v *validate.Validator, user User) { + v.Check(user.Name != "", "name", "must be provided") + v.Check(len(user.Name) <= 500, "name", "must not be more than 500 bytes long") + + ValidateEmail(v, user.Email) + v.Check(user.Color == "" || validate.Matches(user.Color, validate.ColorRX), "color", "must be a valid hex color") + user.Password.Validate(v) +} diff --git a/internal/vcs/doc.go b/internal/vcs/doc.go new file mode 100644 index 0000000..c35dd83 --- /dev/null +++ b/internal/vcs/doc.go @@ -0,0 +1,2 @@ +// Package vcs derives build version information from Go build metadata. +package vcs diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..437f3bd --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,15 @@ +package vcs + +import ( + "runtime/debug" +) + +// Version returns the main module version from Go build metadata. +func Version() string { + bi, ok := debug.ReadBuildInfo() + if ok { + return bi.Main.Version + } + + return "" +} diff --git a/internal/web/account.go b/internal/web/account.go new file mode 100644 index 0000000..c68d8c5 --- /dev/null +++ b/internal/web/account.go @@ -0,0 +1,165 @@ +package web + +import ( + "errors" + "net/http" + "strings" + + "gardomatic.kleiax.de/lib/client" + "github.com/julienschmidt/httprouter" +) + +type accountForm struct { + CSRFToken string `form:"csrf_token"` + Name string `form:"name"` + Color string `form:"color"` + Email string `form:"email"` + CurrentPassword string `form:"current_password"` + NewPassword string `form:"new_password"` + NewPasswordConfirmation string `form:"new_password_confirmation"` + Errors map[string]string + Message string +} + +func (app *application) account(w http.ResponseWriter, r *http.Request) { + user, _ := userFromContext(r.Context()) + app.renderAccount(w, r, accountForm{Name: user.Name, Color: user.Color, Email: user.Email, Errors: map[string]string{}}, http.StatusOK) +} + +func (app *application) accountSessionDeletePost(w http.ResponseWriter, r *http.Request) { + id := httprouter.ParamsFromContext(r.Context()).ByName("sessionID") + response, err := client.FromContext(r.Context()).DeleteAccountSession(r.Context(), id) + if err != nil { + app.handleAPIError(w, r, err) + return + } + client.ForwardCookies(w, response) + http.Redirect(w, r, webPath("account")+gardenQuerySuffix(r), http.StatusSeeOther) +} +func (app *application) accountProfilePost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Name = strings.TrimSpace(form.Name) + form.Color = strings.TrimSpace(form.Color) + form.Errors = map[string]string{} + if form.Name == "" { + form.Errors["name"] = "Ein Name ist erforderlich." + } + if len(form.Errors) == 0 { + user, _, err := client.FromContext(r.Context()).UpdateAccountProfile(r.Context(), form.Name, form.Color) + if err == nil { + form.Name, form.Color, form.Email, form.Message = user.Name, user.Color, user.Email, "Profil gespeichert." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountPasswordPost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + user, _ := userFromContext(r.Context()) + form.Name, form.Color, form.Email, form.Errors = user.Name, user.Color, user.Email, map[string]string{} + if form.NewPassword != form.NewPasswordConfirmation { + form.Errors["new_password_confirmation"] = "Die Passwörter stimmen nicht überein." + } + if len(form.NewPassword) < 8 { + form.Errors["new_password"] = "Mindestens 8 Zeichen erforderlich." + } + if len(form.Errors) == 0 { + _, err := client.FromContext(r.Context()).UpdateAccountPassword(r.Context(), form.CurrentPassword, form.NewPassword) + if err == nil { + form.CurrentPassword, form.NewPassword, form.NewPasswordConfirmation = "", "", "" + form.Message = "Passwort geändert." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountEmailPost(w http.ResponseWriter, r *http.Request) { + var form accountForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + user, _ := userFromContext(r.Context()) + form.Name, form.Color, form.Errors = user.Name, user.Color, map[string]string{} + form.Email = strings.TrimSpace(form.Email) + if form.Email == "" { + form.Errors["email"] = "Eine E-Mail-Adresse ist erforderlich." + } + if len(form.Errors) == 0 { + _, err := client.FromContext(r.Context()).RequestAccountEmailChange(r.Context(), form.Email, form.CurrentPassword) + if err == nil { + form.CurrentPassword = "" + form.Message = "Bestätigungslink wurde an die neue Adresse gesendet." + app.renderAccount(w, r, form, http.StatusOK) + return + } + app.copyAccountError(&form, err) + if len(form.Errors) == 0 { + app.handleAPIError(w, r, err) + return + } + } + app.renderAccount(w, r, form, http.StatusUnprocessableEntity) +} +func (app *application) accountEmailConfirm(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + data.Form = acceptInviteForm{Token: r.URL.Query().Get("token")} + app.render(w, http.StatusOK, "email_confirm.tmpl", data) +} +func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.Request) { + var form acceptInviteForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, _, err := client.FromContext(r.Context()).ConfirmAccountEmail(r.Context(), form.Token); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, webPath("account"), http.StatusSeeOther) +} +func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) { + data := app.newTemplateData(r) + data.Form = form + if !app.loadOptionalGarden(w, r, data) { + return + } + sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + data.AccountSessions = sessions + app.render(w, status, "account.tmpl", data) +} +func (app *application) copyAccountError(form *accountForm, err error) { + var apiError *client.APIError + if errors.As(err, &apiError) { + if apiError.StatusCode == http.StatusUnprocessableEntity { + form.Errors = apiError.Validation + } + if apiError.StatusCode == http.StatusUnauthorized { + form.Errors["current_password"] = "Das aktuelle Passwort ist falsch." + } + } +} diff --git a/internal/web/admin.go b/internal/web/admin.go new file mode 100644 index 0000000..c2db761 --- /dev/null +++ b/internal/web/admin.go @@ -0,0 +1,416 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "gardomatic.kleiax.de/lib/client" +) + +type adminRoleForm struct { + Role string `form:"role"` +} + +type adminUserInviteForm struct { + Name string `form:"name"` + Email string `form:"email"` +} + +type roleSettingsForm struct { + Name string `form:"name"` + Scope string `form:"scope"` + Label string `form:"label"` + Permissions []string `form:"permissions"` +} + +func applicationPermissionOptions() []permissionOption { + return []permissionOption{{"gardens:create", "Gärten anlegen"}, {"global_species:write", "Globale Pflanzen verwalten"}, {"users:manage", "Nutzerrollen verwalten"}, {"application_settings:write", "Instanzeinstellungen verwalten"}, {"roles:manage", "Rollen verwalten"}} +} + +func gardenPermissionOptions() []permissionOption { + return []permissionOption{ + {"garden:read", "Garten sehen"}, {"garden:update", "Garten bearbeiten"}, {"garden:delete", "Garten löschen"}, {"members:write", "Mitglieder verwalten"}, {"species:write", "Pflanzenarten verwalten"}, {"content:write", "Tagebuch und Zuordnungen bearbeiten"}, + {"plants:create", "Pflanzen anlegen"}, {"plants:read:own", "Eigene Pflanzen sehen"}, {"plants:read:other", "Andere Pflanzen sehen"}, {"plants:update:own", "Eigene Pflanzen bearbeiten"}, {"plants:update:other", "Andere Pflanzen bearbeiten"}, {"plants:delete:own", "Eigene Pflanzen löschen"}, {"plants:delete:other", "Andere Pflanzen löschen"}, + {"locations:create", "Orte anlegen"}, {"locations:read:own", "Eigene Orte sehen"}, {"locations:read:other", "Andere Orte sehen"}, {"locations:update:own", "Eigene Orte bearbeiten"}, {"locations:update:other", "Andere Orte bearbeiten"}, {"locations:delete:own", "Eigene Orte löschen"}, {"locations:delete:other", "Andere Orte löschen"}, + {"tasks:create", "Aufgaben anlegen"}, {"tasks:read:own", "Eigene Aufgaben sehen"}, {"tasks:read:other", "Andere Aufgaben sehen"}, {"tasks:update:own", "Eigene Aufgaben bearbeiten"}, {"tasks:update:other", "Andere Aufgaben bearbeiten"}, {"tasks:delete:own", "Eigene Aufgaben löschen"}, {"tasks:delete:other", "Andere Aufgaben löschen"}, {"tasks:complete:own", "Eigene Aufgaben erledigen"}, {"tasks:complete:other", "Andere Aufgaben erledigen"}, + } +} + +type adminSpeciesCategoryForm struct { + Name string `form:"name"` + SortOrder int `form:"sort_order"` + Active bool `form:"active"` + Lifecycle string `form:"lifecycle"` +} + +type adminTaskPriorityForm struct { + Name string `form:"name"` + Value int `form:"value"` + SortOrder int `form:"sort_order"` + Active bool `form:"active"` +} + +type adminApplicationSettingsForm struct { + LifecycleStatusEnabled bool `form:"lifecycle_status_enabled"` + LifecycleRemovalMonth int `form:"lifecycle_removal_month"` + LifecycleRemovalDay int `form:"lifecycle_removal_day"` + Timezone string `form:"timezone"` +} + +type adminTestMailForm struct { + Email string `form:"email"` +} + +func (app *application) requireAdmin(next http.Handler) http.Handler { + return app.requireActivatedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok || !user.IsAdmin() { + app.clientError(w, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + })) +} + +func (app *application) requireApplicationPermission(permission string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok || !user.Can(permission) { + app.clientError(w, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +func (app *application) admin(w http.ResponseWriter, r *http.Request) { + apiClient := client.FromContext(r.Context()) + users, _, err := apiClient.AdminUsers(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + categories, _, err := client.FromContext(r.Context()).AdminSpeciesCategories(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + priorities, _, err := client.FromContext(r.Context()).AdminTaskPriorities(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + settings, _, err := client.FromContext(r.Context()).AdminApplicationSettings(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + environmentVariables, _, err := apiClient.AdminEnvironment(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + applicationRoles, gardenRoles, _, err := apiClient.AdminRoles(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + data := app.newTemplateData(r) + if !app.loadOptionalGarden(w, r, data) { + return + } + data.AdminUsers, data.SpeciesCategories, data.TaskPriorities = users, categories, priorities + data.ApplicationRoles, data.GardenRoles = applicationRoles, gardenRoles + data.ApplicationPermissions, data.GardenPermissions = applicationPermissionOptions(), gardenPermissionOptions() + data.RoleEditors = []roleEditorData{ + globalRoleEditor("roles-settings", "Instanzrollen", "Diese Rollen gelten für die gesamte Serverinstanz.", "application", applicationRoles, data.ApplicationPermissions, data.CSRFToken), + globalRoleEditor("garden-role-templates", "Gartenrollen", "Diese Vorlagen gelten in allen Gärten und können je Garten überschrieben werden.", "garden", gardenRoles, data.GardenPermissions, data.CSRFToken), + } + for i := range data.RoleEditors { + data.RoleEditors[i].Garden = data.Garden + } + data.ApplicationSettings = &settings + data.EnvironmentVariables = append(environmentVariables, app.webEnvironmentVariables()...) + if r.URL.Query().Get("test-mail") == "sent" { + data.Flash = "Die Testmail wurde versendet." + } + if r.URL.Query().Get("test-mail") == "invalid" { + data.Flash = "Bitte gib eine gültige Empfängeradresse für die Testmail ein." + } + if r.URL.Query().Get("invitation") == "sent" { + data.Flash = "Die Einladung wurde versendet." + } + if r.URL.Query().Get("invitation") == "duplicate" { + data.Flash = "Für diese E-Mail-Adresse existiert bereits ein aktiver Account." + } + if r.URL.Query().Get("invitation") == "invalid" { + data.Flash = "Die Einladung ist ungültig. Bitte prüfe Name und E-Mail-Adresse." + } + if r.URL.Query().Get("deletion") == "done" { + data.Flash = "Der Nutzer wurde gelöscht und seine Kontodaten wurden anonymisiert." + } + if r.URL.Query().Get("deletion") == "owner" { + data.Flash = "Der Nutzer besitzt noch mindestens einen Garten. Übertrage zuerst das Eigentum." + } + app.render(w, http.StatusOK, "admin.tmpl", data) +} + +func (app *application) adminUserInvitePost(w http.ResponseWriter, r *http.Request) { + var form adminUserInviteForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + input := client.AdminUserInviteInput{Name: strings.TrimSpace(form.Name), Email: strings.TrimSpace(form.Email)} + if _, _, err := client.FromContext(r.Context()).InviteAdminUser(r.Context(), input); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + status := "invalid" + if apiError.Validation["email"] == "a user with this email address already exists" { + status = "duplicate" + } + http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", status), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#users-settings", "invitation", "sent"), http.StatusSeeOther) +} + +func (app *application) adminUserDeletePost(w http.ResponseWriter, r *http.Request) { + userID, err := app.readPathID(r, "userID") + if err != nil { + app.notFound(w) + return + } + if _, err = client.FromContext(r.Context()).DeleteAdminUser(r.Context(), userID); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusConflict { + http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "owner"), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#users-settings", "deletion", "done"), http.StatusSeeOther) +} + +func (app *application) webEnvironmentVariables() []client.EnvironmentVariable { + return []client.EnvironmentVariable{ + {Component: "Web", Name: "GARDOMATIC_ENV", Value: app.config.Env}, + {Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host}, + {Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)}, + {Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL}, + {Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName}, + {Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)}, + } +} + +func (app *application) adminRoleCreatePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + _, _, err := client.FromContext(r.Context()).CreateAdminRole(r.Context(), client.RoleInput{Name: strings.TrimSpace(form.Name), Scope: form.Scope, Label: strings.TrimSpace(form.Label), Permissions: form.Permissions}) + if err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func (app *application) adminRoleUpdatePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + _, _, err := client.FromContext(r.Context()).UpdateAdminRole(r.Context(), form.Name, client.RoleInput{Label: strings.TrimSpace(form.Label), Permissions: form.Permissions}) + if err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func (app *application) adminRoleDeletePost(w http.ResponseWriter, r *http.Request) { + var form roleSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminRole(r.Context(), form.Name); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, adminRoleEditorAnchor(form.Scope)), http.StatusSeeOther) +} + +func adminRoleEditorAnchor(scope string) string { + if scope == "garden" { + return "#garden-role-templates" + } + return "#roles-settings" +} + +func (app *application) adminApplicationSettingsPost(w http.ResponseWriter, r *http.Request) { + var form adminApplicationSettingsForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + timezone := strings.TrimSpace(form.Timezone) + input := client.ApplicationSettingsInput{ + LifecycleStatusEnabled: &form.LifecycleStatusEnabled, + LifecycleRemovalMonth: &form.LifecycleRemovalMonth, + LifecycleRemovalDay: &form.LifecycleRemovalDay, + Timezone: &timezone, + } + if _, _, err := client.FromContext(r.Context()).UpdateAdminApplicationSettings(r.Context(), input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#lifecycle-settings"), http.StatusSeeOther) +} + +func (app *application) adminTestMailPost(w http.ResponseWriter, r *http.Request) { + var form adminTestMailForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, err := client.FromContext(r.Context()).SendAdminTestMail(r.Context(), strings.TrimSpace(form.Email)); err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "invalid"), http.StatusSeeOther) + return + } + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#mail-settings", "test-mail", "sent"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityCreatePost(w http.ResponseWriter, r *http.Request) { + var form adminTaskPriorityForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name, active := strings.TrimSpace(form.Name), true + if _, _, err := client.FromContext(r.Context()).CreateAdminTaskPriority(r.Context(), client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &active}); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityUpdatePost(w http.ResponseWriter, r *http.Request) { + id, err := app.readPathID(r, "priorityID") + if err != nil { + app.notFound(w) + return + } + var form adminTaskPriorityForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name := strings.TrimSpace(form.Name) + if _, _, err := client.FromContext(r.Context()).UpdateAdminTaskPriority(r.Context(), id, client.TaskPriorityInput{Name: &name, Value: &form.Value, SortOrder: &form.SortOrder, Active: &form.Active}); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminTaskPriorityDeletePost(w http.ResponseWriter, r *http.Request) { + id, err := app.readPathID(r, "priorityID") + if err != nil { + app.notFound(w) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminTaskPriority(r.Context(), id); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#task-priorities"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryCreatePost(w http.ResponseWriter, r *http.Request) { + var form adminSpeciesCategoryForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name, active := strings.TrimSpace(form.Name), true + input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &active, Lifecycle: &form.Lifecycle} + if _, _, err := client.FromContext(r.Context()).CreateAdminSpeciesCategory(r.Context(), input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryUpdatePost(w http.ResponseWriter, r *http.Request) { + categoryID, err := app.readPathID(r, "categoryID") + if err != nil { + app.notFound(w) + return + } + var form adminSpeciesCategoryForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + name := strings.TrimSpace(form.Name) + input := client.SpeciesCategoryInput{Name: &name, SortOrder: &form.SortOrder, Active: &form.Active, Lifecycle: &form.Lifecycle} + if _, _, err := client.FromContext(r.Context()).UpdateAdminSpeciesCategory(r.Context(), categoryID, input); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminSpeciesCategoryDeletePost(w http.ResponseWriter, r *http.Request) { + categoryID, err := app.readPathID(r, "categoryID") + if err != nil { + app.notFound(w) + return + } + if _, err := client.FromContext(r.Context()).DeleteAdminSpeciesCategory(r.Context(), categoryID); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, "#species-categories"), http.StatusSeeOther) +} + +func (app *application) adminUserRolePost(w http.ResponseWriter, r *http.Request) { + userID, err := app.readPathID(r, "userID") + if err != nil { + app.notFound(w) + return + } + var form adminRoleForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + if _, _, err := client.FromContext(r.Context()).UpdateAdminUserRole(r.Context(), userID, form.Role); err != nil { + app.handleAPIError(w, r, err) + return + } + http.Redirect(w, r, adminPath(r, ""), http.StatusSeeOther) +} + +func (app *application) settings(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r) + if !app.loadOptionalGarden(w, r, data) { + return + } + app.render(w, http.StatusOK, "settings.tmpl", data) +} diff --git a/internal/web/auth.go b/internal/web/auth.go new file mode 100644 index 0000000..82f8393 --- /dev/null +++ b/internal/web/auth.go @@ -0,0 +1,191 @@ +package web + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +type signInForm struct { + CSRFToken string `form:"csrf_token"` + Email string `form:"email"` + Password string `form:"password"` + RememberEmail bool `form:"remember_email"` + Errors map[string]string + Message string +} + +type activationForm struct { + CSRFToken string `form:"csrf_token"` + Token string `form:"token"` + Password string `form:"password"` + PasswordConfirm string `form:"password_confirm"` + SetPassword bool `form:"set_password"` + Errors map[string]string + Message string +} + +func (app *application) signIn(w http.ResponseWriter, r *http.Request) { + if app.isAuthenticated(r) { + http.Redirect(w, r, app.authenticatedLandingPage(r), http.StatusSeeOther) + return + } + data := app.newTemplateData(r) + form := signInForm{Errors: make(map[string]string)} + if cookie, err := r.Cookie("gardomatic_remembered_email"); err == nil { + if decoded, decodeErr := base64.RawURLEncoding.DecodeString(cookie.Value); decodeErr == nil { + form.Email, form.RememberEmail = string(decoded), true + } + } + data.Form = form + app.render(w, http.StatusOK, "signin.tmpl", data) +} + +func (app *application) signInPost(w http.ResponseWriter, r *http.Request) { + var form signInForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Email = strings.TrimSpace(form.Email) + form.Errors = make(map[string]string) + if form.Email == "" { + form.Errors["email"] = "E-Mail-Adresse ist erforderlich." + } + if form.Password == "" { + form.Errors["password"] = "Passwort ist erforderlich." + } + if len(form.Errors) != 0 { + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data) + return + } + + apiClient := client.FromContext(r.Context()) + user, response, err := apiClient.CreateSession(r.Context(), client.Credentials{Email: form.Email, Password: form.Password}) + if err != nil { + var apiError *client.APIError + if errors.As(err, &apiError) && (apiError.StatusCode == http.StatusUnauthorized || apiError.StatusCode == http.StatusUnprocessableEntity) { + form.Message = "E-Mail-Adresse oder Passwort ist ungültig." + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "signin.tmpl", data) + return + } + app.serverError(w, err) + return + } + client.ForwardCookies(w, response) + remembered := &http.Cookie{Name: "gardomatic_remembered_email", Path: webPath("login"), HttpOnly: true, Secure: app.config.CookieSecure, SameSite: http.SameSiteLaxMode} + if form.RememberEmail { + remembered.Value = base64.RawURLEncoding.EncodeToString([]byte(form.Email)) + remembered.Expires = time.Now().Add(365 * 24 * time.Hour) + remembered.MaxAge = 365 * 24 * 60 * 60 + } else if _, err := r.Cookie("gardomatic_remembered_email"); err == nil { + remembered.Expires = time.Unix(1, 0) + remembered.MaxAge = -1 + } else { + remembered = nil + } + if remembered != nil { + http.SetCookie(w, remembered) + } + if !user.Activated { + http.Redirect(w, r, webPath("activate"), http.StatusSeeOther) + return + } + http.Redirect(w, r, pathWithQuery(webPath("gardens"), "auto", 1), http.StatusSeeOther) +} + +func (app *application) activateUser(w http.ResponseWriter, r *http.Request) { + if user, ok := userFromContext(r.Context()); ok && user.Activated { + http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther) + return + } + + data := app.newTemplateData(r) + data.Form = activationForm{ + Token: strings.TrimSpace(r.URL.Query().Get("token")), + SetPassword: r.URL.Query().Get("set-password") == "1", + Errors: make(map[string]string), + } + app.render(w, http.StatusOK, "activate.tmpl", data) +} + +func (app *application) activateUserPost(w http.ResponseWriter, r *http.Request) { + var form activationForm + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + return + } + + form.Token = strings.TrimSpace(form.Token) + form.Errors = make(map[string]string) + if form.Token == "" { + form.Errors["token"] = "Aktivierungstoken ist erforderlich." + } + if form.SetPassword { + if len(form.Password) < 8 { + form.Errors["password"] = "Das Passwort muss mindestens 8 Zeichen lang sein." + } else if len(form.Password) > 72 { + form.Errors["password"] = "Das Passwort darf höchstens 72 Zeichen lang sein." + } + if form.Password != form.PasswordConfirm { + form.Errors["password_confirm"] = "Die Passwörter stimmen nicht überein." + } + } + + if len(form.Errors) == 0 { + apiClient := client.FromContext(r.Context()) + var err error + if form.SetPassword { + _, _, err = apiClient.ActivateInvitedUser(r.Context(), form.Token, form.Password) + } else { + _, _, err = apiClient.ActivateUser(r.Context(), form.Token) + } + if err == nil { + http.Redirect(w, r, webPath("gardens"), http.StatusSeeOther) + return + } + + var apiError *client.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity { + if _, ok := apiError.Validation["token"]; ok { + form.Errors["token"] = "Aktivierungstoken ist ungültig oder abgelaufen." + } else { + form.Errors = apiError.Validation + form.Message = "Der Account konnte nicht aktiviert werden." + } + } else { + app.handleAPIError(w, r, err) + return + } + } + + data := app.newTemplateData(r) + data.Form = form + app.render(w, http.StatusUnprocessableEntity, "activate.tmpl", data) +} + +func (app *application) authenticatedLandingPage(r *http.Request) string { + if user, ok := userFromContext(r.Context()); ok && !user.Activated { + return webPath("activate") + } + return webPath("gardens") +} + +func (app *application) signOutPost(w http.ResponseWriter, r *http.Request) { + apiClient := client.FromContext(r.Context()) + response, err := apiClient.DeleteSession(r.Context()) + if err != nil { + app.handleAPIError(w, r, err) + return + } + client.ForwardCookies(w, response) + http.Redirect(w, r, webPath("home"), http.StatusSeeOther) +} diff --git a/internal/web/care_instructions.go b/internal/web/care_instructions.go new file mode 100644 index 0000000..4e4cd64 --- /dev/null +++ b/internal/web/care_instructions.go @@ -0,0 +1,93 @@ +package web + +import ( + "gardomatic.kleiax.de/lib/client" + "net/http" + "strings" +) + +type careInstructionForm struct { + Text string `form:"text"` + Status string `form:"status"` +} + +func (app *application) careInstructionSave(w http.ResponseWriter, r *http.Request) { + gardenID, e := app.readPathID(r, "gardenID") + if e != nil { + app.notFound(w) + return + } + speciesID, e := app.readPathID(r, "speciesID") + if e != nil { + app.notFound(w) + return + } + var form careInstructionForm + if e = app.decodePostForm(r, &form); e != nil { + app.clientError(w, http.StatusBadRequest) + return + } + form.Text = strings.TrimSpace(form.Text) + if form.Status == "" { + form.Status = "untested" + } + input := client.CareInstructionInput{Text: &form.Text, Status: &form.Status} + instructionID, _ := app.readPathID(r, "instructionID") + if instructionID > 0 { + _, _, e = client.FromContext(r.Context()).UpdateCareInstruction(r.Context(), gardenID, speciesID, instructionID, input) + } else { + _, _, e = client.FromContext(r.Context()).CreateCareInstruction(r.Context(), gardenID, speciesID, input) + } + if e != nil { + app.handleAPIError(w, r, e) + return + } + if r.Header.Get("HX-Request") == "true" { + apiClient := client.FromContext(r.Context()) + instructions, _, loadErr := apiClient.CareInstructions(r.Context(), gardenID, speciesID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + data := app.newTemplateData(r) + garden, _, loadErr := apiClient.Garden(r.Context(), gardenID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + species, _, loadErr := apiClient.Species(r.Context(), gardenID, speciesID) + if loadErr != nil { + app.handleAPIError(w, r, loadErr) + return + } + data.Garden = &garden + data.SpeciesID = speciesID + data.SpeciesGlobal = species.GardenID == nil + data.CareInstructions = instructions + app.renderTemplate(w, http.StatusOK, "species_form.tmpl", "care_instruction_list", data) + return + } + http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther) +} +func (app *application) careInstructionDelete(w http.ResponseWriter, r *http.Request) { + gardenID, e := app.readPathID(r, "gardenID") + if e != nil { + app.notFound(w) + return + } + speciesID, e := app.readPathID(r, "speciesID") + if e != nil { + app.notFound(w) + return + } + instructionID, e := app.readPathID(r, "instructionID") + if e != nil { + app.notFound(w) + return + } + if _, e = client.FromContext(r.Context()).DeleteCareInstruction(r.Context(), gardenID, speciesID, instructionID); e != nil { + app.handleAPIError(w, r, e) + return + } + http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, speciesID), "step", "care"), http.StatusSeeOther) +} diff --git a/internal/web/collection_filters.go b/internal/web/collection_filters.go new file mode 100644 index 0000000..21367a4 --- /dev/null +++ b/internal/web/collection_filters.go @@ -0,0 +1,219 @@ +package web + +import ( + "net/http" + "sort" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func collectionFilters(r *http.Request, keys ...string) map[string]string { + filters := make(map[string]string, len(keys)) + for _, key := range keys { + filters[key] = strings.TrimSpace(r.URL.Query().Get(key)) + } + return filters +} + +func filterAndSortGardens(values []client.Garden, filters map[string]string) []client.Garden { + query := strings.ToLower(filters["q"]) + result := make([]client.Garden, 0, len(values)) + for _, value := range values { + if query != "" && !strings.Contains(strings.ToLower(value.Name+" "+value.Description), query) { + continue + } + if filters["role"] != "" && value.Role != filters["role"] { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func filterAndSortPlants(values []client.Plant, assignments map[int][]client.PlantLocation, filters map[string]string) []client.Plant { + query := strings.ToLower(filters["q"]) + speciesID, _ := strconv.Atoi(filters["species"]) + locationID, _ := strconv.Atoi(filters["location"]) + result := make([]client.Plant, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.Name, value.Notes, strings.Join(value.Tags, " ")) { + continue + } + if filters["status"] != "" && value.Status != filters["status"] { + continue + } + if speciesID > 0 && (value.SpeciesID == nil || *value.SpeciesID != speciesID) { + continue + } + if locationID > 0 && !plantHasLocation(assignments[value.ID], locationID) { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func plantHasLocation(assignments []client.PlantLocation, locationID int) bool { + for _, assignment := range assignments { + if assignment.LocationID == locationID && assignment.RemovedAt == nil { + return true + } + } + return false +} + +func filterAndSortSpecies(values []client.Species, filters map[string]string) []client.Species { + query := strings.ToLower(filters["q"]) + result := make([]client.Species, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.CommonName, value.Cultivar, value.BotanicalName, value.Category, strings.Join(value.Tags, " ")) { + continue + } + if filters["origin"] == "global" && value.GardenID != nil || filters["origin"] == "garden" && value.GardenID == nil { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + left, right := strings.ToLower(result[i].CommonName+" "+result[i].Cultivar), strings.ToLower(result[j].CommonName+" "+result[j].Cultivar) + switch filters["sort"] { + case "name_desc": + return left > right + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return left < right + } + }) + return result +} + +func filterAndSortLocations(values []client.Location, filters map[string]string) []client.Location { + query, kind := strings.ToLower(filters["q"]), strings.ToLower(filters["kind"]) + result := make([]client.Location, 0, len(values)) + for _, value := range values { + if query != "" && !containsTerms(query, value.Name, value.Description, value.Kind) { + continue + } + if kind != "" && !strings.Contains(strings.ToLower(value.Kind), kind) { + continue + } + result = append(result, value) + } + sort.SliceStable(result, func(i, j int) bool { + switch filters["sort"] { + case "name_desc": + return strings.ToLower(result[i].Name) > strings.ToLower(result[j].Name) + case "newest": + return result[i].CreatedAt.After(result[j].CreatedAt) + case "oldest": + return result[i].CreatedAt.Before(result[j].CreatedAt) + default: + return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) + } + }) + return result +} + +func sortTasks(values []client.Task, sortBy string) { + sort.SliceStable(values, func(i, j int) bool { + switch sortBy { + case "due_desc": + return taskSortTime(values[i]).After(taskSortTime(values[j])) + case "month_asc": + return taskMonthBefore(values[i], values[j], false) + case "month_desc": + return taskMonthBefore(values[i], values[j], true) + case "period_asc": + return taskPeriodBefore(values[i], values[j], false) + case "period_desc": + return taskPeriodBefore(values[i], values[j], true) + case "name_asc": + return strings.ToLower(values[i].Title) < strings.ToLower(values[j].Title) + case "name_desc": + return strings.ToLower(values[i].Title) > strings.ToLower(values[j].Title) + case "priority_desc": + return values[i].Priority > values[j].Priority + default: + return taskSortTime(values[i]).Before(taskSortTime(values[j])) + } + }) +} + +// taskSortMonth returns the calendar month of the task's due window. Missing +// dates sort after dated tasks in both directions. +func taskSortMonth(task client.Task) int { + value := task.DueAtStart + if value == nil { + value = task.DueAtEnd + } + if value == nil { + return 13 + } + return int(value.Month()) +} + +func taskMonthBefore(left, right client.Task, descending bool) bool { + leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil + rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil + if leftMissing || rightMissing { + return !leftMissing && rightMissing + } + if descending { + return taskSortMonth(left) > taskSortMonth(right) + } + return taskSortMonth(left) < taskSortMonth(right) +} + +// taskSortPeriod returns the length of a task's due window. A single date is +// a zero-length window; tasks without dates sort after dated tasks. +func taskSortPeriod(task client.Task) time.Duration { + if task.DueAtStart == nil && task.DueAtEnd == nil { + return time.Duration(1<<63 - 1) + } + if task.DueAtStart == nil || task.DueAtEnd == nil { + return 0 + } + return task.DueAtEnd.Sub(*task.DueAtStart) +} + +func taskPeriodBefore(left, right client.Task, descending bool) bool { + leftMissing := left.DueAtStart == nil && left.DueAtEnd == nil + rightMissing := right.DueAtStart == nil && right.DueAtEnd == nil + if leftMissing || rightMissing { + return !leftMissing && rightMissing + } + if descending { + return taskSortPeriod(left) > taskSortPeriod(right) + } + return taskSortPeriod(left) < taskSortPeriod(right) +} diff --git a/internal/web/collection_filters_test.go b/internal/web/collection_filters_test.go new file mode 100644 index 0000000..5236667 --- /dev/null +++ b/internal/web/collection_filters_test.go @@ -0,0 +1,94 @@ +package web + +import ( + "testing" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func TestCollectionFiltersAndSorting(t *testing.T) { + older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + gardens := filterAndSortGardens([]client.Garden{ + {Name: "Ziergarten", Role: "viewer", CreatedAt: newer}, + {Name: "Acker", Description: "Gemüse", Role: "owner", CreatedAt: older}, + }, map[string]string{"q": "gemüse", "role": "owner", "sort": "newest"}) + if len(gardens) != 1 || gardens[0].Name != "Acker" { + t.Fatalf("unexpected gardens result: %#v", gardens) + } + + speciesID, locationID := 4, 8 + plants := filterAndSortPlants([]client.Plant{ + {ID: 1, Name: "Zucchini", Status: "active", SpeciesID: &speciesID, CreatedAt: newer}, + {ID: 2, Name: "Aster", Status: "dormant", CreatedAt: older}, + }, map[int][]client.PlantLocation{1: {{PlantID: 1, LocationID: locationID}}}, map[string]string{"status": "active", "species": "4", "location": "8", "sort": "name_desc"}) + if len(plants) != 1 || plants[0].Name != "Zucchini" { + t.Fatalf("unexpected plants result: %#v", plants) + } + + gardenID := 3 + species := filterAndSortSpecies([]client.Species{ + {CommonName: "Tomate", GardenID: &gardenID}, + {CommonName: "Bohne", GardenID: nil}, + }, map[string]string{"origin": "global", "sort": "name_asc"}) + if len(species) != 1 || species[0].CommonName != "Bohne" { + t.Fatalf("unexpected species result: %#v", species) + } + + locations := filterAndSortLocations([]client.Location{ + {Name: "Nordbeet", Kind: "Beet"}, + {Name: "Terrasse", Kind: "Topf"}, + }, map[string]string{"kind": "beet", "sort": "name_asc"}) + if len(locations) != 1 || locations[0].Name != "Nordbeet" { + t.Fatalf("unexpected locations result: %#v", locations) + } +} + +func TestTaskSortingByMonthAndPeriod(t *testing.T) { + date := func(year int, month time.Month, day int) *time.Time { + value := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) + return &value + } + tasks := []client.Task{ + {ID: 1, Title: "Langer Zeitraum", DueAtStart: date(2026, time.March, 1), DueAtEnd: date(2026, time.March, 11)}, + {ID: 2, Title: "Kurzer Zeitraum", DueAtStart: date(2026, time.January, 1), DueAtEnd: date(2026, time.January, 2)}, + {ID: 3, Title: "Einzeltermin", DueAtStart: date(2026, time.February, 1)}, + } + + sortTasks(tasks, "month_asc") + if tasks[0].ID != 2 || tasks[1].ID != 3 || tasks[2].ID != 1 { + t.Fatalf("month sort = %#v, want January, February, March", tasks) + } + sortTasks(tasks, "period_desc") + if tasks[0].ID != 1 || tasks[1].ID != 2 || tasks[2].ID != 3 { + t.Fatalf("period sort = %#v, want longest first", tasks) + } +} + +func TestTaskSearchDateFiltersUseStartOrEnd(t *testing.T) { + start := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, time.February, 2, 0, 0, 0, 0, time.UTC) + task := client.Task{DueAtStart: &start, DueAtEnd: &end} + if !taskInMonth(task, int(time.January)) || !taskInMonth(task, int(time.February)) { + t.Fatal("task should match both its start and end month") + } + if taskInMonth(task, int(time.March)) { + t.Fatal("task should not match a month between start and end") + } + if !taskInYear(task, 2026) || taskInYear(task, 2025) { + t.Fatal("task year filter matched the wrong year") + } + middleYear := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + if got := taskYears([]client.Task{task, {DueAtStart: &middleYear}}); len(got) != 3 || got[0] != 2024 || got[1] != 2025 || got[2] != 2026 { + t.Fatalf("task years = %#v, want [2024 2025 2026]", got) + } + filtered := filterTasks([]client.Task{ + {ID: 1, DueAtStart: &start, DueAtEnd: &end}, + {ID: 2, DueAtStart: func() *time.Time { value := time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC); return &value }()}, + }, map[string]string{"month": "2", "year": "2026"}) + if len(filtered) != 1 || filtered[0].ID != 1 { + t.Fatalf("filtered tasks = %#v, want task 1", filtered) + } +} diff --git a/internal/web/context.go b/internal/web/context.go new file mode 100644 index 0000000..71800fb --- /dev/null +++ b/internal/web/context.go @@ -0,0 +1,19 @@ +package web + +import ( + "context" + + "gardomatic.kleiax.de/lib/client" +) + +type contextKey string + +const ( + isAuthenticatedContextKey = contextKey("isAuthenticated") + userContextKey = contextKey("user") +) + +func userFromContext(ctx context.Context) (client.User, bool) { + user, ok := ctx.Value(userContextKey).(client.User) + return user, ok +} diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go new file mode 100644 index 0000000..7ef8d4a --- /dev/null +++ b/internal/web/dashboard.go @@ -0,0 +1,475 @@ +package web + +import ( + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "gardomatic.kleiax.de/lib/client" +) + +func (app *application) gardenDashboard(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + plants, _, err := apiClient.Plants(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + locations, _, err := apiClient.Locations(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + journalEntries, _, err := apiClient.JournalEntries(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + pinboardEntries, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard) + if err != nil { + app.handleAPIError(w, r, err) + return + } + images, _, err := apiClient.Images(r.Context(), gardenID, "", "") + if err != nil { + var apiError *client.APIError + if !errors.As(err, &apiError) || apiError.StatusCode != http.StatusNotFound { + app.handleAPIError(w, r, err) + return + } + } + horizon := time.Now().AddDate(0, 0, 30) + upcoming := make([]client.Task, 0) + for _, task := range tasks { + if task.CompletedAt != nil { + continue + } + due := task.DueAtEnd + if due == nil { + due = task.DueAtStart + } + if due != nil && !due.After(horizon) { + upcoming = append(upcoming, task) + } + } + sort.SliceStable(upcoming, func(i, j int) bool { return taskSortTime(upcoming[i]).Before(taskSortTime(upcoming[j])) }) + if len(upcoming) > 8 { + upcoming = upcoming[:8] + } + data := app.newTemplateData(r) + data.Garden = &garden + data.Tasks = upcoming + data.Plants = plants + data.Locations = locations + data.JournalEntries = journalEntries + data.PinboardEntries = pinboardEntries + data.PhotoCount = len(images) + app.render(w, http.StatusOK, "dashboard.tmpl", data) +} + +func taskSortTime(task client.Task) time.Time { + if task.DueAtEnd != nil { + return *task.DueAtEnd + } + if task.DueAtStart != nil { + return *task.DueAtStart + } + return time.Unix(1<<62, 0) +} + +func (app *application) taskCalendar(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + view := r.URL.Query().Get("view") + if view != "month" { + view = "week" + } + anchor := time.Now() + if value := r.URL.Query().Get("date"); value != "" { + if parsed, parseErr := time.Parse("2006-01-02", value); parseErr == nil { + anchor = parsed + } + } + start, end := calendarRange(anchor, view) + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + days := make([]calendarDay, 0) + for day := start; day.Before(end); day = day.AddDate(0, 0, 1) { + entry := calendarDay{Date: day} + for _, task := range tasks { + if task.CompletedAt == nil && taskOverlapsDay(task, day) { + entry.Tasks = append(entry.Tasks, task) + } + } + days = append(days, entry) + } + step := 7 + if view == "month" { + step = 1 + } + previous := start.AddDate(0, 0, -step) + next := end + if view == "month" { + previous = start.AddDate(0, -1, 0) + next = start.AddDate(0, 1, 0) + } + data := app.newTemplateData(r) + data.Garden = &garden + data.CalendarDays = days + data.CalendarStart = start + data.CalendarEnd = end.Add(-time.Nanosecond) + data.CalendarView = view + data.PreviousDate = previous.Format("2006-01-02") + data.NextDate = next.Format("2006-01-02") + app.render(w, http.StatusOK, "task_calendar.tmpl", data) +} + +func calendarRange(anchor time.Time, view string) (time.Time, time.Time) { + y, m, d := anchor.Date() + loc := anchor.Location() + start := time.Date(y, m, d, 0, 0, 0, 0, loc) + if view == "month" { + start = time.Date(y, m, 1, 0, 0, 0, 0, loc) + return start, start.AddDate(0, 1, 0) + } + offset := (int(start.Weekday()) + 6) % 7 + start = start.AddDate(0, 0, -offset) + return start, start.AddDate(0, 0, 7) +} + +func taskOverlapsDay(task client.Task, day time.Time) bool { + dayEnd := day.AddDate(0, 0, 1) + start, end := task.DueAtStart, task.DueAtEnd + if start == nil { + start = end + } + if end == nil { + end = start + } + if start == nil { + return false + } + return start.Before(dayEnd) && !end.Before(day) +} + +func (app *application) gardenSearch(w http.ResponseWriter, r *http.Request) { + gardenID, err := app.readPathID(r, "gardenID") + if err != nil { + app.notFound(w) + return + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + mode := r.URL.Query().Get("mode") + month, _ := strconv.Atoi(r.URL.Query().Get("month")) + if month < 1 || month > 12 { + month = 0 + } + year, _ := strconv.Atoi(r.URL.Query().Get("year")) + if year < 1 { + year = 0 + } + apiClient := client.FromContext(r.Context()) + garden, _, err := apiClient.Garden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tasks, _, err := apiClient.Tasks(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + result := searchResults{Query: query, Mode: mode, Month: month, MonthName: germanMonthName(month), Year: year, Years: taskYears(tasks)} + if query != "" || month > 0 || year > 0 { + plants, _, err := apiClient.Plants(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + journal, _, err := apiClient.JournalEntries(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + pinboard, _, err := apiClient.JournalEntriesByType(r.Context(), gardenID, client.JournalEntryTypePinboard) + if err != nil { + app.handleAPIError(w, r, err) + return + } + tags, _, err := apiClient.GardenTags(r.Context(), gardenID) + if err != nil { + app.handleAPIError(w, r, err) + return + } + if mode != "month" { + if queryMonth := parseGermanMonth(query); queryMonth > 0 { + month = queryMonth + } + } + matchedTags := map[string]string{} + if mode != "month" { + for _, tag := range tags { + if containsTerms(query, tag) { + matchedTags[strings.ToLower(tag)] = tag + } + } + } + addMonthTags := func(values []string) { + if mode == "month" { + for _, tag := range values { + matchedTags[strings.ToLower(tag)] = tag + } + } + } + for _, item := range tasks { + textMatch := query == "" || containsTerms(query, item.Title, item.Description, strings.Join(item.Tags, " ")) + monthMatch := month == 0 || taskInMonth(item, month) + yearMatch := year == 0 || taskInYear(item, year) + if textMatch && monthMatch && yearMatch { + addMonthTags(item.Tags) + result.Tasks = append(result.Tasks, taskSearchCard(gardenID, item)) + result.All = append(result.All, result.Tasks[len(result.Tasks)-1]) + } + } + for _, item := range plants { + if (mode == "month" && plantInMonth(item, month)) || (mode != "month" && containsTerms(query, item.Name, item.Notes, string(item.Attributes), strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Plants = append(result.Plants, plantSearchCard(gardenID, item)) + result.All = append(result.All, result.Plants[len(result.Plants)-1]) + } + } + for _, item := range species { + if (mode == "month" && speciesInMonth(item, month)) || (mode != "month" && (containsTerms(query, item.CommonName, item.Cultivar, item.BotanicalName, item.Category, item.Notes, string(item.Attributes), strings.Join(item.Tags, " ")) || (month > 0 && speciesInMonth(item, month)))) { + addMonthTags(item.Tags) + result.Species = append(result.Species, speciesSearchCard(gardenID, item)) + result.All = append(result.All, result.Species[len(result.Species)-1]) + } + } + for _, item := range journal { + if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Journal = append(result.Journal, gardenEntrySearchCard(gardenID, item)) + result.All = append(result.All, result.Journal[len(result.Journal)-1]) + } + } + for _, item := range pinboard { + if (mode == "month" && int(item.CreatedAt.Month()) == month) || (mode != "month" && containsTerms(query, item.Title, item.Body, strings.Join(item.Tags, " "))) { + addMonthTags(item.Tags) + result.Pinboard = append(result.Pinboard, gardenEntrySearchCard(gardenID, item)) + result.All = append(result.All, result.Pinboard[len(result.Pinboard)-1]) + } + } + seenTagUsages := map[string]bool{} + addTagUsages := func(card searchCard, values []string) { + for _, tag := range values { + if _, ok := matchedTags[strings.ToLower(tag)]; !ok { + continue + } + if seenTagUsages[card.URL] { + continue + } + seenTagUsages[card.URL] = true + usage := tagUsageCard(card, matchedTags[strings.ToLower(tag)]) + result.Tags = append(result.Tags, usage) + allSeen := false + for _, existing := range result.All { + if existing.URL == usage.URL { + allSeen = true + break + } + } + if !allSeen { + result.All = append(result.All, usage) + } + } + } + for _, item := range tasks { + addTagUsages(taskSearchCard(gardenID, item), item.Tags) + } + for _, item := range plants { + addTagUsages(plantSearchCard(gardenID, item), item.Tags) + } + for _, item := range species { + addTagUsages(speciesSearchCard(gardenID, item), item.Tags) + } + for _, item := range journal { + addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags) + } + for _, item := range pinboard { + addTagUsages(gardenEntrySearchCard(gardenID, item), item.Tags) + } + } + data := app.newTemplateData(r) + data.Garden = &garden + data.Search = result + app.render(w, http.StatusOK, "search.tmpl", data) +} + +func taskSearchCard(gardenID int, item client.Task) searchCard { + return searchCard{item.Title, item.Description, taskDue(item), "Aufgabe", webPath("task.edit", gardenID, item.ID)} +} +func plantSearchCard(gardenID int, item client.Plant) searchCard { + return searchCard{item.Name, item.Notes, plantStatusName(item.Status), "Im Garten", webPath("plant.edit", gardenID, item.ID)} +} +func speciesSearchCard(gardenID int, item client.Species) searchCard { + title := item.CommonName + if item.Cultivar != "" { + title += " · " + item.Cultivar + } + return searchCard{title, item.BotanicalName, speciesSeason(item), "Pflanze", webPath("species.edit", gardenID, item.ID)} +} +func gardenEntrySearchCard(gardenID int, item client.JournalEntry) searchCard { + path, label := "journal", "Tagebuch" + if item.EntryType == client.JournalEntryTypePinboard { + path, label = "pinboard", "Pinnwand" + } + return searchCard{item.Title, item.AuthorName, item.CreatedAt.Local().Format("02.01.2006 · 15:04 Uhr"), label, webPath("garden."+path, gardenID) + fmt.Sprintf("#entry-%d", item.ID)} +} +func tagUsageCard(card searchCard, tag string) searchCard { + card.Meta = "#" + tag + " · " + card.Meta + return card +} +func speciesSeason(item client.Species) string { + if item.HarvestMonthFrom != nil { + return fmt.Sprintf("Erntezeit ab %s", germanMonthName(*item.HarvestMonthFrom)) + } + return item.Category +} +func plantInMonth(item client.Plant, month int) bool { + return (item.AcquiredAt != nil && int(item.AcquiredAt.Month()) == month) || int(item.CreatedAt.Month()) == month +} +func germanMonthName(month int) string { + names := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"} + if month >= 1 && month <= 12 { + return names[month] + } + return "" +} + +func containsTerms(query string, values ...string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + for _, term := range strings.Fields(query) { + term = strings.TrimPrefix(term, "#") + found := false + for _, value := range values { + if strings.Contains(strings.ToLower(value), term) { + found = true + break + } + } + if !found { + return false + } + } + return query != "" +} + +func parseKeywords(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if part = strings.TrimSpace(part); part != "" { + result = append(result, part) + } + } + return result +} +func parseGermanMonth(value string) int { + names := []string{"januar", "februar", "märz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember"} + value = strings.ToLower(strings.TrimSpace(value)) + for i, name := range names { + if value == name || value == strings.TrimSuffix(name, "uar") { + return i + 1 + } + } + return 0 +} +func taskInMonth(task client.Task, month int) bool { + return (task.DueAtStart != nil && int(task.DueAtStart.Month()) == month) || + (task.DueAtEnd != nil && int(task.DueAtEnd.Month()) == month) +} + +func taskInYear(task client.Task, year int) bool { + return (task.DueAtStart != nil && task.DueAtStart.Year() == year) || + (task.DueAtEnd != nil && task.DueAtEnd.Year() == year) +} + +func taskYears(tasks []client.Task) []int { + minYear, maxYear := 0, 0 + setYear := func(year int) { + if minYear == 0 || year < minYear { + minYear = year + } + if year > maxYear { + maxYear = year + } + } + for _, task := range tasks { + if task.DueAtStart != nil { + setYear(task.DueAtStart.Year()) + } + if task.DueAtEnd != nil { + setYear(task.DueAtEnd.Year()) + } + } + if minYear == 0 { + return nil + } + result := make([]int, 0, maxYear-minYear+1) + for year := minYear; year <= maxYear; year++ { + result = append(result, year) + } + return result +} +func speciesInMonth(item client.Species, month int) bool { + return monthInRange(month, item.SowMonthFrom, item.SowMonthTo) || monthInRange(month, item.PlantingMonthFrom, item.PlantingMonthTo) || monthInRange(month, item.HarvestMonthFrom, item.HarvestMonthTo) +} +func monthInRange(month int, from, to *int) bool { + if from == nil || to == nil { + return false + } + return monthRangeMatches(month, *from, *to) +} + +func monthRangeMatches(month, from, to int) bool { + if from <= to { + return month >= from && month <= to + } + return month >= from || month <= to +} diff --git a/internal/web/doc.go b/internal/web/doc.go new file mode 100644 index 0000000..84879cd --- /dev/null +++ b/internal/web/doc.go @@ -0,0 +1,3 @@ +// Package web implements Gardomatic's server-rendered browser application and +// proxies authenticated user operations through the typed API client. +package web diff --git a/internal/web/edit_flows_test.go b/internal/web/edit_flows_test.go new file mode 100644 index 0000000..4d943bf --- /dev/null +++ b/internal/web/edit_flows_test.go @@ -0,0 +1,218 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gardomatic.kleiax.de/lib/client" + "github.com/julienschmidt/httprouter" +) + +func TestPlantAndLocationCanBeEdited(t *testing.T) { + var plantInput client.PlantInput + var locationInput client.LocationInput + assignmentUpdated := false + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &plantInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"plant":{"id":5,"garden_id":3,"name":"Neue Rose"}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/plants/5/locations/9": + assignmentUpdated = true + _, _ = w.Write([]byte(`{"plant_location":{"id":9,"plant_id":5,"location_id":6,"quantity":2}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/locations/6": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &locationInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"location":{"id":6,"garden_id":3,"name":"Südbeet"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + + plantForm := url.Values{"name": {"Neue Rose"}, "species_id": {"0"}, "location_id": {"6"}, "assignment_id": {"9"}, "quantity": {"2"}, "status": {"active"}} + plantRequest := httptest.NewRequest(http.MethodPost, "/g/3/plants/edit/5", strings.NewReader(plantForm.Encode())) + plantRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + plantRequest = taskWebRequest(plantRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "plantID", Value: "5"}}) + plantResponse := httptest.NewRecorder() + app.plantEditPost(plantResponse, plantRequest) + if plantResponse.Code != http.StatusSeeOther || !plantInput.ClearSpeciesID || !assignmentUpdated { + t.Fatalf("plant edit: status=%d input=%+v assignment=%v body=%s", plantResponse.Code, plantInput, assignmentUpdated, plantResponse.Body.String()) + } + + locationForm := url.Values{"name": {"Südbeet"}, "parent_id": {"0"}, "area_sqm": {"4,5"}, "return_to": {"/g/3/locations"}} + locationRequest := httptest.NewRequest(http.MethodPost, "/g/3/locations/edit/6", strings.NewReader(locationForm.Encode())) + locationRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + locationRequest = taskWebRequest(locationRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "locationID", Value: "6"}}) + locationResponse := httptest.NewRecorder() + app.locationEditPost(locationResponse, locationRequest) + if locationResponse.Code != http.StatusSeeOther || !locationInput.ClearParentID || locationInput.AreaSQM == nil || *locationInput.AreaSQM != 4.5 { + t.Fatalf("location edit: status=%d input=%+v body=%s", locationResponse.Code, locationInput, locationResponse.Body.String()) + } +} + +func TestGardenAndSpeciesCanBeEdited(t *testing.T) { + var gardenInput client.UpdateGardenInput + var speciesInput client.SpeciesInput + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &gardenInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Neuer Garten"}}`)) + case r.Method == http.MethodPatch && r.URL.Path == "/v1/gardens/3/species/7": + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &speciesInput); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{"species":{"id":7,"garden_id":3,"common_name":"Neue Rose"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + gardenForm := url.Values{"name": {"Neuer Garten"}, "description": {"Südseite"}} + gardenRequest := httptest.NewRequest(http.MethodPost, "/gardens/edit/3", strings.NewReader(gardenForm.Encode())) + gardenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + gardenRequest = taskWebRequest(gardenRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + gardenResponse := httptest.NewRecorder() + app.gardenEditPost(gardenResponse, gardenRequest) + if gardenResponse.Code != http.StatusSeeOther || gardenInput.Name == nil || *gardenInput.Name != "Neuer Garten" { + t.Fatalf("garden edit: status=%d input=%+v", gardenResponse.Code, gardenInput) + } + speciesForm := url.Values{"common_name": {"Neue Rose"}, "sow_month_from": {"0"}, "sow_month_to": {"0"}} + speciesRequest := httptest.NewRequest(http.MethodPost, "/g/3/species/edit/7", strings.NewReader(speciesForm.Encode())) + speciesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + speciesRequest = taskWebRequest(speciesRequest, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}, {Key: "speciesID", Value: "7"}}) + speciesResponse := httptest.NewRecorder() + app.speciesSave(speciesResponse, speciesRequest) + 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) + } +} + +func TestGardenCanBeDeleted(t *testing.T) { + deleted := false + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/v1/gardens/3" { + http.NotFound(w, r) + return + } + deleted = true + w.WriteHeader(http.StatusNoContent) + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodPost, "/gardens/delete/3", nil) + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + + app.gardenDeletePost(response, request) + + if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/gardens" || !deleted { + t.Fatalf("garden delete: status=%d location=%q deleted=%v body=%s", response.Code, response.Header().Get("Location"), deleted, response.Body.String()) + } +} + +func TestTaskTemplateCanBeCreated(t *testing.T) { + var input client.SpeciesTaskTemplateInput + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost || r.URL.Path != "/v1/gardens/3/species/7/task-templates" { + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &input); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"task_template":{"id":9,"species_id":7,"title":"Schneiden"}}`)) + }) + app := newAPIBackedTestApplication(t, apiHandler) + form := url.Values{"title": {"Schneiden"}, "trigger_type": {"month_of_year"}, "month_from": {"11"}, "day_from": {"15"}, "duration": {"2"}, "duration_unit": {"week"}, "recurrence": {"monthly"}, "recurrence_interval": {"2"}, "priority": {"5"}, "active": {"true"}} + request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + app.taskTemplateSave(response, request) + if response.Code != http.StatusSeeOther || input.TriggerType == nil || *input.TriggerType != "month_of_year" || input.MonthFrom == nil || *input.MonthFrom != 11 || input.DayFrom == nil || *input.DayFrom != 15 || input.Duration == nil || *input.Duration != 2 || input.RecurrenceInterval == nil || *input.RecurrenceInterval != 2 { + t.Fatalf("template create: status=%d input=%+v body=%s", response.Code, input, response.Body.String()) + } + if input.TriggerOffset != nil || input.TriggerOffsetUnit != nil { + t.Errorf("calendar template unexpectedly sends disabled relative fields: %+v", input) + } +} + +func TestTaskTemplateDialogShowsMissingNameError(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/gardens/3/species/7": + _, _ = w.Write([]byte(`{"species":{"id":7,"common_name":"Tomate"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/task-priorities": + _, _ = w.Write([]byte(`{"priorities":[{"id":1,"name":"Normal","value":0,"active":true}]}`)) + default: + t.Errorf("unexpected API request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + form := url.Values{"trigger_type": {"month_of_year"}, "month_from": {"9"}, "day_from": {"1"}, "duration": {"0"}, "duration_unit": {"day"}, "trigger_offset_unit": {"day"}, "recurrence_interval": {"1"}} + request := httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("HX-Request", "true") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response := httptest.NewRecorder() + + app.taskTemplateSave(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + if body := response.Body.String(); !strings.Contains(body, "Ein Name ist erforderlich.") { + t.Errorf("missing name validation message: %s", body) + } + if response.Header().Get("HX-Retarget") != "#task-template-dialog-host" { + t.Errorf("HX-Retarget: got %q", response.Header().Get("HX-Retarget")) + } + + form.Set("title", "Ausgegeizte Triebe entfernen") + form.Set("month_from", "0") + request = httptest.NewRequest(http.MethodPost, "/g/3/task-templates/new?species_id=7", strings.NewReader(form.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("HX-Request", "true") + request = taskWebRequest(request, app.apiClient, httprouter.Params{{Key: "gardenID", Value: "3"}}) + response = httptest.NewRecorder() + + app.taskTemplateSave(response, request) + + body := response.Body.String() + if response.Code != http.StatusOK { + t.Fatalf("status with filled name: got %d, want %d; body: %s", response.Code, http.StatusOK, body) + } + if !strings.Contains(body, "value='Ausgegeizte Triebe entfernen'") { + t.Errorf("filled name was not preserved: %s", body) + } + if strings.Contains(body, "Ein Name ist erforderlich.") { + t.Errorf("filled name was incorrectly rejected: %s", body) + } + if !strings.Contains(body, "Tag und Monat für „Ab“ sind erforderlich.") { + t.Errorf("actual date validation message is missing: %s", body) + } +} diff --git a/internal/web/efs.go b/internal/web/efs.go new file mode 100644 index 0000000..9006454 --- /dev/null +++ b/internal/web/efs.go @@ -0,0 +1,8 @@ +package web + +import ( + "embed" +) + +//go:embed "templates" "static" +var files embed.FS diff --git a/internal/web/flows_test.go b/internal/web/flows_test.go new file mode 100644 index 0000000..c2b1532 --- /dev/null +++ b/internal/web/flows_test.go @@ -0,0 +1,626 @@ +package web + +import ( + "html" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "gardomatic.kleiax.de/lib/client" + "github.com/go-playground/form/v4" +) + +type webHandlerTransport struct { + handler http.Handler +} + +func (transport webHandlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + transport.handler.ServeHTTP(recorder, request) + response := recorder.Result() + response.Request = request + return response, nil +} + +func newAPIBackedTestApplication(t *testing.T, handler http.Handler) *application { + t.Helper() + templateCache, err := newTemplateCache() + if err != nil { + t.Fatal(err) + } + apiClient, err := client.New("https://api.example", client.WithHTTPClient(&http.Client{Transport: webHandlerTransport{handler: handler}})) + if err != nil { + t.Fatal(err) + } + return &application{ + config: Config{SessionCookieName: "gardomatic_session", CookieSecure: false}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + templateCache: templateCache, + formDecoder: form.NewDecoder(), + apiClient: apiClient, + } +} + +func TestSettingsKeepsSelectedGarden(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/settings?garden=3", nil) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, "href='/g/3'>Hinterhof") || !strings.Contains(body, "href='/settings?garden=3'") { + t.Fatalf("selected garden was not kept in settings: %s", body) + } +} + +func TestHealthAndPrivacyPagesArePublic(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`)) + case "/v1/healthcheck": + _, _ = w.Write([]byte(`{"status":"available","server_time":"2026-09-11T10:00:00Z","system_info":{"environment":"test","version":"v1"}}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + + for _, test := range []struct { + path string + want string + }{ + {"/healtcheck", "Serverzeit"}, + {"/datenschutz", "Verarbeitete Daten"}, + } { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), test.want) { + t.Errorf("GET %s: status=%d, missing %q: %s", test.path, response.Code, test.want, response.Body.String()) + } + } +} + +func TestPinboardRequestsOnlyPinboardEntries(t *testing.T) { + requestedType := "" + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens/3": + _, _ = w.Write([]byte(`{"garden":{"id":3,"name":"Hinterhof","role":"owner"}}`)) + case "/v1/gardens/3/journal": + requestedType = r.URL.Query().Get("type") + _, _ = w.Write([]byte(`{"journal_entries":[{"id":8,"garden_id":3,"entry_type":"pinboard","title":"Sitzecke","body":"Bank bauen"}]}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/g/3/pinboard", nil) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + if requestedType != client.JournalEntryTypePinboard { + t.Fatalf("entry type: got %q, want %q", requestedType, client.JournalEntryTypePinboard) + } + if body := response.Body.String(); !strings.Contains(body, "Sitzecke") || !strings.Contains(body, "/g/3/pinboard/edit/8") { + t.Fatalf("pinboard entry missing: %s", body) + } +} + +func TestGardenPageUsesIncomingSession(t *testing.T) { + apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/session": + cookie, err := r.Cookie("gardomatic_session") + if err != nil || cookie.Value != "browser-session" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"you must be authenticated"}`)) + return + } + http.SetCookie(w, &http.Cookie{Name: "gardomatic_session", Value: "browser-session", Path: "/", HttpOnly: true, MaxAge: 1800}) + _, _ = w.Write([]byte(`{"user":{"id":7,"name":"Alice","email":"alice@example.com","activated":true,"permissions":["gardens:create"]}}`)) + case "/v1/gardens": + _, _ = w.Write([]byte(`{"gardens":[{"id":3,"name":"Hinterhof","description":"Gemüse"}]}`)) + default: + http.NotFound(w, r) + } + }) + app := newAPIBackedTestApplication(t, apiHandler) + request := httptest.NewRequest(http.MethodGet, "/gardens", nil) + request.AddCookie(&http.Cookie{Name: "gardomatic_session", Value: "browser-session"}) + response := httptest.NewRecorder() + + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) + } + var refreshedSession *http.Cookie + for _, cookie := range response.Result().Cookies() { + if cookie.Name == "gardomatic_session" { + refreshedSession = cookie + break + } + } + if refreshedSession == nil || refreshedSession.Value != "browser-session" || refreshedSession.MaxAge != 1800 { + t.Errorf("refreshed session cookie was not forwarded: got %+v", refreshedSession) + } + if body := response.Body.String(); !strings.Contains(body, "Hinterhof") || !strings.Contains(body, "Alice") { + t.Errorf("page does not contain garden and user: %s", body) + } + body := response.Body.String() + if !strings.Contains(body, `href='/gardens/new'`) { + t.Errorf("page does not link to the garden creation page: %s", body) + } + for _, want := range []string{"class='user-menu'", `href='/gardens'`, `href='/account'`, ">Nutzer ", "Abmelden"} { + if !strings.Contains(body, want) { + t.Errorf("user menu does not contain %q: %s", want, body) + } + } + if strings.Contains(body, `