diff --git a/pkg/filemanager/serve_public.go b/pkg/filemanager/serve_public.go new file mode 100644 index 000000000..3b6e93604 --- /dev/null +++ b/pkg/filemanager/serve_public.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package filemanager + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +var ErrPublicFileNotFound = errors.New("public file not found") + +func (s *Service) ServePublicFile( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + fileID gid.GID, +) error { + file, err := s.GetPublicFile(ctx, fileID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrPublicFileNotFound + } + + return err + } + + conds := FileConditions{ + IfNoneMatch: r.Header.Get("If-None-Match"), + IfRange: r.Header.Get("If-Range"), + Range: r.Header.Get("Range"), + } + if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" { + if t, parseErr := http.ParseTime(ifModifiedSince); parseErr == nil { + conds.IfModifiedSince = t + } + } + + obj, err := s.OpenFile(ctx, file, conds) + if err != nil { + return err + } + + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Accept-Ranges", "bytes") + + if obj.ETag != "" { + w.Header().Set("ETag", obj.ETag) + } + + if obj.NotModified { + w.WriteHeader(http.StatusNotModified) + return nil + } + + if obj.RangeNotSatisfiable { + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", file.FileSize)) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + + return nil + } + + defer func() { _ = obj.Body.Close() }() + + w.Header().Set("Content-Type", file.MimeType) + w.Header().Set("Content-Length", strconv.FormatInt(obj.ContentLength, 10)) + + if !obj.LastModified.IsZero() { + w.Header().Set("Last-Modified", obj.LastModified.UTC().Format(http.TimeFormat)) + } + + if obj.PartialContent { + w.Header().Set("Content-Range", obj.ContentRange) + w.WriteHeader(http.StatusPartialContent) + } + + if _, err := io.Copy(w, obj.Body); err != nil { + return err + } + + return nil +} diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index 4942230aa..30d1f58bf 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -23,9 +23,7 @@ package files_v1 import ( "errors" "fmt" - "io" "net/http" - "strconv" "time" "github.com/go-chi/chi/v5" @@ -111,90 +109,20 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { return } - file, err := h.fileSvc.GetPublicFile(r.Context(), fileID) - if err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - jsonx.RenderNotFound(w, fmt.Errorf("file not found")) - return - } + err = h.fileSvc.ServePublicFile(r.Context(), w, r, fileID) + if errors.Is(err, filemanager.ErrPublicFileNotFound) { + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) + return + } + if err != nil { h.logger.ErrorCtx( r.Context(), - "cannot get public file URL", + "cannot serve public file", log.Error(err), log.String("file_id", fileIDStr), ) jsonx.RenderInternalServerError(w) - - return - } - - conds := filemanager.FileConditions{ - IfNoneMatch: r.Header.Get("If-None-Match"), - IfRange: r.Header.Get("If-Range"), - Range: r.Header.Get("Range"), - } - if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" { - if t, parseErr := http.ParseTime(ifModifiedSince); parseErr == nil { - conds.IfModifiedSince = t - } - } - - obj, err := h.fileSvc.OpenFile(r.Context(), file, conds) - if err != nil { - h.logger.ErrorCtx( - r.Context(), - "cannot open public file", - log.Error(err), - log.String("file_id", fileIDStr), - ) - jsonx.RenderInternalServerError(w) - - return - } - - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - w.Header().Set("Accept-Ranges", "bytes") - - if obj.ETag != "" { - w.Header().Set("ETag", obj.ETag) - } - - if obj.NotModified { - w.WriteHeader(http.StatusNotModified) - return - } - - if obj.RangeNotSatisfiable { - w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", file.FileSize)) - w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) - - return - } - - defer func() { _ = obj.Body.Close() }() - - w.Header().Set("Content-Type", file.MimeType) - w.Header().Set("Content-Length", strconv.FormatInt(obj.ContentLength, 10)) - - if !obj.LastModified.IsZero() { - w.Header().Set("Last-Modified", obj.LastModified.UTC().Format(http.TimeFormat)) - } - - if obj.PartialContent { - w.Header().Set("Content-Range", obj.ContentRange) - w.WriteHeader(http.StatusPartialContent) - } - - if _, err := io.Copy(w, obj.Body); err != nil { - h.logger.ErrorCtx( - r.Context(), - "cannot stream public file", - log.Error(err), - log.String("file_id", fileIDStr), - ) - - return } }