This commit is contained in:
onysd 2026-08-27 13:23:17 +03:00
parent ef325f31da
commit 79c64ee916
14 changed files with 283 additions and 50 deletions

View file

@ -23,6 +23,10 @@ type Service struct {
participantCache *participantsReadModelCache
activeIDsCache *activeChannelIDsReadModelCache
botMemberIDsCache *activeBotMemberIDsCache
// reserved blocks the self-service UpdateUsername (not AdminSetUsername)
// from claiming a config.ReservedUsernames entry -- see
// domain.ReservedUsernameSet.
reserved domain.ReservedUsernameSet
}
type Option func(*Service)
@ -68,6 +72,14 @@ func WithSendPermissionChecker(c SendPermissionChecker) Option {
}
}
// WithReservedUsernames mirrors config.ReservedUsernames: the self-service
// UpdateUsername refuses to set any of these.
func WithReservedUsernames(names []string) Option {
return func(s *Service) {
s.reserved = domain.NewReservedUsernameSet(names)
}
}
// CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup.
func (s *Service) CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) {
req.CreatorUserID = userID
@ -501,8 +513,19 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.U
return domain.Channel{}, domain.ErrChannelInvalid
}
req.Username = normalizeChannelUsername(req.Username)
if req.Username != "" && !validChannelUsername(req.Username) {
return domain.Channel{}, domain.ErrUsernameInvalid
// Re-submitting the username the channel already has is a no-op, not a
// claim -- checked before any validation (including the reserved-word
// list) so a name that was fine to keep before this feature existed (or
// before it was added to config.ReservedUsernames) never gets rejected
// just because the client re-sent an unchanged value.
current, err := s.channels.GetChannelByID(ctx, req.ChannelID)
if err != nil {
return domain.Channel{}, err
}
if !strings.EqualFold(current.Username, req.Username) {
if req.Username != "" && (!validChannelUsername(req.Username) || s.reserved.Contains(req.Username)) {
return domain.Channel{}, domain.ErrUsernameInvalid
}
}
return s.channels.UpdateUsername(ctx, req)
}

View file

