fix
This commit is contained in:
parent
ef325f31da
commit
79c64ee916
14 changed files with 283 additions and 50 deletions
|
|
@ -100,6 +100,11 @@ TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org
|
|||
# scam/fake. Leave empty to use the built-in English text.
|
||||
TELESRV_SCAM_WARNING=
|
||||
TELESRV_FAKE_WARNING=
|
||||
# Usernames a user/channel can never self-service claim (account.updateUsername,
|
||||
# channels.updateUsername) -- brand-adjacent or staff-sounding words, plus
|
||||
# your own real handle if you want it protected too. Comma-separated, not
|
||||
# case-sensitive. The admin panel can still assign any of these on purpose.
|
||||
TELESRV_RESERVED_USERNAMES=owpengram,admin,administrator,support,staff,moderator,official,root,owner
|
||||
|
||||
## Admin Panel -- Login and access for the web-based admin dashboard.
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-C5uszvXY.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D8u51wND.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hA2EpjuH.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,23 @@ export function Shell({
|
|||
const [brandIconFailed, setBrandIconFailed] = useState(false);
|
||||
const brandName = identity?.name?.trim() || "OwpenGram";
|
||||
const brandIconSrc = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
|
||||
|
||||
// The browser tab (title + favicon) follows the same custom-identity
|
||||
// override as the sidebar brand above, so a re-labeled server actually
|
||||
// looks like itself in the tab strip too, not just inside the app.
|
||||
useEffect(() => {
|
||||
document.title = `${brandName} Admin`;
|
||||
}, [brandName]);
|
||||
useEffect(() => {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.iconExt, brandIconFailed]);
|
||||
// Third-party verification is additionally hidden by default (not fully
|
||||
// finished) regardless of what the session was granted -- see permissions.tsx.
|
||||
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import (
|
|||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/mtprotoedge"
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
"telesrv/internal/otpdelivery"
|
||||
|
|
@ -829,10 +830,25 @@ func run(logger *zap.Logger) error {
|
|||
zap.Int("blobs", stats.Blobs),
|
||||
)
|
||||
}
|
||||
if seeded, err := filesService.SeedOfficialSystemAvatar(ctx); err != nil {
|
||||
// The official system account (777000) mirrors the operator's own Server
|
||||
// Settings -> Server identity, when set: same "default unless
|
||||
// configured" contract as the client-facing /owpengram/server-info
|
||||
// endpoint, just applied to this one built-in account's display name
|
||||
// and avatar instead of what's shown to a client adding the server.
|
||||
identityStore := identity.NewStore(cfg.IdentityDir)
|
||||
serverIdentity, err := identityStore.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read server identity: %w", err)
|
||||
}
|
||||
domain.SetOfficialSystemUserDisplayName(serverIdentity.Name)
|
||||
var customSystemIcon []byte
|
||||
if iconData, _, ok := identityStore.Icon(); ok {
|
||||
customSystemIcon = iconData
|
||||
}
|
||||
if usingCustom, err := filesService.SeedOfficialSystemAvatar(ctx, customSystemIcon); err != nil {
|
||||
return fmt.Errorf("seed official system avatar: %w", err)
|
||||
} else if seeded {
|
||||
logger.Info("official system account avatar seed import complete", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
} else if usingCustom {
|
||||
logger.Info("official system account avatar seed import complete (custom Server identity icon)", zap.Int64("photo_id", domain.OfficialSystemUserPhotoID))
|
||||
}
|
||||
if seeded, err := filesService.SeedBotFatherAvatar(ctx); err != nil {
|
||||
return fmt.Errorf("seed botfather avatar: %w", err)
|
||||
|
|
@ -1113,7 +1129,7 @@ func run(logger *zap.Logger) error {
|
|||
passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins))
|
||||
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
|
||||
themeService := themesapp.NewService(postgres.NewThemeStore(pool))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification))
|
||||
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames))
|
||||
privacyService.ConfigureReadModels(usersService, channelStore)
|
||||
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
|
||||
botsService.SetAIChatGenerator(aiComposeService)
|
||||
|
|
@ -1132,6 +1148,7 @@ func run(logger *zap.Logger) error {
|
|||
channelapp.WithBotProfileResolver(botsService),
|
||||
channelapp.WithReadModelVersions(readModelVersionStore),
|
||||
channelapp.WithSendPermissionChecker(adminService),
|
||||
channelapp.WithReservedUsernames(cfg.ReservedUsernames),
|
||||
)
|
||||
communitiesService := communitiesapp.NewService(communityStore)
|
||||
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
|
||||
|
|
|
|||
|
|
@ -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,9 +513,20 @@ 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) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -12,23 +12,35 @@ 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))
|
||||
data := officialSystemAvatarPNG
|
||||
usingCustom := len(customIcon) > 0
|
||||
if usingCustom {
|
||||
data = customIcon
|
||||
}
|
||||
sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -43,15 +55,13 @@ func (s *Service) SeedOfficialSystemAvatar(ctx context.Context) (bool, error) {
|
|||
if err := s.media.PutPhoto(ctx, photo); err != nil {
|
||||
return false, err
|
||||
}
|
||||
wrote = true
|
||||
}
|
||||
photo, ok, err := s.SetCurrentProfilePhoto(ctx, domain.PeerTypeUser, domain.OfficialSystemUserID, photoID, int(time.Now().Unix()))
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -112,6 +112,15 @@ type Config struct {
|
|||
// server-provided text, so operators set these to their audience language.
|
||||
ScamWarning string
|
||||
FakeWarning string
|
||||
// ReservedUsernames blocks self-service username registration/changes
|
||||
// (account.updateUsername, channels.updateUsername) from claiming any
|
||||
// of these, case-insensitively -- brand-adjacent or staff-sounding
|
||||
// handles (owpengram, admin, support), or the operator's own real name,
|
||||
// so a user can't squat an identity that would read as official.
|
||||
// Admin-panel-driven username assignment bypasses this deliberately: an
|
||||
// operator setting one of these on an account on purpose is not the
|
||||
// squatting this exists to stop.
|
||||
ReservedUsernames []string
|
||||
// PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。
|
||||
// 生产应只监听 loopback,并由 nginx 将 /<username>、/addstickers/、/addemoji/、
|
||||
// /addlist/ 与 hash-only /appeal/ 路由反代到该地址。
|
||||
|
|
@ -701,6 +710,9 @@ func Load() (Config, error) {
|
|||
PublicDownloadURL: publicDownloadURL,
|
||||
ScamWarning: envAllowEmptyOr("TELESRV_SCAM_WARNING", ""),
|
||||
FakeWarning: envAllowEmptyOr("TELESRV_FAKE_WARNING", ""),
|
||||
ReservedUsernames: envListOr("TELESRV_RESERVED_USERNAMES", []string{
|
||||
"owpengram", "admin", "administrator", "support", "staff", "moderator", "official", "root", "owner",
|
||||
}),
|
||||
PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""),
|
||||
TelegramLoginEnabled: envBoolOr("TELESRV_TELEGRAM_LOGIN_ENABLE", false),
|
||||
TelegramLoginIssuer: strings.TrimSuffix(envOr("TELESRV_TELEGRAM_LOGIN_ISSUER", publicBaseURL), "/"),
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import (
|
|||
|
||||
func TestServiceIdentityAndLoginMessageUseOwpenGramBrand(t *testing.T) {
|
||||
serviceUser := OfficialSystemUser()
|
||||
if serviceUser.FirstName != "OwpenGram" || serviceUser.Username != "owpengram" {
|
||||
t.Fatalf("service user = %+v, want OwpenGram identity", serviceUser)
|
||||
// No Username by design -- see OfficialSystemUser's doc comment: real
|
||||
// Telegram's own 777000 isn't @-addressable either.
|
||||
if serviceUser.FirstName != "OwpenGram" || serviceUser.Username != "" {
|
||||
t.Fatalf("service user = %+v, want OwpenGram identity with no username", serviceUser)
|
||||
}
|
||||
message, err := OfficialLoginCodeMessage(42, "12345", 1)
|
||||
if err != nil {
|
||||
|
|
|
|||
32
internal/domain/reserved_usernames.go
Normal file
32
internal/domain/reserved_usernames.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
// ReservedUsernameSet is a case-insensitive lookup of usernames self-service
|
||||
// registration should never be allowed to claim (see config.ReservedUsernames
|
||||
// -- brand-adjacent/staff-sounding words, or an operator's own real handle).
|
||||
// Admin-panel-driven username assignment does not consult this at all: an
|
||||
// operator setting one of these on an account on purpose is not the
|
||||
// squatting it exists to stop.
|
||||
type ReservedUsernameSet map[string]bool
|
||||
|
||||
// NewReservedUsernameSet builds a set from config.ReservedUsernames. A nil
|
||||
// or empty input is a valid, empty set (Contains always false) -- nothing
|
||||
// reserved beyond what's already taken by another account.
|
||||
func NewReservedUsernameSet(names []string) ReservedUsernameSet {
|
||||
set := make(ReservedUsernameSet, len(names))
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(strings.TrimSpace(n))
|
||||
if n != "" {
|
||||
set[n] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Contains reports whether username (compared case-insensitively, leading
|
||||
// "@" ignored) is reserved.
|
||||
func (s ReservedUsernameSet) Contains(username string) bool {
|
||||
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(username), "@")))
|
||||
return s[username]
|
||||
}
|
||||
|
|
@ -107,6 +107,23 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
|
|||
officialSystemUserPhotoStripped = stripped
|
||||
}
|
||||
|
||||
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName --
|
||||
// empty means "use branding.ProductName" (the compile-time default), set
|
||||
// once at startup from the operator's Server Settings -> Server identity
|
||||
// name, if any. Deliberately only the display name, not Username: the
|
||||
// account's @username is a stable, addressable identifier other things may
|
||||
// already reference, unlike the display name shown in chat headers.
|
||||
var officialSystemUserDisplayName string
|
||||
|
||||
// SetOfficialSystemUserDisplayName records the operator's custom server
|
||||
// name for the official system account (777000), read once at startup from
|
||||
// Server Settings -> Server identity. Pass "" to fall back to
|
||||
// branding.ProductName -- the same "unset -> default" contract the avatar
|
||||
// override above uses.
|
||||
func SetOfficialSystemUserDisplayName(name string) {
|
||||
officialSystemUserDisplayName = strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
|
||||
// 通过 SetBotFatherAvatar 写入一次;写入前 BotFatherUser() 不带头像(PhotoID==0)。
|
||||
var (
|
||||
|
|
@ -167,13 +184,20 @@ func SetVerifyBotAvatar(dcID int, stripped []byte) {
|
|||
}
|
||||
|
||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
||||
// No Username: the real Telegram service account (777000) isn't
|
||||
// @-addressable either, and reserving one here would need it re-blocked in
|
||||
// config.ReservedUsernames (which it now is, by default, precisely because
|
||||
// nothing keeps another account from claiming it once this one has none).
|
||||
func OfficialSystemUser() User {
|
||||
name := branding.ProductName
|
||||
if officialSystemUserDisplayName != "" {
|
||||
name = officialSystemUserDisplayName
|
||||
}
|
||||
u := User{
|
||||
ID: OfficialSystemUserID,
|
||||
AccessHash: 6599886787491911851,
|
||||
Phone: "42777",
|
||||
FirstName: branding.ProductName,
|
||||
Username: branding.ProductUsername,
|
||||
FirstName: name,
|
||||
Verified: true,
|
||||
Support: true,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue