Use stable url for public file

This will allow the CDN infrastructure to cache it properly

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-02 17:29:44 +02:00
committed by Sacha Al Himdani
parent be33f72f7d
commit 3c7a27b7ff
4 changed files with 326 additions and 5 deletions

View File

@@ -17,16 +17,39 @@ package filemanager
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
smithyhttp "github.com/aws/smithy-go/transport/http"
"go.probo.inc/probo/pkg/coredata"
)
// FileObject carries a streamed S3 object body plus the metadata needed to set
// HTTP response headers. NotModified is set when the caller's conditional
// request matched the current object, in which case Body is nil.
type FileObject struct {
Body io.ReadCloser
ContentType string
ContentLength int64
ETag string
LastModified time.Time
NotModified bool
}
// FileConditions carries HTTP conditional-request values forwarded to S3 so it
// can answer with 304 Not Modified without transferring the body. Zero values
// are omitted.
type FileConditions struct {
IfNoneMatch string
IfModifiedSince time.Time
}
func (s *Service) GetFileBase64(
ctx context.Context,
file *coredata.File,
@@ -77,6 +100,53 @@ func (s *Service) GetFileBytes(
return data, nil
}
// OpenFile streams an object from S3 without buffering it in memory. Conditional
// request values in conds are forwarded to S3; a 304 Not Modified response is
// surfaced as a FileObject with NotModified set (and a nil Body). The caller
// owns closing Body.
func (s *Service) OpenFile(
ctx context.Context,
file *coredata.File,
conds FileConditions,
) (*FileObject, error) {
input := &s3.GetObjectInput{
Bucket: new(file.BucketName),
Key: new(file.FileKey),
}
if conds.IfNoneMatch != "" {
input.IfNoneMatch = &conds.IfNoneMatch
}
if !conds.IfModifiedSince.IsZero() {
input.IfModifiedSince = &conds.IfModifiedSince
}
result, err := s.s3Client.GetObject(ctx, input)
if err != nil {
if respErr, ok := errors.AsType[*smithyhttp.ResponseError](err); ok {
if respErr.HTTPStatusCode() == http.StatusNotModified {
return &FileObject{NotModified: true}, nil
}
}
return nil, fmt.Errorf("cannot get file from S3: %w", err)
}
obj := &FileObject{
Body: result.Body,
ContentType: file.MimeType,
ContentLength: file.FileSize,
LastModified: file.UpdatedAt,
}
if result.ETag != nil {
obj.ETag = *result.ETag
}
if result.LastModified != nil {
obj.LastModified = *result.LastModified
}
return obj, nil
}
func (s *Service) PutFile(
ctx context.Context,
file *coredata.File,

155
pkg/filemanager/s3_test.go Normal file
View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 filemanager_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
)
func newTestS3Service(t *testing.T, handler http.HandlerFunc) *filemanager.Service {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
s3Client := awss3.NewFromConfig(
aws.Config{
Region: "us-east-1",
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
},
func(o *awss3.Options) {
o.BaseEndpoint = aws.String(srv.URL)
o.UsePathStyle = true
},
)
return filemanager.NewService(nil, nil, s3Client)
}
func TestOpenFile_StreamsBody(t *testing.T) {
t.Parallel()
const (
etag = `"abc123"`
content = "hello world"
)
svc := newTestS3Service(
t,
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, content)
},
)
file := &coredata.File{
BucketName: "uploads",
FileKey: "tenant/file",
MimeType: "text/plain",
FileSize: int64(len(content)),
}
obj, err := svc.OpenFile(context.Background(), file, filemanager.FileConditions{})
require.NoError(t, err)
require.NotNil(t, obj)
require.False(t, obj.NotModified)
defer func() { _ = obj.Body.Close() }()
assert.Equal(t, etag, obj.ETag)
assert.Equal(t, "text/plain", obj.ContentType)
assert.Equal(t, int64(len(content)), obj.ContentLength)
body, err := io.ReadAll(obj.Body)
require.NoError(t, err)
assert.Equal(t, content, string(body))
}
func TestOpenFile_NotModifiedByETag(t *testing.T) {
t.Parallel()
const etag = `"abc123"`
svc := newTestS3Service(
t,
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
_, _ = io.WriteString(w, "content")
},
)
file := &coredata.File{
BucketName: "uploads",
FileKey: "tenant/file",
MimeType: "text/plain",
}
obj, err := svc.OpenFile(context.Background(), file, filemanager.FileConditions{IfNoneMatch: etag})
require.NoError(t, err)
require.NotNil(t, obj)
assert.True(t, obj.NotModified)
assert.Nil(t, obj.Body)
}
func TestOpenFile_NotModifiedByModifiedSince(t *testing.T) {
t.Parallel()
svc := newTestS3Service(
t,
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = io.WriteString(w, "content")
},
)
file := &coredata.File{
BucketName: "uploads",
FileKey: "tenant/file",
MimeType: "text/plain",
}
obj, err := svc.OpenFile(
context.Background(),
file,
filemanager.FileConditions{IfModifiedSince: time.Now()},
)
require.NoError(t, err)
require.NotNil(t, obj)
assert.True(t, obj.NotModified)
assert.Nil(t, obj.Body)
}

View File

@@ -17,7 +17,9 @@ package files_v1
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
@@ -121,11 +123,18 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
return
}
presignedURL, err := h.fileSvc.GeneratePresignedURL(r.Context(), file, presignedURLExpiry)
conds := filemanager.FileConditions{IfNoneMatch: r.Header.Get("If-None-Match")}
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 get public file URL",
"cannot open public file",
log.Error(err),
log.String("file_id", fileIDStr),
)
@@ -134,7 +143,38 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
return
}
http.Redirect(w, r, presignedURL, http.StatusTemporaryRedirect)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if obj.ETag != "" {
w.Header().Set("ETag", obj.ETag)
}
if obj.NotModified {
w.WriteHeader(http.StatusNotModified)
return
}
defer func() { _ = obj.Body.Close() }()
w.Header().Set("Content-Type", file.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(file.FileSize, 10))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if !obj.LastModified.IsZero() {
w.Header().Set("Last-Modified", obj.LastModified.UTC().Format(http.TimeFormat))
}
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
}
}
func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {