merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -69,7 +69,7 @@ func TestGetFileCachesMetadataAndSmallBlobBytes(t *testing.T) {
t.Fatalf("put: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:42", Backend: domain.MediaBackendLocalFS, ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
t.Fatalf("put blob: %v", err)
}
counting := &countingMediaStore{fakeMediaStore: media}
@ -121,7 +121,7 @@ func TestGetFileLogsCacheHitMiss(t *testing.T) {
t.Fatalf("put: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:log", Backend: domain.MediaBackendLocalFS, ObjectKey: objectKey, Size: 10, MimeType: "application/octet-stream"}); err != nil {
t.Fatalf("put blob: %v", err)
}
blobs := &countingBlobBackend{BlobBackend: local}
@ -176,6 +176,7 @@ func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:large",
Backend: domain.MediaBackendLocalFS,
ObjectKey: objectKey,
Size: int64(len(content)),
MimeType: "application/octet-stream",
@ -199,6 +200,33 @@ func TestGetFileDoesNotByteCacheLargeBlob(t *testing.T) {
}
}
func TestGetFileRejectsStoredBackendMismatch(t *testing.T) {
ctx := context.Background()
local, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
objectKey, err := local.Put(ctx, []byte("must-not-fallback"))
if err != nil {
t.Fatalf("put: %v", err)
}
media := newFakeMediaStore()
if err := media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: "doc:mismatch",
Backend: domain.MediaBackendS3,
ObjectKey: objectKey,
Size: int64(len("must-not-fallback")),
}); err != nil {
t.Fatalf("put blob: %v", err)
}
svc := NewService(media, local, 2)
if _, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: "doc:mismatch", Limit: 128 << 10,
}); err == nil || found {
t.Fatalf("mismatched backend found=%v err=%v", found, err)
}
}
func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
ctx := context.Background()
local, err := NewLocalFS(t.TempDir())
@ -227,10 +255,10 @@ func TestWarmCachesPreloadsStickerSetAndSmallBlobs(t *testing.T) {
if err := media.PutDocument(ctx, doc); err != nil {
t.Fatalf("put doc: %v", err)
}
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100", Backend: domain.MediaBackendLocalFS, ObjectKey: mainKey, Size: 7, MimeType: doc.MimeType}); err != nil {
t.Fatalf("put main blob: %v", err)
}
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Backend: domain.MediaBackendLocalFS, ObjectKey: thumbKey, Size: 5, MimeType: "image/jpeg"}); err != nil {
t.Fatalf("put thumb blob: %v", err)
}
set := domain.StickerSet{

View file

@ -549,7 +549,7 @@ func TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame(t *testing.T) {
assertAvatarImageSize(t, svc, photo.ID, "a", 160, 160, "image/jpeg")
}
func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *testing.T) {
func TestCreateAvatarVideoMarkupRejectsDegeneratePreviewAndFallsBackToVideo(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
@ -562,7 +562,7 @@ func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *te
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1,
Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
Bytes: testJPEG(t, 1, 1),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
@ -581,14 +581,14 @@ func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *te
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 1 {
t.Fatalf("thumbnailer calls = %d, want synthetic preview rejected and video fallback used", thumbnailer.calls)
t.Fatalf("thumbnailer calls = %d, want degenerate preview rejected and video fallback used", thumbnailer.calls)
}
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("photo:%d:c", photo.ID), Limit: 1 << 20})
if err != nil || !found {
t.Fatalf("avatar c blob found=%v err=%v", found, err)
}
if !bytes.Equal(chunk.Bytes, frame) {
t.Fatal("avatar still did not use extracted video frame after rejecting synthetic preview")
t.Fatal("avatar still did not use extracted video frame after rejecting degenerate preview")
}
}

View file

