diff --git a/internal/app/themes/service.go b/internal/app/themes/service.go index 59877a80..103e790e 100644 --- a/internal/app/themes/service.go +++ b/internal/app/themes/service.go @@ -102,7 +102,14 @@ func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.The func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) { if ref.ID != 0 { - return s.store.GetThemeByID(ctx, ref.ID) + t, ok, err := s.store.GetThemeByID(ctx, ref.ID) + if err != nil || !ok { + return domain.Theme{}, ok, err + } + if t.AccessHash != ref.AccessHash { + return domain.Theme{}, false, nil + } + return t, true, nil } if ref.Slug != "" { return s.store.GetThemeBySlug(ctx, ref.Slug) diff --git a/internal/app/themes/service_test.go b/internal/app/themes/service_test.go index 9557dcd7..0b857e1d 100644 --- a/internal/app/themes/service_test.go +++ b/internal/app/themes/service_test.go @@ -35,14 +35,14 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) { } // 非创建者不能改。 - if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) { + if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) { t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err) } // 创建者可改 title + document。 newTitle := "A2" newDoc := int64(99) - updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc}) + updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc}) if err != nil { t.Fatalf("creator update: %v", err) } @@ -51,7 +51,7 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) { } // install 计数 + 列表。 - if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil { + if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash}, true); err != nil { t.Fatalf("install: %v", err) } got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug}) @@ -67,6 +67,20 @@ func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) { if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) { t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err) } + + forged := domain.ThemeRef{ID: a.ID, AccessHash: a.AccessHash + 1} + if _, ok, err := svc.Get(ctx, forged); err != nil || ok { + t.Fatalf("get forged access hash = ok %v err %v, want false/nil", ok, err) + } + if err := svc.Save(ctx, owner, forged); !errors.Is(err, domain.ErrThemeInvalid) { + t.Fatalf("save forged access hash err = %v, want ErrThemeInvalid", err) + } + if err := svc.Install(ctx, owner, forged, true); !errors.Is(err, domain.ErrThemeInvalid) { + t.Fatalf("install forged access hash err = %v, want ErrThemeInvalid", err) + } + if _, err := svc.Update(ctx, owner, forged, domain.ThemeUpdate{Title: strptr("forged")}); !errors.Is(err, domain.ErrThemeNotFound) { + t.Fatalf("update forged access hash err = %v, want ErrThemeNotFound", err) + } } func strptr(s string) *string { return &s } diff --git a/internal/compat/tdesktop/appearance_seed.go b/internal/compat/tdesktop/appearance_seed.go index 8177e119..34f8eabf 100644 --- a/internal/compat/tdesktop/appearance_seed.go +++ b/internal/compat/tdesktop/appearance_seed.go @@ -38,6 +38,18 @@ func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) { return DefaultWallPaper(wallpaper), true } } + // account.getThemes/account.getChatThemes also advertise wallpapers nested + // in ThemeSettings. The default getWallPapers export is a filtered list and + // does not contain every nested entry, so these identities must remain part + // of the same lookup boundary or Android can render a theme that it cannot + // subsequently install. + for _, theme := range catalog.ChatThemes { + for _, settings := range theme.Settings { + if inputWallPaperMatches(input, settings.Wallpaper) { + return DefaultWallPaper(settings.Wallpaper), true + } + } + } return nil, false } diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go index e6dab967..12c0a873 100644 --- a/internal/compat/tdesktop/startup_stubs.go +++ b/internal/compat/tdesktop/startup_stubs.go @@ -182,6 +182,31 @@ func DefaultThemeList() []tg.Theme { return themes } +// LookupDefaultTheme resolves an InputTheme against the exact identity emitted +// by DefaultThemeList. ID references must carry the matching access hash; slug +// references are public and match by their exact non-empty slug. +// +// These themes are an immutable server catalog rather than rows in the custom +// cloud-theme store. RPCs which accept a Theme returned by account.getThemes +// use this lookup before consulting that store. +func LookupDefaultTheme(input tg.InputThemeClass) (tg.Theme, bool) { + for _, theme := range DefaultThemeList() { + switch in := input.(type) { + case *tg.InputTheme: + if in.ID == theme.ID && in.AccessHash == theme.AccessHash { + return theme, true + } + case *tg.InputThemeSlug: + if in.Slug != "" && in.Slug == theme.Slug { + return theme, true + } + default: + return tg.Theme{}, false + } + } + return tg.Theme{}, false +} + func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass { if hash == uniqueGiftChatThemesHash { return &tg.AccountChatThemesNotModified{} diff --git a/internal/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go index 8b3b090c..d0330632 100644 --- a/internal/compat/tdesktop/startup_stubs_test.go +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -216,6 +216,35 @@ func TestDefaultThemesAreDefaultFlaggedForPicker(t *testing.T) { } } +func TestLookupDefaultThemeValidatesIdentity(t *testing.T) { + themes := DefaultThemeList() + if len(themes) == 0 { + t.Fatal("DefaultThemeList is empty") + } + for _, theme := range themes { + byID, ok := LookupDefaultTheme(&tg.InputTheme{ + ID: theme.ID, + AccessHash: theme.AccessHash, + }) + if !ok || byID.ID != theme.ID { + t.Fatalf("lookup id %d = id %d ok=%v, want exact theme", theme.ID, byID.ID, ok) + } + bySlug, ok := LookupDefaultTheme(&tg.InputThemeSlug{Slug: theme.Slug}) + if !ok || bySlug.ID != theme.ID { + t.Fatalf("lookup slug %q = id %d ok=%v, want %d", theme.Slug, bySlug.ID, ok, theme.ID) + } + if _, ok := LookupDefaultTheme(&tg.InputTheme{ + ID: theme.ID, + AccessHash: theme.AccessHash + 1, + }); ok { + t.Fatalf("lookup id %d accepted forged access hash", theme.ID) + } + } + if _, ok := LookupDefaultTheme(&tg.InputThemeSlug{}); ok { + t.Fatal("lookup accepted empty slug") + } +} + func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) { got, ok := UniqueGiftChatThemes(0).(*tg.AccountChatThemes) if !ok { @@ -299,6 +328,50 @@ func TestLookupWallPaperByIDAndSlug(t *testing.T) { } } +func TestDefaultThemeWallpaperReferencesResolve(t *testing.T) { + themes := DefaultThemeList() + var checked int + for _, theme := range themes { + settings, ok := theme.GetSettings() + if !ok { + continue + } + for i, setting := range settings { + wallpaperClass, ok := setting.GetWallpaper() + if !ok { + continue + } + wallpaper, ok := wallpaperClass.(*tg.WallPaper) + if !ok { + continue + } + checked++ + byID, ok := LookupWallPaper(&tg.InputWallPaper{ + ID: wallpaper.ID, + AccessHash: wallpaper.AccessHash, + }) + if !ok { + t.Fatalf("theme %d settings[%d] wallpaper id %d was not resolvable", theme.ID, i, wallpaper.ID) + } + gotByID, ok := byID.(*tg.WallPaper) + if !ok || gotByID.ID != wallpaper.ID || gotByID.AccessHash != wallpaper.AccessHash { + t.Fatalf("theme %d settings[%d] wallpaper id lookup = %#v", theme.ID, i, byID) + } + bySlug, ok := LookupWallPaper(&tg.InputWallPaperSlug{Slug: wallpaper.Slug}) + if !ok { + t.Fatalf("theme %d settings[%d] wallpaper slug %q was not resolvable", theme.ID, i, wallpaper.Slug) + } + gotBySlug, ok := bySlug.(*tg.WallPaper) + if !ok || gotBySlug.Slug != wallpaper.Slug { + t.Fatalf("theme %d settings[%d] wallpaper slug lookup = %#v", theme.ID, i, bySlug) + } + } + } + if checked == 0 { + t.Fatal("default themes exposed no file wallpapers") + } +} + func TestStarsRevenueStatsIsZeroBalanceCompatStub(t *testing.T) { got := StarsRevenueStats(false) if got == nil { diff --git a/internal/rpc/account_themes.go b/internal/rpc/account_themes.go index be113522..683625d8 100644 --- a/internal/rpc/account_themes.go +++ b/internal/rpc/account_themes.go @@ -220,6 +220,12 @@ func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThem if userID == 0 { return false, authKeyUnregisteredErr() } + if _, ok := tdesktop.LookupDefaultTheme(req.Theme); ok { + // Default catalog themes are always present in account.getThemes. Saving + // or unsaving one is therefore an idempotent signal and must not create a + // synthetic custom-theme row or user install. + return true, nil + } if r.deps.Themes == nil { return false, notImplementedErr() } @@ -252,14 +258,20 @@ func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInsta if userID == 0 { return false, authKeyUnregisteredErr() } - if r.deps.Themes == nil { - return false, notImplementedErr() - } dark := req.GetDark() theme, ok := req.GetTheme() if !ok { return true, nil // 无 theme 引用:基础主题 no-op 安装 } + if _, ok := tdesktop.LookupDefaultTheme(theme); ok { + // The immutable defaults were issued by account.getThemes but do not + // live in the custom theme store. Applying one is a successful signal; + // the Android client owns the active day/night choice locally. + return true, nil + } + if r.deps.Themes == nil { + return false, notImplementedErr() + } ref, ok := themeRefFromInput(theme) if !ok { return false, themeInvalidErr() @@ -280,6 +292,9 @@ func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeR if err != nil { return nil, internalErr() } + if t, ok := tdesktop.LookupDefaultTheme(req.Theme); ok { + return projectThemeForClient(ctx, &t), nil + } if r.deps.Themes == nil { return nil, notImplementedErr() } diff --git a/internal/rpc/account_themes_compat_test.go b/internal/rpc/account_themes_compat_test.go index 93618dbf..c36e1383 100644 --- a/internal/rpc/account_themes_compat_test.go +++ b/internal/rpc/account_themes_compat_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zaptest/observer" + "telesrv/internal/compat/tdesktop" "telesrv/internal/domain" ) @@ -119,6 +120,26 @@ func TestLegacyThemeWireDispatch(t *testing.T) { t.Fatalf("installTheme legacy wire id = %#x err=%v, want boolTrue", id, err) } + // 同一 legacy overlay 必须接受 account.getThemes 下发的静态默认主题, + // 且不能要求该引用存在于自定义主题 store。 + defaultTheme := tdesktop.DefaultThemeList()[0] + var defaultIB bin.Buffer + defaultIB.PutID(legacyInstallThemeID) + defaultIB.PutInt32((1 << 0) | (1 << 1)) + defaultIB.PutString("android") + (&tg.InputTheme{ID: defaultTheme.ID, AccessHash: defaultTheme.AccessHash}).Encode(&defaultIB) + enc, err = r.Dispatch(ctx, authKeyID, sessionID, &defaultIB) + if err != nil { + t.Fatalf("installTheme legacy default dispatch: %v", err) + } + boolWire.Reset() + if err := enc.Encode(&boolWire); err != nil { + t.Fatalf("encode installTheme legacy default result: %v", err) + } + if id, err := boolWire.ID(); err != nil || id != tg.BoolTrueTypeID { + t.Fatalf("installTheme legacy default wire id = %#x err=%v, want boolTrue", id, err) + } + // 已声明 legacy 方法仍必须由静态 decoder 精确消费完整结构。 var malformed bin.Buffer malformed.PutID(legacyCreateThemeID) diff --git a/internal/rpc/account_themes_rpc_test.go b/internal/rpc/account_themes_rpc_test.go index c018f422..114e81a9 100644 --- a/internal/rpc/account_themes_rpc_test.go +++ b/internal/rpc/account_themes_rpc_test.go @@ -126,6 +126,95 @@ func TestAccountCreateThemeFullFlow(t *testing.T) { } } +func TestAccountDefaultThemeReferencesResolveWithoutCustomPersistence(t *testing.T) { + const userID = 1000012 + ctx := WithClientInfo( + WithUserID(context.Background(), userID), + ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.9.0 (69669)"}, + ) + r := newThemeRouter(t, &fakeFiles{}) + defaults := tdesktop.DefaultThemeList() + if len(defaults) == 0 { + t.Fatal("default theme catalog is empty") + } + + for i, theme := range defaults { + t.Run(theme.Slug, func(t *testing.T) { + input := &tg.InputTheme{ID: theme.ID, AccessHash: theme.AccessHash} + install := &tg.AccountInstallThemeRequest{} + install.SetDark(i%2 == 0) + install.SetTheme(input) + install.SetFormat("android") + if ok, err := r.onAccountInstallTheme(ctx, install); err != nil || !ok { + t.Fatalf("install default theme %d = %v/%v, want true/nil", theme.ID, ok, err) + } + + for _, unsave := range []bool{false, true} { + if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{ + Theme: input, + Unsave: unsave, + }); err != nil || !ok { + t.Fatalf("save default theme %d unsave=%v = %v/%v, want true/nil", theme.ID, unsave, ok, err) + } + } + + got, err := r.onAccountGetTheme(ctx, &tg.AccountGetThemeRequest{ + Format: "android", + Theme: &tg.InputThemeSlug{Slug: theme.Slug}, + }) + if err != nil || got.ID != theme.ID || got.AccessHash != theme.AccessHash { + t.Fatalf("get default theme %d = %#v/%v", theme.ID, got, err) + } + + if ok, err := r.onAccountSaveTheme(ctx, &tg.AccountSaveThemeRequest{ + Theme: &tg.InputThemeSlug{Slug: theme.Slug}, + }); err != nil || !ok { + t.Fatalf("save default theme slug %q = %v/%v, want true/nil", theme.Slug, ok, err) + } + }) + } + + // Reproduce issue #29 through the canonical Layer 228 wire constructor, + // not only by invoking the typed handler directly. + canonical := &tg.AccountInstallThemeRequest{} + canonical.SetDark(true) + canonical.SetTheme(&tg.InputTheme{ID: defaults[0].ID, AccessHash: defaults[0].AccessHash}) + canonical.SetFormat("android") + var body bin.Buffer + if err := canonical.Encode(&body); err != nil { + t.Fatalf("encode canonical installTheme: %v", err) + } + var authKeyID [8]byte + authKeyID[0] = 2 + encoded, err := r.Dispatch(ctx, authKeyID, 1001, &body) + if err != nil { + t.Fatalf("canonical installTheme dispatch: %v", err) + } + var result bin.Buffer + if err := encoded.Encode(&result); err != nil { + t.Fatalf("encode canonical installTheme result: %v", err) + } + if id, err := result.ID(); err != nil || id != tg.BoolTrueTypeID { + t.Fatalf("canonical installTheme result id = %#x err=%v, want boolTrue", id, err) + } + + installed, err := r.deps.Themes.ListInstalled(ctx, userID) + if err != nil { + t.Fatalf("list custom installs after default signals: %v", err) + } + if len(installed) != 0 { + t.Fatalf("default signals created %d custom installs, want 0", len(installed)) + } + + forged := defaults[0] + badInstall := &tg.AccountInstallThemeRequest{} + badInstall.SetTheme(&tg.InputTheme{ID: forged.ID, AccessHash: forged.AccessHash + 1}) + badInstall.SetFormat("android") + if _, err := r.onAccountInstallTheme(ctx, badInstall); !tgerr.Is(err, "THEME_INVALID") { + t.Fatalf("install forged default reference err = %v, want THEME_INVALID", err) + } +} + // TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题 // (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true); // hash 稳定→NotModified,集合变化→重取。