diff --git a/pkg/routes/api/v2/avatar.go b/pkg/routes/api/v2/avatar.go
new file mode 100644
index 000000000..13c5cb4b4
--- /dev/null
+++ b/pkg/routes/api/v2/avatar.go
@@ -0,0 +1,115 @@
+// 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 (
+ "context"
+ "net/http"
+
+ "code.vikunja.io/api/pkg/config"
+ "code.vikunja.io/api/pkg/db"
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/modules/avatar"
+ "code.vikunja.io/api/pkg/modules/avatar/botmarble"
+ "code.vikunja.io/api/pkg/modules/avatar/empty"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/danielgtaylor/huma/v2"
+)
+
+// avatarResponse carries raw image bytes plus the runtime Content-Type. Huma writes
+// the []byte Body straight to the wire; the header:"Content-Type" field overrides
+// content negotiation so the provider's actual mime type reaches the client.
+type avatarResponse struct {
+ ContentType string `header:"Content-Type"`
+ Body []byte
+}
+
+type avatarInput struct {
+ Username string `path:"username" doc:"The username of the user whose avatar to fetch."`
+ Size int64 `query:"size" default:"250" minimum:"1" doc:"Desired avatar edge length in pixels. Clamped to the server's configured maximum if larger; providers that render fixed-size images may ignore it."`
+}
+
+// RegisterAvatarRoutes wires the avatar binary endpoint onto the Huma API.
+func RegisterAvatarRoutes(api huma.API) {
+ Register(api, huma.Operation{
+ OperationID: "avatar-get",
+ Summary: "Get a user's avatar",
+ Description: "Returns the user's avatar as raw image bytes. The Content-Type is chosen at " +
+ "runtime by the user's avatar provider (gravatar, initials, marble, an uploaded image, " +
+ "or the default placeholder). An unknown username is not an error — the default " +
+ "placeholder avatar is returned. Authenticated like every other endpoint.",
+ Method: http.MethodGet,
+ Path: "/avatar/{username}",
+ Tags: []string{"user"},
+ // Spell out the binary response; a bare []byte Body would otherwise be
+ // modeled as a base64 JSON string instead of binary image data.
+ Responses: map[string]*huma.Response{
+ "200": {
+ Description: "The avatar image bytes. The Content-Type header carries the actual image type.",
+ Content: map[string]*huma.MediaType{
+ "application/octet-stream": {
+ Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"},
+ },
+ },
+ },
+ },
+ }, avatarGet)
+}
+
+func avatarGet(ctx context.Context, in *avatarInput) (*avatarResponse, error) {
+ // Pull auth so the endpoint behaves as authenticated even though it reads
+ // no per-user permission (any authenticated caller may view any avatar,
+ // matching v1). The token middleware already rejects anonymous requests;
+ // this surfaces a clean 401 if a handler is somehow reached without auth.
+ if _, err := authFromCtx(ctx); err != nil {
+ return nil, err
+ }
+
+ s := db.NewSession()
+ defer s.Close()
+
+ u, err := user.GetUserWithEmail(s, &user.User{Username: in.Username})
+ if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
+ log.Errorf("Error getting user for avatar: %v", err)
+ return nil, translateDomainError(err)
+ }
+
+ found := err == nil || user.IsErrUserStatusError(err)
+
+ avatarProvider := avatar.GetProvider(u)
+ if !found {
+ // Unknown user: serve the default placeholder, exactly like v1.
+ avatarProvider = &empty.Provider{}
+ }
+ if found && u.IsBot() {
+ avatarProvider = &botmarble.Provider{}
+ }
+
+ size := in.Size
+ if size > config.ServiceMaxAvatarSize.GetInt64() {
+ size = config.ServiceMaxAvatarSize.GetInt64()
+ }
+
+ a, mimeType, err := avatarProvider.GetAvatar(u, size)
+ if err != nil {
+ log.Errorf("Error getting avatar for user %d: %v", u.ID, err)
+ return nil, translateDomainError(err)
+ }
+
+ return &avatarResponse{ContentType: mimeType, Body: a}, nil
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 9bfd530ba..4380ad687 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -420,6 +420,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
apiv2.RegisterTaskDuplicateRoutes(api)
apiv2.RegisterProjectViewRoutes(api)
apiv2.RegisterAdminProjectRoutes(api)
+ apiv2.RegisterAvatarRoutes(api)
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.
diff --git a/pkg/webtests/huma_avatar_test.go b/pkg/webtests/huma_avatar_test.go
new file mode 100644
index 000000000..16c481015
--- /dev/null
+++ b/pkg/webtests/huma_avatar_test.go
@@ -0,0 +1,84 @@
+// 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 webtests
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAvatar covers the v2 binary-response endpoint GET /api/v2/avatar/{username}.
+// It is the reference for serving raw bytes with a runtime-chosen Content-Type.
+// Unlike v1's CRUD resources there is no model — the input is a path username and
+// an optional size query param. The endpoint is authenticated (global security),
+// so an anonymous request must be rejected with 401.
+func TestAvatar(t *testing.T) {
+ t.Run("Authenticated known user returns bytes and a content type", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/avatar/user1", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotEmpty(t, rec.Body.Bytes(), "avatar bytes must be returned")
+ assert.NotEmpty(t, rec.Header().Get("Content-Type"), "a content type must be set")
+ // user1 has no avatar_provider configured, so the empty provider serves the default SVG.
+ assert.Equal(t, "image/svg+xml", rec.Header().Get("Content-Type"))
+ })
+
+ t.Run("Size query param is accepted and clamped", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // A size far above config.ServiceMaxAvatarSize must be clamped, not rejected.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/avatar/user1?size=99999", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "oversized size must clamp, not error; body: %s", rec.Body.String())
+ assert.NotEmpty(t, rec.Body.Bytes())
+
+ // A normal size is honored.
+ rec = humaRequest(t, e, http.MethodGet, "/api/v2/avatar/user1?size=64", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotEmpty(t, rec.Body.Bytes())
+ })
+
+ t.Run("Anonymous request is rejected with 401", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+
+ // Empty token => no Authorization header => anonymous. Proves the endpoint
+ // inherits the global security and is NOT public.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/avatar/user1", "", "", "")
+ assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String())
+ })
+
+ t.Run("Unknown user falls back to the default avatar like v1", func(t *testing.T) {
+ e, err := setupTestEnv()
+ require.NoError(t, err)
+ token := humaTokenFor(t, &testuser1)
+
+ // v1 GetAvatar does not 404 for an unknown user — it serves the empty
+ // provider's default SVG with a 200. v2 must match that behaviour.
+ rec := humaRequest(t, e, http.MethodGet, "/api/v2/avatar/this-user-does-not-exist", "", token, "")
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ assert.NotEmpty(t, rec.Body.Bytes())
+ assert.Equal(t, "image/svg+xml", rec.Header().Get("Content-Type"))
+ })
+}