diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index 8bd8c2168..2a3393c5a 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -22,26 +22,50 @@ import ( "github.com/go-chi/chi/v5" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/filesign" + "go.probo.inc/probo/pkg/file" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/securecookie" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/jsonutil" ) const presignedURLExpiry = 1 * time.Hour type Handler struct { logger *log.Logger - fileSvc *filesign.Service + fileSvc *file.Service + probo *probo.Service + iamSvc *iam.Service } -func NewMux(logger *log.Logger, fileSvc *filesign.Service) *chi.Mux { +func NewMux( + logger *log.Logger, + fileSvc *file.Service, + proboSvc *probo.Service, + iamSvc *iam.Service, + cookieConfig securecookie.Config, + tokenSecret string, +) *chi.Mux { h := &Handler{ logger: logger, fileSvc: fileSvc, + probo: proboSvc, + iamSvc: iamSvc, } r := chi.NewRouter() - r.Get("/{fileID}", h.handleGetPublicFile) + r.Get("/public/{fileID}", h.handleGetPublicFile) + + r.Group(func(r chi.Router) { + r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) + r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) + r.Use(authn.NewOAuth2AccessTokenMiddleware(iamSvc)) + r.Use(authn.NewIdentityPresenceMiddleware()) + r.Get("/{fileID}", h.handleGetFile) + }) return r } @@ -51,14 +75,14 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { fileID, err := gid.ParseGID(fileIDStr) if err != nil { - http.NotFound(w, r) + jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) return } - presignedURL, err := h.fileSvc.GeneratePresignedFileURL(r.Context(), fileID, presignedURLExpiry) + presignedURL, err := h.fileSvc.GeneratePublicPresignedURL(r.Context(), fileID, presignedURLExpiry) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - http.NotFound(w, r) + jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) return } @@ -68,7 +92,60 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { log.Error(err), log.String("file_id", fileIDStr), ) - http.Error(w, "internal server error", http.StatusInternalServerError) + jsonutil.RenderInternalServerError(w) + + return + } + + http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect) +} + +func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { + fileIDStr := chi.URLParam(r, "fileID") + + fileID, err := gid.ParseGID(fileIDStr) + if err != nil { + jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + return + } + + ctx := r.Context() + identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) + + params := iam.AuthorizeParams{ + Principal: identity.ID, + Resource: fileID, + Action: probo.ActionFileGet, + ResourceAttributes: make(map[string]string), + } + if session != nil { + params.Session = &session.ID + } + + scope, err := h.iamSvc.Authorizer.Authorize(ctx, params) + if err != nil { + jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + return + } + + f, err := h.probo.Files.Get(ctx, scope, fileID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + return + } + + h.logger.ErrorCtx(ctx, "cannot get file", log.Error(err), log.String("file_id", fileIDStr)) + jsonutil.RenderInternalServerError(w) + + return + } + + presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry) + if err != nil { + h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr)) + jsonutil.RenderInternalServerError(w) return } diff --git a/pkg/server/api/files/v1/handler_test.go b/pkg/server/api/files/v1/handler_test.go new file mode 100644 index 000000000..afe343581 --- /dev/null +++ b/pkg/server/api/files/v1/handler_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package files_v1 + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/securecookie" +) + +func testHandler() *Handler { + return &Handler{ + logger: log.NewLogger(log.WithOutput(io.Discard)), + } +} + +func TestHandleGetPublicFile_InvalidGID(t *testing.T) { + t.Parallel() + + h := testHandler() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/public/not-a-valid-gid", nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("fileID", "not-a-valid-gid") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + h.handleGetPublicFile(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestHandleGetFile_InvalidGID(t *testing.T) { + t.Parallel() + + h := testHandler() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/not-a-valid-gid", nil) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("fileID", "not-a-valid-gid") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + h.handleGetFile(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestHandleGetFile_UnauthenticatedReturns401(t *testing.T) { + t.Parallel() + + // NewMux with nil services — safe because auth middleware returns 401 + // before any service is called when no credentials are present. + mux := NewMux( + log.NewLogger(log.WithOutput(io.Discard)), + nil, // fileSvc — not reached + nil, // proboSvc — not reached + nil, // iamSvc — not reached when no token/cookie present + securecookie.Config{}, + "test-secret", + ) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/some-valid-looking-id", nil) + mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) +}