diff --git a/pkg/routes/api/v2/huma.go b/pkg/routes/api/v2/huma.go index ce8614a03..7ad6f18f6 100644 --- a/pkg/routes/api/v2/huma.go +++ b/pkg/routes/api/v2/huma.go @@ -41,7 +41,8 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { cfg.OpenAPIPath = "/openapi" // Huma's built-in docs would load from unpkg.com — we serve Scalar locally instead. cfg.DocsPath = "" - // Match v1's permissive partial-update convention; govalidator enforces real rules. + // Real presence/format rules live in `valid:` tags, enforced by govalidator in + // the Register wrapper; leave the schema permissive so partial updates match v1. cfg.FieldsOptionalByDefault = true api := humaecho5.NewWithGroup(e, g, GroupPrefix, cfg) @@ -86,6 +87,9 @@ func NewAPI(e *echo.Echo, g *echo.Group) huma.API { // Register wraps huma.Register with verb-based DefaultStatus: POST → 201, // DELETE → 204. Anything else (including an explicit op.DefaultStatus) is untouched. +// +// It also runs govalidator before the handler — i.e. before handler.Do*'s +// permission check — so v2 validates-then-authorizes like v1. func Register[I, O any](api huma.API, op huma.Operation, handler func(context.Context, *I) (*O, error)) { if op.DefaultStatus == 0 { switch op.Method { @@ -95,7 +99,13 @@ func Register[I, O any](api huma.API, op huma.Operation, handler func(context.Co op.DefaultStatus = http.StatusNoContent } } - huma.Register(api, op, handler) + wrapped := func(ctx context.Context, in *I) (*O, error) { + if err := validateInputBody(in); err != nil { + return nil, translateDomainError(err) + } + return handler(ctx, in) + } + huma.Register(api, op, wrapped) } // EnableAutoPatch synthesises a PATCH for every resource that already diff --git a/pkg/routes/api/v2/validation.go b/pkg/routes/api/v2/validation.go new file mode 100644 index 000000000..7f9d14bbc --- /dev/null +++ b/pkg/routes/api/v2/validation.go @@ -0,0 +1,57 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "reflect" + "sort" + + "code.vikunja.io/api/pkg/models" + + "github.com/asaskevich/govalidator" +) + +// validateInputBody runs govalidator over the request body so v2 enforces the +// `valid:` tag rules (required, url, …) that Huma's schema validation doesn't, +// matching v1. The payload sits in an input field named Body by convention; +// inputs without one (read/list/delete) validate to nil. +func validateInputBody(in any) error { + v := reflect.Indirect(reflect.ValueOf(in)) + if v.Kind() != reflect.Struct { + return nil + } + body := v.FieldByName("Body") + if !body.IsValid() || !body.CanInterface() { + return nil + } + // Only struct bodies carry `valid:` tags. Binary/primitive bodies (e.g. the + // avatar endpoint's []byte) would make govalidator.ValidateStruct error out. + if reflect.Indirect(body).Kind() != reflect.Struct { + return nil + } + if _, err := govalidator.ValidateStruct(body.Interface()); err != nil { + byField := govalidator.ErrorsByField(err) + fields := make([]string, 0, len(byField)) + for field, msg := range byField { + fields = append(fields, field+": "+msg) + } + // Map iteration order is non-deterministic; sort for a stable errors[]. + sort.Strings(fields) + return models.InvalidFieldError(fields) + } + return nil +}