From 57b7829a9e35bdd9d4e3d0346b7d46e848a00cc1 Mon Sep 17 00:00:00 2001 From: Astra Date: Tue, 8 Sep 2026 16:29:37 +0100 Subject: [PATCH] media: never project zero image/video dimensions (crashes Telegram Desktop on reactions) --- internal/app/files/sticker_creator.go | 22 +++++++++- internal/rpc/convert_media.go | 46 +++++++++++++++++++-- internal/rpc/stickers_test.go | 58 +++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/internal/app/files/sticker_creator.go b/internal/app/files/sticker_creator.go index eeff4fff..3d98e616 100644 --- a/internal/app/files/sticker_creator.go +++ b/internal/app/files/sticker_creator.go @@ -304,6 +304,10 @@ func (s *Service) prepareStickerSetDocument(ctx context.Context, doc domain.Docu 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 { @@ -311,7 +315,9 @@ func (s *Service) ensureStickerMaterialShape(ctx context.Context, doc domain.Doc case domain.DocAttrImageSize: hasImageSize = true case domain.DocAttrVideo: - hasVideo = true + if attr.W > 0 && attr.H > 0 { + hasVideo = true + } } } switch mimeType { @@ -444,6 +450,20 @@ func (s *Service) rewriteStickerMaterialBlob(ctx context.Context, docID int64, d 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 { diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index 587705e1..02a38f78 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -353,6 +353,11 @@ func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass { func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass { switch s.Kind { case domain.PhotoSizeKindDefault: + if s.W <= 0 || s.H <= 0 { + // A zero-dimension photoSize is malformed and divides to a crash on + // the client; dropping it lets clients fall back to another size. + return nil + } return &tg.PhotoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size} case domain.PhotoSizeKindStripped: return &tg.PhotoStrippedSize{Type: s.Type, Bytes: s.Bytes} @@ -450,12 +455,47 @@ func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass { return out } +// stickerCanvasSize is the square canvas a sticker / custom emoji is laid out on. +// It is the fallback dimension when stored metadata carries a zero, which clients +// divide by while sizing the render (a 0 there crashes Telegram Desktop). +const stickerCanvasSize = 512 + +func stickerLikeMime(mimeType string) bool { + switch mimeType { + case mimeApplicationXTGSticker, "image/webp", "video/webm": + return true + default: + return false + } +} + +// safeMediaDimension replaces a non-positive width/height with a usable value: +// the sticker canvas for sticker-like documents, otherwise 1, so no projected +// attribute ever carries a zero a client would divide by. +func safeMediaDimension(v int, mimeType string) int { + if v > 0 { + return v + } + if stickerLikeMime(mimeType) { + return stickerCanvasSize + } + return 1 +} + func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []tg.DocumentAttributeClass { out := make([]tg.DocumentAttributeClass, 0, len(attrs)) for _, a := range attrs { switch a.Kind { case domain.DocAttrImageSize: - out = append(out, &tg.DocumentAttributeImageSize{W: a.W, H: a.H}) + if (a.W <= 0 || a.H <= 0) && !stickerLikeMime(mimeType) { + // Malformed size on a plain image: drop it rather than emit a zero. + // Clients cope with a missing imageSize; a zero one crashes them. + continue + } + out = append(out, &tg.DocumentAttributeImageSize{ + W: safeMediaDimension(a.W, mimeType), + H: safeMediaDimension(a.H, mimeType), + }) case domain.DocAttrAnimated: if mimeType == mimeApplicationXTGSticker { continue @@ -473,8 +513,8 @@ func tgDocumentAttributes(mimeType string, attrs []domain.DocumentAttribute) []t SupportsStreaming: a.SupportsStreaming, Nosound: a.NoSound, Duration: a.Duration, - W: a.W, - H: a.H, + W: safeMediaDimension(a.W, mimeType), + H: safeMediaDimension(a.H, mimeType), } if a.VideoCodec != "" { video.SetVideoCodec(a.VideoCodec) diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index a88cf6fc..e9c63be2 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -721,6 +721,64 @@ func TestTGDocumentDoesNotEmitAnimatedAttributeForTGSSticker(t *testing.T) { } } +func TestTGDocumentNeverEmitsZeroImageOrVideoDimensions(t *testing.T) { + // A sticker / custom emoji whose stored metadata carries a zero dimension + // must not reach the client as documentAttributeImageSize#0 -- Telegram + // Desktop divides by it and crashes when the emoji is used as a reaction. + sticker := tgDocument(domain.Document{ + ID: 100, AccessHash: 1, DCID: 2, MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrImageSize, W: 0, H: 0}, + {Kind: domain.DocAttrCustomEmoji, Alt: "🙂", StickerSetID: 10, StickerSetAccessHash: 20}, + }, + }).(*tg.Document) + var gotImage bool + for _, attr := range sticker.Attributes { + if a, ok := attr.(*tg.DocumentAttributeImageSize); ok { + gotImage = true + if a.W != 512 || a.H != 512 { + t.Fatalf("sticker imageSize = %dx%d, want 512x512 fallback", a.W, a.H) + } + } + } + if !gotImage { + t.Fatal("sticker lost its imageSize attribute entirely") + } + + video := tgDocument(domain.Document{ + ID: 101, AccessHash: 1, DCID: 2, MimeType: "video/webm", + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrVideo, W: 0, H: 0, Duration: 3}, + {Kind: domain.DocAttrSticker, Alt: "😀", StickerSetID: 10, StickerSetAccessHash: 20}, + }, + }).(*tg.Document) + for _, attr := range video.Attributes { + if a, ok := attr.(*tg.DocumentAttributeVideo); ok && (a.W <= 0 || a.H <= 0) { + t.Fatalf("video sticker attribute has zero dimension: %+v", a) + } + } + + // A malformed plain image drops the attribute rather than emitting a zero. + plain := tgDocument(domain.Document{ + ID: 102, AccessHash: 1, DCID: 2, MimeType: "image/jpeg", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrImageSize, W: 0, H: 480}}, + }).(*tg.Document) + for _, attr := range plain.Attributes { + if _, ok := attr.(*tg.DocumentAttributeImageSize); ok { + t.Fatalf("plain image kept a malformed imageSize: %+v", attr) + } + } + + // A zero-dimension thumbnail is dropped, not emitted. + thumbed := tgDocument(domain.Document{ + ID: 103, AccessHash: 1, DCID: 2, MimeType: "image/webp", + Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "s", W: 0, H: 0, Size: 10}}, + }).(*tg.Document) + if len(thumbed.Thumbs) != 0 { + t.Fatalf("kept a zero-dimension thumb: %#v", thumbed.Thumbs) + } +} + func TestTGDocumentUsesDomainDocumentID(t *testing.T) { const documentID int64 = 1382305375846410902