internal/customfragment/http.go
Rama main · réplica de lectura del repositorio original.
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(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark"><title>{{.Title}} · CustomFragment</title>
<script defer src="https://unpkg.com/@tonconnect/ui@2.3.1/dist/tonconnect-ui.min.js"></script>
<style>
:root{font-family:Arial,system-ui,sans-serif;color:#17212b;background:#f4f6f8;--tg-accent:#2481cc;--tg-muted:#8794a1;--tg-card:#fff;--tg-line:#d8e0e8}*{box-sizing:border-box}body{min-height:100vh;margin:0;background:linear-gradient(#e9f3fc,#f4f6f8 45%);padding:calc(18px + env(safe-area-inset-top)) 18px calc(24px + env(safe-area-inset-bottom))}.shell{width:min(100%,520px);margin:0 auto}.brand{display:flex;align-items:center;gap:10px;color:#52606d;font-size:14px;font-weight:700;margin:4px 0 14px}.brand i{width:34px;height:34px;border-radius:10px;background:var(--tg-accent);display:grid;place-items:center;color:white;font-style:normal}.card{position:relative;overflow:hidden;border:1px solid var(--tg-line);background:var(--tg-card);border-radius:12px;padding:22px;box-shadow:0 2px 12px #17212b12}.orb{width:132px;height:132px;margin:2px auto 20px;border-radius:50%;display:grid;place-items:center;background:#e9f3fc;box-shadow:inset 0 0 0 1px #2481cc22;font-size:48px}.eyebrow{text-align:center;color:var(--tg-accent);text-transform:uppercase;font-size:12px;font-weight:700;letter-spacing:.1em}.title{text-align:center;margin:8px 0 6px;font-size:clamp(28px,8vw,38px);line-height:1.08}.subtitle{text-align:center;color:var(--tg-muted);margin:0 0 24px}.facts{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:20px 0}.fact{background:#f4f7fa;border:1px solid var(--tg-line);border-radius:8px;padding:13px}.fact span{display:block;color:var(--tg-muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.fact strong{font-size:14px}.notice{color:#52606d;font-size:13px;line-height:1.5;background:#f0f7fd;border-left:3px solid var(--tg-accent);border-radius:8px;padding:13px 14px;margin:16px 0}.actions{display:grid;gap:12px;margin-top:20px}#wallet{display:flex;justify-content:center;min-height:48px}button.primary{appearance:none;border:0;border-radius:8px;padding:13px 18px;background:var(--tg-accent);color:white;font:inherit;font-weight:700;cursor:pointer}button.primary:disabled{opacity:.45;cursor:not-allowed}.status{min-height:22px;text-align:center;color:var(--tg-muted);font-size:13px;margin-top:14px;overflow-wrap:anywhere}.status.error{color:#c83d42}.status.done{color:#168552}.chain{display:flex;align-items:center;justify-content:center;gap:7px;color:var(--tg-muted);font-size:12px;margin-top:20px}.dot{width:7px;height:7px;border-radius:50%;background:#2aa36b}.addresses{margin-top:18px;color:var(--tg-muted);font-size:12px;overflow-wrap:anywhere}.addresses p{padding:10px;background:#f4f7fa;border-radius:8px}.expired{text-align:center;color:#b56b2c}.footer{text-align:center;color:var(--tg-muted);font-size:11px;margin:18px 0}@media(prefers-color-scheme:dark){:root{color:#e8edf2;background:#17212b;--tg-card:#212a33;--tg-line:#35424f;--tg-muted:#aab7c4}body{background:linear-gradient(#1b2d3d,#17212b 50%)}.fact,.addresses p{background:#26333d}.notice{background:#263744;color:#c5d0da}.orb{background:#263744}}
</style></head><body><main class="shell" id="app" data-request="{{.RequestID}}"><div class="brand"><i>◆</i><span>{{.AppName}} CustomFragment</span></div><section class="card"><div class="orb">🎁</div><div class="eyebrow">Collectible gift</div><h1 class="title">{{.Title}}</h1><p class="subtitle">{{.Slug}} · #{{.Number}}</p><div class="facts"><div class="fact"><span>Network</span><strong>TON Mainnet</strong></div><div class="fact"><span>Status</span><strong id="state">{{.Status}}</strong></div></div>
{{if .Pending}}<div class="notice">Connect the wallet that should receive this NFT. The mint authorization is bound to that address and expires at {{.ExpiresAt}}.</div><div class="actions"><div id="wallet"></div><button class="primary" id="mint" disabled>Mint gift on TON</button></div><div class="status" id="message">Connect a TON mainnet wallet to continue.</div>{{else if .Completed}}<div class="status done">This gift is on TON mainnet.</div><div class="addresses"><p>Owner: {{.OwnerAddress}}</p><p>NFT: {{.GiftAddress}}</p></div>{{else}}<p class="expired">This withdrawal link is no longer active.</p>{{end}}
<div class="chain"><span class="dot"></span><span>Verified by a TON lite server</span></div></section><div class="footer">A self-hosted Gramsrv mint flow · no official Fragment server</div></main>
{{if .Pending}}<script>
window.addEventListener('DOMContentLoaded',()=>{const root=document.getElementById('app'),button=document.getElementById('mint'),message=document.getElementById('message'),state=document.getElementById('state'),requestID=root.dataset.request;let account=null,busy=false;const say=(text,kind='')=>{message.textContent=text;message.className='status '+kind};if(!window.TON_CONNECT_UI){say('TON Connect failed to load. Reload the page.','error');return}const tc=new TON_CONNECT_UI.TonConnectUI({manifestUrl:location.origin+'/custom-fragment/tonconnect-manifest.json',buttonRootId:'wallet'});tc.onStatusChange(wallet=>{account=wallet&&wallet.account?wallet.account:null;button.disabled=!account||busy;if(account&&String(account.chain)!=='-239'){button.disabled=true;say('Switch the wallet to TON Mainnet.','error')}else if(account){say('Wallet connected. You can mint the gift.')}else{say('Connect a TON mainnet wallet to continue.')}});const post=async(endpoint)=>{const response=await fetch('/custom-fragment/api/gifts/'+encodeURIComponent(requestID)+'/'+endpoint,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({wallet_address:account.address})});let data={};try{data=await response.json()}catch(_){}return{response,data}};const waitForMint=async()=>{for(let i=0;i<45;i++){const{response,data}=await post('confirm');if(response.ok){state.textContent='completed';say('Gift minted and verified on TON mainnet.','done');button.remove();document.getElementById('wallet').remove();return}if(response.status!==409)throw new Error(data.error||'Could not verify the NFT');say('Transaction sent. Waiting for mainnet finalization…');await new Promise(resolve=>setTimeout(resolve,3000))}throw new Error('Mainnet verification timed out. You can safely retry confirmation.')};button.addEventListener('click',async()=>{if(!account||busy)return;if(String(account.chain)!=='-239'){say('Switch the wallet to TON Mainnet.','error');return}busy=true;button.disabled=true;try{say('Creating a wallet-bound mint authorization…');const{response,data}=await post('intent');if(!response.ok)throw new Error(data.error||'Could not create mint authorization');await tc.sendTransaction({validUntil:data.valid_until,network:data.network,messages:[{address:data.collection_address,amount:data.amount,payload:data.payload}]});await waitForMint()}catch(error){say(error&&error.message?error.message:'Mint cancelled.','error');busy=false;button.disabled=!account}})});
</script>{{end}}</body></html>`))
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, `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>CustomFragment</title><style>body{font:16px/1.5 system-ui;background:#0b1220;color:#eef5ff;max-width:680px;margin:12vh auto;padding:24px}code{overflow-wrap:anywhere;color:#76c4ff}</style><h1>%s</h1><p>Self-hosted TON mainnet withdrawal for unique Gramsrv gifts. Open the private withdrawal URL returned by <code>payments.getStarGiftWithdrawalUrl</code>.</p><p>Collection: <code>%s</code></p><p>Signing public key: <code>%s</code></p>`, 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(`<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="#35adff"/><stop offset="1" stop-color="#725cff"/></linearGradient></defs><rect width="512" height="512" rx="118" fill="url(#g)"/><path fill="white" d="M129 196h254v205H129zM107 144h298v76H107z"/><path fill="none" stroke="#5c63f0" stroke-width="34" d="M256 144v257M178 144c-49-55 38-99 78 0m78 0c49-55-38-99-78 0"/></svg>`))
}
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)
}