From 5fd8a467b1251902b3c17e101e0ff0fae792afae Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 11:48:47 +0200 Subject: [PATCH] feat(api/v2): add project background download and unsplash proxies Port the remaining read-only background blob endpoints to /api/v2: - GET /projects/{project}/background streams the stored background (project CanRead, in-handler), modeled as an image/jpeg binary response. Honors If-Modified-Since (304) and serves through the shared WriteProjectBackground. - GET /backgrounds/unsplash/images/{image} and .../thumb proxy the upstream Unsplash image through the SSRF-safe client, gated on the unsplash provider like the sibling unsplash routes, modeled as image/jpeg binary responses. All three reuse the v1 business logic extracted in the previous commit. --- pkg/routes/api/v2/backgrounds.go | 142 +++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/pkg/routes/api/v2/backgrounds.go b/pkg/routes/api/v2/backgrounds.go index f01fcb4e3..4d7b5befe 100644 --- a/pkg/routes/api/v2/backgrounds.go +++ b/pkg/routes/api/v2/backgrounds.go @@ -18,6 +18,7 @@ package apiv2 import ( "context" + "io" "net/http" "code.vikunja.io/api/pkg/config" @@ -26,6 +27,8 @@ import ( "code.vikunja.io/api/pkg/modules/background" backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler" "code.vikunja.io/api/pkg/modules/background/unsplash" + "code.vikunja.io/api/pkg/modules/humaecho5" + webfiles "code.vikunja.io/api/pkg/web/files" "github.com/danielgtaylor/huma/v2" ) @@ -55,6 +58,26 @@ func RegisterBackgroundRoutes(api huma.API) { Tags: tags, }, backgroundRemove) + Register(api, huma.Operation{ + OperationID: "projects-background-get", + Summary: "Get a project background", + Description: "Streams a project's background image, whichever provider set it. Requires read access to the project. Always served as image/jpeg with a revalidation Last-Modified header, so a conditional If-Modified-Since request gets a 304. Returns 404 when the project has no background.", + Method: http.MethodGet, + Path: "/projects/{project}/background", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The project background as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, backgroundGet) + if config.BackgroundsUploadEnabled.GetBool() { Register(api, huma.Operation{ OperationID: "projects-background-upload", @@ -89,6 +112,39 @@ func RegisterBackgroundRoutes(api huma.API) { Path: "/projects/{project}/backgrounds/unsplash", Tags: tags, }, backgroundUnsplashSet) + + unsplashProxyResponses := map[string]*huma.Response{ + "200": { + Description: "The proxied Unsplash image as a jpeg image.", + Content: map[string]*huma.MediaType{ + "image/jpeg": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + } + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-image", + Summary: "Proxy a full-resolution Unsplash image", + Description: "Proxies the full-resolution Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashImage) + + Register(api, huma.Operation{ + OperationID: "backgrounds-unsplash-thumb", + Summary: "Proxy an Unsplash image thumbnail", + Description: "Proxies a thumbnail (max width 200px) of the Unsplash image for the given image id through Vikunja, so the client never contacts Unsplash directly (privacy). Vikunja fires the required Unsplash pingback as a side effect. Returns 404 if the image does not exist.", + Method: http.MethodGet, + Path: "/backgrounds/unsplash/images/{image}/thumb", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: unsplashProxyResponses, + }, backgroundUnsplashThumb) } } @@ -227,6 +283,92 @@ func backgroundUpload(ctx context.Context, in *backgroundUploadInput) (*singleBo return &singleBody[models.Project]{Body: project}, nil } +// backgroundGet owns auth, the session and the permission check because there is no +// handler.Do* for a file body. CanRead hydrates the project (including its +// BackgroundFileID), which the shared loader then needs. +func backgroundGet(ctx context.Context, in *struct { + ProjectID int64 `path:"project" doc:"The id of the project whose background to fetch."` +}) (*huma.StreamResponse, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + project := &models.Project{ID: in.ProjectID} + can, _, err := project.CanRead(s, a) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if !can { + _ = s.Rollback() + return nil, huma.Error403Forbidden("forbidden") + } + + bgFile, stat, err := backgroundHandler.LoadProjectBackgroundForDownload(s, project) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + // The stream callback (which closes the reader) won't run on this error path. + _ = bgFile.File.Close() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = bgFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteProjectBackground((*c).Response(), (*c).Request(), bgFile, stat) + }}, nil +} + +func backgroundUnsplashImage(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashImageByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +func backgroundUnsplashThumb(ctx context.Context, in *struct { + ImageID string `path:"image" doc:"The Unsplash image id, from a prior background search."` +}) (*huma.StreamResponse, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + body, err := unsplash.FetchUnsplashThumbByID(in.ImageID) + if err != nil { + return nil, translateDomainError(err) + } + return streamUnsplashProxy(body), nil +} + +// streamUnsplashProxy copies the open upstream Unsplash body to the response as +// image/jpeg and closes it, mirroring v1's c.Stream. +func streamUnsplashProxy(body io.ReadCloser) *huma.StreamResponse { + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = body.Close() }() + c := humaecho5.Unwrap(hctx) + resp := (*c).Response() + resp.Header().Set("Content-Type", "image/jpg") + resp.WriteHeader(http.StatusOK) + _, _ = io.Copy(resp, body) + }} +} + func backgroundRemove(ctx context.Context, in *struct { ProjectID int64 `path:"project"` }) (*singleBody[models.Project], error) {