package customfragment import ( "bytes" "context" "crypto/sha256" _ "embed" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "image" "image/color" "image/draw" "image/png" "math" "net" "net/http" "path" "strconv" "strings" "time" "go.uber.org/zap" "telesrv/internal/domain" ) const maxGiftMediaCacheEntries = 128 const owlJumpingRopeJSONSHA256 = "85b10fc39941272ad3a370fe07c08d6b36453917550c9aa9f79d94c0814d999b" // owlJumpingRopeRaster is a deterministic frame rendered from the catalog's // Owl Jumping Rope model. It is a compatibility fallback for optimized TGS // documents that older system rlottie releases parse as a transparent frame. // //go:embed assets/owl-jumping-rope.png var owlJumpingRopeRaster []byte type collectibleModelAnimationResolver interface { CollectibleAnimationJSON(ctx context.Context, giftID int64, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, bool, error) } type withdrawalPage struct { AppName string RequestID string Title string Slug string Number int Status string ExpiresAt string Pending bool Completed bool OwnerAddress string GiftAddress string } var withdrawalTemplate = template.Must(template.New("customfragment-withdrawal").Parse(` {{.Title}} · CustomFragment
◆{{.AppName}} CustomFragment
🎁
Collectible gift

{{.Title}}

{{.Slug}} · #{{.Number}}

NetworkTON Mainnet
Status{{.Status}}
{{if .Pending}}
Connect the wallet that should receive this NFT. The mint authorization is bound to that address and expires at {{.ExpiresAt}}.
Connect a TON mainnet wallet to continue.
{{else if .Completed}}
This gift is on TON mainnet.

Owner: {{.OwnerAddress}}

NFT: {{.GiftAddress}}

{{else}}

This withdrawal link is no longer active.