@ -2716,6 +2716,46 @@ func TestChannelUsernameAndSignatures(t *testing.T) {
}
}
// TestChannelUsernameReservedBlocksNewClaimsButKeepsExisting mirrors
// internal/app/users' identical test: adding a word to
// config.ReservedUsernames (or turning the feature on after a channel
// already owns a matching username) must never break a channel that
// already has it -- only a genuinely new claim of a reserved word is
// refused.
func TestChannelUsernameReservedBlocksNewClaimsButKeepsExisting(t *testing.T) {
ctx := context.Background()
channelStore := memory.NewChannelStore()
service := NewService(channelStore, WithReservedUsernames([]string{"admin"}))
grandfathered, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{Title: "Old", Date: 10})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat: %v", err)
}
if _, err := channelStore.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{
ChannelID: grandfathered.Channel.ID,
UserID: 1001,
Username: "admin",
}); err != nil {
t.Fatalf("seed grandfathered username directly on the store: %v", err)
}
newcomer, err := service.CreateMegagroupFromCreateChat(ctx, 1002, domain.CreateChannelRequest{Title: "New", Date: 11})
if err != nil {
t.Fatalf("CreateMegagroupFromCreateChat other: %v", err)
}
// Re-submitting the exact same (grandfathered) reserved username falls
// through to the store's own no-op detection (ErrChannelNotModified),
// not a validation rejection -- reaching that error at all proves the
// reserved check was bypassed for the unchanged value.
if _, err := service.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ChannelID: grandfathered.Channel.ID, Username: "@Admin"}); !errors.Is(err, domain.ErrChannelNotModified) {
t.Fatalf("re-submit grandfathered username err = %v, want ErrChannelNotModified", err)
}
// A different channel claiming the same reserved word for the first
// time must still be refused.
if _, err := service.UpdateUsername(ctx, 1002, domain.UpdateChannelUsernameRequest{ChannelID: newcomer.Channel.ID, Username: "admin"}); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("new claim of reserved username err = %v, want username invalid", err)
}
}
func TestListStoryPostableChannelsFiltersPostStoryRights(t *testing.T) {
ctx := context.Background()
service := NewService(memory.NewChannelStore())

View file

@ -12,46 +12,56 @@ import (
//go:embed seedassets/owpengram_system_avatar.png
var officialSystemAvatarPNG []byte
// SeedOfficialSystemAvatar idempotently seeds the built-in official system
// account's (777000) profile photo from the bundled brand logo, writing it
// under the fixed domain.OfficialSystemUserPhotoID so the photo/blob layer
// and the pure domain.OfficialSystemUser() struct literal stay in sync
// across restarts. It also registers the photo as the account's *current*
// profile photo (the profile_photos association) — without this, list
// views render the avatar from the hardcoded User struct fields, but
// users.getFullUser (triggered on chat open) reads only the association,
// finds nothing, and the client wipes the avatar it just showed.
// Returns true if it actually wrote a new photo.
func (s *Service) SeedOfficialSystemAvatar(ctx context.Context) (bool, error) {
// SeedOfficialSystemAvatar seeds the built-in official system account's
// (777000) profile photo, writing it under the fixed
// domain.OfficialSystemUserPhotoID so the photo/blob layer and the pure
// domain.OfficialSystemUser() struct literal stay in sync across restarts.
// It also registers the photo as the account's *current* profile photo (the
// profile_photos association) — without this, list views render the avatar
// from the hardcoded User struct fields, but users.getFullUser (triggered on
// chat open) reads only the association, finds nothing, and the client
// wipes the avatar it just showed.
//
// customIcon, when non-empty, is the operator's own Server Settings ->
// Server identity icon (any of the formats Server Settings accepts --
// putPhotoStaticSizes stores it as-is, no re-encoding, so format doesn't
// matter here); it replaces the bundled default OwpenGram logo. This
// deliberately re-upserts the same fixed photo ID on *every* boot (not just
// the first) rather than skipping once a row exists, so switching the
// custom icon on/off in the admin panel is reflected here on the next
// restart, the same "changes take effect on next Restart/Update" contract
// identity's other settings already have. Returns true if a custom icon is
// in effect (for the startup log line) -- not whether anything on disk
// actually changed since the last boot.
func (s *Service) SeedOfficialSystemAvatar(ctx context.Context, customIcon []byte) (bool, error) {
photoID := domain.OfficialSystemUserPhotoID
wrote := false
if _, found, err := s.media.GetPhoto(ctx, photoID); err != nil {
return false, err
} else if !found {
sizes, err := s.putPhotoStaticSizes(ctx, photoID, officialSystemAvatarPNG, photoSizeSpecsForAvatar(officialSystemAvatarPNG))
if err != nil {
return false, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: domain.OfficialSystemUserPhotoAccessHash,
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return false, err
}
wrote = true
data := officialSystemAvatarPNG
usingCustom := len(customIcon) > 0
if usingCustom {
data = customIcon
}
photo, ok, err := s.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, domain.OfficialSystemUserID, photoID, int(time.Now().Unix()))
sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
if err != nil {
return false, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: domain.OfficialSystemUserPhotoAccessHash,
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return false, err
}
current, ok, err := s.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, domain.OfficialSystemUserID, photoID, int(time.Now().Unix()))
if err != nil {
return false, err
}
if !ok {
return false, fmt.Errorf("official system avatar photo %d not found after seeding", photoID)
}
domain.SetOfficialSystemUserAvatar(photo.DCID, domain.StrippedFromSizes(photo.Sizes))
return wrote, nil
domain.SetOfficialSystemUserAvatar(current.DCID, domain.StrippedFromSizes(current.Sizes))
return usingCustom, nil
}

View file

@ -31,6 +31,9 @@ type Service struct {
// while true, ResolveUsername never resolves @marksbot
// (domain.VerifierBotUserID), so a client cannot discover it by username.
hideThirdPartyVerification bool
// reserved blocks UpdateUsername (self-service only) from claiming a
// config.ReservedUsernames entry -- see domain.ReservedUsernameSet.
reserved domain.ReservedUsernameSet
}
type usernameAvailabilityStore interface {
@ -74,6 +77,12 @@ func WithHideThirdPartyVerification(hidden bool) Option {
return func(s *Service) { s.hideThirdPartyVerification = hidden }
}
// WithReservedUsernames mirrors config.ReservedUsernames: UpdateUsername
// (self-service only) refuses to set any of these.
func WithReservedUsernames(names []string) Option {
return func(s *Service) { s.reserved = domain.NewReservedUsernameSet(names) }
}
const (
minUsernameLen = 5
maxUsernameLen = 32
@ -240,8 +249,16 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
return domain.User{}, err
}
username = normalizeUsername(username)
// Re-submitting the username the account already has is a no-op, not a
// claim -- checked before any validation (including the reserved-word
// list) so a name that was fine to keep before this feature existed
// (or before it was added to config.ReservedUsernames) never gets
// rejected just because the client re-sent an unchanged value.
if self.Username == username {
return s.projectOne(ctx, self.ID, self)
}
if username != "" {
if !validUsername(username) {
if !validUsername(username) || s.reserved.Contains(username) {
return domain.User{}, domain.ErrUsernameInvalid
}
ok, err := s.checkUsernameAvailable(ctx, self.ID, username)
@ -252,9 +269,6 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str
return domain.User{}, domain.ErrUsernameOccupied
}
}
if self.Username == username {
return s.projectOne(ctx, self.ID, self)
}
u, err := s.users.UpdateUsername(ctx, self.ID, username)
if err != nil {
return domain.User{}, err

View file

@ -89,6 +89,43 @@ func TestServiceUsernameLifecycle(t *testing.T) {
}
}
// TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting locks in the
// grandfather behavior config.ReservedUsernames needs: adding a word to the
// list (or turning the feature on after channels/accounts already own a
// matching username) must never break an account that already has it --
// only a genuinely new claim of a reserved word is refused.
func TestServiceUsernameReservedBlocksNewClaimsButKeepsExisting(t *testing.T) {
ctx := context.Background()
store := memory.NewUserStore()
grandfathered, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000003", FirstName: "Old", Username: "admin"})
if err != nil {
t.Fatalf("create grandfathered: %v", err)
}
newcomer, err := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000004", FirstName: "New"})
if err != nil {
t.Fatalf("create newcomer: %v", err)
}
svc := NewService(store, WithReservedUsernames([]string{"admin"}))
// Re-submitting the exact same (grandfathered) reserved username -- the
// shape a client resubmitting an unmodified field sends, "@" prefix and
// all -- must be a no-op, not a rejection.
if u, err := svc.UpdateUsername(ctx, grandfathered.ID, "@admin"); err != nil || u.Username != "admin" {
t.Fatalf("re-submit grandfathered username = user %+v err %v, want no-op keeping %q", u, err, "admin")
}
// A different account claiming the same reserved word for the first time
// must still be refused.
if _, err := svc.UpdateUsername(ctx, newcomer.ID, "admin"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("new claim of reserved username err = %v, want username invalid", err)
}
// The grandfathered account moving to a *different* reserved word is a
// genuine new claim too, and must be refused the same way.
svc2 := NewService(store, WithReservedUsernames([]string{"admin", "support"}))
if _, err := svc2.UpdateUsername(ctx, grandfathered.ID, "support"); !errors.Is(err, domain.ErrUsernameInvalid) {
t.Fatalf("grandfathered account claiming a different reserved word err = %v, want username invalid", err)
}
}
// marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser()
// for a fixed username lookup, since memory.UserStore.Create always assigns an
// id from its own auto-increment sequence and can never produce the fixed