@ -1,14 +1,10 @@
package files
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"regexp"
@ -485,6 +481,9 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
thumbs = append(thumbs, ps)
}
doc.Thumbs = thumbs
if data, ok := seedBundledDocumentPreview(doc.ID); ok {
doc.Thumbs = appendSeedBundledDocumentPreview(doc.Thumbs, data)
}
if existingFound {
doc.Thumbs = mergeSeedDocumentThumbs(existing.Thumbs, doc.Thumbs)
}
@ -492,10 +491,6 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
return domain.Document{}, err
}
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
return domain.Document{}, err
}
if err := s.media.PutDocument(ctx, doc); err != nil {
return domain.Document{}, err
}
@ -503,6 +498,27 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
return doc, nil
}
func appendSeedBundledDocumentPreview(thumbs []domain.PhotoSize, data []byte) []domain.PhotoSize {
for _, thumb := range thumbs {
if thumb.Type == seedBundledDocumentThumbType && seedPhotoSizePreviewTier(thumb) >= 4 {
return thumbs
}
}
out := thumbs[:0]
for _, thumb := range thumbs {
if thumb.Type != seedBundledDocumentThumbType {
out = append(out, thumb)
}
}
return append(out, domain.PhotoSize{
Kind: domain.PhotoSizeKindCached,
Type: seedBundledDocumentThumbType,
W: 128,
H: 128,
Bytes: append([]byte(nil), data...),
})
}
func (s *Service) prewarmSmallBlob(objectKey string, data []byte) {
if len(data) > 0 && len(data) <= blobBytesCacheMaxEntryBytes {
s.byteCache.put(objectKey, data)
@ -520,10 +536,8 @@ var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`)
var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`)
const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024
const seedSyntheticDocumentThumbType = "m"
var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`)
var seedSyntheticTGStickerPreviewThumbPNG = makeSeedSyntheticTGStickerPreviewThumbPNG()
func scanSeedDir(dir string) (seedDirIndex, error) {
idx := seedDirIndex{main: map[int64]string{}, thumb: map[int64]map[string]string{}}
@ -758,23 +772,7 @@ func mergeSeedDocumentThumbs(existing, incoming []domain.PhotoSize) []domain.Pho
out = append(out, thumb)
}
hasRealPreview := false
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) && seedPhotoSizePreviewTier(thumb) > 1 {
hasRealPreview = true
break
}
}
if !hasRealPreview {
return out
}
filtered := out[:0]
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) {
filtered = append(filtered, thumb)
}
}
return filtered
return out
}
func seedDocumentThumbByType(thumbs []domain.PhotoSize, typ string) (domain.PhotoSize, bool) {
@ -800,9 +798,6 @@ func seedPhotoSizeBetter(a, b domain.PhotoSize) bool {
}
func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
if seedSyntheticTGStickerPreviewThumb(thumb) {
return 0
}
switch thumb.Kind {
case domain.PhotoSizeKindCached:
if len(thumb.Bytes) > 0 && thumb.W > 0 && thumb.H > 0 {
@ -820,13 +815,6 @@ func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
return 1
}
func seedSyntheticTGStickerPreviewThumb(thumb domain.PhotoSize) bool {
return thumb.Kind == domain.PhotoSizeKindCached &&
thumb.Type == seedSyntheticDocumentThumbType &&
thumb.W == 1 && thumb.H == 1 &&
bytes.Equal(thumb.Bytes, seedSyntheticTGStickerPreviewThumbPNG)
}
// ensureSeedCachedThumbBlobs keeps the RPC conversion invariant: document cached
// previews are exposed as downloadable PhotoSize entries, so every advertised type
// must have a matching blob even when the source JSON carried the bytes inline.
@ -866,43 +854,6 @@ func (s *Service) ensureSeedCachedThumbBlobs(ctx context.Context, doc domain.Doc
return nil
}
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
return nil
}
if s.blobs == nil {
return fmt.Errorf("blob backend not configured for synthetic sticker preview thumb")
}
data := seedSyntheticTGStickerPreviewThumbPNG
objectKey, err := s.blobs.Put(ctx, data)
if err != nil {
return err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, seedSyntheticDocumentThumbType),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(data)),
MimeType: "image/png",
}); err != nil {
return err
}
doc.Thumbs = append(doc.Thumbs, domain.PhotoSize{
Kind: domain.PhotoSizeKindCached,
Type: seedSyntheticDocumentThumbType,
W: 1,
H: 1,
Bytes: append([]byte(nil), data...),
})
s.prewarmSmallBlob(objectKey, data)
stats.Blobs++
return nil
}
func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool {
return doc.MimeType == "application/x-tgsticker" && len(doc.Thumbs) == 0
}
func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool {
for _, attr := range attrs {
if attr.Kind == kind {
@ -912,14 +863,6 @@ func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.Docu
return false
}
func makeSeedSyntheticTGStickerPreviewThumbPNG() []byte {
var buf bytes.Buffer
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.NRGBA{})
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func seedThumbMimeType(data []byte) string {
switch {
case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
@ -980,9 +923,6 @@ func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (boo
return false, err
}
for _, doc := range docs {
if seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc) {
return true, nil
}
for _, thumb := range doc.Thumbs {
if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes {
return true, nil

View file

@ -106,13 +106,12 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
}
}
if _, ok := seedBundledDocumentPreview(dj.ID); ok {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, seedBundledDocumentThumbType))
}
return keys
}
func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool {
return dj.MimeType == "application/x-tgsticker" && len(dj.Thumbs) == 0
}
func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) {
expected := make(map[int64]seedDocumentJSON, len(docs))
ids := make([]int64, 0, len(docs))
@ -152,27 +151,11 @@ func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumen
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
return false, nil
}
// A catalog without its own thumbnail may share this document with a richer
// catalog. Readiness follows the preview that is actually stored instead of
// demanding the synthetic "m" key and repeatedly downgrading that richer
// document on every import.
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
if len(doc.Thumbs) == 0 {
if _, bundled := seedBundledDocumentPreview(dj.ID); bundled {
thumb, ok := seedDocumentThumbByType(doc.Thumbs, seedBundledDocumentThumbType)
if !ok || seedPhotoSizePreviewTier(thumb) < 4 {
return false, nil
}
for _, thumb := range doc.Thumbs {
switch thumb.Kind {
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive, domain.PhotoSizeKindCached:
if thumb.Type == "" {
return false, nil
}
key := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
if _, seen := seenLocationKeys[key]; !seen {
seenLocationKeys[key] = struct{}{}
locationKeys = append(locationKeys, key)
}
}
}
}
delete(expected, doc.ID)
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"image/png"
"os"
"path/filepath"
"sort"
@ -463,8 +464,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
svc := NewService(media, blobs, 2)
if stats, err := svc.SeedMedia(context.Background(), seedDir, 0); err != nil {
t.Fatalf("initial seed: %v", err)
} else if stats.Reactions != 1 || stats.Blobs != 3 {
t.Fatalf("initial stats = %+v, want one reaction and three blobs", stats)
} else if stats.Reactions != 1 || stats.Blobs != 2 {
t.Fatalf("initial stats = %+v, want one reaction and two document blobs", stats)
}
chunk, ok, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{LocationKey: "doc:2222222", Offset: 0, Limit: 4})
if err != nil || !ok {
@ -486,7 +487,7 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
t.Fatalf("repair seed: %v", err)
}
if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
t.Fatalf("repair stats = %+v, want two revalidated main document blobs", stats)
}
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
t.Fatal("missing reaction blob was not repaired")
@ -496,10 +497,10 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
}
}
func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
func TestSeedStatusPackTGSWithoutExportedThumbUsesBundledPreview(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 4444444
const sourceID int64 = 5247133031235329609
writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17)
media := newFakeMediaStore()
@ -513,7 +514,7 @@ func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
t.Fatalf("seed media: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("stats = %+v, want one set, one doc, main blob plus synthetic preview", stats)
t.Fatalf("stats = %+v, want one set, one doc, main blob plus bundled preview", stats)
}
set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack")
@ -529,21 +530,52 @@ func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) {
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok {
t.Fatalf("document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
t.Fatalf("document thumbs = %+v, want bundled cached preview", doc.Thumbs)
}
if thumb.Type != seedSyntheticDocumentThumbType || thumb.W != 1 || thumb.H != 1 || len(thumb.Bytes) == 0 {
t.Fatalf("synthetic thumb = %+v, want 1x1 cached %q thumb", thumb, seedSyntheticDocumentThumbType)
want, ok := seedBundledDocumentPreview(sourceID)
if !ok {
t.Fatal("bundled StatusPack preview missing")
}
if thumb.Type != seedBundledDocumentThumbType || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, want) {
t.Fatalf("bundled thumb = %+v, want visible 128x128 cached %q preview", thumb, seedBundledDocumentThumbType)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type))
if err != nil || !ok {
t.Fatalf("synthetic thumb blob ok=%v err=%v", ok, err)
t.Fatalf("bundled thumb blob ok=%v err=%v", ok, err)
}
if blob.MimeType != "image/png" {
t.Fatalf("synthetic thumb blob mime = %q, want image/png", blob.MimeType)
t.Fatalf("bundled thumb blob mime = %q, want image/png", blob.MimeType)
}
}
func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
func TestBundledStatusPackPreviewsAreVisibleTransparentPNGs(t *testing.T) {
if len(seedBundledDocumentPreviews) != 11 {
t.Fatalf("bundled StatusPack previews = %d, want 11", len(seedBundledDocumentPreviews))
}
for documentID, data := range seedBundledDocumentPreviews {
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode bundled preview %d: %v", documentID, err)
}
if bounds := img.Bounds(); bounds.Dx() != 128 || bounds.Dy() != 128 {
t.Fatalf("bundled preview %d bounds = %v, want 128x128", documentID, bounds)
}
visible := false
transparent := false
for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ {
for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ {
_, _, _, alpha := img.At(x, y).RGBA()
visible = visible || alpha != 0
transparent = transparent || alpha != 0xffff
}
}
if !visible || !transparent {
t.Fatalf("bundled preview %d visible=%v transparent=%v", documentID, visible, transparent)
}
}
}
func TestSeedMediaDoesNotInventPreviewForUnknownTGS(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 5555555
@ -586,15 +618,15 @@ func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) {
if err != nil {
t.Fatalf("repair seed: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("repair stats = %+v, want forced reimport", stats)
if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 1 || stats.Skipped {
t.Fatalf("reimport stats = %+v, want main document blob only", stats)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("repaired document ok=%v err=%v", ok, err)
}
if _, ok := findCachedThumb(doc.Thumbs); !ok {
t.Fatalf("repaired document thumbs = %+v, want synthetic cached preview", doc.Thumbs)
if len(doc.Thumbs) != 0 {
t.Fatalf("document thumbs = %+v, want no invented preview for unknown TGS", doc.Thumbs)
}
}
@ -614,8 +646,8 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
if err != nil {
t.Fatalf("first seed: %v", err)
}
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 2 {
t.Fatalf("first stats = %+v, want one imported effect document with main plus synthetic preview blobs", first)
if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 {
t.Fatalf("first stats = %+v, want one imported effect document with its main blob", first)
}
second, err := svc.SeedMedia(ctx, seedDir, 0)
@ -665,7 +697,7 @@ func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
if !ok {
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
}
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
@ -692,55 +724,6 @@ func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
}
}
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 8888888
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
media := newFakeMediaStore()
if err := media.PutDocument(ctx, domain.Document{
ID: sourceID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
}},
}); err != nil {
t.Fatalf("put stale document: %v", err)
}
if err := media.PutStickerSet(ctx, domain.StickerSet{
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
}); err != nil {
t.Fatalf("put stale sticker set: %v", err)
}
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
stats, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("migration seed: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 {
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("migrated document ok=%v err=%v", ok, err)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
}
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
}
}
func TestSeedMediaFromRealExport(t *testing.T) {
seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR")
if seedDir == "" {
@ -841,7 +824,7 @@ func TestSeedMediaFromRealExport(t *testing.T) {
t.Fatalf("sample sticker thumb mime = %q, want %q", blob.MimeType, want)
}
if !hasPathThumb(doc.Thumbs) {
t.Logf("sample sticker document has no exported PhotoPathSize placeholder; synthetic cached preview is present: %+v", doc.Thumbs)
t.Logf("sample sticker document has no exported PhotoPathSize placeholder; cached preview is present: %+v", doc.Thumbs)
}
}
}

View file

@ -0,0 +1,46 @@
package files
import (
"embed"
"fmt"
)
const seedBundledDocumentThumbType = "m"
// StatusPack is exported without document thumbnails, while Android uses a
// non-empty thumbs vector to recognize application/x-tgsticker documents as
// animated custom emoji. Keep real, visible first-frame previews with the
// server's default media assets instead of inventing transparent metadata.
//
//go:embed statuspack_previews/*.png
var statusPackPreviewFS embed.FS
var seedBundledDocumentPreviews = map[int64][]byte{
5244508282231465075: mustReadStatusPackPreview(5244508282231465075),
5246743378917334735: mustReadStatusPackPreview(5246743378917334735),
5246772116543512028: mustReadStatusPackPreview(5246772116543512028),
5246828303305678732: mustReadStatusPackPreview(5246828303305678732),
5246842176050046092: mustReadStatusPackPreview(5246842176050046092),
5246960163096632543: mustReadStatusPackPreview(5246960163096632543),
5247100325059370738: mustReadStatusPackPreview(5247100325059370738),
5247133031235329609: mustReadStatusPackPreview(5247133031235329609),
5247176827016847212: mustReadStatusPackPreview(5247176827016847212),
5247209275494769660: mustReadStatusPackPreview(5247209275494769660),
5249273776079640466: mustReadStatusPackPreview(5249273776079640466),
}
func mustReadStatusPackPreview(documentID int64) []byte {
data, err := statusPackPreviewFS.ReadFile(fmt.Sprintf("statuspack_previews/%d.png", documentID))
if err != nil {
panic(fmt.Sprintf("read bundled StatusPack preview %d: %v", documentID, err))
}
return data
}
func seedBundledDocumentPreview(documentID int64) ([]byte, bool) {
data, ok := seedBundledDocumentPreviews[documentID]
if !ok {
return nil, false
}
return append([]byte(nil), data...), true
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -94,6 +94,47 @@ func isWebPData(data []byte) bool {
return len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP"
}
// ValidateAdminAddStickerToSet is a pure check (no store writes), used by a
// dry-run preview before AdminAddStickerToSet actually mutates the pack.
func (s *Service) ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error {
set, _, found, err := s.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByID, ID: setID})
if err != nil {
return err
}
if !found || set.ID == 0 || set.Deleted || set.Kind == domain.StickerSetKindSystem {
return domain.ErrStickerSetInvalid
}
if len(set.DocumentIDs) >= domain.MaxStickerSetItems {
return domain.ErrStickerSetTooMuch
}
return validateStickerEmoji(strings.TrimSpace(emoji))
}
// ValidateAdminCreateStickerSet is a pure check (no store writes), used by a
// dry-run preview before AdminCreateStickerSet actually creates the pack.
func (s *Service) ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error {
if err := validateStickerSetTitle(strings.TrimSpace(title)); err != nil {
return err
}
if kind != domain.StickerSetKindEmoji && kind != domain.StickerSetKindMasks && kind != "" {
return domain.ErrStickerSetTypeInvalid
}
normalizedShortName := normalizeStickerSetShortName(shortName)
if normalizedShortName != "" {
if err := validateStickerSetShortName(normalizedShortName); err != nil {
return err
}
available, err := s.media.StickerSetShortNameAvailable(ctx, normalizedShortName)
if err != nil {
return err
}
if !available {
return domain.ErrStickerSetShortNameOccupied
}
}
return validateStickerEmoji(strings.TrimSpace(emoji))
}
// AdminAddStickerToSet appends an already-materialized document (from
// AdminUploadStickerMaterial) to an existing pack with no ownership check —
// same convention as AdminSetStickerSetArchived and friends.

View file

@ -142,7 +142,7 @@ func (s *Service) AdminSetStickerSetArchived(ctx context.Context, setID int64, a
if err != nil {
return false, err
}
if !found {
if !found || set.Deleted {
return false, domain.ErrStickerSetInvalid
}
if set.Archived == archived {
@ -163,7 +163,7 @@ func (s *Service) AdminSetStickerSetSortOrder(ctx context.Context, setID int64,
if err != nil {
return false, err
}
if !found {
if !found || set.Deleted {
return false, domain.ErrStickerSetInvalid
}
if set.SortOrder == order {
@ -188,7 +188,7 @@ func (s *Service) AdminRenameStickerSet(ctx context.Context, setID int64, title
if err != nil {
return domain.StickerSet{}, err
}
if !found {
if !found || set.Deleted {
return domain.StickerSet{}, domain.ErrStickerSetInvalid
}
set.Title = title
@ -203,15 +203,15 @@ func (s *Service) AdminRenameStickerSet(ctx context.Context, setID int64, title
// AdminDeleteStickerSet deletes (soft-delete) a set with no ownership check;
// see AdminSetStickerSetArchived for why that's needed here. Safe to bypass
// ownership for: sticker_sets has no incoming foreign keys, so there's no
// cascade to worry about (unlike star gifts, which have ~15 dependent
// tables). Seed-imported sets will reappear on next restart if their source
// files are still under data/sticker-seed — this only removes the DB row.
// cascade to worry about. Seed-imported sets will reappear on next restart
// if their source files are still under data/sticker-seed — this only
// removes the DB row.
func (s *Service) AdminDeleteStickerSet(ctx context.Context, setID int64) (domain.StickerSetKind, error) {
set, found, err := s.media.GetStickerSetByID(ctx, setID)
if err != nil {
return "", err
}
if !found {
if !found || set.Deleted {
return "", domain.ErrStickerSetInvalid
}
if err := s.media.AdminDeleteStickerSet(ctx, setID); err != nil {

View file

@ -138,6 +138,50 @@ func TestManageStickerSetRejectsNonCreator(t *testing.T) {
}
}
func TestValidateAdminStickerSetUploadPreconditions(t *testing.T) {
ctx := context.Background()
fullIDs := make([]int64, domain.MaxStickerSetItems)
for i := range fullIDs {
fullIDs[i] = int64(i + 1)
}
media := &fakeMediaStore{
docs: map[int64]domain.Document{},
sets: map[int64]domain.StickerSet{
10: {ID: 10, Kind: domain.StickerSetKindEmoji, DocumentIDs: fullIDs},
20: {ID: 20, Kind: domain.StickerSetKindSystem, DocumentIDs: []int64{1}},
30: {ID: 30, Kind: domain.StickerSetKindEmoji, DocumentIDs: []int64{1}},
40: {ID: 40, Kind: domain.StickerSetKindEmoji, Deleted: true, DocumentIDs: []int64{1}},
},
}
svc := NewService(media, nil, 2)
if err := svc.ValidateAdminAddStickerToSet(ctx, 10, "🙂"); !errors.Is(err, domain.ErrStickerSetTooMuch) {
t.Fatalf("full pack validation err = %v, want ErrStickerSetTooMuch", err)
}
for _, setID := range []int64{20, 40, 999} {
if err := svc.ValidateAdminAddStickerToSet(ctx, setID, "🙂"); !errors.Is(err, domain.ErrStickerSetInvalid) {
t.Fatalf("set %d validation err = %v, want ErrStickerSetInvalid", setID, err)
}
}
if err := svc.ValidateAdminAddStickerToSet(ctx, 30, ""); !errors.Is(err, domain.ErrStickerSetEmojiInvalid) {
t.Fatalf("empty emoji validation err = %v, want ErrStickerSetEmojiInvalid", err)
}
if err := svc.ValidateAdminAddStickerToSet(ctx, 30, "🙂"); err != nil {
t.Fatalf("editable pack validation: %v", err)
}
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "new_emoji", "🙂", domain.StickerSetKindEmoji); err != nil {
t.Fatalf("create validation: %v", err)
}
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "new_emoji", "🙂", domain.StickerSetKindSystem); !errors.Is(err, domain.ErrStickerSetTypeInvalid) {
t.Fatalf("system create validation err = %v, want ErrStickerSetTypeInvalid", err)
}
media.sets[50] = domain.StickerSet{ID: 50, ShortName: "occupied_name"}
if err := svc.ValidateAdminCreateStickerSet(ctx, "New Emoji", "occupied_name", "🙂", domain.StickerSetKindEmoji); !errors.Is(err, domain.ErrStickerSetShortNameOccupied) {
t.Fatalf("occupied name validation err = %v, want ErrStickerSetShortNameOccupied", err)
}
}
func TestAddStickerToSetAcceptsUploadedMaterial(t *testing.T) {
ctx := context.Background()
media := &fakeMediaStore{

View file

@ -176,7 +176,7 @@ func (f *webpageFetcher) fetch(ctx context.Context, rawURL, accept string) ([]by
if err != nil {
// SSRF 拦截dial Control 返回的 terminal经 url.Error 传上来errors.Is 仍能识别;
// 其余 dial/超时错误是瞬时。
return nil, "", fmt.Errorf("%w: %v", ErrWebPagePreviewInvalid, err)
return nil, "", fmt.Errorf("%w: %w", ErrWebPagePreviewInvalid, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@ -293,7 +293,7 @@ func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL
if err != nil {
// 终态失败SSRF/4xx/非法 URL→ 负缓存为空预览,避免重复按键/发送重打 PG+外网。
// 瞬时失败5xx/超时/dial/限速)→ 上抛 errorGetOrLoad 不缓存、可重试。
if errors.Is(err, errWebPageTerminal) {
if isTerminalWebPageFetchError(err) {
return emptyWebPage(normalizedURL, urlHash), nil
}
return domain.MessageWebPage{}, err
@ -316,6 +316,14 @@ func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL
return page, nil
}
func isTerminalWebPageFetchError(err error) bool {
if errors.Is(err, errWebPageTerminal) {
return true
}
var dnsErr *net.DNSError
return errors.As(err, &dnsErr) && dnsErr.IsNotFound
}
// fetchImage 抓取并铸造预览图best-effort。解码前按尺寸拦截解压炸弹非图片/失败丢弃。
func (f *webpageFetcher) fetchImage(ctx context.Context, s *Service, imageURL string) (domain.Photo, bool) {
data, _, err := f.fetch(ctx, imageURL, acceptImage)

View file

@ -201,7 +201,8 @@ func TestResolveWebPageNonHTMLIsEmpty(t *testing.T) {
}
}
// TestResolveWebPageSSRFBlocksLoopback 验证生产配置allowPrivate=false拦截指向 loopback 的 URL。
// TestResolveWebPageSSRFBlocksLoopback 验证生产配置allowPrivate=false拦截指向 loopback 的 URL
// 并把该确定性失败收敛为空预览。
func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
@ -210,8 +211,12 @@ func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) {
defer srv.Close()
svc := newWebpageTestService(t, false) // 生产口径:禁 loopback
if _, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x"); err == nil {
t.Fatalf("expected SSRF guard to block loopback fetch")
page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x")
if err != nil {
t.Fatalf("SSRF guard should resolve as terminal-empty: %v", err)
}
if page.State != domain.MessageWebPageStateEmpty {
t.Fatalf("state = %q, want empty", page.State)
}
}