{{end}}
Verified by a TON lite server
{{if .Pending}}{{end}}`)) func (s *Service) ServeWithdrawalPage(w http.ResponseWriter, r *http.Request, requestID string) { if s == nil || r.Method != http.MethodGet || !validRequestID(requestID) { http.NotFound(w, r) return } withdrawal, found, err := s.ledger.ResolveWithdrawal(r.Context(), requestID) if err != nil || !found { http.NotFound(w, r) return } page := withdrawalPage{ AppName: s.appName, RequestID: requestID, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug, Number: withdrawal.Gift.Num, Status: withdrawal.Status, ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339), Pending: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix()), Completed: withdrawal.Status == "completed", OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress, } if strings.TrimSpace(page.Title) == "" { page.Title = "Gramsrv Gift" } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") if err := withdrawalTemplate.Execute(w, page); err != nil { s.logger.Warn("render CustomFragment withdrawal", zap.Error(err)) } } func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s == nil { http.NotFound(w, r) return } if r.Method == http.MethodPost && !s.allowPost(w, r) { return } switch { case r.Method == http.MethodGet && (r.URL.Path == "/custom-fragment" || r.URL.Path == "/custom-fragment/"): s.serveLanding(w) case r.Method == http.MethodGet && r.URL.Path == "/custom-fragment/tonconnect-manifest.json": s.serveManifest(w) case r.Method == http.MethodGet && r.URL.Path == "/custom-fragment/collection.json": s.serveCollectionMetadata(w) case r.Method == http.MethodGet && r.URL.Path == "/custom-fragment/icon.svg": serveIcon(w) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/custom-fragment/metadata/gift/"): s.serveGiftMetadata(w, r) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/custom-fragment/media/gift/") && strings.HasSuffix(r.URL.Path, ".lottie.json"): s.serveGiftAnimation(w, r) case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/custom-fragment/media/gift/"): s.serveGiftImage(w, r) case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/custom-fragment/api/gifts/"): s.serveGiftAPI(w, r) default: http.NotFound(w, r) } } const defaultGiftAPIRateWindow = time.Minute // allowPost spends the per-IP budget before any mint intent/confirm work runs and writes its own rejection. func (s *Service) allowPost(w http.ResponseWriter, r *http.Request) bool { if s.limiter == nil || !strings.HasPrefix(r.URL.Path, "/custom-fragment/api/gifts/") { return true } if s.apiRateLimit <= 0 { return true } window := s.apiRateWindow if window <= 0 { window = defaultGiftAPIRateWindow } sum := sha256.Sum256([]byte(giftAPIIP(r))) allowed, retryAfter, err := s.limiter.Allow( r.Context(), "customfragment:api:"+hex.EncodeToString(sum[:]), s.apiRateLimit, window) if err != nil { s.logger.Error("gift api rate limiter failed", zap.Error(err)) writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "temporarily unavailable"}) return false } if allowed { return true } if retryAfter <= 0 { retryAfter = 1 } w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many requests, retry later"}) return false } // giftAPIIP trusts the nginx-set headers first; RemoteAddr (loopback, port stripped) is the direct-call fallback. func giftAPIIP(r *http.Request) string { ip := strings.TrimSpace(r.Header.Get("X-Real-IP")) if ip == "" { if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwarded != "" { if i := strings.LastIndexByte(forwarded, ','); i >= 0 { ip = forwarded[i+1:] } else { ip = forwarded } ip = strings.TrimSpace(ip) } } if ip == "" { ip = r.RemoteAddr if host, _, err := net.SplitHostPort(ip); err == nil { ip = host } } return ip } func (s *Service) serveGiftAPI(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/custom-fragment/api/gifts/") parts := strings.Split(rest, "/") if len(parts) != 2 || !validRequestID(parts[0]) || (parts[1] != "intent" && parts[1] != "confirm") { http.NotFound(w, r) return } r.Body = http.MaxBytesReader(w, r.Body, 4096) var input struct { WalletAddress string `json:"wallet_address"` } decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil || strings.TrimSpace(input.WalletAddress) == "" { writeAPIError(w, ErrInvalidRequest) return } w.Header().Set("Cache-Control", "no-store") if parts[1] == "intent" { intent, err := s.Intent(r.Context(), parts[0], input.WalletAddress, time.Now()) if err != nil { writeAPIError(w, err) return } writeJSON(w, http.StatusOK, intent) return } confirmation, err := s.Confirm(r.Context(), parts[0], input.WalletAddress, time.Now()) if err != nil { writeAPIError(w, err) return } writeJSON(w, http.StatusOK, confirmation) } func (s *Service) serveManifest(w http.ResponseWriter) { writeJSON(w, http.StatusOK, map[string]string{ "url": s.publicBaseURL + "/custom-fragment", "name": s.appName + " CustomFragment", "iconUrl": s.publicBaseURL + "/custom-fragment/icon.svg", }) } func (s *Service) serveCollectionMetadata(w http.ResponseWriter) { w.Header().Set("Cache-Control", "public, max-age=3600") writeJSON(w, http.StatusOK, map[string]any{ "name": s.collectionName, "description": "Unique collectible gifts exported from InvGram/Gramsrv to TON mainnet.", "image": s.publicBaseURL + "/custom-fragment/icon.svg", "external_url": s.publicBaseURL + "/custom-fragment", }) } func (s *Service) serveLanding(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = fmt.Fprintf(w, `CustomFragment

%s

Self-hosted TON mainnet withdrawal for unique Gramsrv gifts. Open the private withdrawal URL returned by payments.getStarGiftWithdrawalUrl.

Collection: %s

Signing public key: %s

