merged from gramsrv upstream
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
internal/app/files/statuspack_previews.go
Normal 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
|
||||
}
|
||||
BIN
internal/app/files/statuspack_previews/5244508282231465075.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246743378917334735.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246772116543512028.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5246828303305678732.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
internal/app/files/statuspack_previews/5246842176050046092.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
internal/app/files/statuspack_previews/5246960163096632543.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
internal/app/files/statuspack_previews/5247100325059370738.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
internal/app/files/statuspack_previews/5247133031235329609.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
internal/app/files/statuspack_previews/5247176827016847212.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
internal/app/files/statuspack_previews/5247209275494769660.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
internal/app/files/statuspack_previews/5249273776079640466.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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/限速)→ 上抛 error,GetOrLoad 不缓存、可重试。
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||