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)
}