feat: serve public blobs from the backend and fix avatar uploads on filesystem

This commit is contained in:
Matthew Meszaros
2026-07-20 09:56:02 +02:00
parent 0f1886b9dd
commit 8de314ae8b
2 changed files with 67 additions and 23 deletions
+7 -23
View File
@@ -29,9 +29,6 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -43,9 +40,8 @@ import (
)
const (
avatarMaxBytes int64 = 2 * 1024 * 1024
avatarMaxDimension = 1024 // px — reject anything bigger so a phone-camera dump doesn't sneak through
avatarPublicURLFormat = "https://%s.s3.amazonaws.com/%s"
avatarMaxBytes int64 = 2 * 1024 * 1024
avatarMaxDimension = 1024 // px — reject anything bigger so a phone-camera dump doesn't sneak through
)
// Intentionally narrow allowlist: only PNG and JPEG. WebP, GIF and
@@ -266,26 +262,14 @@ func putPublicObject(ctx context.Context, store storage.Store, key string, body
if store == nil {
return "", errx.New(errx.ServiceUnavailable, "object storage not configured")
}
// Avatars need a permanent public-read URL with long-lived cache headers.
// The generic Store interface can't express ACL or CacheControl, and the
// hardcoded https://<bucket>.s3.amazonaws.com URL format is S3-only — so
// fall through to the S3 client when available, otherwise reject.
s3client, ok := store.(*storage.Client)
if !ok {
return "", errx.New(errx.ServiceUnavailable, "avatar uploads require an S3-compatible backend")
}
_, err := s3client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s3client.Bucket),
Key: aws.String(key),
Body: bytes.NewReader(body),
ContentType: aws.String(mime),
CacheControl: aws.String("public, max-age=31536000, immutable"),
ACL: s3types.ObjectCannedACLPublicRead,
})
// Public-read URL with long-lived cache. The backend chooses the right
// semantics per store (S3 sets an ACL + s3 URL; filesystem writes and
// serves via /public), so callers stay backend-agnostic.
url, err := store.PutPublic(ctx, key, bytes.NewReader(body), mime)
if err != nil {
return "", errx.InternalError()
}
return fmt.Sprintf(avatarPublicURLFormat, s3client.Bucket, key), nil
return url, nil
}
// Imports below are referenced so the file compiles cleanly even if
+60
View File
@@ -0,0 +1,60 @@
package handler
import (
"errors"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/infrastructure/storage"
)
// ServePublicObject streams a publicly-readable blob (avatar, org logo) from the
// active storage backend. It exists for the filesystem backend, which has no
// authority to serve objects itself; the S3 backend returns object-storage URLs
// from PutPublic and never routes through here. Only the fixed public key
// prefixes are served so this can't be used to read arbitrary stored objects.
func (h *Handler) ServePublicObject(c *gin.Context) {
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" || !isPublicKey(key) {
c.Status(http.StatusNotFound)
return
}
if h.Storage == nil {
c.Status(http.StatusServiceUnavailable)
return
}
body, err := h.Storage.Get(c.Request.Context(), key)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.Status(http.StatusNotFound)
return
}
c.Status(http.StatusInternalServerError)
return
}
defer body.Close()
if ct := mime.TypeByExtension(filepath.Ext(key)); ct != "" {
c.Header("Content-Type", ct)
}
// Keys are content-addressed (they carry an epoch suffix), so they're safe
// to cache immutably.
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, body)
}
// isPublicKey guards the /public route to the key prefixes PutPublic writes, so
// it can't be turned into a reader for arbitrary blob keys.
func isPublicKey(key string) bool {
if strings.Contains(key, "..") {
return false
}
return strings.HasPrefix(key, "avatars/") || strings.HasPrefix(key, "oauth-app-logos/")
}