diff --git a/internal/app/files/sticker_creator.go b/internal/app/files/sticker_creator.go index d4446d8a..baa2be4d 100644 --- a/internal/app/files/sticker_creator.go +++ b/internal/app/files/sticker_creator.go @@ -102,10 +102,6 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker return domain.StickerSet{}, nil, err } docByID := documentsByID(loaded) - documentIDs := make([]int64, 0, len(req.Items)) - packs := make([]domain.StickerPack, 0, len(req.Items)) - packIndex := map[string]int{} - keywords := []domain.StickerKeyword{} items := make([]domain.StickerSetItemInput, 0, len(req.Items)) seenDocs := map[int64]struct{}{} for _, item := range req.Items { @@ -122,40 +118,55 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker return domain.StickerSet{}, nil, err } item.Emoji = emoji - documentIDs = append(documentIDs, item.DocumentID) - if idx, ok := packIndex[emoji]; ok { - packs[idx].DocumentIDs = append(packs[idx].DocumentIDs, item.DocumentID) - } else { - packIndex[emoji] = len(packs) - packs = append(packs, domain.StickerPack{Emoticon: emoji, DocumentIDs: []int64{item.DocumentID}}) - } - if kw := parseStickerKeywords(item.DocumentID, item.Keywords); len(kw.Keywords) > 0 { - keywords = append(keywords, kw) - } 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, - Count: len(documentIDs), Kind: kind, Emojis: kind == domain.StickerSetKindEmoji, Masks: kind == domain.StickerSetKindMasks, TextColor: kind == domain.StickerSetKindEmoji && req.TextColor, Creator: true, CreatorUserID: req.CreatorUserID, - DocumentIDs: documentIDs, - Packs: packs, - Keywords: keywords, 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 := docByID[thumbID] - if !ok || thumb.AccessHash != thumbAccess { - return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + 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) @@ -165,17 +176,6 @@ func (s *Service) CreateStickerSet(ctx context.Context, req domain.CreateSticker } } set.Hash = stickerSetHash(set) - - updatedDocs := make([]domain.Document, 0, len(items)) - for _, item := range items { - doc := docByID[item.DocumentID] - doc, err = s.prepareStickerSetDocument(ctx, doc, set, item.Emoji) - if err != nil { - return domain.StickerSet{}, nil, err - } - docByID[item.DocumentID] = doc - updatedDocs = append(updatedDocs, doc) - } if err := s.media.CreateStickerSet(ctx, set, updatedDocs); err != nil { if errors.Is(err, domain.ErrStickerSetShortNameOccupied) { return domain.StickerSet{}, nil, domain.ErrStickerSetShortNameOccupied @@ -286,10 +286,7 @@ func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Docu } func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Document) (domain.Document, error) { - if doc.IsStickerLike() { - return doc, nil - } - mimeType := doc.StickerSetMaterialMime() + mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime()) hasImageSize := false hasVideo := false for _, attr := range doc.Attributes { @@ -301,7 +298,7 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc } } switch mimeType { - case "application/json": + case stickerMaterialMimeJSON: data, ok := s.readStickerMaterialBlob(ctx, doc) if !ok { return domain.Document{}, domain.ErrStickerSetFileInvalid @@ -314,10 +311,10 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc if err != nil || int64(len(tgsData)) > domain.MaxStickerMaterialDocumentSize { return domain.Document{}, domain.ErrStickerSetFileInvalid } - if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, "application/x-tgsticker"); err != nil { + if err := s.rewriteStickerMaterialBlob(ctx, doc.ID, tgsData, stickerMaterialMimeTGS); err != nil { return domain.Document{}, err } - doc.MimeType = "application/x-tgsticker" + doc.MimeType = stickerMaterialMimeTGS doc.Size = int64(len(tgsData)) doc.Attributes = replaceStickerMaterialFilename(doc.Attributes, "sticker.tgs") if !hasImageSize { @@ -327,10 +324,15 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc H: 512, }) } - case "application/x-tgsticker": + 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, @@ -338,7 +340,12 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc H: 512, }) } - case "image/webp": + 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 { @@ -354,11 +361,19 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc H: h, }) } - case "video/webm", "video/mp4": + 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 diff --git a/internal/app/files/sticker_creator_test.go b/internal/app/files/sticker_creator_test.go index b1fcdb4d..71259d1b 100644 --- a/internal/app/files/sticker_creator_test.go +++ b/internal/app/files/sticker_creator_test.go @@ -106,6 +106,106 @@ func TestCreateStickerSetAcceptsUploadedStickerMaterial(t *testing.T) { } } +func TestCreateStickerSetNormalizesUploadedTGSMime(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + media.docs[206] = domain.Document{ + ID: 206, + AccessHash: 2006, + DCID: 2, + Size: 1024, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "local.tgs"}}, + } + media.blobs["doc:206"] = domain.FileBlob{ + LocationKey: "doc:206", + Size: 1024, + } + svc := NewService(media, nil, 2) + + _, docs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Uploads", + ShortName: "tgs_uploads", + Items: []domain.StickerSetItemInput{{ + DocumentID: 206, + DocumentAccessHash: 2006, + Emoji: "👋", + }}, + }) + if err != nil { + t.Fatalf("create with uploaded tgs material: %v", err) + } + if len(docs) != 1 || docs[0].MimeType != stickerMaterialMimeTGS { + t.Fatalf("created docs = %+v, want normalized tgs mime", docs) + } + stored := media.docs[206] + if stored.MimeType != stickerMaterialMimeTGS { + t.Fatalf("stored document mime = %q, want %q", stored.MimeType, stickerMaterialMimeTGS) + } + blob := media.blobs["doc:206"] + if blob.MimeType != stickerMaterialMimeTGS { + t.Fatalf("stored blob mime = %q, want %q", blob.MimeType, stickerMaterialMimeTGS) + } +} + +func TestCreateStickerSetClonesDocumentWhenReusedAcrossSets(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 301: {ID: 301, AccessHash: 3001, DCID: 2, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + emojiSet, emojiDocs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Emoji Pack", + ShortName: "emoji_pack", + Kind: domain.StickerSetKindEmoji, + Items: []domain.StickerSetItemInput{{ + DocumentID: 301, + DocumentAccessHash: 3001, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create emoji set: %v", err) + } + stickerSet, stickerDocs, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Sticker Pack", + ShortName: "sticker_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 301, + DocumentAccessHash: 3001, + Emoji: "😄", + }}, + }) + if err != nil { + t.Fatalf("create sticker set from existing emoji doc: %v", err) + } + if len(emojiDocs) != 1 || len(stickerDocs) != 1 { + t.Fatalf("created docs: emoji=%+v sticker=%+v, want one doc each", emojiDocs, stickerDocs) + } + if stickerDocs[0].ID == 301 || stickerSet.DocumentIDs[0] == 301 { + t.Fatalf("sticker set reused source doc id, set=%+v docs=%+v", stickerSet, stickerDocs) + } + source := media.docs[301] + if !source.IsCustomEmoji() { + t.Fatalf("source doc attrs = %+v, want custom emoji preserved", source.Attributes) + } + if id, _, ok := source.StickerSetRef(); !ok || id != emojiSet.ID { + t.Fatalf("source doc set ref = %d/%v, want emoji set %d", id, ok, emojiSet.ID) + } + if !stickerDocs[0].IsSticker() || stickerDocs[0].IsCustomEmoji() { + t.Fatalf("cloned doc attrs = %+v, want regular sticker", stickerDocs[0].Attributes) + } + if id, _, ok := stickerDocs[0].StickerSetRef(); !ok || id != stickerSet.ID { + t.Fatalf("cloned doc set ref = %d/%v, want sticker set %d", id, ok, stickerSet.ID) + } +} + func TestCreateStickerSetAcceptsWebPMaterialWithClientImageSize(t *testing.T) { ctx := context.Background() media := &fakeMediaStore{ diff --git a/internal/app/files/sticker_management.go b/internal/app/files/sticker_management.go index 2de6b5b7..9c2ee692 100644 --- a/internal/app/files/sticker_management.go +++ b/internal/app/files/sticker_management.go @@ -19,8 +19,9 @@ func (s *Service) AddStickerToSet(ctx context.Context, actorUserID int64, ref do if err != nil { return domain.StickerSet{}, nil, err } - if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != set.ID { - return domain.StickerSet{}, nil, domain.ErrStickerSetFileInvalid + doc, err = s.materialDocumentForStickerSet(ctx, doc, set.ID) + if err != nil { + return domain.StickerSet{}, nil, err } if containsInt64(set.DocumentIDs, doc.ID) { return set, docs, nil diff --git a/internal/app/files/sticker_management_test.go b/internal/app/files/sticker_management_test.go index 4b4198d4..6d931eb3 100644 --- a/internal/app/files/sticker_management_test.go +++ b/internal/app/files/sticker_management_test.go @@ -178,3 +178,71 @@ func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) { t.Fatalf("added doc = %+v, want sticker-tagged video material", added) } } + +func TestAddStickerToSetClonesDocumentWhenReusedAcrossSets(t *testing.T) { + ctx := context.Background() + media := &fakeMediaStore{ + docs: map[int64]domain.Document{ + 401: {ID: 401, AccessHash: 4001, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 402: {ID: 402, AccessHash: 4002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + }, + sets: map[int64]domain.StickerSet{}, + } + svc := NewService(media, nil, 2) + + emojiSet, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Emoji Pack", + ShortName: "emoji_pack", + Kind: domain.StickerSetKindEmoji, + Items: []domain.StickerSetItemInput{{ + DocumentID: 401, + DocumentAccessHash: 4001, + Emoji: "🙂", + }}, + }) + if err != nil { + t.Fatalf("create emoji set: %v", err) + } + stickerSet, _, err := svc.CreateStickerSet(ctx, domain.CreateStickerSetRequest{ + CreatorUserID: 1000000001, + Title: "Sticker Pack", + ShortName: "sticker_pack", + Items: []domain.StickerSetItemInput{{ + DocumentID: 402, + DocumentAccessHash: 4002, + Emoji: "😄", + }}, + }) + if err != nil { + t.Fatalf("create sticker set: %v", err) + } + stickerSet, docs, err := svc.AddStickerToSet(ctx, 1000000001, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: stickerSet.ID, AccessHash: stickerSet.AccessHash}, domain.StickerSetItemInput{ + DocumentID: 401, + DocumentAccessHash: 4001, + Emoji: "👋", + }) + if err != nil { + t.Fatalf("add existing emoji doc to sticker set: %v", err) + } + if stickerSet.Count != 2 || len(docs) != 2 { + t.Fatalf("after add set=%+v docs=%d, want two docs", stickerSet, len(docs)) + } + added := docs[1] + if added.ID == 401 || stickerSet.DocumentIDs[1] == 401 { + t.Fatalf("added doc reused source id, set=%+v docs=%+v", stickerSet, docs) + } + source := media.docs[401] + if !source.IsCustomEmoji() { + t.Fatalf("source doc attrs = %+v, want custom emoji preserved", source.Attributes) + } + if id, _, ok := source.StickerSetRef(); !ok || id != emojiSet.ID { + t.Fatalf("source doc set ref = %d/%v, want emoji set %d", id, ok, emojiSet.ID) + } + if !added.IsSticker() || added.IsCustomEmoji() { + t.Fatalf("added clone attrs = %+v, want regular sticker", added.Attributes) + } + if id, _, ok := added.StickerSetRef(); !ok || id != stickerSet.ID { + t.Fatalf("added clone set ref = %d/%v, want sticker set %d", id, ok, stickerSet.ID) + } +} diff --git a/internal/app/files/sticker_material.go b/internal/app/files/sticker_material.go new file mode 100644 index 00000000..30d2a2d8 --- /dev/null +++ b/internal/app/files/sticker_material.go @@ -0,0 +1,142 @@ +package files + +import ( + "context" + "fmt" + "strings" + + "telesrv/internal/domain" +) + +const ( + stickerMaterialMimeTGS = "application/x-tgsticker" + stickerMaterialMimeWebP = "image/webp" + stickerMaterialMimeWebM = "video/webm" + stickerMaterialMimeMP4 = "video/mp4" + stickerMaterialMimeJSON = "application/json" + stickerMaterialMimeOctet = "application/octet-stream" +) + +func normalizeStickerMaterialDocumentMIME(doc domain.Document) (domain.Document, bool) { + mimeType := canonicalStickerMaterialMime(doc.StickerSetMaterialMime()) + if mimeType == "" || mimeType == stickerMaterialMimeJSON { + return doc, false + } + if !shouldReplaceStickerMaterialMime(doc.MimeType, mimeType) { + return doc, false + } + doc.MimeType = mimeType + return doc, true +} + +func (s *Service) materialDocumentForStickerSet(ctx context.Context, doc domain.Document, targetSetID int64) (domain.Document, error) { + if ownedSetID, _, ok := doc.StickerSetRef(); ok && ownedSetID != 0 && ownedSetID != targetSetID { + return s.cloneStickerSetDocument(ctx, doc) + } + return doc, nil +} + +func (s *Service) cloneStickerSetDocument(ctx context.Context, source domain.Document) (domain.Document, error) { + copied := copyDocuments([]domain.Document{source}) + if len(copied) == 0 { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + clone := copied[0] + oldID := clone.ID + clone.ID = randomID() + clone.AccessHash = randomID() + clone.FileReference = randomFileReference() + + if source.Size > 0 { + blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", oldID)) + if err != nil { + return domain.Document{}, err + } + if !found { + return domain.Document{}, domain.ErrStickerSetFileInvalid + } + blob.LocationKey = fmt.Sprintf("doc:%d", clone.ID) + if normalized, changed := normalizeStickerMaterialDocumentMIME(clone); changed { + clone = normalized + } + if mimeType := canonicalStickerMaterialMime(clone.StickerSetMaterialMime()); mimeType != "" && shouldReplaceStickerMaterialMime(blob.MimeType, mimeType) { + blob.MimeType = mimeType + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return domain.Document{}, err + } + s.blobCache.put(blob.LocationKey, blob) + } + + for _, thumb := range source.Thumbs { + if !thumb.Downloadable() || thumb.Type == "" { + continue + } + oldKey := fmt.Sprintf("doc:%d:%s", oldID, thumb.Type) + blob, found, err := s.media.GetFileBlob(ctx, oldKey) + if err != nil { + return domain.Document{}, err + } + if !found { + continue + } + blob.LocationKey = fmt.Sprintf("doc:%d:%s", clone.ID, thumb.Type) + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return domain.Document{}, err + } + s.blobCache.put(blob.LocationKey, blob) + } + + return clone, nil +} + +func (s *Service) ensureStickerMaterialMIME(ctx context.Context, doc domain.Document, mimeType string) (domain.Document, error) { + mimeType = canonicalStickerMaterialMime(mimeType) + if mimeType == "" || mimeType == stickerMaterialMimeJSON { + return doc, nil + } + if shouldReplaceStickerMaterialMime(doc.MimeType, mimeType) { + doc.MimeType = mimeType + } + if s == nil || s.media == nil || doc.ID == 0 { + return doc, nil + } + blob, found, err := s.media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID)) + if err != nil { + return domain.Document{}, err + } + if found && shouldReplaceStickerMaterialMime(blob.MimeType, mimeType) { + blob.MimeType = mimeType + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return domain.Document{}, err + } + s.blobCache.put(blob.LocationKey, blob) + } + return doc, nil +} + +func canonicalStickerMaterialMime(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case stickerMaterialMimeTGS: + return stickerMaterialMimeTGS + case stickerMaterialMimeWebP: + return stickerMaterialMimeWebP + case stickerMaterialMimeWebM: + return stickerMaterialMimeWebM + case stickerMaterialMimeMP4: + return stickerMaterialMimeMP4 + case stickerMaterialMimeJSON, "text/json", "application/lottie+json": + return stickerMaterialMimeJSON + default: + return "" + } +} + +func shouldReplaceStickerMaterialMime(current, inferred string) bool { + inferred = canonicalStickerMaterialMime(inferred) + if inferred == "" { + return false + } + current = strings.ToLower(strings.TrimSpace(current)) + return current == "" || current == stickerMaterialMimeOctet +}