Initial commit
CI / test (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:17 +02:00
commit 904d14b64c
314 changed files with 31884 additions and 0 deletions
+250
View File
@@ -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)
}
}
+153
View File
@@ -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)
}
+78
View File
@@ -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)
}
}
+166
View File
@@ -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)
}
}
+177
View File
@@ -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
}
+100
View File
@@ -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
}
+121
View File
@@ -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)
}
}
}
+1
View File
@@ -0,0 +1 @@
package api
+157
View File
@@ -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
}
+33
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
// Package api implements the Gardomatic HTTP API, including authentication,
// authorization middleware, request validation, and JSON response handling.
package api
+99
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
+187
View File
@@ -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)
}
}
+275
View File
@@ -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())
}
}
+22
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+175
View File
@@ -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()
})
}
+72
View File
@@ -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)
}
+332
View File
@@ -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
}
+129
View File
@@ -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)
}
}
+243
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+351
View File
@@ -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)
})
}
+147
View File
@@ -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)
}
}
}
+195
View File
@@ -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
}
+257
View File
@@ -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
}
+250
View File
@@ -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)
}
}
+256
View File
@@ -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)
}
+140
View File
@@ -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)))
}
+93
View File
@@ -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)
}
+53
View File
@@ -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)
}
}
+72
View File
@@ -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
}
+97
View File
@@ -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)
}
+210
View File
@@ -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)
}
}
+396
View File
@@ -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)
}
}
+141
View File
@@ -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
}
+83
View File
@@ -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())
}
}
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"fmt"
"net/http"
"strings"
"gardomatic.kleiax.de/internal/platform/validate"
"gardomatic.kleiax.de/internal/storage"
)
type speciesTaskTemplateInput struct {
Title *string `json:"title"`
Description *string `json:"description"`
TriggerType *storage.TaskTriggerType `json:"trigger_type"`
MonthFrom *int `json:"month_from"`
DayFrom *int `json:"day_from"`
ClearDayFrom bool `json:"clear_day_from"`
MonthTo *int `json:"month_to"`
DayTo *int `json:"day_to"`
ClearDayTo bool `json:"clear_day_to"`
OffsetDaysFrom *int `json:"offset_days_from"`
OffsetDaysTo *int `json:"offset_days_to"`
IntervalDays *int `json:"interval_days"`
ClearIntervalDays bool `json:"clear_interval_days"`
TriggerOffset *int `json:"trigger_offset"`
TriggerOffsetUnit *storage.TaskDurationUnit `json:"trigger_offset_unit"`
Duration *int `json:"duration"`
DurationUnit *storage.TaskDurationUnit `json:"duration_unit"`
Recurrence *storage.TaskRecurrence `json:"recurrence"`
RecurrenceInterval *int `json:"recurrence_interval"`
Priority *int `json:"priority"`
Active *bool `json:"active"`
}
func (input speciesTaskTemplateInput) apply(template *storage.SpeciesTaskTemplate) {
if input.Title != nil {
template.Title = strings.TrimSpace(*input.Title)
}
if input.Description != nil {
template.Description = strings.TrimSpace(*input.Description)
}
if input.TriggerType != nil {
template.TriggerType = *input.TriggerType
}
if input.MonthFrom != nil {
template.MonthFrom = positivePointer(input.MonthFrom)
}
if input.DayFrom != nil {
template.DayFrom = positivePointer(input.DayFrom)
}
if input.ClearDayFrom {
template.DayFrom = nil
}
if input.MonthTo != nil {
template.MonthTo = positivePointer(input.MonthTo)
}
if input.DayTo != nil {
template.DayTo = positivePointer(input.DayTo)
}
if input.ClearDayTo {
template.DayTo = nil
}
if input.OffsetDaysFrom != nil {
template.OffsetDaysFrom = input.OffsetDaysFrom
}
if input.OffsetDaysTo != nil {
template.OffsetDaysTo = input.OffsetDaysTo
}
if input.IntervalDays != nil {
template.IntervalDays = positivePointer(input.IntervalDays)
}
if input.ClearIntervalDays {
template.IntervalDays = nil
}
if input.TriggerOffset != nil {
template.TriggerOffset = *input.TriggerOffset
}
if input.TriggerOffsetUnit != nil {
template.TriggerOffsetUnit = *input.TriggerOffsetUnit
}
if input.Duration != nil {
template.Duration = *input.Duration
}
if input.DurationUnit != nil {
template.DurationUnit = *input.DurationUnit
}
if input.Recurrence != nil {
template.Recurrence = *input.Recurrence
}
if input.RecurrenceInterval != nil {
template.RecurrenceInterval = *input.RecurrenceInterval
}
if input.Priority != nil {
template.Priority = *input.Priority
}
if input.Active != nil {
template.Active = *input.Active
}
if template.TriggerType == storage.TaskTriggerMonthOfYear {
template.OffsetDaysFrom, template.OffsetDaysTo = nil, nil
} else {
template.MonthFrom, template.DayFrom, template.MonthTo, template.DayTo = nil, nil, nil, nil
}
}
func positivePointer(value *int) *int {
if value != nil && *value <= 0 {
return nil
}
return value
}
func (app *application) createSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
speciesID, _ := app.readIDParam(r)
if _, ok := app.requireWritableSpecies(w, r, gardenID, speciesID); !ok {
return
}
var input speciesTaskTemplateInput
if err := app.readJSON(w, r, &input); err != nil {
app.badRequestResponse(w, r, err)
return
}
template := storage.SpeciesTaskTemplate{SpeciesID: speciesID, Origin: storage.TaskTemplateOriginManual, Active: true, TriggerOffsetUnit: storage.TaskDurationDay, DurationUnit: storage.TaskDurationDay, RecurrenceInterval: 1}
input.apply(&template)
if !app.validateSpeciesTaskTemplate(w, r, template) {
return
}
template, err := app.models.SpeciesTaskTemplates.Insert(template)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d/task-templates/%d", gardenID, speciesID, template.ID))
if err := app.writeJSON(w, http.StatusCreated, envelope{"task_template": template}, headers); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) listSpeciesTaskTemplatesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
speciesID, _ := app.readIDParam(r)
if _, err := app.models.Species.Get(gardenID, speciesID); err != nil {
app.respondToEntityModelError(w, r, err)
return
}
templates, err := app.models.SpeciesTaskTemplates.GetAllForSpecies(gardenID, speciesID)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
if err := app.writeJSON(w, http.StatusOK, envelope{"task_templates": templates}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) showSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
speciesID, _ := app.readIDParam(r)
templateID, err := app.readNamedIDParam(r, "templateID")
if err != nil {
app.notFoundResponse(w, r)
return
}
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
if err != nil || template.SpeciesID != speciesID {
if err == nil {
err = storage.ErrRecordNotFound
}
app.respondToEntityModelError(w, r, err)
return
}
if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
speciesID, _ := app.readIDParam(r)
templateID, err := app.readNamedIDParam(r, "templateID")
if err != nil {
app.notFoundResponse(w, r)
return
}
species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID)
if !writable {
return
}
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
if err != nil || template.SpeciesID != speciesID {
if err == nil {
err = storage.ErrRecordNotFound
}
app.respondToEntityModelError(w, r, err)
return
}
var input speciesTaskTemplateInput
if err := app.readJSON(w, r, &input); err != nil {
app.badRequestResponse(w, r, err)
return
}
input.apply(&template)
if !app.validateSpeciesTaskTemplate(w, r, template) {
return
}
writeScope := gardenID
if species.GardenID == nil {
writeScope = 0
}
template, err = app.models.SpeciesTaskTemplates.Update(writeScope, template)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
if err := app.writeJSON(w, http.StatusOK, envelope{"task_template": template}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteSpeciesTaskTemplateHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
speciesID, _ := app.readIDParam(r)
templateID, err := app.readNamedIDParam(r, "templateID")
if err != nil {
app.notFoundResponse(w, r)
return
}
species, writable := app.requireWritableSpecies(w, r, gardenID, speciesID)
if !writable {
return
}
template, err := app.models.SpeciesTaskTemplates.Get(gardenID, templateID)
if err != nil || template.SpeciesID != speciesID {
if err == nil {
err = storage.ErrRecordNotFound
}
app.respondToEntityModelError(w, r, err)
return
}
writeScope := gardenID
if species.GardenID == nil {
writeScope = 0
}
if template.Origin != storage.TaskTemplateOriginManual {
template.Active = false
if _, err := app.models.SpeciesTaskTemplates.Update(writeScope, template); err != nil {
app.respondToEntityModelError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
if err := app.models.SpeciesTaskTemplates.Delete(writeScope, templateID); err != nil {
app.respondToEntityModelError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (app *application) requireWritableSpecies(w http.ResponseWriter, r *http.Request, gardenID, speciesID int) (storage.Species, bool) {
species, err := app.models.Species.Get(gardenID, speciesID)
if err != nil {
app.respondToEntityModelError(w, r, err)
return storage.Species{}, false
}
if species.GardenID == nil {
user, _ := app.contextGetAuthenticatedUser(r)
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return storage.Species{}, false
}
return species, true
}
if *species.GardenID != gardenID {
app.respondToEntityModelError(w, r, storage.ErrRecordNotFound)
return storage.Species{}, false
}
member, _ := app.contextGetGardenMember(r)
if !member.Can(storage.GardenPermissionSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return storage.Species{}, false
}
return species, true
}
func (app *application) validateSpeciesTaskTemplate(w http.ResponseWriter, r *http.Request, template storage.SpeciesTaskTemplate) bool {
v := validate.New()
storage.ValidateSpeciesTaskTemplate(v, template)
if !app.validateConfiguredPriority(w, r, v, template.Priority) {
return false
}
if !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return false
}
return true
}
@@ -0,0 +1,36 @@
package api
import (
"net/http"
"testing"
"gardomatic.kleiax.de/internal/storage"
)
func TestSpeciesTaskTemplatesRequireWritePermission(t *testing.T) {
app, _, members := newGardenTestApplication()
user := storage.User{ID: 14, Activated: true}
members.members[[2]int{3, user.ID}] = storage.GardenMember{GardenID: 3, UserID: user.ID, Role: storage.GardenRoleAdmin}
gardenID := 3
app.models.Species = &speciesTestModel{items: map[int]storage.Species{1: {ID: 1, CommonName: "Global"}, 2: {ID: 2, GardenID: &gardenID, CommonName: "Rose"}}}
templates := &templateTestModel{items: map[int]storage.SpeciesTaskTemplate{}, nextID: 10}
app.models.SpeciesTaskTemplates = templates
body := []byte(`{"title":"Schneiden","trigger_type":"month_of_year","month_from":2,"day_from":15,"duration":2,"duration_unit":"week","active":true}`)
global := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body)
if global.Code != http.StatusForbidden {
t.Fatalf("global write status=%d", global.Code)
}
created := serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/2/task-templates", body)
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", created.Code, created.Body.String())
}
listed := serveResourceRequest(app, user, http.MethodGet, "/v1/gardens/3/species/2/task-templates", nil)
if listed.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listed.Code, listed.Body.String())
}
user.Role = storage.ApplicationRoleAdmin
global = serveResourceRequest(app, user, http.MethodPost, "/v1/gardens/3/species/1/task-templates", body)
if global.Code != http.StatusCreated {
t.Fatalf("global admin write status=%d body=%s", global.Code, global.Body.String())
}
}
+34
View File
@@ -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)
}
+142
View File
@@ -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()
}
+73
View File
@@ -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)
}
}
}
+153
View File
@@ -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
}
+44
View File
@@ -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)
}
+371
View File
@@ -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
}
+133
View File
@@ -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)
}
}
+189
View File
@@ -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)
}
}
+210
View File
@@ -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)
}
}