owpengram-server/internal/app/files/sticker_creator.go

717 lines
21 KiB
Go

package files
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"telesrv/internal/domain"
)
func (s *Service) CheckStickerSetShortName(ctx context.Context, shortName string) (bool, error) {
shortName = normalizeStickerSetShortName(shortName)
if err := validateStickerSetShortName(shortName); err != nil {
return false, err
}
return s.media.StickerSetShortNameAvailable(ctx, shortName)
}
func (s *Service) SuggestStickerSetShortName(ctx context.Context, title string, userID int64) (string, error) {
if userID <= 0 {
return "", domain.ErrStickerSetCreatorInvalid
}
title = strings.TrimSpace(title)
if err := validateStickerSetTitle(title); err != nil {
return "", err
}
base := stickerSetShortNameBase(title)
candidates := []string{base, base + "_pack"}
if suffix := userIDSuffix(userID); suffix != "" {
candidates = append(candidates, base+"_"+suffix)
}
for i := 2; i <= 99; i++ {
candidates = append(candidates, trimStickerSetShortNameBase(base, 3)+"_"+itoaSmall(i))
}
for _, candidate := range candidates {
if err := validateStickerSetShortName(candidate); err != nil {
continue
}
available, err := s.media.StickerSetShortNameAvailable(ctx, candidate)
if err != nil {
return "", err
}
if available {
return candidate, nil
}
}
return "", domain.ErrStickerSetShortNameOccupied
}
func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
if req.CreatorUserID <= 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetCreatorInvalid
}
return s.createStickerSet(ctx, req)
}
// AdminCreateStickerSet creates a pack with no owning user (CreatorUserID
// forced to 0) so it's editable only through the Admin* bypass methods —
// the same convention seed-imported packs already use. Unlike CreateStickerSet,
// the caller must supply a non-empty ShortName: short-name suggestion needs a
// real user id to build a stable suffix, which an unowned pack doesn't have.
func (s *Service) AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
req.CreatorUserID = 0
if strings.TrimSpace(req.ShortName) == "" {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameInvalid
}
return s.createStickerSet(ctx, req)
}
func (s *Service) createStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error) {
title := strings.TrimSpace(req.Title)
if err := validateStickerSetTitle(title); err != nil {
return domain.StickerSet{}, nil, err
}
kind := normalizeStickerSetKind(req.Kind)
if len(req.Items) == 0 {
return domain.StickerSet{}, nil, domain.ErrStickerSetEmpty
}
if len(req.Items) > domain.MaxStickerSetItems {
return domain.StickerSet{}, nil, domain.ErrStickerSetTooMuch
}
shortName := normalizeStickerSetShortName(req.ShortName)
var err error
if shortName == "" {
shortName, err = s.SuggestStickerSetShortName(ctx, title, req.CreatorUserID)
if err != nil {
return domain.StickerSet{}, nil, err
}
} else {
if err := validateStickerSetShortName(shortName); err != nil {
return domain.StickerSet{}, nil, err
}
available, err := s.media.StickerSetShortNameAvailable(ctx, shortName)
if err != nil {
return domain.StickerSet{}, nil, err
}
if !available {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied
}
}
docIDs, docAccess, thumbID, thumbAccess, err := stickerSetInputDocumentRefs(req)
if err != nil {
return domain.StickerSet{}, nil, err
}
loaded, err := s.media.GetDocuments(ctx, docIDs)
if err != nil {
return domain.StickerSet{}, nil, err
}
docByID := documentsByID(loaded)
items := make([]domain.StickerSetItemInput, 0, len(req.Items))
seenDocs := map[int64]struct{}{}
for _, item := range req.Items {
doc, ok := docByID[item.DocumentID]
if !ok || doc.AccessHash != docAccess[item.DocumentID] || !doc.IsStickerSetMaterial() {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
if _, dup := seenDocs[item.DocumentID]; dup {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
seenDocs[item.DocumentID] = struct{}{}
emoji := strings.TrimSpace(item.Emoji)
if err := validateStickerEmoji(emoji); err != nil {
return domain.StickerSet{}, nil, err
}
item.Emoji = emoji
items = append(items, item)
}
if thumbID != 0 {
thumb, ok := docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
}
set := domain.StickerSet{
ID: randomID(),
AccessHash: randomID(),
ShortName: shortName,
Title: title,
Kind: kind,
Emojis: kind == domain.StickerSetKindEmoji,
Masks: kind == domain.StickerSetKindMasks,
TextColor: kind == domain.StickerSetKindEmoji && req.TextColor,
Creator: true,
CreatorUserID: req.CreatorUserID,
Software: strings.TrimSpace(req.Software),
}
updatedDocs := make([]domain.Document, 0, len(items))
finalBySourceID := make(map[int64]domain.Document, len(items))
for _, item := range items {
doc := docByID[item.DocumentID]
doc, err = s.materialDocumentForStickerSet(ctx, doc, set.ID)
if err != nil {
return domain.StickerSet{}, nil, err
}
doc, err = s.prepareStickerSetDocument(ctx, doc, set, item.Emoji)
if err != nil {
return domain.StickerSet{}, nil, err
}
set.DocumentIDs = append(set.DocumentIDs, doc.ID)
set.Packs = addDocumentToStickerPacks(set.Packs, item.Emoji, doc.ID)
set.Keywords = upsertStickerKeywords(set.Keywords, parseStickerKeywords(doc.ID, item.Keywords))
finalBySourceID[item.DocumentID] = doc
updatedDocs = append(updatedDocs, doc)
}
set.Count = len(set.DocumentIDs)
if thumbID != 0 {
thumb, ok := finalBySourceID[thumbID]
if !ok {
thumb, ok = docByID[thumbID]
if !ok || thumb.AccessHash != thumbAccess {
return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid
}
}
set.ThumbDocumentID = thumb.ID
set.Thumbs = copyPhotoSizes(thumb.Thumbs)
set.ThumbDCID = thumb.DCID
if len(set.Thumbs) > 0 {
set.ThumbVersion = 1
}
}
set.Hash = stickerSetHash(set)
if err := s.media.CreateStickerSet(ctx, set, updatedDocs); err != nil {
if errors.Is(err, domain.ErrStickerSetShortNameOccupied) {
return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied
}
return domain.StickerSet{}, nil, err
}
ordered := orderDocuments(updatedDocs, set.DocumentIDs)
s.cacheStickerSet(set, ordered)
return set, ordered, nil
}
func (s *Service) ListCreatedStickerSets(ctx context.Context, userID int64, offsetID int64, limit int) ([]domain.StickerSet, int, error) {
if userID <= 0 {
return nil, 0, domain.ErrStickerSetCreatorInvalid
}
return s.media.ListStickerSetsByCreator(ctx, userID, offsetID, limit)
}
func (s *Service) cacheStickerSet(set domain.StickerSet, docs []domain.Document) {
if s.stickerSetNegCache != nil {
refs := []domain.StickerSetRef{{Kind: domain.StickerSetRefByID, ID: set.ID}}
if set.ShortName != "" {
refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: set.ShortName})
}
if set.SystemKey != "" {
refs = append(refs, domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: set.SystemKey})
}
s.stickerSetNegCache.delete(refs...)
}
if s.stickerSetCache != nil {
s.stickerSetCache.put(set, docs)
}
}
func stickerSetInputDocumentRefs(req domain.CreateStickerSetRequest) ([]int64, map[int64]int64, int64, int64, error) {
ids := make([]int64, 0, len(req.Items)+1)
access := make(map[int64]int64, len(req.Items))
seen := map[int64]struct{}{}
for _, item := range req.Items {
if item.DocumentID == 0 || item.DocumentAccessHash == 0 {
return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid
}
if _, ok := seen[item.DocumentID]; !ok {
ids = append(ids, item.DocumentID)
seen[item.DocumentID] = struct{}{}
}
access[item.DocumentID] = item.DocumentAccessHash
}
if req.ThumbDocumentID != 0 {
if req.ThumbAccessHash == 0 {
return nil, nil, 0, 0, domain.ErrStickerSetFileInvalid
}
if _, ok := seen[req.ThumbDocumentID]; !ok {
ids = append(ids, req.ThumbDocumentID)
}
}
return ids, access, req.ThumbDocumentID, req.ThumbAccessHash, nil
}
func documentsByID(docs []domain.Document) map[int64]domain.Document {
out := make(map[int64]domain.Document, len(docs))
for _, doc := range docs {
out[doc.ID] = doc
}
return out
}
func attachStickerSetToDocument(doc domain.Document, set domain.StickerSet, emoji string) domain.Document {
want := domain.DocAttrSticker
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
want = domain.DocAttrCustomEmoji
}
attrs := append([]domain.DocumentAttribute(nil), doc.Attributes...)
replaced := false
for i := range attrs {
if attrs[i].Kind != domain.DocAttrSticker && attrs[i].Kind != domain.DocAttrCustomEmoji {
continue
}
attrs[i].Kind = want
attrs[i].Alt = emoji
attrs[i].StickerSetID = set.ID
attrs[i].StickerSetAccessHash = set.AccessHash
attrs[i].Mask = set.Kind == domain.StickerSetKindMasks || set.Masks
attrs[i].TextColor = set.TextColor
replaced = true
break
}
if !replaced {
attrs = append(attrs, domain.DocumentAttribute{
Kind: want,
Alt: emoji,
Mask: set.Kind == domain.StickerSetKindMasks || set.Masks,
StickerSetID: set.ID,
StickerSetAccessHash: set.AccessHash,
TextColor: set.TextColor,
})
}
doc.Attributes = attrs
return doc
}
func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Document, set domain.StickerSet, emoji string) (domain.Document, error) {
doc, err := s.ensureStickerMaterialShape(ctx, doc)
if err != nil {
return domain.Document{}, err
}
return attachStickerSetToDocument(doc, set, emoji), nil
}
func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) {
mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime())
// A stored documentAttributeImageSize with a zero dimension is worse than a
// missing one: clients divide by it and crash. Strip any such attribute here
// so the branches below re-derive a real 512x512 (or decoded) size.
doc.Attributes = dropZeroImageSizeAttributes(doc.Attributes)
hasImageSize := false
hasVideo := false
for _, attr := range doc.Attributes {
switch attr.Kind {
case domain.DocAttrImageSize:
hasImageSize = true
case domain.DocAttrVideo:
if attr.W > 0 && attr.H > 0 {
hasVideo = true
}
}
}
switch mimeType {
case stickerMaterialMimeJSON:
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
lottieJSON := normalizeLottieStickerJSON(data)
if _, _, ok := lottieStickerDimensions(lottieJSON); !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
tgsData, err := gzipLottieStickerData(lottieJSON)
if err != nil || int64(len(tgsData)) > domain.MaxStickerMaterialDocumentSize {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, stickerMaterialMimeTGS); err != nil {
return domain.Document{}, err
}
doc.MimeType = stickerMaterialMimeTGS
doc.Size = int64(len(tgsData))
doc.Attributes = replaceStickerMaterialFilename(doc.Attributes, "sticker.tgs")
if !hasImageSize {
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: 512,
H: 512,
})
}
case stickerMaterialMimeTGS:
if data, ok := s.readStickerMaterialBlob(ctx, doc); ok && !validTGSStickerData(data) {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, stickerMaterialMimeTGS)
if err != nil {
return domain.Document{}, err
}
if !hasImageSize {
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: 512,
H: 512,
})
}
case stickerMaterialMimeWebP:
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, stickerMaterialMimeWebP)
if err != nil {
return domain.Document{}, err
}
if !hasImageSize {
data, ok := s.readStickerMaterialBlob(ctx, doc)
if !ok {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
w, h := imageDimensions(data, 0, 0)
if w <= 0 || h <= 0 {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
doc.Attributes = append(doc.Attributes, domain.DocumentAttribute{
Kind: domain.DocAttrImageSize,
W: w,
H: h,
})
}
case stickerMaterialMimeWebM, stickerMaterialMimeMP4:
if !hasVideo {
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
var err error
doc, err = s.ensureStickerMaterialMIME(ctx, doc, mimeType)
if err != nil {
return domain.Document{}, err
}
default:
if doc.IsStickerLike() {
return doc, nil
}
return domain.Document{}, domain.ErrStickerSetFileInvalid
}
return doc, nil
}
func (s *Service) readStickerMaterialBlob(ctx context.Context, doc domain.Document) ([]byte, bool) {
if s == nil || s.media == nil || s.blobs == nil || doc.ID == 0 || doc.Size <= 0 || doc.Size > domain.MaxStickerMaterialDocumentSize {
return nil, false
}
blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID))
if err != nil || !found || blob.Size <= 0 || blob.Size > domain.MaxStickerMaterialDocumentSize {
return nil, false
}
backend, err := s.backendFor(blob.Backend)
if err != nil {
return nil, false
}
data, total, err := backend.GetRange(ctx, blob.ObjectKey, 0, blob.Size)
if err != nil || int64(len(data)) != total || total != blob.Size {
return nil, false
}
return data, true
}
func (s *Service) rewriteStickerMaterialBlob(ctx context.Context, docID int64, data []byte, mimeType string) error {
if s == nil || s.media == nil || s.blobs == nil || docID == 0 || len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return domain.ErrStickerSetFileInvalid
}
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return err
}
sum := sha256.Sum256(data)
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d", docID),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
SHA256: append([]byte(nil), sum[:]...),
MimeType: mimeType,
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return err
}
if s.blobCache != nil {
s.blobCache.put(blob.LocationKey, blob)
}
if s.byteCache != nil {
s.byteCache.put(blob.ObjectKey, data)
}
return nil
}
// dropZeroImageSizeAttributes removes documentAttributeImageSize entries whose
// width or height is not positive. Such an attribute reaches clients as
// documentAttributeImageSize#0 and is divided by while sizing the render.
func dropZeroImageSizeAttributes(attrs []domain.DocumentAttribute) []domain.DocumentAttribute {
out := attrs[:0:0]
for _, a := range attrs {
if a.Kind == domain.DocAttrImageSize && (a.W <= 0 || a.H <= 0) {
continue
}
out = append(out, a)
}
return out
}
func replaceStickerMaterialFilename(attrs []domain.DocumentAttribute, fallback string) []domain.DocumentAttribute {
out := append([]domain.DocumentAttribute(nil), attrs...)
for i := range out {
if out[i].Kind != domain.DocAttrFilename {
continue
}
out[i].FileName = tgsFileName(out[i].FileName, fallback)
return out
}
return append(out, domain.DocumentAttribute{
Kind: domain.DocAttrFilename,
FileName: fallback,
})
}
func tgsFileName(fileName, fallback string) string {
fileName = strings.TrimSpace(fileName)
if fileName == "" {
return fallback
}
ext := filepath.Ext(fileName)
if ext == "" {
return fileName + ".tgs"
}
return strings.TrimSuffix(fileName, ext) + ".tgs"
}
func validTGSStickerData(data []byte) bool {
if len(data) == 0 {
return false
}
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return false
}
defer gz.Close()
data, err = io.ReadAll(io.LimitReader(gz, domain.MaxStickerMaterialDocumentSize+1))
if err != nil || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return false
}
_, _, ok := lottieStickerDimensions(normalizeLottieStickerJSON(data))
return ok
}
func normalizeLottieStickerJSON(data []byte) []byte {
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return bytes.TrimSpace(data)
}
func lottieStickerDimensions(data []byte) (int, int, bool) {
if len(data) == 0 || int64(len(data)) > domain.MaxStickerMaterialDocumentSize {
return 0, 0, false
}
var root struct {
Version string `json:"v"`
W int `json:"w"`
H int `json:"h"`
}
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&root); err != nil {
return 0, 0, false
}
return root.W, root.H, root.Version != "" && root.W > 0 && root.H > 0
}
func gzipLottieStickerData(data []byte) ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(data); err != nil {
_ = gz.Close()
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func normalizeStickerSetKind(kind domain.StickerSetKind) domain.StickerSetKind {
switch kind {
case domain.StickerSetKindEmoji, domain.StickerSetKindMasks:
return kind
default:
return domain.StickerSetKindStickers
}
}
func validateStickerSetTitle(title string) error {
if title == "" || utf8.RuneCountInString(title) > domain.MaxStickerSetTitleLen {
return domain.ErrStickerSetTitleInvalid
}
return nil
}
func normalizeStickerSetShortName(shortName string) string {
return strings.ToLower(strings.TrimSpace(shortName))
}
func validateStickerSetShortName(shortName string) error {
if len(shortName) < domain.MinStickerSetShortNameLen || len(shortName) > domain.MaxStickerSetShortNameLen {
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore := false
for i := 0; i < len(shortName); i++ {
ch := shortName[i]
switch {
case ch >= 'a' && ch <= 'z':
case ch >= '0' && ch <= '9':
if i == 0 {
return domain.ErrStickerSetShortNameInvalid
}
case ch == '_':
if i == 0 || i == len(shortName)-1 || prevUnderscore {
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore = true
continue
default:
return domain.ErrStickerSetShortNameInvalid
}
prevUnderscore = false
}
return nil
}
func validateStickerEmoji(emoji string) error {
if emoji == "" || utf8.RuneCountInString(emoji) > 64 {
return domain.ErrStickerSetEmojiInvalid
}
return nil
}
func stickerSetShortNameBase(title string) string {
var b strings.Builder
prevUnderscore := false
for _, r := range strings.ToLower(title) {
var out rune
switch {
case r >= 'a' && r <= 'z':
out = r
case r >= '0' && r <= '9':
out = r
case unicode.IsSpace(r) || r == '-' || r == '_':
out = '_'
default:
continue
}
if out == '_' {
if b.Len() == 0 || prevUnderscore {
continue
}
prevUnderscore = true
} else {
prevUnderscore = false
}
b.WriteRune(out)
if b.Len() >= domain.MaxStickerSetShortNameLen {
break
}
}
base := strings.Trim(b.String(), "_")
if base == "" || base[0] < 'a' || base[0] > 'z' {
base = "stickers_" + base
}
base = strings.Trim(base, "_")
if len(base) < domain.MinStickerSetShortNameLen {
base += "_pack"
}
return trimStickerSetShortNameBase(base, 0)
}
func trimStickerSetShortNameBase(base string, suffixReserve int) string {
max := domain.MaxStickerSetShortNameLen - suffixReserve
if max < domain.MinStickerSetShortNameLen {
max = domain.MinStickerSetShortNameLen
}
if len(base) <= max {
return strings.Trim(base, "_")
}
return strings.Trim(base[:max], "_")
}
func userIDSuffix(userID int64) string {
if userID <= 0 {
return ""
}
return itoaSmall(int(userID % 10000))
}
func itoaSmall(v int) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
func parseStickerKeywords(documentID int64, raw string) domain.StickerKeyword {
parts := strings.Split(raw, ",")
seen := map[string]struct{}{}
keywords := make([]string, 0, len(parts))
for _, part := range parts {
kw := strings.ToLower(strings.TrimSpace(part))
if kw == "" || utf8.RuneCountInString(kw) > domain.MaxStickerSetKeywordLen {
continue
}
if _, ok := seen[kw]; ok {
continue
}
seen[kw] = struct{}{}
keywords = append(keywords, kw)
if len(keywords) >= domain.MaxStickerSetKeywords {
break
}
}
return domain.StickerKeyword{DocumentID: documentID, Keywords: keywords}
}
func stickerSetHash(set domain.StickerSet) int {
h := fnv.New32a()
writeHashString(h, set.ShortName)
writeHashString(h, set.Title)
writeHashString(h, string(set.Kind))
var buf [8]byte
for _, id := range set.DocumentIDs {
binary.LittleEndian.PutUint64(buf[:], uint64(id))
_, _ = h.Write(buf[:])
}
for _, pack := range set.Packs {
writeHashString(h, pack.Emoticon)
for _, id := range pack.DocumentIDs {
binary.LittleEndian.PutUint64(buf[:], uint64(id))
_, _ = h.Write(buf[:])
}
}
sum := int(h.Sum32() & 0x7fffffff)
if sum == 0 {
return 1
}
return sum
}
func writeHashString(h interface{ Write([]byte) (int, error) }, s string) {
_, _ = h.Write([]byte(s))
_, _ = h.Write([]byte{0})
}