mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
e0ced97ba1
Move audit logs into org-scoped Postgres storage, wire audit events across backend handlers, and surface actor details in the dashboard activity log. Add realtime audit invalidation and retention pruning so the trail stays current and bounded.
294 lines
9.0 KiB
Go
294 lines
9.0 KiB
Go
// Avatar handlers — upload, replace and clear avatars for users and
|
|
// organizations.
|
|
//
|
|
// Strategy: server receives a multipart upload, validates size + mime,
|
|
// stores the image in S3 under a deterministic key, marks the object
|
|
// public-readable and saves the public URL on the user/org row.
|
|
//
|
|
// Constants:
|
|
//
|
|
// - max size: 2 MiB. Anything larger gets a 400.
|
|
// - accepted MIME: image/png, image/jpeg, image/webp, image/gif.
|
|
// - object key: avatars/{kind}/{id}-{epoch}.{ext}
|
|
//
|
|
// The epoch suffix forces cache busting on replacement so the
|
|
// browser doesn't keep showing the old avatar at the same URL.
|
|
|
|
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"image"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"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"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/storage"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
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"
|
|
)
|
|
|
|
// Intentionally narrow allowlist: only PNG and JPEG. WebP, GIF and
|
|
// SVG are excluded because:
|
|
//
|
|
// - SVG can carry script payloads served from the same origin.
|
|
// - GIF historically has decoder CVEs and we don't need motion in
|
|
// an avatar.
|
|
// - WebP has had several Chrome decoder CVEs (2023's heap overflow
|
|
// being the loudest) and it's not worth the surface area when
|
|
// PNG/JPEG cover the same use cases.
|
|
//
|
|
// The client-side resizer always re-encodes to JPEG anyway, so this
|
|
// list constrains what survives a bypass of the JS path.
|
|
var allowedAvatarMIME = map[string]string{
|
|
"image/png": ".png",
|
|
"image/jpg": ".jpg",
|
|
"image/jpeg": ".jpg",
|
|
}
|
|
|
|
// UploadUserAvatar — POST /me/avatar (multipart, field "file")
|
|
func (h *Handler) UploadUserAvatar(c *gin.Context) {
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
bytesRead, mime, ext, xerr := readAvatarUpload(c)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("avatars/users/%s-%d%s", userID.String(), time.Now().Unix(), ext)
|
|
url, xerr := putPublicObject(c.Request.Context(), h.Storage, key, bytesRead, mime)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
if err := h.UserRepo.UpdateAvatar(c.Request.Context(), userID, &url); err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityUser, &userID, nil, map[string]string{"field": "avatar_url"})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"avatar_url": url})
|
|
}
|
|
|
|
// DeleteUserAvatar — DELETE /me/avatar
|
|
func (h *Handler) DeleteUserAvatar(c *gin.Context) {
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
if err := h.UserRepo.UpdateAvatar(c.Request.Context(), userID, nil); err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityUser, &userID, nil, map[string]string{"field": "avatar_url", "value": "cleared"})
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// UploadOrganizationAvatar — POST /organization/avatar (owner only)
|
|
func (h *Handler) UploadOrganizationAvatar(c *gin.Context) {
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
// Only the owner can change the workspace's avatar — covers the
|
|
// same trust boundary as billing.
|
|
if xerr := h.requireOrgOwner(c, *orgID, userID); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
bytesRead, mime, ext, xerr := readAvatarUpload(c)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("avatars/organizations/%s-%d%s", orgID.String(), time.Now().Unix(), ext)
|
|
url, xerr := putPublicObject(c.Request.Context(), h.Storage, key, bytesRead, mime)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
if err := h.OrgRepo.UpdateAvatar(c.Request.Context(), *orgID, &url); err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityOrganization, orgID, nil, map[string]string{"field": "avatar_url"})
|
|
|
|
c.JSON(http.StatusOK, gin.H{"avatar_url": url})
|
|
}
|
|
|
|
// DeleteOrganizationAvatar — DELETE /organization/avatar (owner only)
|
|
func (h *Handler) DeleteOrganizationAvatar(c *gin.Context) {
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
if xerr := h.requireOrgOwner(c, *orgID, userID); xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
if err := h.OrgRepo.UpdateAvatar(c.Request.Context(), *orgID, nil); err != nil {
|
|
errx.Handle(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) requireOrgOwner(c *gin.Context, orgID, userID uuid.UUID) *errx.Error {
|
|
m, err := h.OrgRepo.GetMember(c.Request.Context(), orgID, userID)
|
|
if err != nil || m == nil {
|
|
return errx.ErrForbidden
|
|
}
|
|
if !strings.EqualFold(m.Role, string(models.RoleOwner)) {
|
|
return errx.ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readAvatarUpload pulls the "file" field out of the request, enforces
|
|
// the size cap, sniffs the mime, and returns the bytes ready for S3.
|
|
func readAvatarUpload(c *gin.Context) ([]byte, string, string, *errx.Error) {
|
|
// Cap the request body before parsing so a 50MB upload doesn't
|
|
// pin a gin worker.
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, avatarMaxBytes+1024)
|
|
|
|
fh, err := c.FormFile("file")
|
|
if err != nil {
|
|
return nil, "", "", errx.New(errx.BadRequest, "file is required")
|
|
}
|
|
if fh.Size > avatarMaxBytes {
|
|
return nil, "", "", errx.New(errx.BadRequest, "avatar must be smaller than 2 MB")
|
|
}
|
|
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
return nil, "", "", errx.InternalError()
|
|
}
|
|
defer src.Close()
|
|
|
|
buf := &bytes.Buffer{}
|
|
if _, err := io.Copy(buf, src); err != nil {
|
|
return nil, "", "", errx.InternalError()
|
|
}
|
|
body := buf.Bytes()
|
|
|
|
// Trust the client-declared content type only if it's on the
|
|
// allowlist. http.DetectContentType is a stronger signal, so
|
|
// prefer that if it disagrees.
|
|
declared := strings.ToLower(fh.Header.Get("Content-Type"))
|
|
sniffed := http.DetectContentType(body)
|
|
mime := declared
|
|
if _, ok := allowedAvatarMIME[sniffed]; ok {
|
|
mime = sniffed
|
|
}
|
|
ext, ok := allowedAvatarMIME[mime]
|
|
if !ok {
|
|
return nil, "", "", errx.New(errx.BadRequest, "avatar must be a PNG or JPG")
|
|
}
|
|
// Fallback ext from the filename when mime sniff is ambiguous.
|
|
if ext == "" {
|
|
ext = strings.ToLower(path.Ext(fh.Filename))
|
|
if ext == "" {
|
|
ext = ".png"
|
|
}
|
|
}
|
|
|
|
// Dimension cap. We expect the client to resize before upload —
|
|
// this is a backstop against a raw camera dump or a bypass of the
|
|
// JS resizer.
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", "", errx.New(errx.BadRequest, "image could not be parsed")
|
|
}
|
|
if cfg.Width > avatarMaxDimension || cfg.Height > avatarMaxDimension {
|
|
return nil, "", "", errx.New(
|
|
errx.BadRequest,
|
|
fmt.Sprintf("avatar dimensions must be %dpx or smaller — please resize before uploading", avatarMaxDimension),
|
|
)
|
|
}
|
|
return body, mime, ext, nil
|
|
}
|
|
|
|
func putPublicObject(ctx context.Context, store storage.Store, key string, body []byte, mime string) (string, *errx.Error) {
|
|
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,
|
|
})
|
|
if err != nil {
|
|
return "", errx.InternalError()
|
|
}
|
|
return fmt.Sprintf(avatarPublicURLFormat, s3client.Bucket, key), nil
|
|
}
|
|
|
|
// Imports below are referenced so the file compiles cleanly even if
|
|
// future refactors remove specific dependencies above.
|
|
var _ repository.UserRepository = (repository.UserRepository)(nil)
|