331 lines
9.8 KiB
Go
331 lines
9.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
|
"github.com/Silo-Server/silo-server/internal/imageutil"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
const (
|
|
profileAvatarPresetPrefix = "preset:"
|
|
profileAvatarUploadPrefix = "upload:"
|
|
profileAvatarMaxFileSize = 10 << 20
|
|
)
|
|
|
|
var legacyProfileAvatarIDs = map[string]struct{}{
|
|
"avatar-1": {},
|
|
"avatar-2": {},
|
|
"avatar-3": {},
|
|
"avatar-4": {},
|
|
"avatar-5": {},
|
|
"avatar-6": {},
|
|
"avatar-7": {},
|
|
"avatar-8": {},
|
|
}
|
|
|
|
var supportedDiceBearAvatarStyles = map[string]struct{}{
|
|
"identicon": {},
|
|
"initials": {},
|
|
"bottts-neutral": {},
|
|
"fun-emoji": {},
|
|
"pixel-art-neutral": {},
|
|
}
|
|
|
|
type profileAvatarStore interface {
|
|
PutObject(ctx context.Context, bucket, key string, data []byte) error
|
|
DeleteObject(ctx context.Context, bucket, key string) error
|
|
ListObjects(ctx context.Context, bucket, prefix string) ([]string, error)
|
|
PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error)
|
|
Bucket() string
|
|
}
|
|
|
|
func normalizePresetAvatarReference(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
if strings.HasPrefix(value, profileAvatarUploadPrefix) {
|
|
return "", fmt.Errorf("custom upload avatar references are not allowed in JSON profile updates")
|
|
}
|
|
if strings.HasPrefix(value, profileAvatarPresetPrefix) {
|
|
presetID := strings.TrimPrefix(value, profileAvatarPresetPrefix)
|
|
if !isKnownPresetAvatarID(presetID) {
|
|
return "", fmt.Errorf("unknown avatar preset")
|
|
}
|
|
return profileAvatarPresetPrefix + presetID, nil
|
|
}
|
|
if !isKnownPresetAvatarID(value) {
|
|
return "", fmt.Errorf("unknown avatar preset")
|
|
}
|
|
return profileAvatarPresetPrefix + value, nil
|
|
}
|
|
|
|
func isKnownPresetAvatarID(id string) bool {
|
|
if _, ok := legacyProfileAvatarIDs[id]; ok {
|
|
return true
|
|
}
|
|
_, _, ok := parseDiceBearPresetID(id)
|
|
return ok
|
|
}
|
|
|
|
func isUploadedAvatarRef(ref string) bool {
|
|
return strings.HasPrefix(strings.TrimSpace(ref), profileAvatarUploadPrefix)
|
|
}
|
|
|
|
func avatarRefReplacesUpload(currentRef, nextRef string) bool {
|
|
return isUploadedAvatarRef(currentRef) && strings.TrimSpace(currentRef) != strings.TrimSpace(nextRef)
|
|
}
|
|
|
|
func resolveProfileAvatar(ctx context.Context, store profileAvatarStore, ttl time.Duration, ref string) (source string, url string) {
|
|
trimmed := strings.TrimSpace(ref)
|
|
if trimmed == "" {
|
|
return "none", ""
|
|
}
|
|
if strings.HasPrefix(trimmed, profileAvatarPresetPrefix) {
|
|
presetID := strings.TrimPrefix(trimmed, profileAvatarPresetPrefix)
|
|
if !isKnownPresetAvatarID(presetID) {
|
|
return "none", ""
|
|
}
|
|
return "preset", bundledProfileAvatarURL(presetID)
|
|
}
|
|
if strings.HasPrefix(trimmed, profileAvatarUploadPrefix) {
|
|
if store == nil {
|
|
return "upload", ""
|
|
}
|
|
displayKey := uploadedAvatarDisplayKey(strings.TrimPrefix(trimmed, profileAvatarUploadPrefix))
|
|
presignTTL := ttl
|
|
if presignTTL <= 0 {
|
|
presignTTL = 15 * time.Minute
|
|
}
|
|
presignedURL, err := store.PresignGetURL(ctx, store.Bucket(), displayKey, presignTTL)
|
|
if err != nil {
|
|
return "upload", ""
|
|
}
|
|
return "upload", presignedURL
|
|
}
|
|
if isKnownPresetAvatarID(trimmed) {
|
|
return "preset", bundledProfileAvatarURL(trimmed)
|
|
}
|
|
return "none", ""
|
|
}
|
|
|
|
func bundledProfileAvatarURL(id string) string {
|
|
if style, seed, ok := parseDiceBearPresetID(id); ok {
|
|
return diceBearAvatarURL(style, seed)
|
|
}
|
|
return "/profile-avatars/" + id + ".svg"
|
|
}
|
|
|
|
func parseDiceBearPresetID(id string) (style string, seed string, ok bool) {
|
|
parts := strings.Split(id, ":")
|
|
if len(parts) != 3 || parts[0] != "dicebear" {
|
|
return "", "", false
|
|
}
|
|
style = strings.TrimSpace(parts[1])
|
|
seed = strings.TrimSpace(parts[2])
|
|
if _, supported := supportedDiceBearAvatarStyles[style]; !supported {
|
|
return "", "", false
|
|
}
|
|
if !isSafeAvatarSeed(seed) {
|
|
return "", "", false
|
|
}
|
|
return style, seed, true
|
|
}
|
|
|
|
func isSafeAvatarSeed(seed string) bool {
|
|
if seed == "" || len(seed) > 64 {
|
|
return false
|
|
}
|
|
for _, char := range seed {
|
|
switch {
|
|
case char >= 'a' && char <= 'z':
|
|
case char >= 'A' && char <= 'Z':
|
|
case char >= '0' && char <= '9':
|
|
case char == '-':
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func diceBearAvatarURL(style string, seed string) string {
|
|
query := url.Values{}
|
|
query.Set("seed", seed)
|
|
query.Set("size", "128")
|
|
query.Set("radius", "24")
|
|
query.Set("backgroundType", "gradientLinear")
|
|
return "https://api.dicebear.com/9.x/" + style + "/svg?" + query.Encode()
|
|
}
|
|
|
|
func profileAvatarPrefix(userID int, profileID string) string {
|
|
return fmt.Sprintf("profile-avatars/%d/%s", userID, profileID)
|
|
}
|
|
|
|
func uploadedAvatarOriginalKey(userID int, profileID string) string {
|
|
return profileAvatarPrefix(userID, profileID) + "/original.webp"
|
|
}
|
|
|
|
func uploadedAvatarDisplayKey(originalKey string) string {
|
|
if strings.HasSuffix(originalKey, "/original.webp") {
|
|
return strings.TrimSuffix(originalKey, "/original.webp") + "/w256.webp"
|
|
}
|
|
return strings.TrimRight(originalKey, "/") + "/w256.webp"
|
|
}
|
|
|
|
func deleteUploadedAvatarObjects(ctx context.Context, store profileAvatarStore, userID int, profileID string) error {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
keys, err := store.ListObjects(ctx, store.Bucket(), profileAvatarPrefix(userID, profileID)+"/")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, key := range keys {
|
|
if err := store.DeleteObject(ctx, store.Bucket(), key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *ProfileHandler) HandleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
if userID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
if h.AvatarStore == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "Avatar upload storage is not configured")
|
|
return
|
|
}
|
|
|
|
profileID := chi.URLParam(r, "id")
|
|
if profileID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
|
|
return
|
|
}
|
|
|
|
store, err := h.storeProvider.ForUser(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
}
|
|
profile, err := store.GetProfile(r.Context(), profileID)
|
|
if err != nil || profile == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
|
|
return
|
|
}
|
|
|
|
if err := r.ParseMultipartForm(profileAvatarMaxFileSize); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("avatar")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Missing avatar file")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
if posterExtension(header.Header.Get("Content-Type")) == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use JPEG, PNG, or WebP")
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(file, profileAvatarMaxFileSize+1))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
|
|
return
|
|
}
|
|
if len(data) > profileAvatarMaxFileSize {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Avatar must be under 10 MB")
|
|
return
|
|
}
|
|
|
|
result, err := imageutil.GenerateSquareVariants(data, []int{256})
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid image file")
|
|
return
|
|
}
|
|
|
|
bucket := h.AvatarStore.Bucket()
|
|
originalKey := uploadedAvatarOriginalKey(userID, profileID)
|
|
for _, variant := range result.Variants {
|
|
key := profileAvatarPrefix(userID, profileID) + "/" + variant.Key + result.Ext
|
|
if err := h.AvatarStore.PutObject(r.Context(), bucket, key, variant.Data); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store avatar")
|
|
return
|
|
}
|
|
}
|
|
|
|
avatarRef := profileAvatarUploadPrefix + originalKey
|
|
if err := store.UpdateProfile(r.Context(), profileID, userstore.UpdateProfileInput{Avatar: &avatarRef}); err != nil {
|
|
// Uploaded avatar keys are stable per profile, so rolling back by deleting the
|
|
// prefix here can remove the avatar the profile still references after a DB failure.
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save avatar")
|
|
return
|
|
}
|
|
|
|
updatedProfile, err := store.GetProfile(r.Context(), profileID)
|
|
if err != nil || updatedProfile == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
|
|
}
|
|
|
|
func (h *ProfileHandler) HandleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
|
userID := apimw.GetUserID(r.Context())
|
|
if userID == 0 {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
profileID := chi.URLParam(r, "id")
|
|
if profileID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Profile ID is required")
|
|
return
|
|
}
|
|
|
|
store, err := h.storeProvider.ForUser(r.Context(), userID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
|
|
return
|
|
}
|
|
profile, err := store.GetProfile(r.Context(), profileID)
|
|
if err != nil || profile == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Profile not found")
|
|
return
|
|
}
|
|
|
|
if isUploadedAvatarRef(profile.Avatar) {
|
|
emptyRef := ""
|
|
if err := store.UpdateProfile(r.Context(), profileID, userstore.UpdateProfileInput{Avatar: &emptyRef}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear avatar")
|
|
return
|
|
}
|
|
_ = deleteUploadedAvatarObjects(r.Context(), h.AvatarStore, userID, profileID)
|
|
}
|
|
|
|
updatedProfile, err := store.GetProfile(r.Context(), profileID)
|
|
if err != nil || updatedProfile == nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retrieve updated profile")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.toProfileResponse(r.Context(), *updatedProfile))
|
|
}
|