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.
This commit is contained in:
kolaente 2026-06-12 11:48:47 +02:00
parent 5dead874d2
commit 5fd8a467b1
1 changed files with 142 additions and 0 deletions

View File

@ -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) {