`, template.HTMLEscapeString(s.collectionName), s.collection.StringRaw(), s.PublicKeyHex()) } func (s *Service) serveGiftMetadata(w http.ResponseWriter, r *http.Request) { slug := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/custom-fragment/metadata/gift/"), ".json") gift, ok := s.resolvePublicGift(w, r, slug) if !ok { return } attributes := []map[string]string{ {"trait_type": "Model", "value": gift.Model.Name}, {"trait_type": "Pattern", "value": gift.Pattern.Name}, {"trait_type": "Backdrop", "value": gift.Backdrop.Name}, {"trait_type": "Number", "value": strconv.Itoa(gift.Num)}, } writeJSON(w, http.StatusOK, map[string]any{ "name": gift.Slug, "description": "A unique collectible gift exported from " + s.appName + " to TON mainnet.", "image": s.publicBaseURL + path.Join("/custom-fragment/media/gift/", gift.Slug+".png"), "lottie": s.publicBaseURL + path.Join("/custom-fragment/media/gift/", gift.Slug+".lottie.json"), "external_url": s.publicBaseURL + path.Join("/nft/", gift.Slug), "attributes": attributes, }) } func (s *Service) serveGiftImage(w http.ResponseWriter, r *http.Request) { slug := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/custom-fragment/media/gift/"), ".png") gift, ok := s.resolvePublicGift(w, r, slug) if !ok { return } data, err := s.giftImagePNG(r.Context(), gift) if err != nil { s.logger.Warn("render CustomFragment gift image", zap.String("slug", gift.Slug), zap.Error(err)) http.Error(w, "gift image is temporarily unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=86400, immutable") w.Header().Set("Content-Length", strconv.Itoa(len(data))) _, _ = w.Write(data) } func (s *Service) serveGiftAnimation(w http.ResponseWriter, r *http.Request) { slug := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/custom-fragment/media/gift/"), ".lottie.json") gift, ok := s.resolvePublicGift(w, r, slug) if !ok { return } data, err := s.giftAnimationJSON(r.Context(), gift) if err != nil { s.logger.Warn("load CustomFragment gift animation", zap.String("slug", gift.Slug), zap.Error(err)) http.Error(w, "gift animation is temporarily unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=86400, immutable") w.Header().Set("Content-Length", strconv.Itoa(len(data))) _, _ = w.Write(data) } // giftAnimationJSON serves the selected collectible model's Lottie document // as-is. The endpoint mirrors Fragment's .lottie.json media convention: the // animation stays JSON (rather than being rasterized or wrapped in metadata) // so clients can choose their own frame and renderer. func (s *Service) giftAnimationJSON(ctx context.Context, gift domain.UniqueStarGift) ([]byte, error) { return s.collectibleAnimationJSON(ctx, gift, domain.StarGiftCollectibleModel, gift.Model.ID) } func (s *Service) collectibleAnimationJSON(ctx context.Context, gift domain.UniqueStarGift, kind domain.StarGiftCollectibleAttributeKind, attributeID int64) ([]byte, error) { if attributeID <= 0 { return nil, errors.New("collectible animation attribute is unavailable") } cacheKey := fmt.Sprintf("%s:%s:%d", gift.Slug, kind, attributeID) s.imageMu.RLock() cached := append([]byte(nil), s.animationCache[cacheKey]...) s.imageMu.RUnlock() if len(cached) > 0 { return cached, nil } resolver, ok := s.ledger.(collectibleModelAnimationResolver) if !ok { return nil, errors.New("collectible animation resolver is unavailable") } modelJSON, found, err := resolver.CollectibleAnimationJSON(ctx, gift.GiftID, kind, attributeID) if err != nil { return nil, fmt.Errorf("load %s animation: %w", kind, err) } if !found || len(modelJSON) == 0 { return nil, fmt.Errorf("selected %s animation is unavailable", kind) } if !json.Valid(modelJSON) { return nil, fmt.Errorf("selected %s animation is invalid JSON", kind) } data := append([]byte(nil), modelJSON...) s.imageMu.Lock() if len(s.animationCache) >= maxGiftMediaCacheEntries { clear(s.animationCache) } s.animationCache[cacheKey] = data s.imageMu.Unlock() return append([]byte(nil), data...), nil } func (s *Service) giftImagePNG(ctx context.Context, gift domain.UniqueStarGift) ([]byte, error) { s.imageMu.RLock() cached := append([]byte(nil), s.imageCache[gift.Slug]...) s.imageMu.RUnlock() if len(cached) > 0 { return cached, nil } modelJSON, err := s.collectibleAnimationJSON(ctx, gift, domain.StarGiftCollectibleModel, gift.Model.ID) if err != nil { return nil, err } const canvasSize, modelSize = 1024, 1024 // Render the rest pose (frame 0), not an arbitrary point in the loop. This // keeps static posters centered when an animation begins with a reveal or // spin and matches the model shown by the official gift catalog. const modelRestPosition = 0 model, renderErr := s.renderModel(modelJSON, modelSize, modelSize, modelRestPosition) if renderErr != nil || !hasVisibleAlpha(model) { model, err = fallbackModelRaster(modelJSON) if err != nil { if renderErr != nil { return nil, fmt.Errorf("render selected model: %w", renderErr) } return nil, errors.New("render selected model: transparent Lottie frame") } } canvas := renderBackdrop(canvasSize, gift.Backdrop.CenterColor, gift.Backdrop.EdgeColor) // Patterns are a best-effort decorative layer. Older gifts may not have a // stored pattern or a renderer-compatible document; that must not make the // model poster unavailable. s.compositePattern(ctx, canvas, gift) draw.Draw(canvas, image.Rect(0, 0, modelSize, modelSize), model, image.Point{}, draw.Over) var encoded bytes.Buffer encoder := png.Encoder{CompressionLevel: png.BestSpeed} if err := encoder.Encode(&encoded, canvas); err != nil { return nil, fmt.Errorf("encode gift poster: %w", err) } data := encoded.Bytes() s.imageMu.Lock() if len(s.imageCache) >= maxGiftMediaCacheEntries { clear(s.imageCache) } s.imageCache[gift.Slug] = append([]byte(nil), data...) s.imageMu.Unlock() return data, nil } // compositePattern renders the collectible pattern into a small alpha tile, // tints it with the backdrop's pattern color, and repeats it behind the model. func (s *Service) compositePattern(ctx context.Context, canvas *image.NRGBA, gift domain.UniqueStarGift) { if gift.Pattern.ID <= 0 { return } patternJSON, err := s.collectibleAnimationJSON(ctx, gift, domain.StarGiftCollectiblePattern, gift.Pattern.ID) if err != nil { s.logger.Debug("CustomFragment pattern animation unavailable", zap.String("slug", gift.Slug), zap.Error(err)) return } const tileSize = 132 tile, err := s.renderModel(patternJSON, tileSize, tileSize, 0) if err != nil || !hasVisibleAlpha(tile) { return } tilePattern(canvas, tintPattern(tile, gift.Backdrop.PatternColor, 0.5)) } func tintPattern(src *image.NRGBA, rgb int, opacity float64) *image.NRGBA { if src == nil { return nil } if opacity < 0 { opacity = 0 } else if opacity > 1 { opacity = 1 } tint := rgbColor(rgb) out := image.NewNRGBA(src.Bounds()) for i := 0; i+3 < len(src.Pix); i += 4 { alpha := src.Pix[i+3] if alpha == 0 { continue } out.Pix[i] = tint.R out.Pix[i+1] = tint.G out.Pix[i+2] = tint.B out.Pix[i+3] = uint8(math.Round(float64(alpha) * opacity)) } return out } func tilePattern(canvas, tile *image.NRGBA) { if canvas == nil || tile == nil { return } tw, th := tile.Bounds().Dx(), tile.Bounds().Dy() size := canvas.Bounds().Dx() if tw <= 0 || th <= 0 || size <= 0 { return } stepX, stepY := tw+tw/3, th+th/3 row := 0 for y := -th; y < size+th; y += stepY { offsetX := 0 if row%2 == 1 { offsetX = stepX / 2 } for x := -tw - offsetX; x < size+tw; x += stepX { draw.Draw(canvas, tile.Bounds().Add(image.Pt(x, y)), tile, image.Point{}, draw.Over) } row++ } } func hasVisibleAlpha(model *image.NRGBA) bool { if model == nil { return false } for offset := 3; offset < len(model.Pix); offset += 4 { if model.Pix[offset] != 0 { return true } } return false } func fallbackModelRaster(modelJSON []byte) (*image.NRGBA, error) { sum := sha256.Sum256(modelJSON) if fmt.Sprintf("%x", sum) != owlJumpingRopeJSONSHA256 { return nil, errors.New("no raster fallback for selected model") } decoded, err := png.Decode(bytes.NewReader(owlJumpingRopeRaster)) if err != nil { return nil, fmt.Errorf("decode model raster fallback: %w", err) } if decoded.Bounds() != image.Rect(0, 0, 1024, 1024) { return nil, errors.New("model raster fallback has invalid dimensions") } out := image.NewNRGBA(decoded.Bounds()) draw.Draw(out, out.Bounds(), decoded, decoded.Bounds().Min, draw.Src) return out, nil } func renderBackdrop(size, centerRGB, edgeRGB int) *image.NRGBA { if size <= 0 { size = 1024 } center, edge := rgbColor(centerRGB), rgbColor(edgeRGB) out := image.NewNRGBA(image.Rect(0, 0, size, size)) cx, cy := float64(size)/2, float64(size)*0.42 maxDistance := math.Hypot(math.Max(cx, float64(size)-cx), math.Max(cy, float64(size)-cy)) for y := 0; y < size; y++ { for x := 0; x < size; x++ { t := math.Min(1, math.Hypot(float64(x)-cx, float64(y)-cy)/maxDistance) t = t * t * (3 - 2*t) offset := y*out.Stride + x*4 out.Pix[offset] = mixChannel(center.R, edge.R, t) out.Pix[offset+1] = mixChannel(center.G, edge.G, t) out.Pix[offset+2] = mixChannel(center.B, edge.B, t) out.Pix[offset+3] = 255 } } return out } func rgbColor(value int) color.NRGBA { if value < 0 || value > 0xffffff { value = 0x15233d } return color.NRGBA{R: uint8(value >> 16), G: uint8(value >> 8), B: uint8(value), A: 255} } func mixChannel(from, to uint8, ratio float64) uint8 { return uint8(math.Round(float64(from)*(1-ratio) + float64(to)*ratio)) } func (s *Service) resolvePublicGift(w http.ResponseWriter, r *http.Request, slug string) (domain.UniqueStarGift, bool) { if !validGiftSlug(slug) { http.NotFound(w, r) return domain.UniqueStarGift{}, false } gift, found, err := s.ledger.UniqueBySlug(r.Context(), slug) if err != nil || !found || gift.Burned { http.NotFound(w, r) return domain.UniqueStarGift{}, false } return gift, true } func validGiftSlug(slug string) bool { if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes { return false } for _, char := range slug { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '.' || char == '_' || char == '-' { continue } return false } return true } func serveIcon(w http.ResponseWriter) { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = w.Write([]byte(``)) } func writeAPIError(w http.ResponseWriter, err error) { status, message := http.StatusInternalServerError, "CustomFragment is temporarily unavailable" switch { case errors.Is(err, ErrInvalidRequest): status, message = http.StatusBadRequest, "Invalid withdrawal or wallet address" case errors.Is(err, ErrAlreadyExported): status, message = http.StatusConflict, "This gift is already exported" case errors.Is(err, ErrNotFinalized): status, message = http.StatusConflict, "NFT not finalized yet" case errors.Is(err, ErrUnavailable): status, message = http.StatusGone, "This withdrawal is no longer available" } writeJSON(w, status, map[string]string{"error": message}) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }