@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package api
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package api implements the Gardomatic HTTP API, including authentication,
|
||||
// authorization middleware, request validation, and JSON response handling.
|
||||
package api
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user