diff --git a/internal/handler/sadhna.go b/internal/handler/sadhna.go index a0e4480..35a7f27 100644 --- a/internal/handler/sadhna.go +++ b/internal/handler/sadhna.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strings" "time" "github.com/go-chi/chi/v5" @@ -140,6 +141,48 @@ func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) response.JSON(w, http.StatusOK, list) } +type customGiftReq struct { + Name string `json:"name"` + Meaning string `json:"meaning"` + ImageURL string `json:"image_url"` +} + +// CreateCustomGift — POST /admin/reward-gifts/catalog +// +// Adds a sticker to the catalog. It reaches nobody until it is sent. +func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) { + var req customGiftReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL) + if err != nil { + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.JSON(w, http.StatusCreated, reward) +} + +// PresignGiftImage — POST /admin/reward-gifts/image-presign +func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) { + var req struct { + ContentType string `json:"content_type"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType)) + if err != nil { + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.JSON(w, http.StatusOK, map[string]string{ + "upload_url": upload, "image_url": public, + }) +} + type giftReq struct { RewardKey string `json:"reward_key"` } diff --git a/internal/repository/sadhna_reward_repo.go b/internal/repository/sadhna_reward_repo.go index b8213cd..06e4255 100644 --- a/internal/repository/sadhna_reward_repo.go +++ b/internal/repository/sadhna_reward_repo.go @@ -187,6 +187,25 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m return nil } +// CreateCustom adds a reward to the catalog from the admin panel. +// +// Custom entries survive re-seeding: Seed upserts only the keys in its +// embedded JSON, and these keys are namespaced away from those. +func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) { + var w model.SadhnaReward + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort) + -- Sorted after the seeded catalog so custom gifts don't reshuffle it. + VALUES ($1, $2, $3, $4, true, 1000) + RETURNING id, key, name, meaning, image_url, active, sort`, + key, name, meaning, imageURL, + ).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort) + if err != nil { + return nil, fmt.Errorf("create custom reward: %w", err) + } + return &w, nil +} + // ByKey looks up one reward from the catalog. func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) { var w model.SadhnaReward diff --git a/internal/server/server.go b/internal/server/server.go index e6137b9..05b57b0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -327,7 +327,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { if err := rewardRepo.Seed(context.Background()); err != nil { logger.Warn("sadhna: could not seed reward catalog", zap.Error(err)) } - sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger) + sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, r2, logger) s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc) // Feedback + stats — share Postgres with the rest of /api/v1 and @@ -1163,6 +1163,8 @@ func (s *Server) setupRouter() { // Announcements composed and published from the admin panel. r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone) + r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift) + r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage) r.Get("/broadcasts", s.broadcastH.List) r.Post("/broadcasts", s.broadcastH.Create) diff --git a/internal/service/broadcast_service.go b/internal/service/broadcast_service.go index 7c1b4c7..23ae72e 100644 --- a/internal/service/broadcast_service.go +++ b/internal/service/broadcast_service.go @@ -223,7 +223,9 @@ func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string if !ok || kind != model.AttachmentImage { return "", "", ErrChatBadMedia } - return presignImage(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType) + // Returns the KEY: broadcasts.banner_key stores a key, and withBanner + // builds the URL from it at read time. + return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType) } func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) { diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go index 57eb00b..1562b37 100644 --- a/internal/service/sadhna_service.go +++ b/internal/service/sadhna_service.go @@ -16,7 +16,12 @@ package service import ( "context" + crand "crypto/rand" + "encoding/hex" + "errors" "math/rand" + "regexp" + "strings" "time" "github.com/prabhupadasays/spsays-bff/internal/model" @@ -64,7 +69,10 @@ type SadhnaService struct { posts *repository.PostRepo users *repository.UserRepo rewards *repository.RewardRepo - logger *zap.Logger + // r2 stores custom gift artwork. Optional: nil disables custom gifts + // rather than failing anything that already works. + r2 *repository.R2 + logger *zap.Logger } func NewSadhnaService( @@ -72,9 +80,13 @@ func NewSadhnaService( posts *repository.PostRepo, users *repository.UserRepo, rewards *repository.RewardRepo, + r2 *repository.R2, logger *zap.Logger, ) *SadhnaService { - return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger} + return &SadhnaService{ + logs: logs, posts: posts, users: users, rewards: rewards, + r2: r2, logger: logger, + } } const dayFmt = "2006-01-02" @@ -415,6 +427,57 @@ func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (i return s.rewards.GrantGiftToEveryone(ctx, reward.ID) } +// customKeyPrefix namespaces admin-made rewards away from the seeded catalog, +// so Seed can never collide with one. +const customKeyPrefix = "custom_" + +var nonSlug = regexp.MustCompile(`[^a-z0-9]+`) + +// customRewardKey derives a stable, unique key from a name. +// +// The random suffix is what makes it unique: two gifts may reasonably share a +// name, and a collision would overwrite the first one's artwork. +func customRewardKey(name string) (string, error) { + slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_") + if slug == "" { + slug = "gift" + } + if len(slug) > 32 { + slug = slug[:32] + } + suffix := make([]byte, 4) + if _, err := crand.Read(suffix); err != nil { + return "", err + } + return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil +} + +// CreateCustomGift adds a new sticker to the catalog. It is not granted to +// anyone until it is sent. +func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("a gift needs a name") + } + if strings.TrimSpace(imageURL) == "" { + return nil, errors.New("a gift needs an image") + } + key, err := customRewardKey(name) + if err != nil { + return nil, err + } + return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL) +} + +// PresignGiftImage issues an upload URL for custom sticker artwork. +// +// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an +// absolute address, and stickers render for every participant in a chat, so +// the artwork belongs in the public bucket alongside avatars and post images. +func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) { + return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType) +} + // GiftableRewards is the catalog an admin picks from. func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) { return s.rewards.ListGiftable(ctx) diff --git a/internal/service/social_service.go b/internal/service/social_service.go index 8369f97..adccc0e 100644 --- a/internal/service/social_service.go +++ b/internal/service/social_service.go @@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp // presignImage builds an R2 presign for a user-scoped media key under the // given prefix. The random suffix invalidates browser caches when the image // is replaced. +// presignImageKey is presignImage's sibling for callers that store the storage +// KEY rather than a URL — so the CDN domain can change without rewriting rows. +// Handing those callers a public URL silently doubles it at read time. +func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) { + if r2 == nil { + return "", "", errors.New("media uploads are not configured") + } + ext, ok := imageExtensionFor(contentType) + if !ok { + return "", "", fmt.Errorf("unsupported content type: %s", contentType) + } + suffix := make([]byte, 8) + if _, err := rand.Read(suffix); err != nil { + return "", "", err + } + key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext) + uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute) + if err != nil { + return "", "", err + } + return uploadURL, key, nil +} + func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) { if r2 == nil { return "", "", errors.New("media uploads are not configured")