mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-25 16:01:57 +00:00
feat: serve public blobs from the backend and fix avatar uploads on filesystem
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/")
|
||||
}
|
||||
Reference in New Issue
Block a user