Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling). Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
201 lines
6.7 KiB
Go
201 lines
6.7 KiB
Go
package stargifts
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
// PrepareAnimation normalizes a .tgs or plain Lottie JSON (.json/.lottie) into the
|
|
// single canonical pair used by both the Telegram download path and admin preview.
|
|
func (s *Service) PrepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
|
return prepareAnimation(fileName, data)
|
|
}
|
|
|
|
// PrepareOfficialAnimation preserves expressions present in Telegram's signed-in official
|
|
// snapshot. Callers must first verify the file against manifest size and SHA-256; ordinary
|
|
// operator uploads continue through PrepareAnimation and reject expressions.
|
|
func (s *Service) PrepareOfficialAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
|
return prepareAnimationWithPolicy(fileName, data, true)
|
|
}
|
|
|
|
func prepareAnimation(fileName string, data []byte) (domain.StarGiftAnimation, error) {
|
|
return prepareAnimationWithPolicy(fileName, data, false)
|
|
}
|
|
|
|
func prepareAnimationWithPolicy(fileName string, data []byte, allowExpressions bool) (domain.StarGiftAnimation, error) {
|
|
fileName = strings.TrimSpace(filepath.Base(fileName))
|
|
ext := strings.ToLower(filepath.Ext(fileName))
|
|
format := domain.StarGiftAnimationLottie
|
|
var rawJSON []byte
|
|
if ext == ".tgs" || isGzip(data) {
|
|
format = domain.StarGiftAnimationTGS
|
|
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftTGSBytes {
|
|
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
var err error
|
|
rawJSON, err = decompressSingleTGS(data)
|
|
if err != nil {
|
|
return domain.StarGiftAnimation{}, err
|
|
}
|
|
} else {
|
|
if ext != ".json" && ext != ".lottie" {
|
|
return domain.StarGiftAnimation{}, fmt.Errorf("%w: expected .tgs, .json or plain .lottie", domain.ErrStarGiftFileInvalid)
|
|
}
|
|
if int64(len(data)) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes {
|
|
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
rawJSON = data
|
|
}
|
|
|
|
normalized, meta, err := normalizeAndValidateLottie(rawJSON, allowExpressions)
|
|
if err != nil {
|
|
return domain.StarGiftAnimation{}, err
|
|
}
|
|
tgs, err := gzipLottie(normalized)
|
|
if err != nil || int64(len(tgs)) > domain.MaxStarGiftTGSBytes {
|
|
return domain.StarGiftAnimation{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
sum := sha256.Sum256(tgs)
|
|
return domain.StarGiftAnimation{
|
|
SourceName: fileName,
|
|
SourceFormat: format,
|
|
JSON: normalized,
|
|
TGS: tgs,
|
|
SHA256: append([]byte(nil), sum[:]...),
|
|
Width: meta.W,
|
|
Height: meta.H,
|
|
FrameRate: meta.FrameRate,
|
|
InPoint: meta.InPoint,
|
|
OutPoint: meta.OutPoint,
|
|
}, nil
|
|
}
|
|
|
|
type lottieMetadata struct {
|
|
Version string `json:"v"`
|
|
W int `json:"w"`
|
|
H int `json:"h"`
|
|
FrameRate float64 `json:"fr"`
|
|
InPoint float64 `json:"ip"`
|
|
OutPoint float64 `json:"op"`
|
|
Layers []json.RawMessage `json:"layers"`
|
|
Assets []json.RawMessage `json:"assets"`
|
|
}
|
|
|
|
func normalizeAndValidateLottie(data []byte, allowExpressions bool) ([]byte, lottieMetadata, error) {
|
|
data = bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
|
|
if len(data) == 0 || int64(len(data)) > domain.MaxStarGiftLottieBytes || !json.Valid(data) {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.UseNumber()
|
|
var root any
|
|
if err := dec.Decode(&root); err != nil {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
if _, ok := root.(map[string]any); !ok {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
if !allowExpressions && containsLottieExpression(root) {
|
|
return nil, lottieMetadata{}, fmt.Errorf("%w: expressions are not allowed", domain.ErrStarGiftFileInvalid)
|
|
}
|
|
var meta lottieMetadata
|
|
if err := json.Unmarshal(data, &meta); err != nil {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
frameSpan := meta.OutPoint - meta.InPoint
|
|
if meta.Version == "" || meta.W != 512 || meta.H != 512 ||
|
|
math.IsNaN(meta.FrameRate) || math.IsInf(meta.FrameRate, 0) || meta.FrameRate <= 0 || meta.FrameRate > domain.MaxStarGiftAnimationFrameRate ||
|
|
math.IsNaN(meta.InPoint) || math.IsInf(meta.InPoint, 0) || meta.InPoint < 0 ||
|
|
math.IsNaN(meta.OutPoint) || math.IsInf(meta.OutPoint, 0) || meta.OutPoint <= meta.InPoint ||
|
|
frameSpan > meta.FrameRate*domain.MaxStarGiftAnimationSeconds || len(meta.Layers) == 0 {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
// Telegram animated stickers are self-contained. Reject remote or embedded image assets;
|
|
// pre-composition assets with only an id/layers payload remain valid.
|
|
for _, raw := range meta.Assets {
|
|
var asset map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &asset) != nil {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
for _, key := range []string{"p", "u"} {
|
|
if value := asset[key]; len(value) > 0 && string(value) != `""` && string(value) != "null" {
|
|
return nil, lottieMetadata{}, fmt.Errorf("%w: external assets are not allowed", domain.ErrStarGiftFileInvalid)
|
|
}
|
|
}
|
|
}
|
|
var compact bytes.Buffer
|
|
if err := json.Compact(&compact, data); err != nil || int64(compact.Len()) > domain.MaxStarGiftLottieBytes {
|
|
return nil, lottieMetadata{}, domain.ErrStarGiftFileInvalid
|
|
}
|
|
return compact.Bytes(), meta, nil
|
|
}
|
|
|
|
func containsLottieExpression(value any) bool {
|
|
switch node := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range node {
|
|
if key == "x" {
|
|
if expression, ok := child.(string); ok && strings.TrimSpace(expression) != "" {
|
|
return true
|
|
}
|
|
}
|
|
if containsLottieExpression(child) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, child := range node {
|
|
if containsLottieExpression(child) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isGzip(data []byte) bool {
|
|
return len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b
|
|
}
|
|
|
|
func decompressSingleTGS(data []byte) ([]byte, error) {
|
|
reader := bytes.NewReader(data)
|
|
gz, err := gzip.NewReader(reader)
|
|
if err != nil {
|
|
return nil, domain.ErrStarGiftFileInvalid
|
|
}
|
|
gz.Multistream(false)
|
|
raw, readErr := io.ReadAll(io.LimitReader(gz, domain.MaxStarGiftLottieBytes+1))
|
|
closeErr := gz.Close()
|
|
if readErr != nil || closeErr != nil || int64(len(raw)) > domain.MaxStarGiftLottieBytes || reader.Len() != 0 {
|
|
return nil, domain.ErrStarGiftFileInvalid
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func gzipLottie(data []byte) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
gz, err := gzip.NewWriterLevel(&out, gzip.BestCompression)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gz.Header.ModTime = time.Unix(0, 0)
|
|
gz.Header.OS = 255
|
|
if _, err := gz.Write(data); err != nil {
|
|
_ = gz.Close()
|
|
return nil, err
|
|
}
|
|
if err := gz.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|