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:
parent
8bec654595
commit
5ccbd0d74e
|
|
@ -18,6 +18,7 @@ package apiv2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.vikunja.io/api/pkg/config"
|
"code.vikunja.io/api/pkg/config"
|
||||||
|
|
@ -26,6 +27,8 @@ import (
|
||||||
"code.vikunja.io/api/pkg/modules/background"
|
"code.vikunja.io/api/pkg/modules/background"
|
||||||
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
|
backgroundHandler "code.vikunja.io/api/pkg/modules/background/handler"
|
||||||
"code.vikunja.io/api/pkg/modules/background/unsplash"
|
"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"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
)
|
)
|
||||||
|
|
@ -55,6 +58,26 @@ func RegisterBackgroundRoutes(api huma.API) {
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}, backgroundRemove)
|
}, 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() {
|
if config.BackgroundsUploadEnabled.GetBool() {
|
||||||
Register(api, huma.Operation{
|
Register(api, huma.Operation{
|
||||||
OperationID: "projects-background-upload",
|
OperationID: "projects-background-upload",
|
||||||
|
|
@ -89,6 +112,39 @@ func RegisterBackgroundRoutes(api huma.API) {
|
||||||
Path: "/projects/{project}/backgrounds/unsplash",
|
Path: "/projects/{project}/backgrounds/unsplash",
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
}, backgroundUnsplashSet)
|
}, 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
|
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 {
|
func backgroundRemove(ctx context.Context, in *struct {
|
||||||
ProjectID int64 `path:"project"`
|
ProjectID int64 `path:"project"`
|
||||||
}) (*singleBody[models.Project], error) {
|
}) (*singleBody[models.Project], error) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue