diff --git a/.env.example b/.env.example index e3297c03..b5f7b2c7 100644 --- a/.env.example +++ b/.env.example @@ -23,8 +23,10 @@ TELESRV_SMTP_FROM_NAME=telesrv TELESRV_SMTP_TLS=starttls TELESRV_SMTP_TIMEOUT=10s -# Client-visible telesrv links. Production uses https://telesrv.net. -# For local link/deeplink smoke tests use http://127.0.0.1:2401. +# Client-visible telesrv links. This is an HTTP(S) URL, not a listen address. +# Production uses https://telesrv.net. For local link/deeplink smoke tests use +# http://127.0.0.1:2401. Invalid schemes, credentials, query strings, fragments, +# or a missing host fail startup instead of silently falling back. TELESRV_PUBLIC_BASE_URL=https://telesrv.net # Admin API / Admin UI 配置 @@ -32,18 +34,19 @@ TELESRV_PUBLIC_BASE_URL=https://telesrv.net # TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token, # 也是 Admin UI (cmd/telesrv-admin) 调用 Admin API 时使用的凭证。 # 重要:主服务与 Admin UI 必须使用完全相同的 TELESRV_ADMIN_API_TOKEN,否则管理后台无法执行写操作。 -TELESRV_ADMIN_API_TOKEN=change-me-to-a-strong-random-token +TELESRV_ADMIN_API_TOKEN= # TELESRV_ADMIN_UI_PASSWORD 是登录管理后台的密码; # 也可改用 TELESRV_ADMIN_UI_TOKEN,二者至少填一个。 -TELESRV_ADMIN_UI_PASSWORD=abc123456 +TELESRV_ADMIN_UI_PASSWORD= +TELESRV_ADMIN_UI_TOKEN= # TELESRV_ADMIN_SESSION_KEY 用于加密 Admin UI 的登录 session cookie。 # 生产环境请使用至少 32 字节的强随机字符串,修改后会导致已登录会话失效。 -TELESRV_ADMIN_SESSION_KEY=change-me-to-a-strong-random-key-at-least-32-chars +TELESRV_ADMIN_SESSION_KEY= -# 主服务 Admin API 默认关闭;Admin UI 写操作需要显式开启该 loopback 监听地址。 -TELESRV_ADMIN_API_ADDR=127.0.0.1:2599 +# 主服务 Admin API 默认关闭;Admin UI 写操作需要同时配置强 token 并显式开启该 loopback 监听地址。 +TELESRV_ADMIN_API_ADDR= # Admin UI 监听地址,默认值通常无需修改;RTMP ingest 保留 2400。 TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600 @@ -61,8 +64,11 @@ TELESRV_LANGPACK_SEED_DIR=data/langpack TELESRV_BLOB_DIR=data/blobs TELESRV_STICKER_SEED_DIR=data/sticker-seed -# Optional sticker/custom emoji/chatlist landing endpoint. -# Public canonical URLs use TELESRV_PUBLIC_BASE_URL. +# Optional public-link Web listener for /, profile avatars, +# sticker/custom emoji sets, and shared folders. This is a host:port bind +# address without a URL scheme. It is not replaced by TELESRV_PUBLIC_BASE_URL. +# Production should keep it on loopback and reverse-proxy the documented routes +# through nginx; public canonical URLs use TELESRV_PUBLIC_BASE_URL. TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 # AI compose for TDesktop/Android input box rewrite/polish. diff --git a/README.md b/README.md index 6e747e32..6255629d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ codebase. | ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. | | ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. | | ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. | -| ✅ | Chatlists and public links | Chat folder sharing, exported chatlist invite links, join/import flows, revoked invite handling, and shared public link landing pages. | +| ✅ | Chatlists and public links | Chat folder sharing, exported chatlist invite links, join/import flows, revoked invite handling, public username landing pages, and shared public link landing pages. | | ✅ | Private chats | Send, history, read receipts, edit, delete, forward, reply, rich entities, grouped/media messages, reactions, scheduled/TTL-oriented paths. | | ✅ | Rich messages | Telegram Desktop rich text messages, rich content conversion, send/edit/scheduled flows, dialog/history projections, and memory/PostgreSQL persistence. | | ✅ | AI compose and ChatBot | Input-box rewrite/polish, default and custom tones, addstyle previews, local and external provider chains, streamed `@ChatBot` draft replies, and Business AI reply hooks. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 58e271fa..8e340113 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c | ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 | | ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 | | ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 | -| ✅ | Chatlists 与公开链接 | 聊天文件夹分享、chatlist invite links、加入/导入流程、撤销邀请处理,以及统一公开链接落地页。 | +| ✅ | Chatlists 与公开链接 | 聊天文件夹分享、chatlist invite links、加入/导入流程、撤销邀请处理、公开 username 落地页,以及统一公开链接落地页。 | | ✅ | 私聊消息 | send、history、read receipts、edit、delete、forward、reply、富文本实体、媒体/相册消息、reactions、scheduled/TTL 相关路径。 | | ✅ | 富文本消息 | Telegram Desktop rich text message、富文本内容转换、send/edit/scheduled 流程、dialog/history 投影,以及 memory/PostgreSQL 持久化。 | | ✅ | AI 输入框与 ChatBot | 输入框改写/润色、默认和自定义 tone、addstyle 预览、本地与外部 provider 链、流式 `@ChatBot` 草稿回复、Business AI 回复钩子。 | diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 8d8359a8..d098e9dd 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -315,6 +315,7 @@ func run(logger *zap.Logger) error { adminStore := postgres.NewAdminStore(pool) updateStateStore := postgres.NewUpdateStateStore(pool) updateEventStore := postgres.NewUpdateEventStore(pool, postgres.WithUpdateEventLogger(logger.Named("store").Named("updates"))) + phoneChangeStore := postgres.NewPhoneChangeStore(pool) readModelVersionStore := storepkg.NewCachedReadModelVersionStore(postgres.NewReadModelVersionStore(pool), 0, 0) dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(cfg.OutboxLeaseTimeout)) bootstrapUpdateStore := postgres.NewBootstrapUpdateJobStore(pool) @@ -393,7 +394,7 @@ func run(logger *zap.Logger) error { return fmt.Errorf("seed appearance: %w", err) } else if !stats.Skipped { logger.Info("外观种子导入完成", - zap.String("source", "default-seed"), + zap.String("source", "orange-live"), zap.Int("wallpapers", stats.Wallpapers), zap.Int("documents", stats.Documents), zap.Int("blobs", stats.Blobs), @@ -475,6 +476,7 @@ func run(logger *zap.Logger) error { account.WithSavedMusic(passwordStore), account.WithBusinessAutomation(passwordStore), account.WithUsers(userStore), + account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), account.WithPublicBaseURL(cfg.PublicBaseURL), } var loginEmailSender mailpkg.Sender @@ -762,6 +764,9 @@ func run(logger *zap.Logger) error { Addr: cfg.PublicLinkWebAddr, PublicBaseURL: cfg.PublicBaseURL, Users: userStore, + Channels: channelStore, + Privacy: privacyService, + Photos: filesService, }, filesService, logger.Named("stickerlinks")); err != nil { return fmt.Errorf("start sticker links: %w", err) } diff --git a/deploy/migrations/0061_user_phone_change_update.down.sql b/deploy/migrations/0061_user_phone_change_update.down.sql new file mode 100644 index 00000000..cca70746 --- /dev/null +++ b/deploy/migrations/0061_user_phone_change_update.down.sql @@ -0,0 +1,17 @@ +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', + 'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', + 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', + 'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', + 'read_channel_discussion_outbox' + ]::text[]) +); + +ALTER TABLE user_update_events + DROP COLUMN IF EXISTS event_phone; diff --git a/deploy/migrations/0061_user_phone_change_update.up.sql b/deploy/migrations/0061_user_phone_change_update.up.sql new file mode 100644 index 00000000..130a719c --- /dev/null +++ b/deploy/migrations/0061_user_phone_change_update.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE user_update_events + ADD COLUMN event_phone text NOT NULL DEFAULT ''; + +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', + 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', + 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', + 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', + 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', + 'read_channel_discussion_outbox' + ]::text[]) +); diff --git a/internal/app/account/phone_change.go b/internal/app/account/phone_change.go new file mode 100644 index 00000000..cc559f27 --- /dev/null +++ b/internal/app/account/phone_change.go @@ -0,0 +1,185 @@ +package account + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "strings" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type reliablePhoneChangeDispatcher interface { + UsesReliableDispatch() bool +} + +func (s *Service) PhoneChangeUsesReliableDispatch() bool { + if s == nil { + return false + } + reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher) + return ok && reporter.UsesReliableDispatch() +} + +// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。 +// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求 +// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。 +// SessionID 被记录用于审计,但验证时不要求相等:同一设备在等待短信期间发生 +// MTProto session 重建仍可完成流程;其它设备因 auth_key 不同无法复用。 +func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) { + phone = domain.NormalizePhone(phone) + if !domain.ValidPhone(phone) { + return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid + } + if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil { + return "", domain.AuthCodeDelivery{}, err + } + if existing, found, err := s.users.ByPhone(ctx, phone); err != nil { + return "", domain.AuthCodeDelivery{}, err + } else if found && existing.ID != 0 { + return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied + } + if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" { + return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured") + } + hash, err := phoneChangeHash() + if err != nil { + return "", domain.AuthCodeDelivery{}, err + } + rec := store.PhoneCode{ + Phone: phone, + Code: s.phoneChangeCode, + Channel: "phone", + Purpose: store.PhoneCodePurposeChangePhone, + UserID: userID, + AuthKeyID: authKeyID, + SessionID: sessionID, + MaxAttempts: s.phoneChangeMaxAttempts, + } + if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil { + return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err) + } + return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil +} + +// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的 +// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。 +func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) { + if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" { + return domain.PhoneChangeResult{}, domain.ErrPhoneCodeEmpty + } + phone = domain.NormalizePhone(phone) + if !domain.ValidPhone(phone) { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid + } + if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil { + return domain.PhoneChangeResult{}, err + } + if s.codes == nil || s.phoneChanges == nil { + return domain.PhoneChangeResult{}, fmt.Errorf("phone change service is not configured") + } + rec, found, err := s.codes.Get(ctx, phoneCodeHash) + if err != nil { + return domain.PhoneChangeResult{}, err + } + if !found { + return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired + } + if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != phone || rec.UserID != userID || rec.AuthKeyID != authKeyID { + return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid + } + code = strings.TrimSpace(code) + if subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) != 1 { + return domain.PhoneChangeResult{}, s.rejectPhoneChangeCode(ctx, phoneCodeHash, rec) + } + if existing, occupied, err := s.users.ByPhone(ctx, phone); err != nil { + return domain.PhoneChangeResult{}, err + } else if occupied && existing.ID != userID { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied + } + // 正确 code 必须在进入持久化事务前原子消费。并发重放中只有一个请求能 + // 获得记录,其余请求不得再次推进 pts 或追加 user_phone event。 + consumed, found, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, store.PhoneCodeScope{ + Purpose: store.PhoneCodePurposeChangePhone, + UserID: userID, + AuthKeyID: authKeyID, + Phone: phone, + }) + if err != nil { + return domain.PhoneChangeResult{}, err + } + if !found { + return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired + } + if consumed.Purpose != rec.Purpose || consumed.UserID != rec.UserID || consumed.AuthKeyID != rec.AuthKeyID || consumed.Phone != rec.Phone || + subtle.ConstantTimeCompare([]byte(consumed.Code), []byte(code)) != 1 { + return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid + } + if date == 0 { + date = int(time.Now().Unix()) + } + result, err := s.phoneChanges.ChangePhone(ctx, domain.PhoneChangeRequest{ + UserID: userID, + Phone: phone, + Date: date, + ExcludeAuthKeyID: authKeyID, + ExcludeSessionID: sessionID, + }) + if err != nil { + return domain.PhoneChangeResult{}, err + } + if s.userCache != nil && result.User.ID != 0 { + _ = s.userCache.Delete(ctx, []int64{result.User.ID}) + } + return result, nil +} + +func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID [8]byte) (domain.User, error) { + if s == nil || s.users == nil || s.authorizations == nil || userID == 0 || authKeyID == ([8]byte{}) { + return domain.User{}, domain.ErrPhoneChangeAuthInvalid + } + a, found, err := s.authorizations.ByAuthKey(ctx, authKeyID) + if err != nil { + return domain.User{}, err + } + if !found || a.UserID != userID || a.PasswordPending { + return domain.User{}, domain.ErrPhoneChangeAuthInvalid + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !found { + return domain.User{}, domain.ErrPhoneChangeAuthInvalid + } + if u.Bot || domain.IsSystemUserID(u.ID) { + return domain.User{}, domain.ErrPhoneChangeForbidden + } + return u, nil +} + +func (s *Service) rejectPhoneChangeCode(ctx context.Context, hash string, rec store.PhoneCode) error { + rec.Attempts++ + max := rec.MaxAttempts + if max <= 0 { + max = s.phoneChangeMaxAttempts + } + if max > 0 && rec.Attempts >= max { + _ = s.codes.Del(ctx, hash) + return domain.ErrPhoneCodeInvalid + } + _ = s.codes.Update(ctx, hash, rec) + return domain.ErrPhoneCodeInvalid +} + +func phoneChangeHash() (string, error) { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate phone change hash: %w", err) + } + return hex.EncodeToString(raw[:]), nil +} diff --git a/internal/app/account/phone_change_test.go b/internal/app/account/phone_change_test.go new file mode 100644 index 00000000..e6987dab --- /dev/null +++ b/internal/app/account/phone_change_test.go @@ -0,0 +1,196 @@ +package account + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +type phoneChangeFixture struct { + ctx context.Context + service *Service + users *memory.UserStore + auths *memory.AuthorizationStore + codes *memory.CodeStore + events *memory.UpdateEventStore + user domain.User + authKeyID [8]byte +} + +func newPhoneChangeFixture(t *testing.T) phoneChangeFixture { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + auths := memory.NewAuthorizationStore() + codes := memory.NewCodeStore() + events := memory.NewUpdateEventStore() + u, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15550012001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + authKeyID := [8]byte{1, 2, 3, 4} + if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: u.ID, CreatedAt: time.Now().Add(-48 * time.Hour)}); err != nil { + t.Fatalf("bind auth: %v", err) + } + service := NewService( + memory.NewPasswordStore(), + WithUsers(users), + WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 3), + ) + return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID} +} + +func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) { + f := newPhoneChangeFixture(t) + hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002") + if err != nil { + t.Fatalf("send change code: %v", err) + } + if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 5 { + t.Fatalf("delivery = hash %q %+v", hash, delivery) + } + rec, found, err := f.codes.Get(f.ctx, hash) + if err != nil || !found { + t.Fatalf("load code found=%v err=%v", found, err) + } + if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != "15550012002" || rec.UserID != f.user.ID || rec.AuthKeyID != f.authKeyID || rec.SessionID != 77 { + t.Fatalf("scoped code = %+v", rec) + } + + result, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "+1 555 001 2002", hash, "12345", 1700000000) + if err != nil { + t.Fatalf("change phone after session reconnect: %v", err) + } + if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 { + t.Fatalf("change result = %+v", result) + } + if _, found, _ := f.users.ByPhone(f.ctx, "15550012001"); found { + t.Fatal("old phone still resolves") + } + if got, found, _ := f.users.ByPhone(f.ctx, "15550012002"); !found || got.ID != f.user.ID { + t.Fatalf("new phone resolves to %+v found=%v", got, found) + } + events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10) + if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" { + t.Fatalf("durable events = %+v err=%v", events, err) + } + if _, found, _ := f.codes.Get(f.ctx, hash); found { + t.Fatal("successful code was not consumed") + } +} + +func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) { + f := newPhoneChangeFixture(t) + occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 102, Phone: "15550012003", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create occupied user: %v", err) + } + if _, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, occupied.Phone); !errors.Is(err, domain.ErrPhoneNumberOccupied) { + t.Fatalf("occupied send err = %v", err) + } + + hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012004") + if err != nil { + t.Fatalf("send code: %v", err) + } + otherKey := [8]byte{9, 9, 9} + if err := f.auths.Bind(f.ctx, domain.Authorization{AuthKeyID: otherKey, UserID: occupied.ID}); err != nil { + t.Fatalf("bind other auth: %v", err) + } + if _, err := f.service.ChangePhone(f.ctx, occupied.ID, otherKey, 99, "15550012004", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) { + t.Fatalf("cross-auth change err = %v", err) + } + if got, found, _ := f.users.ByID(f.ctx, occupied.ID); !found || got.Phone != "15550012003" { + t.Fatalf("other user changed = %+v found=%v", got, found) + } +} + +func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) { + f := newPhoneChangeFixture(t) + hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005") + if err != nil { + t.Fatalf("send code: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "00000", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) { + t.Fatalf("wrong attempt %d err = %v", i+1, err) + } + } + if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeExpired) { + t.Fatalf("exhausted code err = %v", err) + } + if got, _, _ := f.users.ByID(f.ctx, f.user.ID); got.Phone != "15550012001" { + t.Fatalf("phone changed after exhausted code: %q", got.Phone) + } +} + +func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) { + f := newPhoneChangeFixture(t) + oldHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012006") + if err != nil { + t.Fatalf("first send: %v", err) + } + newHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 88, "15550012006") + if err != nil { + t.Fatalf("second send: %v", err) + } + if oldHash == newHash { + t.Fatalf("hash was not rotated: %q", oldHash) + } + if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", oldHash, "12345", 1700000001); !errors.Is(err, domain.ErrPhoneCodeExpired) { + t.Fatalf("old hash replay err = %v", err) + } + if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", newHash, "12345", 1700000002); err != nil { + t.Fatalf("new hash change: %v", err) + } + events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10) + if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone { + t.Fatalf("events = %+v err=%v", events, err) + } +} + +func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) { + f := newPhoneChangeFixture(t) + hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007") + if err != nil { + t.Fatalf("send: %v", err) + } + const workers = 24 + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "15550012007", hash, "12345", 1700000003) + errs <- err + }() + } + wg.Wait() + close(errs) + successes := 0 + expired := 0 + for err := range errs { + switch { + case err == nil: + successes++ + case errors.Is(err, domain.ErrPhoneCodeExpired): + expired++ + default: + t.Fatalf("unexpected concurrent error: %v", err) + } + } + if successes != 1 || expired != workers-1 { + t.Fatalf("successes=%d expired=%d", successes, expired) + } + events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10) + if err != nil || len(events) != 1 || events[0].Pts != 1 { + t.Fatalf("events = %+v err=%v", events, err) + } +} diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 28adbf8f..9c39fd38 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -38,8 +38,14 @@ type Service struct { business store.BusinessAutomationStore // users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。 users store.UserStore + userCache store.UserCache + authorizations store.AuthorizationStore + phoneChanges store.PhoneChangeStore publicBaseURL string codes store.CodeStore + phoneChangeCode string + phoneChangeCodeTTL time.Duration + phoneChangeMaxAttempts int loginEmailSender mail.Sender loginEmailCodeTTL time.Duration loginEmailCodeMaxAttempts int @@ -105,6 +111,24 @@ func WithUsers(users store.UserStore) ServiceOption { } } +// WithPhoneChange 注入改号所需的授权校验、一次性验证码、原子 user+update +// 写入与基础用户缓存失效依赖。 +func WithPhoneChange(phoneChanges store.PhoneChangeStore, authorizations store.AuthorizationStore, codes store.CodeStore, cache store.UserCache, fixedCode string, ttl time.Duration, maxAttempts int) ServiceOption { + return func(s *Service) { + s.phoneChanges = phoneChanges + s.authorizations = authorizations + s.codes = codes + s.userCache = cache + s.phoneChangeCode = fixedCode + if ttl > 0 { + s.phoneChangeCodeTTL = ttl + } + if maxAttempts > 0 { + s.phoneChangeMaxAttempts = maxAttempts + } + } +} + func WithPublicBaseURL(baseURL string) ServiceOption { return func(s *Service) { s.publicBaseURL = links.NormalizeBaseURL(baseURL) @@ -129,7 +153,15 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t // NewService 创建 account 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { - s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL, loginEmailCodeTTL: 5 * time.Minute, loginEmailCodeMaxAttempts: 5, loginEmailCodeLength: 6} + s := &Service{ + passwords: passwords, + publicBaseURL: links.DefaultPublicBaseURL, + loginEmailCodeTTL: 5 * time.Minute, + loginEmailCodeMaxAttempts: 5, + loginEmailCodeLength: 6, + phoneChangeCodeTTL: 5 * time.Minute, + phoneChangeMaxAttempts: 5, + } for _, opt := range opts { opt(s) } diff --git a/internal/app/auth/change_phone_resend_test.go b/internal/app/auth/change_phone_resend_test.go new file mode 100644 index 00000000..b139768f --- /dev/null +++ b/internal/app/auth/change_phone_resend_test.go @@ -0,0 +1,71 @@ +package auth + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestResendCodePreservesChangePhoneScopeAndSMSDelivery(t *testing.T) { + ctx := context.Background() + codes := memory.NewCodeStore() + authKeyID := [8]byte{8, 7, 6} + rec := store.PhoneCode{ + Phone: "15550014001", + Code: "old", + Channel: codeChannelPhone, + Purpose: store.PhoneCodePurposeChangePhone, + UserID: 42, + AuthKeyID: authKeyID, + SessionID: 77, + Attempts: 2, + MaxAttempts: 5, + } + if err := codes.Set(ctx, "old-hash", rec, time.Minute); err != nil { + t.Fatalf("set old code: %v", err) + } + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345", WithCodeTTL(time.Minute)) + if _, err := svc.ResendCodeForAuthKey(ctx, [8]byte{1}, rec.Phone, "old-hash"); err != ErrCodeInvalid { + t.Fatalf("cross-auth resend err = %v", err) + } + if _, found, _ := codes.Get(ctx, "old-hash"); !found { + t.Fatal("cross-auth resend invalidated victim hash") + } + hash, err := svc.ResendCodeForAuthKey(ctx, authKeyID, rec.Phone, "old-hash") + if err != nil { + t.Fatalf("resend change code: %v", err) + } + if hash == "" || hash == "old-hash" { + t.Fatalf("new hash = %q", hash) + } + if _, found, _ := codes.Get(ctx, "old-hash"); found { + t.Fatal("old hash remains valid") + } + got, found, err := codes.Get(ctx, hash) + if err != nil || !found { + t.Fatalf("new code found=%v err=%v", found, err) + } + if got.Purpose != rec.Purpose || got.UserID != rec.UserID || got.AuthKeyID != rec.AuthKeyID || got.SessionID != rec.SessionID || got.Code != "12345" || got.Attempts != 0 { + t.Fatalf("resent scoped code = %+v", got) + } + delivery, found, err := svc.CodeDelivery(ctx, hash) + if err != nil || !found || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 5 { + t.Fatalf("delivery = %+v found=%v err=%v", delivery, found, err) + } + if err := svc.CancelCodeForAuthKey(ctx, [8]byte{2}, rec.Phone, hash); err != ErrCodeInvalid { + t.Fatalf("cross-auth cancel err = %v", err) + } + if _, found, _ := codes.Get(ctx, hash); !found { + t.Fatal("cross-auth cancel invalidated victim hash") + } + if err := svc.CancelCodeForAuthKey(ctx, authKeyID, rec.Phone, hash); err != nil { + t.Fatalf("scoped cancel: %v", err) + } + if _, found, _ := codes.Get(ctx, hash); found { + t.Fatal("scoped cancel left hash valid") + } +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 9b56695d..e18756ed 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -46,15 +46,7 @@ const ( // 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号), // 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。 func validPhone(phone string) bool { - if len(phone) < 5 || len(phone) > 32 { - return false - } - for _, r := range phone { - if r < '0' || r > '9' { - return false - } - } - return true + return domain.ValidPhone(phone) } func systemUserLoginForbidden(u domain.User) bool { @@ -364,6 +356,9 @@ func (s *Service) CodeDelivery(ctx context.Context, phoneCodeHash string) (domai } func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery { + if rec.Purpose == store.PhoneCodePurposeChangePhone { + return domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)} + } switch rec.Channel { case codeChannelEmailLogin: return domain.AuthCodeDelivery{ @@ -380,6 +375,16 @@ func codeDelivery(rec store.PhoneCode) domain.AuthCodeDelivery { // ResendCode invalidates an existing code hash and sends a fresh code to the same phone. func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) { + return s.resendCode(ctx, [8]byte{}, phone, phoneCodeHash) +} + +// ResendCodeForAuthKey 对已登录敏感操作额外校验发起 auth key;普通登录码 +// 没有 AuthKeyID 作用域,行为与 ResendCode 相同。 +func (s *Service) ResendCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) { + return s.resendCode(ctx, authKeyID, phone, phoneCodeHash) +} + +func (s *Service) resendCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) (string, error) { phone = normalizePhone(phone) rec, found, err := s.codes.Get(ctx, phoneCodeHash) if err != nil { @@ -391,7 +396,13 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) ( if rec.Phone != phone { return "", ErrCodeInvalid } + if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) { + return "", ErrCodeInvalid + } _ = s.codes.Del(ctx, phoneCodeHash) + if rec.Purpose == store.PhoneCodePurposeChangePhone { + return s.recreateChangePhoneCode(ctx, rec) + } if rec.Channel == codeChannelEmailLogin && strings.TrimSpace(rec.Email) != "" { return s.createEmailLoginCode(ctx, phone, rec.Email) } @@ -401,8 +412,34 @@ func (s *Service) ResendCode(ctx context.Context, phone, phoneCodeHash string) ( return s.SendCode(ctx, phone) } +func (s *Service) recreateChangePhoneCode(ctx context.Context, rec store.PhoneCode) (string, error) { + hash, err := randomHex(8) + if err != nil { + return "", err + } + rec.Code = s.fixedCode + rec.Channel = codeChannelPhone + rec.Attempts = 0 + if rec.MaxAttempts <= 0 { + rec.MaxAttempts = s.codeMaxAttempts + } + if err := s.codes.Set(ctx, hash, rec, s.codeTTL); err != nil { + return "", fmt.Errorf("store resent phone change code: %w", err) + } + return hash, nil +} + // CancelCode invalidates a pending login code hash. func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) error { + return s.cancelCode(ctx, [8]byte{}, phone, phoneCodeHash) +} + +// CancelCodeForAuthKey 是 ResendCodeForAuthKey 对应的取消路径。 +func (s *Service) CancelCodeForAuthKey(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error { + return s.cancelCode(ctx, authKeyID, phone, phoneCodeHash) +} + +func (s *Service) cancelCode(ctx context.Context, authKeyID [8]byte, phone, phoneCodeHash string) error { phone = normalizePhone(phone) rec, found, err := s.codes.Get(ctx, phoneCodeHash) if err != nil { @@ -414,6 +451,9 @@ func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) e if rec.Phone != phone { return ErrCodeInvalid } + if rec.Purpose == store.PhoneCodePurposeChangePhone && (authKeyID == ([8]byte{}) || rec.AuthKeyID != authKeyID) { + return ErrCodeInvalid + } return s.codes.Del(ctx, phoneCodeHash) } diff --git a/internal/app/privacy/service.go b/internal/app/privacy/service.go index c920e6e6..4ee92f56 100644 --- a/internal/app/privacy/service.go +++ b/internal/app/privacy/service.go @@ -110,6 +110,21 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k return Evaluate(rules, evalCtx), nil } +// CanSeeAnonymous evaluates one owner's privacy rules for an unauthenticated +// public-web viewer. Anonymous viewers are never contacts, premium users, +// close friends, bots, or shared-chat participants; explicit allow-all and +// disallow rules still retain their normal precedence through Evaluate. +func (s *Service) CanSeeAnonymous(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (bool, error) { + if ownerUserID == 0 { + return false, nil + } + rules, err := s.GetRules(ctx, ownerUserID, key) + if err != nil { + return false, err + } + return Evaluate(rules, domain.PrivacyContext{OwnerUserID: ownerUserID}), nil +} + // CanSeeBatch 批量评估多个 owner 对同一 viewer 在多个 key 上的可见性,结果等价于对每个 // (owner,key) 调一次 CanSee,但只用一次 ListPrivacyRules + 一次 GetReverseContacts + 内存 // Evaluate(消除 projectBatch / fan-out 投影里 per-user 3×CanSee×2行 的 N+1)。返回 diff --git a/internal/app/privacy/service_test.go b/internal/app/privacy/service_test.go index 3bb5731c..da1fff74 100644 --- a/internal/app/privacy/service_test.go +++ b/internal/app/privacy/service_test.go @@ -34,6 +34,32 @@ func TestDefaultPrivacyRules(t *testing.T) { } } +func TestCanSeeAnonymousHonorsPublicOnlyRules(t *testing.T) { + ctx := context.Background() + store := memory.NewPrivacyStore() + svc := NewService(store, nil) + const ownerID int64 = 1001 + + if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyAbout); err != nil || !visible { + t.Fatalf("default anonymous about visibility = %v, err=%v; want true", visible, err) + } + if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set contacts-only profile photo: %v", err) + } + if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || visible { + t.Fatalf("contacts-only anonymous photo visibility = %v, err=%v; want false", visible, err) + } + if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{2002}}, + {Kind: domain.PrivacyRuleAllowAll}, + }); err != nil { + t.Fatalf("set public profile photo: %v", err) + } + if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || !visible { + t.Fatalf("allow-all anonymous photo visibility = %v, err=%v; want true", visible, err) + } +} + func TestAddAllowUserOverridesDisallowAll(t *testing.T) { ctx := context.Background() svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()) diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go index 7ad972eb..91f11722 100644 --- a/internal/app/updates/service.go +++ b/internal/app/updates/service.go @@ -2,6 +2,7 @@ package updates import ( "context" + "fmt" "sort" "time" @@ -113,6 +114,23 @@ func (s *Service) ConfirmedState(ctx context.Context, authKeyID [8]byte, userID return st, found, nil } +// ConfirmEvent 把一个已在其它业务事务中原子提交的事件标记为当前设备已消费。 +// 典型调用是 account.changePhone:RPC result 已携最新 User,当前设备不应再收 +// updateUserPhone,但仍需把设备确认水位推进到该事件 pts。 +func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error { + if event.UserID != 0 && event.UserID != userID { + return fmt.Errorf("confirm update event user mismatch: event=%d caller=%d", event.UserID, userID) + } + if event.Pts <= 0 { + return nil + } + date := event.Date + if date == 0 { + date = int(time.Now().Unix()) + } + return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0}) +} + // AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。 // // 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的 diff --git a/internal/compat/android/suggestions.go b/internal/compat/android/suggestions.go new file mode 100644 index 00000000..5848f46a --- /dev/null +++ b/internal/compat/android/suggestions.go @@ -0,0 +1,14 @@ +// Package android 收敛 DrKLO Android 官方客户端的兼容决策。 +package android + +import "strings" + +const ValidatePhoneNumberSuggestion = "VALIDATE_PHONE_NUMBER" + +// DismissSuggestion 返回 telesrv 对 suggestion dismissal 的有界兼容结果。 +// telesrv 当前不向 config/channelFull 发布 pending suggestions,因此不存在需要 +// 持久化的服务端 suggestion 状态;对已登录客户端的非空 dismissal 做幂等确认, +// 并阻止 DrKLO 对 generic 500 无限重试。 +func DismissSuggestion(suggestion string) bool { + return strings.TrimSpace(suggestion) != "" +} diff --git a/internal/config/config.go b/internal/config/config.go index d8129718..fdd7ede8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -306,8 +306,12 @@ func Load() (Config, error) { envIntOr := fileEnv.envIntOr envInt64Or := fileEnv.envInt64Or envDurationOr := fileEnv.envDurationOr + envAllowEmptyOr := fileEnv.envAllowEmptyOr - publicBaseURL := links.NormalizeBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL)) + publicBaseURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_BASE_URL", links.DefaultPublicBaseURL)) + if err != nil { + return Config{}, fmt.Errorf("TELESRV_PUBLIC_BASE_URL: %w", err) + } cfg := Config{ ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), @@ -322,12 +326,12 @@ func Load() (Config, error) { AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), DC: envIntOr("TELESRV_DC", 2), - DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), - BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""), - AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""), + DebugAddr: envAllowEmptyOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), + BotAPIAddr: envAllowEmptyOr("TELESRV_BOT_API_ADDR", ""), + AdminAPIAddr: envAllowEmptyOr("TELESRV_ADMIN_API_ADDR", ""), AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), PublicBaseURL: publicBaseURL, - PublicLinkWebAddr: envOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), + PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""), @@ -666,6 +670,18 @@ func (e envSource) envOr(key, def string) string { return def } +// envAllowEmptyOr is for nullable settings where an explicitly empty process +// environment value must override a non-empty config-file value or default. +func (e envSource) envAllowEmptyOr(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + if v, ok := e[key]; ok { + return v + } + return def +} + func (e envSource) envListOr(key string, def []string) []string { v := e.envOr(key, "") if v == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 71f61322..288c4a89 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -224,6 +224,38 @@ func TestLoadNormalizesLocalPublicBaseURL(t *testing.T) { } } +func TestLoadRejectsInvalidPublicBaseURL(t *testing.T) { + disableDefaultConfigFile(t) + t.Setenv("TELESRV_PUBLIC_BASE_URL", "https://links.example.test/root?tenant=one") + + if _, err := Load(); err == nil { + t.Fatal("Load succeeded with a query-bearing public base URL") + } +} + +func TestLoadExplicitEmptyEnvironmentDisablesNullableListeners(t *testing.T) { + path := filepath.Join(t.TempDir(), "telesrv.env") + writeConfigFile(t, path, ` +TELESRV_DEBUG_ADDR=127.0.0.1:6060 +TELESRV_BOT_API_ADDR=127.0.0.1:8081 +TELESRV_ADMIN_API_ADDR=127.0.0.1:2599 +TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 +`) + t.Setenv("TELESRV_CONFIG", path) + t.Setenv("TELESRV_DEBUG_ADDR", "") + t.Setenv("TELESRV_BOT_API_ADDR", "") + t.Setenv("TELESRV_ADMIN_API_ADDR", "") + t.Setenv("TELESRV_PUBLIC_LINK_WEB_ADDR", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.DebugAddr != "" || cfg.BotAPIAddr != "" || cfg.AdminAPIAddr != "" || cfg.PublicLinkWebAddr != "" { + t.Fatalf("nullable listeners were not disabled: debug=%q bot=%q admin=%q public=%q", cfg.DebugAddr, cfg.BotAPIAddr, cfg.AdminAPIAddr, cfg.PublicLinkWebAddr) + } +} + func TestLoadEnvironmentOverridesConfigFile(t *testing.T) { path := filepath.Join(t.TempDir(), "telesrv.env") writeConfigFile(t, path, `TELESRV_MAPBOX_TOKEN=file-token`) diff --git a/internal/domain/account.go b/internal/domain/account.go index d6798c67..9102e9a1 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -6,23 +6,31 @@ import ( ) var ( - ErrPasswordHashInvalid = errors.New("password hash invalid") - ErrSRPIDInvalid = errors.New("srp id invalid") - ErrSRPPasswordChanged = errors.New("srp password changed") - ErrNewSettingsInvalid = errors.New("new password settings invalid") - ErrNewSaltInvalid = errors.New("new password salt invalid") - ErrPasswordRecoveryNA = errors.New("password recovery not available") - ErrEmailCodeInvalid = errors.New("email code invalid") - ErrEmailInvalid = errors.New("email invalid") - ErrEmailNotAllowed = errors.New("email not allowed") - ErrEmailOccupied = errors.New("email occupied") - ErrSessionPasswordNeeded = errors.New("session password needed") + ErrPasswordHashInvalid = errors.New("password hash invalid") + ErrSRPIDInvalid = errors.New("srp id invalid") + ErrSRPPasswordChanged = errors.New("srp password changed") + ErrNewSettingsInvalid = errors.New("new password settings invalid") + ErrNewSaltInvalid = errors.New("new password salt invalid") + ErrPasswordRecoveryNA = errors.New("password recovery not available") + ErrEmailCodeInvalid = errors.New("email code invalid") + ErrEmailInvalid = errors.New("email invalid") + ErrEmailNotAllowed = errors.New("email not allowed") + ErrEmailOccupied = errors.New("email occupied") + ErrSessionPasswordNeeded = errors.New("session password needed") + ErrPhoneNumberInvalid = errors.New("phone number invalid") + ErrPhoneNumberOccupied = errors.New("phone number occupied") + ErrPhoneCodeEmpty = errors.New("phone code empty") + ErrPhoneCodeInvalid = errors.New("phone code invalid") + ErrPhoneCodeExpired = errors.New("phone code expired") + ErrPhoneChangeAuthInvalid = errors.New("phone change auth invalid") + ErrPhoneChangeForbidden = errors.New("phone change forbidden") ) type AuthCodeDeliveryKind string const ( AuthCodeDeliveryPhone AuthCodeDeliveryKind = "phone" + AuthCodeDeliverySMS AuthCodeDeliveryKind = "sms" AuthCodeDeliveryEmail AuthCodeDeliveryKind = "email" AuthCodeDeliveryEmailSetupRequired AuthCodeDeliveryKind = "email_setup_required" ) @@ -240,3 +248,18 @@ func NormalizePhone(phone string) string { } return b.String() } + +// ValidPhone 校验 NormalizePhone 后的持久化形态:5-32 位纯数字。 +// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度, +// 但拒绝空串、非数字和会截断的超长输入。 +func ValidPhone(phone string) bool { + if len(phone) < 5 || len(phone) > 32 { + return false + } + for _, r := range phone { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/domain/phone_change.go b/internal/domain/phone_change.go new file mode 100644 index 00000000..6ccb7605 --- /dev/null +++ b/internal/domain/phone_change.go @@ -0,0 +1,18 @@ +package domain + +// PhoneChangeRequest 是账号改号的持久化命令。PG 实现必须把 User、Event 与 +// dispatch outbox 放在同一事务;Exclude* 精确排除发起设备,因为当前设备从 +// account.changePhone 的 User 返回值更新本地状态。 +type PhoneChangeRequest struct { + UserID int64 + Phone string + Date int + ExcludeAuthKeyID [8]byte + ExcludeSessionID int64 +} + +type PhoneChangeResult struct { + User User + Event UpdateEvent + Changed bool +} diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index dcf07de6..3d37da73 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -27,7 +27,10 @@ const ( UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark" UpdateEventPeerSettings UpdateEventType = "peer_settings" UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked" - UpdateEventDeleteMessages UpdateEventType = "delete_messages" + // UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL + // 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。 + UpdateEventUserPhone UpdateEventType = "user_phone" + UpdateEventDeleteMessages UpdateEventType = "delete_messages" // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消 // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 @@ -77,6 +80,7 @@ type UpdateEvent struct { Peer Peer Peers []Peer Bool bool + Phone string Settings PeerSettings MessageIDs []int MaxID int @@ -122,6 +126,7 @@ func (e UpdateEvent) LacksWirePts() bool { UpdateEventDialogUnreadMark, UpdateEventPeerSettings, UpdateEventPeerStoryBlocked, + UpdateEventUserPhone, UpdateEventDialogFilter, UpdateEventDialogFilterOrder, UpdateEventDialogFilters, diff --git a/internal/links/links.go b/internal/links/links.go index f61317b6..fdc02112 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -1,6 +1,7 @@ package links import ( + "fmt" "net/url" "strings" ) @@ -19,13 +20,40 @@ func NormalizeBaseURL(raw string) string { return strings.TrimRight(raw, "/") } -func Build(baseURL, path string, query url.Values) string { - baseURL = NormalizeBaseURL(baseURL) - parsed, err := url.Parse(baseURL) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - baseURL = DefaultPublicBaseURL - parsed, _ = url.Parse(baseURL) +// ValidateBaseURL normalizes and validates a client-visible HTTP(S) base URL. +// A path prefix is allowed, but credentials, query parameters, and fragments +// are not part of a stable public-link root. +func ValidateBaseURL(raw string) (string, error) { + normalized := NormalizeBaseURL(raw) + parsed, err := url.Parse(normalized) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) } + if parsed.Opaque != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("scheme must be http or https") + } + if parsed.Host == "" || parsed.Hostname() == "" { + return "", fmt.Errorf("host is required") + } + if parsed.User != nil { + return "", fmt.Errorf("credentials are not allowed") + } + if parsed.RawQuery != "" || parsed.ForceQuery { + return "", fmt.Errorf("query parameters are not allowed") + } + if parsed.Fragment != "" { + return "", fmt.Errorf("fragment is not allowed") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} + +func Build(baseURL, path string, query url.Values) string { + baseURL, err := ValidateBaseURL(baseURL) + if err != nil { + baseURL = DefaultPublicBaseURL + } + parsed, _ := url.Parse(baseURL) basePath := strings.TrimRight(parsed.Path, "/") path = strings.TrimLeft(path, "/") if path != "" { @@ -38,10 +66,11 @@ func Build(baseURL, path string, query url.Values) string { } func Host(baseURL string) string { - parsed, err := url.Parse(NormalizeBaseURL(baseURL)) - if err != nil || parsed.Host == "" { + baseURL, err := ValidateBaseURL(baseURL) + if err != nil { return "telesrv.net" } + parsed, _ := url.Parse(baseURL) if host := parsed.Hostname(); host != "" { return host } diff --git a/internal/links/links_test.go b/internal/links/links_test.go index 180ca7f2..d0c06049 100644 --- a/internal/links/links_test.go +++ b/internal/links/links_test.go @@ -24,6 +24,41 @@ func TestNormalizeBaseURL(t *testing.T) { } } +func TestValidateBaseURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "default", raw: "", want: "https://telesrv.net"}, + {name: "host and path", raw: "links.example.test/root/", want: "https://links.example.test/root"}, + {name: "local HTTP", raw: "http://127.0.0.1:2401/", want: "http://127.0.0.1:2401"}, + {name: "missing host", raw: "https://", wantErr: true}, + {name: "unsupported scheme", raw: "ftp://links.example.test", wantErr: true}, + {name: "credentials", raw: "https://user:pass@links.example.test", wantErr: true}, + {name: "query", raw: "https://links.example.test/root?tenant=one", wantErr: true}, + {name: "fragment", raw: "https://links.example.test/root#links", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateBaseURL(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateBaseURL(%q) succeeded with %q", tt.raw, got) + } + return + } + if err != nil { + t.Fatalf("ValidateBaseURL(%q): %v", tt.raw, err) + } + if got != tt.want { + t.Fatalf("ValidateBaseURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + func TestBuildPreservesBasePathAndQuery(t *testing.T) { got := Build("http://127.0.0.1:2401/root/", "/call/abc", url.Values{"slug": []string{"abc"}}) if want := "http://127.0.0.1:2401/root/call/abc?slug=abc"; got != want { diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 707ec2d7..9c218e6b 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -19,6 +19,8 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) { return true, nil }) + d.OnAccountSendChangePhoneCode(r.onAccountSendChangePhoneCode) + d.OnAccountChangePhone(r.onAccountChangePhone) d.OnAccountCheckUsername(r.onAccountCheckUsername) d.OnAccountUpdateProfile(r.onAccountUpdateProfile) d.OnAccountUpdateUsername(r.onAccountUpdateUsername) diff --git a/internal/rpc/account_phone.go b/internal/rpc/account_phone.go new file mode 100644 index 00000000..03e71113 --- /dev/null +++ b/internal/rpc/account_phone.go @@ -0,0 +1,89 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +type phoneChangeEventConfirmer interface { + ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error +} + +type phoneChangeReliableDispatchReporter interface { + PhoneChangeUsesReliableDispatch() bool +} + +func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.AccountSendChangePhoneCodeRequest) (tg.AuthSentCodeClass, error) { + userID, found, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !found || r.deps.Account == nil { + return nil, authKeyUnregisteredErr() + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok || authKeyID == ([8]byte{}) { + return nil, authKeyUnregisteredErr() + } + sessionID, _ := SessionIDFrom(ctx) + hash, delivery, err := r.deps.Account.SendChangePhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber) + if err != nil { + return nil, phoneChangeErr(err) + } + return tgSMSSentCode(hash, delivery.Length), nil +} + +func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChangePhoneRequest) (tg.UserClass, error) { + userID, found, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !found || r.deps.Account == nil { + return nil, authKeyUnregisteredErr() + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok || authKeyID == ([8]byte{}) { + return nil, authKeyUnregisteredErr() + } + sessionID, _ := SessionIDFrom(ctx) + result, err := r.deps.Account.ChangePhone( + ctx, + userID, + authKeyID, + sessionID, + req.PhoneNumber, + req.PhoneCodeHash, + req.PhoneCode, + int(r.clock.Now().Unix()), + ) + if err != nil { + return nil, phoneChangeErr(err) + } + if result.User.ID == 0 { + return nil, internalErr() + } + r.invalidateRPCProjectionForUser(result.User.ID) + if result.Event.Pts > 0 { + if confirmer, ok := r.deps.Updates.(phoneChangeEventConfirmer); ok { + if err := confirmer.ConfirmEvent(ctx, authKeyID, userID, result.Event); err != nil { + // user/event/outbox 已原子提交,不能把已成功改号伪装成失败;当前 + // session 仍会收到 pts 簿记,设备水位存储可由后续 getDifference 自愈。 + r.log.Warn("confirm phone change event", zap.Int64("user_id", userID), zap.Int("pts", result.Event.Pts), zap.Error(err)) + } + } + reliable := false + if reporter, ok := r.deps.Account.(phoneChangeReliableDispatchReporter); ok { + reliable = reporter.PhoneChangeUsesReliableDispatch() + } + if !reliable { + r.pushUserUpdates(ctx, userID, tgUpdateForOutboxEvent(result.Event)) + } + r.bookkeepAuxPtsForCurrentSession(ctx, result.Event) + } + return r.tgSelfUser(result.User), nil +} diff --git a/internal/rpc/account_phone_rpc_test.go b/internal/rpc/account_phone_rpc_test.go new file mode 100644 index 00000000..f0ce8fcc --- /dev/null +++ b/internal/rpc/account_phone_rpc_test.go @@ -0,0 +1,128 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + appupdates "telesrv/internal/app/updates" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + auths := memory.NewAuthorizationStore() + codes := memory.NewCodeStore() + events := memory.NewUpdateEventStore() + user, err := users.Create(ctx, domain.User{AccessHash: 401, Phone: "15550013001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + authKeyID := [8]byte{4, 3, 2, 1} + if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: user.ID, CreatedAt: time.Now().Add(-48 * time.Hour)}); err != nil { + t.Fatalf("bind auth: %v", err) + } + accountSvc := appaccount.NewService( + memory.NewPasswordStore(), + appaccount.WithUsers(users), + appaccount.WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 5), + ) + sessions := &captureSessions{} + r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 77) + + sentClass, err := r.onAccountSendChangePhoneCode(reqCtx, &tg.AccountSendChangePhoneCodeRequest{PhoneNumber: "+1 555 001 3002"}) + if err != nil { + t.Fatalf("send change phone code: %v", err) + } + sent, ok := sentClass.(*tg.AuthSentCode) + if !ok { + t.Fatalf("sent code = %T", sentClass) + } + if _, ok := sent.Type.(*tg.AuthSentCodeTypeSMS); !ok || sent.PhoneCodeHash == "" { + t.Fatalf("sent code type/hash = %T/%q", sent.Type, sent.PhoneCodeHash) + } + + userClass, err := r.onAccountChangePhone(reqCtx, &tg.AccountChangePhoneRequest{ + PhoneNumber: "15550013002", + PhoneCodeHash: sent.PhoneCodeHash, + PhoneCode: "12345", + }) + if err != nil { + t.Fatalf("change phone: %v", err) + } + self, ok := userClass.(*tg.User) + if !ok || self.ID != user.ID || self.Phone != "15550013002" { + t.Fatalf("returned self = %T %+v", userClass, userClass) + } + + otherPush, ok := sessions.lastUserPush().(*tg.Updates) + if !ok || len(otherPush.Updates) != 2 { + t.Fatalf("other-session push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush()) + } + phoneUpdate, ok := otherPush.Updates[0].(*tg.UpdateUserPhone) + if !ok || phoneUpdate.UserID != user.ID || phoneUpdate.Phone != "15550013002" { + t.Fatalf("phone update = %T %+v", otherPush.Updates[0], otherPush.Updates[0]) + } + if _, ok := otherPush.Updates[1].(*tg.UpdateDeleteMessages); !ok { + t.Fatalf("pts bookkeeping = %T", otherPush.Updates[1]) + } + currentPush, ok := sessions.snapshot().message.(*tg.Updates) + if !ok || len(currentPush.Updates) != 1 { + t.Fatalf("current-session bookkeeping = %T %+v", sessions.snapshot().message, sessions.snapshot().message) + } + if _, ok := currentPush.Updates[0].(*tg.UpdateDeleteMessages); !ok { + t.Fatalf("current bookkeeping update = %T", currentPush.Updates[0]) + } + + updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), events) + diff, err := updateSvc.GetDifference(ctx, [8]byte{9}, user.ID, domain.UpdateState{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + tgDiff, ok := tgUpdatesDifference(user.ID, diff).(*tg.UpdatesDifference) + if !ok || len(tgDiff.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v", tgUpdatesDifference(user.ID, diff), tgUpdatesDifference(user.ID, diff)) + } + replayed, ok := tgDiff.OtherUpdates[0].(*tg.UpdateUserPhone) + if !ok || replayed.UserID != user.ID || replayed.Phone != "15550013002" { + t.Fatalf("replayed update = %T %+v", tgDiff.OtherUpdates[0], tgDiff.OtherUpdates[0]) + } +} + +func TestAccountChangePhoneRPCMapsCodeAndOccupiedErrors(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + auths := memory.NewAuthorizationStore() + codes := memory.NewCodeStore() + events := memory.NewUpdateEventStore() + user, _ := users.Create(ctx, domain.User{AccessHash: 411, Phone: "15550013101", FirstName: "Alice"}) + occupied, _ := users.Create(ctx, domain.User{AccessHash: 412, Phone: "15550013102", FirstName: "Bob"}) + authKeyID := [8]byte{5, 4, 3, 2} + if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: user.ID}); err != nil { + t.Fatalf("bind auth: %v", err) + } + accountSvc := appaccount.NewService(memory.NewPasswordStore(), + appaccount.WithUsers(users), + appaccount.WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 5)) + r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 88) + + if _, err := r.onAccountSendChangePhoneCode(reqCtx, &tg.AccountSendChangePhoneCodeRequest{PhoneNumber: occupied.Phone}); err == nil { + t.Fatal("occupied phone unexpectedly accepted") + } else { + assertPhoneRPCErr(t, err, "PHONE_NUMBER_OCCUPIED") + } + if _, err := r.onAccountChangePhone(reqCtx, &tg.AccountChangePhoneRequest{PhoneNumber: "15550013103"}); err == nil { + t.Fatal("empty code unexpectedly accepted") + } else { + assertPhoneRPCErr(t, err, "PHONE_CODE_EMPTY") + } +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index a85c78e8..5e84417e 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -267,6 +267,16 @@ func tgSentCodeWithLength(hash string, length int) tg.AuthSentCodeClass { } } +func tgSMSSentCode(hash string, length int) tg.AuthSentCodeClass { + if length <= 0 { + length = devCodeLength + } + return &tg.AuthSentCode{ + Type: &tg.AuthSentCodeTypeSMS{Length: length}, + PhoneCodeHash: hash, + } +} + func tgEmailSentCode(hash, emailPattern string, length int) tg.AuthSentCodeClass { if length <= 0 { length = devCodeLength @@ -303,6 +313,8 @@ func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSen return nil, signInErr(auth.ErrCodeExpired) } switch delivery.Kind { + case domain.AuthCodeDeliverySMS: + return tgSMSSentCode(hash, delivery.Length), nil case domain.AuthCodeDeliveryEmail: return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length), nil case domain.AuthCodeDeliveryEmailSetupRequired: @@ -361,7 +373,16 @@ func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, loginMessa } func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) { - hash, err := r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash) + var hash string + var err error + if scoped, ok := r.deps.Auth.(interface { + ResendCodeForAuthKey(context.Context, [8]byte, string, string) (string, error) + }); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + hash, err = scoped.ResendCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash) + } else { + hash, err = r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash) + } if err != nil { return nil, signInErr(err) } @@ -369,7 +390,16 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq } func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) { - if err := r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash); err != nil { + var err error + if scoped, ok := r.deps.Auth.(interface { + CancelCodeForAuthKey(context.Context, [8]byte, string, string) error + }); ok { + authKeyID, _ := AuthKeyIDFrom(ctx) + err = scoped.CancelCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash) + } else { + err = r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash) + } + if err != nil { return false, signInErr(err) } return true, nil diff --git a/internal/rpc/convert_updates.go b/internal/rpc/convert_updates.go index a94fea36..bdd6d8aa 100644 --- a/internal/rpc/convert_updates.go +++ b/internal/rpc/convert_updates.go @@ -232,6 +232,11 @@ func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.Upd func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { switch event.Type { + case domain.UpdateEventUserPhone: + if event.UserID == 0 || event.Phone == "" { + return nil + } + return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone} case domain.UpdateEventChannelState: if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { return nil diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 912155e6..74a28250 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -254,6 +254,8 @@ type UserPremiumStatusService interface { // AccountService 抽象账号设置查询。 type AccountService interface { + SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) + ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) GetPasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck) (domain.PrivatePasswordSettings, error) UpdatePasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck, input domain.PasswordInputSettings) error diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 049c0a31..a99d5c2e 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -295,8 +295,35 @@ func floodWaitErr(seconds int) error { // phoneNumberInvalidErr 表示手机号为空或格式非法(auth.sendCode/signIn/signUp)。 func phoneNumberInvalidErr() error { return tgerr.New(406, "PHONE_NUMBER_INVALID") } +func phoneNumberOccupiedErr() error { return tgerr.New(400, "PHONE_NUMBER_OCCUPIED") } +func phoneCodeEmptyErr() error { return tgerr.New(400, "PHONE_CODE_EMPTY") } +func phoneCodeInvalidErr() error { return tgerr.New(400, "PHONE_CODE_INVALID") } +func phoneCodeExpiredErr() error { return tgerr.New(400, "PHONE_CODE_EXPIRED") } + +func phoneChangeErr(err error) error { + switch { + case errors.Is(err, domain.ErrPhoneNumberInvalid): + return phoneNumberInvalidErr() + case errors.Is(err, domain.ErrPhoneNumberOccupied): + return phoneNumberOccupiedErr() + case errors.Is(err, domain.ErrPhoneCodeEmpty): + return phoneCodeEmptyErr() + case errors.Is(err, domain.ErrPhoneCodeInvalid): + return phoneCodeInvalidErr() + case errors.Is(err, domain.ErrPhoneCodeExpired): + return phoneCodeExpiredErr() + case errors.Is(err, domain.ErrPhoneChangeAuthInvalid): + return authKeyUnregisteredErr() + case errors.Is(err, domain.ErrPhoneChangeForbidden): + return tgerr.New(400, "BOT_METHOD_INVALID") + default: + return internalErr() + } +} + // authKeyUnregisteredErr 表示请求要求登录态而当前连接未授权。 func authKeyUnregisteredErr() error { return tgerr.New(401, "AUTH_KEY_UNREGISTERED") } +func botMethodInvalidErr() error { return tgerr.New(400, "BOT_METHOD_INVALID") } // 私聊通话(phone.*)错误;触发点见 internal/rpc/phone_calls.go 与 app/phone 错误映射。 func callPeerInvalidErr() error { return tgerr.New(400, "CALL_PEER_INVALID") } diff --git a/internal/rpc/help.go b/internal/rpc/help.go index db03f7f2..8f6ebe37 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -6,6 +6,7 @@ import ( "github.com/gotd/td/tg" + androidcompat "telesrv/internal/compat/android" "telesrv/internal/compat/tdesktop" ) @@ -69,9 +70,31 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) { d.OnHelpGetDeepLinkInfo(func(ctx context.Context, path string) (tg.HelpDeepLinkInfoClass, error) { return &tg.HelpDeepLinkInfoEmpty{}, nil }) + d.OnHelpDismissSuggestion(r.onHelpDismissSuggestion) d.OnHelpGetPremiumPromo(r.onHelpGetPremiumPromo) } +// onHelpDismissSuggestion 为 DrKLO 改号成功后的 suggestion 清理提供有界兼容。 +// Android 会先把 suggestion 从本地状态删除,再发送该 RPC,且 generic 500 会被 +// 连接层持续重试。当前 server 不发布 pending suggestions,故非空 dismissal +// 无需持久化,幂等 BoolTrue 即为完整的当前边界语义。 +func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismissSuggestionRequest) (bool, error) { + userID, found, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if !found { + return false, authKeyUnregisteredErr() + } + if r.userIsBot(ctx, userID) { + return false, botMethodInvalidErr() + } + if req == nil { + return false, nil + } + return androidcompat.DismissSuggestion(req.Suggestion), nil +} + // onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer // 的会员有效期生成;videos/period_options 留空——购买入口已被 appConfig // premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段(TDesktop diff --git a/internal/rpc/help_dismiss_suggestion_test.go b/internal/rpc/help_dismiss_suggestion_test.go new file mode 100644 index 00000000..47a816f7 --- /dev/null +++ b/internal/rpc/help_dismiss_suggestion_test.go @@ -0,0 +1,56 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" +) + +func TestHelpDismissSuggestionAndroidChangePhone(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 42) + req := &tg.HelpDismissSuggestionRequest{ + Peer: &tg.InputPeerEmpty{}, + Suggestion: "VALIDATE_PHONE_NUMBER", + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode: %v", err) + } + enc, err := r.Dispatch(ctx, [8]byte{1, 2, 3}, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.BoolBox) + if !ok { + t.Fatalf("response = %T, want *tg.BoolBox", enc) + } + if _, ok := box.Bool.(*tg.BoolTrue); !ok { + t.Fatalf("bool response = %T, want BoolTrue", box.Bool) + } +} + +func TestHelpDismissSuggestionRequiresAuthorization(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.HelpDismissSuggestionRequest{Peer: &tg.InputPeerEmpty{}, Suggestion: "VALIDATE_PHONE_NUMBER"} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := r.Dispatch(context.Background(), [8]byte{1}, 77, &in); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("unauthorized err = %v", err) + } +} + +func TestHelpDismissSuggestionEmptyIsFalse(t *testing.T) { + r := &Router{} + ok, err := r.onHelpDismissSuggestion(WithUserID(context.Background(), 42), &tg.HelpDismissSuggestionRequest{Peer: &tg.InputPeerEmpty{}}) + if err != nil || ok { + t.Fatalf("empty suggestion result=%v err=%v", ok, err) + } +} diff --git a/internal/store/channel.go b/internal/store/channel.go index f86e5262..b758f7d3 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -37,6 +37,9 @@ type ChannelStore interface { ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + // ResolvePublicChannelUsername resolves an active public channel/supergroup. + // viewerUserID may be zero for anonymous public-link projection; this lookup + // never returns viewer-specific membership or dialog state. ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) diff --git a/internal/store/code.go b/internal/store/code.go index 8dacbb7a..6bf75c3c 100644 --- a/internal/store/code.go +++ b/internal/store/code.go @@ -5,11 +5,18 @@ import ( "time" ) -// PhoneCode 是一条登录验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。 +const PhoneCodePurposeChangePhone = "change_phone" + +// PhoneCode 是一条验证码记录(与某次 sendCode 的 phone_code_hash 或邮箱验证键关联)。 +// Purpose/UserID/AuthKeyID/SessionID 为已登录敏感操作提供作用域;登录验证码保持零值。 type PhoneCode struct { Phone string Code string Channel string + Purpose string + UserID int64 + AuthKeyID [8]byte + SessionID int64 Email string PendingEmail string Attempts int @@ -19,11 +26,39 @@ type PhoneCode struct { LoginEmailHash string } -// CodeStore 暂存登录验证码:phone_code_hash → 手机号 + 验证码,带 TTL。 +// PhoneCodeScope 标识已登录敏感操作的一次性验证码作用域。SessionID 故意不在 +// 作用域内:同一 perm auth key 等待验证码期间允许重建 MTProto session。 +// 登录/注册验证码没有 Purpose/UserID/AuthKeyID,保持非 scoped 行为。 +type PhoneCodeScope struct { + Purpose string + UserID int64 + AuthKeyID [8]byte + Phone string +} + +func (c PhoneCode) Scope() PhoneCodeScope { + return PhoneCodeScope{ + Purpose: c.Purpose, + UserID: c.UserID, + AuthKeyID: c.AuthKeyID, + Phone: c.Phone, + } +} + +func (s PhoneCodeScope) Valid() bool { + return s.Purpose != "" && s.UserID != 0 && s.AuthKeyID != ([8]byte{}) && s.Phone != "" +} + +// CodeStore 暂存验证码:phone_code_hash → 作用域 + 手机号 + 验证码,带 TTL。 // 实现见 store/memory(测试替身)、store/redisstore。 type CodeStore interface { + // Set 对 scoped code 必须原子替换同作用域旧 hash,保证单作用域至多一个 + // 活跃验证码;普通登录码仍按 hash 独立保存。 Set(ctx context.Context, phoneCodeHash string, code PhoneCode, ttl time.Duration) error Get(ctx context.Context, phoneCodeHash string) (PhoneCode, bool, error) Update(ctx context.Context, phoneCodeHash string, code PhoneCode) error Del(ctx context.Context, phoneCodeHash string) error + // ConsumeScoped 仅当 hash 仍是 scope 的当前活跃 hash 时原子读取并删除; + // 并发调用至多一个返回 found=true。 + ConsumeScoped(ctx context.Context, phoneCodeHash string, scope PhoneCodeScope) (PhoneCode, bool, error) } diff --git a/internal/store/memory/auth.go b/internal/store/memory/auth.go index 1535d1b5..895e2c48 100644 --- a/internal/store/memory/auth.go +++ b/internal/store/memory/auth.go @@ -262,17 +262,28 @@ func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, // CodeStore 是 store.CodeStore 的内存实现(带 TTL)。 type CodeStore struct { - mu sync.Mutex - m map[string]codeEntry + mu sync.Mutex + m map[string]codeEntry + scopes map[store.PhoneCodeScope]string } // NewCodeStore 创建内存 CodeStore。 func NewCodeStore() *CodeStore { - return &CodeStore{m: make(map[string]codeEntry)} + return &CodeStore{ + m: make(map[string]codeEntry), + scopes: make(map[store.PhoneCodeScope]string), + } } func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { s.mu.Lock() + scope := code.Scope() + if scope.Valid() { + if oldHash, ok := s.scopes[scope]; ok && oldHash != hash { + delete(s.m, oldHash) + } + s.scopes[scope] = hash + } s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)} s.mu.Unlock() return nil @@ -283,6 +294,9 @@ func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool, defer s.mu.Unlock() e, ok := s.m[hash] if !ok || time.Now().After(e.expires) { + if ok { + s.deleteCodeLocked(hash, e.code) + } return store.PhoneCode{}, false, nil } return e.code, true, nil @@ -293,6 +307,9 @@ func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) defer s.mu.Unlock() e, ok := s.m[hash] if !ok || time.Now().After(e.expires) { + if ok { + s.deleteCodeLocked(hash, e.code) + } return nil } e.code = code @@ -302,7 +319,41 @@ func (s *CodeStore) Update(_ context.Context, hash string, code store.PhoneCode) func (s *CodeStore) Del(_ context.Context, hash string) error { s.mu.Lock() - delete(s.m, hash) + if e, ok := s.m[hash]; ok { + s.deleteCodeLocked(hash, e.code) + } else { + delete(s.m, hash) + } s.mu.Unlock() return nil } + +func (s *CodeStore) ConsumeScoped(_ context.Context, hash string, scope store.PhoneCodeScope) (store.PhoneCode, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !scope.Valid() || s.scopes[scope] != hash { + return store.PhoneCode{}, false, nil + } + e, ok := s.m[hash] + if !ok || time.Now().After(e.expires) { + if ok { + s.deleteCodeLocked(hash, e.code) + } else { + delete(s.scopes, scope) + } + return store.PhoneCode{}, false, nil + } + if e.code.Scope() != scope { + return store.PhoneCode{}, false, nil + } + s.deleteCodeLocked(hash, e.code) + return e.code, true, nil +} + +func (s *CodeStore) deleteCodeLocked(hash string, code store.PhoneCode) { + delete(s.m, hash) + scope := code.Scope() + if scope.Valid() && s.scopes[scope] == hash { + delete(s.scopes, scope) + } +} diff --git a/internal/store/memory/channel_settings.go b/internal/store/memory/channel_settings.go index 7e59ff3b..17fa3140 100644 --- a/internal/store/memory/channel_settings.go +++ b/internal/store/memory/channel_settings.go @@ -198,9 +198,7 @@ func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, ve } func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { - if viewerUserID == 0 { - return domain.Channel{}, false, domain.ErrChannelInvalid - } + _ = viewerUserID // zero is the anonymous public-web view; no membership state is projected. username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if username == "" { return domain.Channel{}, false, nil diff --git a/internal/store/memory/code_test.go b/internal/store/memory/code_test.go new file mode 100644 index 00000000..b01227b8 --- /dev/null +++ b/internal/store/memory/code_test.go @@ -0,0 +1,92 @@ +package memory + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/store" +) + +func TestCodeStoreScopedRotationAndSingleConsume(t *testing.T) { + ctx := context.Background() + codes := NewCodeStore() + rec := store.PhoneCode{ + Phone: "15550015001", + Code: "12345", + Purpose: store.PhoneCodePurposeChangePhone, + UserID: 42, + AuthKeyID: [8]byte{1, 2, 3}, + } + if err := codes.Set(ctx, "old-hash", rec, time.Minute); err != nil { + t.Fatalf("set old: %v", err) + } + if err := codes.Set(ctx, "new-hash", rec, time.Minute); err != nil { + t.Fatalf("rotate new: %v", err) + } + if _, found, err := codes.Get(ctx, "old-hash"); err != nil || found { + t.Fatalf("old hash found=%v err=%v", found, err) + } + + const workers = 24 + results := make(chan bool, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + _, found, err := codes.ConsumeScoped(ctx, "new-hash", rec.Scope()) + if err != nil { + errs <- err + return + } + results <- found + }() + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + t.Fatalf("consume: %v", err) + } + foundCount := 0 + for found := range results { + if found { + foundCount++ + } + } + if foundCount != 1 { + t.Fatalf("successful consumes = %d, want 1", foundCount) + } + if _, found, _ := codes.Get(ctx, "new-hash"); found { + t.Fatal("consumed hash remains") + } +} + +func TestCodeStoreScopedIsolation(t *testing.T) { + ctx := context.Background() + codes := NewCodeStore() + a := store.PhoneCode{Phone: "15550015002", Code: "12345", Purpose: store.PhoneCodePurposeChangePhone, UserID: 42, AuthKeyID: [8]byte{1}} + b := a + b.AuthKeyID = [8]byte{2} + if err := codes.Set(ctx, "hash-a", a, time.Minute); err != nil { + t.Fatal(err) + } + if err := codes.Set(ctx, "hash-b", b, time.Minute); err != nil { + t.Fatal(err) + } + if _, found, _ := codes.ConsumeScoped(ctx, "hash-a", b.Scope()); found { + t.Fatal("cross-scope consume succeeded") + } + if _, found, _ := codes.Get(ctx, "hash-a"); !found { + t.Fatal("cross-scope consume removed victim code") + } + if _, found, err := codes.ConsumeScoped(ctx, "hash-a", a.Scope()); err != nil || !found { + t.Fatalf("own-scope consume found=%v err=%v", found, err) + } + if _, found, _ := codes.Get(ctx, "hash-b"); !found { + t.Fatal("other scope was removed") + } +} diff --git a/internal/store/memory/phone_change.go b/internal/store/memory/phone_change.go new file mode 100644 index 00000000..7bccaaa8 --- /dev/null +++ b/internal/store/memory/phone_change.go @@ -0,0 +1,72 @@ +package memory + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// PhoneChangeStore 是测试用内存实现。用户唯一性在 UserStore 锁内维护;事件写入 +// 共享 UpdateEventStore 后可由 updates.getDifference 重放。 +type PhoneChangeStore struct { + users *UserStore + events store.UpdateEventStore +} + +func NewPhoneChangeStore(users *UserStore, events store.UpdateEventStore) *PhoneChangeStore { + return &PhoneChangeStore{users: users, events: events} +} + +func (*PhoneChangeStore) UsesReliableDispatch() bool { return false } + +func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) { + if s == nil || s.users == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid + } + s.users.mu.Lock() + u, ok := s.users.byID[req.UserID] + if !ok { + s.users.mu.Unlock() + return domain.PhoneChangeResult{}, domain.ErrUserNotFound + } + if u.Phone == req.Phone { + s.users.mu.Unlock() + return domain.PhoneChangeResult{User: u}, nil + } + for id, existing := range s.users.byID { + if id != req.UserID && existing.Phone == req.Phone { + s.users.mu.Unlock() + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied + } + } + currentPhone := u.Phone + u.Phone = req.Phone + s.users.byID[req.UserID] = u + + date := req.Date + if date == 0 { + date = int(time.Now().Unix()) + } + event := domain.UpdateEvent{ + UserID: req.UserID, + Type: domain.UpdateEventUserPhone, + Date: date, + Phone: req.Phone, + PtsCount: 1, + } + if s.events != nil { + var err error + event, err = s.events.AppendAllocated(ctx, req.UserID, event) + if err != nil { + // 保持内存替身与 PG 的 user+event 原子可见语义。 + u.Phone = currentPhone + s.users.byID[req.UserID] = u + s.users.mu.Unlock() + return domain.PhoneChangeResult{}, err + } + } + s.users.mu.Unlock() + return domain.PhoneChangeResult{User: u, Event: event, Changed: true}, nil +} diff --git a/internal/store/phone_change.go b/internal/store/phone_change.go new file mode 100644 index 00000000..00ab9458 --- /dev/null +++ b/internal/store/phone_change.go @@ -0,0 +1,13 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// PhoneChangeStore 原子修改账号手机号并记录可恢复的 updateUserPhone 事件。 +// 生产实现还必须在同一事务入 dispatch outbox。 +type PhoneChangeStore interface { + ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) +} diff --git a/internal/store/postgres/channel_settings.go b/internal/store/postgres/channel_settings.go index fefa5823..8c105980 100644 --- a/internal/store/postgres/channel_settings.go +++ b/internal/store/postgres/channel_settings.go @@ -285,9 +285,7 @@ func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64, } func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { - if viewerUserID == 0 { - return domain.Channel{}, false, domain.ErrChannelInvalid - } + _ = viewerUserID // zero is the anonymous public-web view; this query is viewer-independent. usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) if usernameLower == "" { return domain.Channel{}, false, nil diff --git a/internal/store/postgres/channel_username_integration_test.go b/internal/store/postgres/channel_username_integration_test.go index 9e9c0f47..8bcba09a 100644 --- a/internal/store/postgres/channel_username_integration_test.go +++ b/internal/store/postgres/channel_username_integration_test.go @@ -59,6 +59,10 @@ func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) { if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, usernames[1]); err != nil || found { t.Fatalf("resolve missing username found %v err %v, want not found", found, err) } + anonymous, found, err := channels.ResolvePublicChannelUsername(ctx, 0, strings.ToUpper(publicUsername)) + if err != nil || !found || anonymous.ID != publicChannel.ID { + t.Fatalf("anonymous resolve public username = %+v found=%v err=%v", anonymous, found, err) + } if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ UserID: owner.ID, ChannelID: publicChannel.ID, diff --git a/internal/store/postgres/phone_change.go b/internal/store/postgres/phone_change.go new file mode 100644 index 00000000..d5eff6f0 --- /dev/null +++ b/internal/store/postgres/phone_change.go @@ -0,0 +1,112 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// PhoneChangeStore 把 users.phone、账号 pts、durable event 与 dispatch outbox +// 作为一个事务提交,避免任何一边单独可见。 +type PhoneChangeStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +func NewPhoneChangeStore(db sqlcgen.DBTX) *PhoneChangeStore { + return &PhoneChangeStore{db: db, q: sqlcgen.New(db)} +} + +func (*PhoneChangeStore) UsesReliableDispatch() bool { return true } + +func (s *PhoneChangeStore) ChangePhone(ctx context.Context, req domain.PhoneChangeRequest) (domain.PhoneChangeResult, error) { + if s == nil || req.UserID == 0 || !domain.ValidPhone(req.Phone) { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.PhoneChangeResult{}, fmt.Errorf("change phone: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("begin change phone: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + qtx := s.q.WithTx(tx) + + var currentPhone string + if err := tx.QueryRow(ctx, `SELECT phone FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(¤tPhone); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PhoneChangeResult{}, domain.ErrUserNotFound + } + return domain.PhoneChangeResult{}, fmt.Errorf("lock user for phone change: %w", err) + } + if currentPhone == req.Phone { + row, err := qtx.GetUserByID(ctx, req.UserID) + if err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("reload unchanged phone user: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("commit unchanged phone: %w", err) + } + committed = true + return domain.PhoneChangeResult{User: userFromModel(row)}, nil + } + + row, err := qtx.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ID: req.UserID, Phone: req.Phone}) + if err != nil { + if isUniqueConstraint(err, "users_phone_unique_idx") { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.PhoneChangeResult{}, domain.ErrUserNotFound + } + return domain.PhoneChangeResult{}, fmt.Errorf("update user phone: %w", err) + } + date := req.Date + if date == 0 { + date = int(time.Now().Unix()) + } + event := domain.UpdateEvent{ + UserID: req.UserID, + Type: domain.UpdateEventUserPhone, + Date: date, + Phone: req.Phone, + PtsCount: 1, + } + event.Pts, err = reserveUserPts(ctx, tx, req.UserID, event.PtsCount) + if err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("reserve phone change pts: %w", err) + } + if err := appendUserUpdateEvent(ctx, tx, qtx, req.UserID, event); err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("append phone change event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.UserID, + Pts: int32(event.Pts), + EventType: string(event.Type), + ExcludeAuthKeyID: authKeyIDToInt64(req.ExcludeAuthKeyID), + ExcludeSessionID: req.ExcludeSessionID, + }); err != nil { + return domain.PhoneChangeResult{}, fmt.Errorf("enqueue phone change dispatch: %w", err) + } + if err := tx.Commit(ctx); err != nil { + if isUniqueConstraint(err, "users_phone_unique_idx") { + return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied + } + return domain.PhoneChangeResult{}, fmt.Errorf("commit phone change: %w", err) + } + committed = true + return domain.PhoneChangeResult{User: userFromModel(row), Event: event, Changed: true}, nil +} diff --git a/internal/store/postgres/phone_change_integration_test.go b/internal/store/postgres/phone_change_integration_test.go new file mode 100644 index 00000000..a2d9219e --- /dev/null +++ b/internal/store/postgres/phone_change_integration_test.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestPhoneChangeStoreAtomicUserEventOutboxPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + changes := NewPhoneChangeStore(pool) + events := NewUpdateEventStore(pool) + suffix := time.Now().UnixNano() % 1_000_000_000 + oldPhone := fmt.Sprintf("1661%d01", suffix) + occupiedPhone := fmt.Sprintf("1661%d02", suffix) + newPhone := fmt.Sprintf("1661%d03", suffix) + u1, err := users.Create(ctx, domain.User{AccessHash: 301, Phone: oldPhone, FirstName: "PhoneOne"}) + if err != nil { + t.Fatalf("create user1: %v", err) + } + u2, err := users.Create(ctx, domain.User{AccessHash: 302, Phone: occupiedPhone, FirstName: "PhoneTwo"}) + if err != nil { + t.Fatalf("create user2: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID}) + _, _ = pool.Exec(context.Background(), "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID}) + _, _ = pool.Exec(context.Background(), "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", []int64{u1.ID, u2.ID}) + _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{u1.ID, u2.ID}) + }) + + authKeyID := [8]byte{7, 6, 5, 4} + result, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{ + UserID: u1.ID, Phone: newPhone, Date: 1700000001, + ExcludeAuthKeyID: authKeyID, ExcludeSessionID: 77, + }) + if err != nil { + t.Fatalf("change phone: %v", err) + } + if !result.Changed || result.User.Phone != newPhone || result.Event.Pts != 1 || result.Event.Phone != newPhone { + t.Fatalf("result = %+v", result) + } + loaded, found, err := users.ByID(ctx, u1.ID) + if err != nil || !found || loaded.Phone != newPhone { + t.Fatalf("loaded user = %+v found=%v err=%v", loaded, found, err) + } + storedEvents, err := events.ListAfter(ctx, u1.ID, 0, 10) + if err != nil || len(storedEvents) != 1 || storedEvents[0].Type != domain.UpdateEventUserPhone || storedEvents[0].Phone != newPhone { + t.Fatalf("stored events = %+v err=%v", storedEvents, err) + } + var outboxCount int + var excludedAuth, excludedSession int64 + if err := pool.QueryRow(ctx, `SELECT count(*), max(exclude_auth_key_id), max(exclude_session_id) FROM dispatch_outbox WHERE target_user_id = $1 AND pts = $2`, u1.ID, result.Event.Pts).Scan(&outboxCount, &excludedAuth, &excludedSession); err != nil { + t.Fatalf("query outbox: %v", err) + } + if outboxCount != 1 || excludedAuth != authKeyIDToInt64(authKeyID) || excludedSession != 77 { + t.Fatalf("outbox count/auth/session = %d/%d/%d", outboxCount, excludedAuth, excludedSession) + } + + // 同号重试是幂等读,不得重复推进 pts 或重复入 outbox。 + retry, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{UserID: u1.ID, Phone: newPhone, Date: 1700000002}) + if err != nil || retry.Changed || retry.Event.Pts != 0 || retry.User.Phone != newPhone { + t.Fatalf("idempotent retry = %+v err=%v", retry, err) + } + if pts, err := events.MaxContiguousPts(ctx, u1.ID); err != nil || pts != 1 { + t.Fatalf("pts after retry = %d err=%v", pts, err) + } + + // 冲突更新整体回滚:号码和 pts/event 都不变。 + if _, err := changes.ChangePhone(ctx, domain.PhoneChangeRequest{UserID: u2.ID, Phone: newPhone}); !errors.Is(err, domain.ErrPhoneNumberOccupied) { + t.Fatalf("occupied change err = %v", err) + } + loaded2, found, err := users.ByID(ctx, u2.ID) + if err != nil || !found || loaded2.Phone != occupiedPhone { + t.Fatalf("occupied rollback user = %+v found=%v err=%v", loaded2, found, err) + } + if pts, err := events.MaxContiguousPts(ctx, u2.ID); err != nil || pts != 0 { + t.Fatalf("occupied rollback pts = %d err=%v", pts, err) + } +} diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index ebc823bb..11d93d77 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -125,6 +125,13 @@ SET first_name = $2, WHERE id = $1 RETURNING *; +-- name: UpdateUserPhone :one +UPDATE users +SET phone = sqlc.arg(phone)::text, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + -- name: SetUserPremiumUntil :one UPDATE users SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index 7d3794c0..b5b4bb5d 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -6,6 +6,7 @@ INSERT INTO user_update_events ( date, event_type, event_bool, + event_phone, event_peers, peer_settings, message_ids, @@ -30,6 +31,7 @@ INSERT INTO user_update_events ( $4, $5, sqlc.arg(event_bool)::boolean, + sqlc.arg(event_phone)::text, sqlc.arg(event_peers)::jsonb, sqlc.arg(peer_settings)::jsonb, sqlc.arg(message_ids)::jsonb, @@ -57,6 +59,7 @@ SELECT e.date, e.event_type, e.event_bool, + e.event_phone, COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, @@ -267,6 +270,7 @@ SELECT e.date, e.event_type, e.event_bool, + e.event_phone, COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, diff --git a/internal/store/postgres/sqlcgen/authkey.sql.go b/internal/store/postgres/sqlcgen/authkey.sql.go index b3ea11d4..53186025 100644 --- a/internal/store/postgres/sqlcgen/authkey.sql.go +++ b/internal/store/postgres/sqlcgen/authkey.sql.go @@ -7,6 +7,8 @@ package sqlcgen import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const getAuthKey = `-- name: GetAuthKey :one @@ -15,9 +17,16 @@ FROM auth_keys WHERE auth_key_id = $1 ` -func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (AuthKey, error) { +type GetAuthKeyRow struct { + AuthKeyID int64 + Body []byte + ServerSalt int64 + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) GetAuthKey(ctx context.Context, authKeyID int64) (GetAuthKeyRow, error) { row := q.db.QueryRow(ctx, getAuthKey, authKeyID) - var i AuthKey + var i GetAuthKeyRow err := row.Scan( &i.AuthKeyID, &i.Body, diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index 53b7534b..d8906579 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -164,10 +164,16 @@ type AttachMenuUserState struct { } type AuthKey struct { - AuthKeyID int64 - Body []byte - ServerSalt int64 - CreatedAt pgtype.Timestamptz + AuthKeyID int64 + Body []byte + ServerSalt int64 + CreatedAt pgtype.Timestamptz + Layer int32 + DeviceModel string + Platform string + SystemVersion string + ApiID int32 + AppVersion string } type Authorization struct { @@ -201,6 +207,22 @@ type AvailableReaction struct { SortOrder int32 } +type BootstrapUpdateJob struct { + ID int64 + Kind string + UserID int64 + AuthKeyID int64 + SessionID int64 + MessageBoxID int32 + Status string + Attempts int32 + LastError string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ReadyAt pgtype.Timestamptz + PublishedAt pgtype.Timestamptz +} + type Bot struct { BotUserID int64 OwnerUserID int64 @@ -218,6 +240,24 @@ type Bot struct { BotInlineGeo bool } +type BotApiUpdate struct { + ID int64 + BotUserID int64 + UpdateKind string + PeerType string + PeerID int64 + MessageID int32 + SourcePts int32 + Date int32 + CreatedAt pgtype.Timestamptz +} + +type BotApiUpdateState struct { + BotUserID int64 + ConfirmedUpdateID int64 + UpdatedAt pgtype.Timestamptz +} + type BotApp struct { ID int64 BotUserID int64 @@ -663,6 +703,30 @@ type ChannelUpdateEvent struct { CreatedAt pgtype.Timestamptz } +type ChatlistInvite struct { + ID int64 + OwnerUserID int64 + FilterID int32 + Slug string + Title string + Peers []byte + Revoked bool + Deleted bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ChatlistMembership struct { + UserID int64 + LocalFilterID int32 + OwnerUserID int64 + OwnerFilterID int32 + Slug string + HiddenUpdates bool + JoinedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type Contact struct { UserID int64 ContactUserID int64 @@ -857,6 +921,8 @@ type GroupCall struct { InviteLink string RandomID int64 MigratedFromPhoneCallID int64 + RtmpStream bool + ScheduleDate int32 } type GroupCallChainBlock struct { @@ -895,6 +961,7 @@ type GroupCallParticipant struct { LastCheckDate int32 PublicKey []byte JoinBlock []byte + JoinAsChannelID int64 } type GroupCallParticipantOverride struct { @@ -905,6 +972,17 @@ type GroupCallParticipantOverride struct { Volume int32 } +type GroupCallRtmpKey struct { + ChannelID int64 + StreamKey string + UpdatedAt int32 +} + +type GroupCallScheduleSubscriber struct { + CallID int64 + UserID int64 +} + type LangPack struct { LangPack string LangCode string @@ -1197,6 +1275,7 @@ type ScheduledMessage struct { Body string Entities []byte Media []byte + RichMessage []byte Silent bool Noforwards bool ReplyToMsgID int32 @@ -1555,6 +1634,7 @@ type UserUpdateEvent struct { QuickReplyMessage []byte StoryPayload []byte ReactionPayload []byte + EventPhone string } type UserUpdateWatermark struct { diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 1a4861f6..8b04ac8f 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -881,6 +881,56 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP return i, err } +const updateUserPhone = `-- name: UpdateUserPhone :one +UPDATE users +SET phone = $1::text, + updated_at = now() +WHERE id = $2::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserPhoneParams struct { + Phone string + ID int64 +} + +func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserPhone, arg.Phone, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + const updateUserProfile = `-- name: UpdateUserProfile :one UPDATE users SET first_name = $2, diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go index 5bfb0752..250e63ce 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -17,6 +17,7 @@ INSERT INTO user_update_events ( date, event_type, event_bool, + event_phone, event_peers, peer_settings, message_ids, @@ -41,7 +42,7 @@ INSERT INTO user_update_events ( $4, $5, $6::boolean, - $7::jsonb, + $7::text, $8::jsonb, $9::jsonb, $10::jsonb, @@ -49,15 +50,16 @@ INSERT INTO user_update_events ( $12::jsonb, $13::jsonb, $14::jsonb, - $15, - $16::text, - $17::bigint, - $18::int, + $15::jsonb, + $16, + $17::text, + $18::bigint, $19::int, $20::int, $21::int, - $22::boolean, - $23::int + $22::int, + $23::boolean, + $24::int ) ` @@ -68,6 +70,7 @@ type AppendUserUpdateEventParams struct { Date int32 EventType string EventBool bool + EventPhone string EventPeers []byte PeerSettings []byte MessageIds []byte @@ -95,6 +98,7 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat arg.Date, arg.EventType, arg.EventBool, + arg.EventPhone, arg.EventPeers, arg.PeerSettings, arg.MessageIds, @@ -124,6 +128,7 @@ SELECT e.date, e.event_type, e.event_bool, + e.event_phone, COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, @@ -260,6 +265,7 @@ type BatchListDispatchEventsRow struct { Date int32 EventType string EventBool bool + EventPhone string EventPeersJson string PeerSettingsJson string MessageIdsJson string @@ -394,6 +400,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.Date, &i.EventType, &i.EventBool, + &i.EventPhone, &i.EventPeersJson, &i.PeerSettingsJson, &i.MessageIdsJson, @@ -681,6 +688,7 @@ SELECT e.date, e.event_type, e.event_bool, + e.event_phone, COALESCE(e.event_peers::text, '[]')::text AS event_peers_json, COALESCE(e.peer_settings::text, '{}')::text AS peer_settings_json, COALESCE(e.message_ids::text, '[]')::text AS message_ids_json, @@ -820,6 +828,7 @@ type ListUserUpdateEventsAfterRow struct { Date int32 EventType string EventBool bool + EventPhone string EventPeersJson string PeerSettingsJson string MessageIdsJson string @@ -952,6 +961,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.Date, &i.EventType, &i.EventBool, + &i.EventPhone, &i.EventPeersJson, &i.PeerSettingsJson, &i.MessageIdsJson, diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index 60a63da8..43619f44 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -218,6 +218,7 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer Date: int32(event.Date), EventType: string(event.Type), EventBool: event.Bool, + EventPhone: event.Phone, EventPeers: peers, PeerSettings: settings, MessageIds: messageIDs, @@ -406,6 +407,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim Story: story, Peers: peers, Bool: row.EventBool, + Phone: row.EventPhone, Settings: settings, MessageIDs: messageIDs, MaxID: int(row.MaxID), @@ -599,6 +601,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev Story: story, Peers: peers, Bool: row.EventBool, + Phone: row.EventPhone, Settings: settings, MessageIDs: messageIDs, MaxID: int(row.MaxID), diff --git a/internal/store/redisstore/code.go b/internal/store/redisstore/code.go index 52dfb4c9..3eae5454 100644 --- a/internal/store/redisstore/code.go +++ b/internal/store/redisstore/code.go @@ -2,6 +2,8 @@ package redisstore import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -22,14 +24,80 @@ func NewCodeStore(c *redis.Client) *CodeStore { return &CodeStore{c: c} } -func codeKey(hash string) string { return "phonecode:" + hash } +const codeKeyPrefix = "phonecode:" + +func codeKey(hash string) string { return codeKeyPrefix + hash } + +func codeScopeKey(scope store.PhoneCodeScope) string { + // 不把手机号/auth key 明文放进 Redis key;JSON 仅作为稳定的长度分隔编码输入。 + raw, _ := json.Marshal(scope) + digest := sha256.Sum256(raw) + return "phonecodescope:" + hex.EncodeToString(digest[:]) +} + +const rotateScopedCodeScript = ` +local old_hash = redis.call('GET', KEYS[2]) +if old_hash and old_hash ~= ARGV[1] then + redis.call('DEL', ARGV[4] .. old_hash) +end +local ttl_ms = tonumber(ARGV[3]) +if ttl_ms and ttl_ms > 0 then + redis.call('PSETEX', KEYS[1], ttl_ms, ARGV[2]) + redis.call('PSETEX', KEYS[2], ttl_ms, ARGV[1]) +else + redis.call('SET', KEYS[1], ARGV[2]) + redis.call('SET', KEYS[2], ARGV[1]) +end +return 1 +` + +const deleteScopedCodeScript = ` +redis.call('DEL', KEYS[1]) +if redis.call('GET', KEYS[2]) == ARGV[1] then + redis.call('DEL', KEYS[2]) +end +return 1 +` + +const consumeScopedCodeScript = ` +if redis.call('GET', KEYS[2]) ~= ARGV[1] then + return false +end +local raw = redis.call('GET', KEYS[1]) +if not raw then + redis.call('DEL', KEYS[2]) + return false +end +redis.call('DEL', KEYS[1]) +redis.call('DEL', KEYS[2]) +return raw +` func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { v, err := json.Marshal(code) if err != nil { return fmt.Errorf("marshal phone code: %w", err) } - if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil { + scope := code.Scope() + if !scope.Valid() { + if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil { + return fmt.Errorf("redis set phone code: %w", err) + } + return nil + } + ttlMillis := ttl.Milliseconds() + if ttl > 0 && ttlMillis == 0 { + ttlMillis = 1 + } + if err := s.c.Eval( + ctx, + rotateScopedCodeScript, + []string{codeKey(hash), codeScopeKey(scope)}, + hash, + string(v), + ttlMillis, + codeKeyPrefix, + ).Err(); err != nil { return fmt.Errorf("redis set phone code: %w", err) } return nil @@ -52,7 +120,7 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error { key := codeKey(hash) - ttl, err := s.c.TTL(ctx, key).Result() + ttl, err := s.c.PTTL(ctx, key).Result() if err != nil { return fmt.Errorf("redis ttl phone code: %w", err) } @@ -70,5 +138,57 @@ func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCod } func (s *CodeStore) Del(ctx context.Context, hash string) error { - return s.c.Del(ctx, codeKey(hash)).Err() + key := codeKey(hash) + raw, err := s.c.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return s.c.Del(ctx, key).Err() + } + return fmt.Errorf("redis get phone code for delete: %w", err) + } + var code store.PhoneCode + if err := json.Unmarshal(raw, &code); err != nil { + return fmt.Errorf("unmarshal phone code for delete: %w", err) + } + scope := code.Scope() + if !scope.Valid() { + return s.c.Del(ctx, key).Err() + } + if err := s.c.Eval(ctx, deleteScopedCodeScript, []string{key, codeScopeKey(scope)}, hash).Err(); err != nil { + return fmt.Errorf("redis delete scoped phone code: %w", err) + } + return nil +} + +func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.PhoneCodeScope) (store.PhoneCode, bool, error) { + if !scope.Valid() { + return store.PhoneCode{}, false, nil + } + result, err := s.c.Eval( + ctx, + consumeScopedCodeScript, + []string{codeKey(hash), codeScopeKey(scope)}, + hash, + ).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return store.PhoneCode{}, false, nil + } + return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: %w", err) + } + if result == nil { + return store.PhoneCode{}, false, nil + } + raw, ok := result.(string) + if !ok { + return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: unexpected result %T", result) + } + var code store.PhoneCode + if err := json.Unmarshal([]byte(raw), &code); err != nil { + return store.PhoneCode{}, false, fmt.Errorf("unmarshal consumed phone code: %w", err) + } + if code.Scope() != scope { + return store.PhoneCode{}, false, fmt.Errorf("consumed phone code scope mismatch") + } + return code, true, nil } diff --git a/internal/store/redisstore/code_integration_test.go b/internal/store/redisstore/code_integration_test.go new file mode 100644 index 00000000..4b970c0c --- /dev/null +++ b/internal/store/redisstore/code_integration_test.go @@ -0,0 +1,83 @@ +package redisstore + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + "time" + + "telesrv/internal/store" +) + +func TestRedisCodeStoreScopedRotationAndSingleConsume(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + suffix := time.Now().UnixNano() + oldHash := fmt.Sprintf("scope-old-%d", suffix) + newHash := fmt.Sprintf("scope-new-%d", suffix) + rec := store.PhoneCode{ + Phone: fmt.Sprintf("1555%d", suffix), + Code: "12345", + Purpose: store.PhoneCodePurposeChangePhone, + UserID: suffix, + AuthKeyID: [8]byte{1, 2, 3, 4}, + } + scopeKey := codeScopeKey(rec.Scope()) + t.Cleanup(func() { _ = c.Del(ctx, codeKey(oldHash), codeKey(newHash), scopeKey).Err() }) + codes := NewCodeStore(c) + if err := codes.Set(ctx, oldHash, rec, time.Minute); err != nil { + t.Fatalf("set old: %v", err) + } + if err := codes.Set(ctx, newHash, rec, time.Minute); err != nil { + t.Fatalf("rotate new: %v", err) + } + if _, found, err := codes.Get(ctx, oldHash); err != nil || found { + t.Fatalf("old hash found=%v err=%v", found, err) + } + + const workers = 24 + results := make(chan bool, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + _, found, err := codes.ConsumeScoped(ctx, newHash, rec.Scope()) + if err != nil { + errs <- err + return + } + results <- found + }() + } + wg.Wait() + close(results) + close(errs) + for err := range errs { + t.Fatalf("consume: %v", err) + } + foundCount := 0 + for found := range results { + if found { + foundCount++ + } + } + if foundCount != 1 { + t.Fatalf("successful consumes = %d, want 1", foundCount) + } + if exists, err := c.Exists(ctx, codeKey(newHash), scopeKey).Result(); err != nil || exists != 0 { + t.Fatalf("remaining redis keys=%d err=%v", exists, err) + } +} diff --git a/internal/web/stickerlinks/server.go b/internal/web/stickerlinks/server.go index df9d8e4e..67c80654 100644 --- a/internal/web/stickerlinks/server.go +++ b/internal/web/stickerlinks/server.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "go.uber.org/zap" @@ -22,6 +23,9 @@ type Config struct { Addr string PublicBaseURL string Users UsernameResolver + Channels PublicChannelResolver + Privacy AnonymousPrivacyResolver + Photos ProfilePhotoResolver } type Resolver interface { @@ -32,6 +36,22 @@ type UsernameResolver interface { ByUsername(ctx context.Context, username string) (domain.User, bool, error) } +// PublicChannelResolver exposes only the viewer-independent public username +// projection. viewerUserID is always zero for this anonymous Web endpoint. +type PublicChannelResolver interface { + ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) +} + +type AnonymousPrivacyResolver interface { + CanSeeAnonymous(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (bool, error) +} + +type ProfilePhotoResolver interface { + CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) + GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) + GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) +} + func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logger) (*http.Server, error) { addr := strings.TrimSpace(cfg.Addr) if addr == "" { @@ -43,11 +63,15 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge if logger == nil { logger = zap.NewNop() } - handler := NewHandlerWithUsers(resolver, cfg.Users, cfg.PublicBaseURL) + handler := newHandler(resolver, cfg.Users, cfg.Channels, cfg.Privacy, cfg.Photos, cfg.PublicBaseURL, logger) srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + MaxHeaderBytes: 16 << 10, } ln, err := net.Listen("tcp", addr) if err != nil { @@ -69,28 +93,64 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge } func NewHandler(resolver Resolver, publicBaseURL string) http.Handler { - return NewHandlerWithUsers(resolver, nil, publicBaseURL) + return newHandler(resolver, nil, nil, nil, nil, publicBaseURL, zap.NewNop()) } func NewHandlerWithUsers(resolver Resolver, users UsernameResolver, publicBaseURL string) http.Handler { + return newHandler(resolver, users, nil, nil, nil, publicBaseURL, zap.NewNop()) +} + +func NewHandlerWithPublicPeers( + resolver Resolver, + users UsernameResolver, + channels PublicChannelResolver, + privacy AnonymousPrivacyResolver, + photos ProfilePhotoResolver, + publicBaseURL string, +) http.Handler { + return newHandler(resolver, users, channels, privacy, photos, publicBaseURL, zap.NewNop()) +} + +func newHandler( + resolver Resolver, + users UsernameResolver, + channels PublicChannelResolver, + privacy AnonymousPrivacyResolver, + photos ProfilePhotoResolver, + publicBaseURL string, + logger *zap.Logger, +) http.Handler { + if logger == nil { + logger = zap.NewNop() + } h := &handler{ resolver: resolver, users: users, + channels: channels, + privacy: privacy, + photos: photos, publicBaseURL: normalizePublicBaseURL(publicBaseURL), + logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) + mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar) mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addlist/{slug}", h.addList) mux.HandleFunc("GET /{username}", h.usernameLink) - return mux + mux.HandleFunc("GET /{username}/{$}", h.usernameLink) + return publicSecurityHeaders(mux) } type handler struct { resolver Resolver users UsernameResolver + channels PublicChannelResolver + privacy AnonymousPrivacyResolver + photos ProfilePhotoResolver publicBaseURL string + logger *zap.Logger } func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) { @@ -132,41 +192,127 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PathValue("username")) - if h.users == nil || !validUsernamePath(username) { - http.NotFound(w, r) + if !validUsernamePath(username) { + h.serveUsernameNotFound(w, username) return } - u, found, err := h.users.ByUsername(r.Context(), username) + params, ok := publicResolveQuery(r.URL.RawQuery) + if !ok { + http.Error(w, "public link query is too large or invalid", http.StatusRequestURITooLong) + return + } + peer, found, err := h.resolvePublicPeer(r.Context(), username) if err != nil { + h.logger.Error("Public username lookup failed", zap.String("username", username), zap.Error(err)) http.Error(w, "username lookup failed", http.StatusInternalServerError) return } - if !found || !u.Bot || strings.TrimSpace(u.Username) == "" { - http.NotFound(w, r) + if !found { + h.serveUsernameNotFound(w, username) return } - title := strings.TrimSpace(u.FirstName) - if title == "" { - title = u.Username + params.Set("domain", peer.username) + app := schemeURLValues("telesrv", "resolve", params) + legacy := schemeURLValues("tg", "resolve", params) + description := peer.about + if description == "" { + description = peer.fallbackDescription() } - app := schemeURL("telesrv", "resolve", "domain", u.Username) - data := pageData{ - Title: title, - KindLabel: "bot", - Subtitle: "@" + u.Username, - Description: "This page opens the app so you can start a chat with this bot.", - CanonicalURL: h.publicUsernameURL(u.Username), + data := usernamePageData{ + Title: peer.title, + Username: peer.username, + Verified: peer.verified, + Extra: peer.extra(), + Description: description, + CanonicalURL: h.publicUsernameURL(peer.username), + HomeURL: h.publicBaseURL + "/", AppURL: template.URL(app), - LegacyTgURL: template.URL(schemeURL("tg", "resolve", "domain", u.Username)), + LegacyTgURL: template.URL(legacy), + WebURL: template.URL(publicWebAppURL(legacy)), + ButtonLabel: peer.buttonLabel(), + Initials: peer.initials(), + } + if peer.hasPhoto { + data.PhotoURL = h.publicAvatarURL(peer.username, peer.photo.ID) } data.AppURLJS = template.JS(strconv.Quote(app)) w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "public, max-age=60") - if err := landingTemplate.Execute(w, data); err != nil { - http.Error(w, "render bot page failed", http.StatusInternalServerError) + w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") + if err := usernameLandingTemplate.Execute(w, data); err != nil { + h.logger.Error("Render public username page failed", zap.String("username", peer.username), zap.Error(err)) } } +const maxPublicAvatarBytes = 4 << 20 + +func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.PathValue("username")) + photoID, err := strconv.ParseInt(r.PathValue("photoID"), 10, 64) + if err != nil || photoID <= 0 || !validUsernamePath(username) || h.photos == nil { + http.NotFound(w, r) + return + } + peer, found, err := h.resolvePublicPeer(r.Context(), username) + if err != nil { + h.logger.Error("Public avatar peer lookup failed", zap.String("username", username), zap.Error(err)) + http.Error(w, "avatar lookup failed", http.StatusInternalServerError) + return + } + if !found || !peer.hasPhoto || peer.photo.ID != photoID { + http.NotFound(w, r) + return + } + size, inline, ok := bestPublicPhotoSize(peer.photo.Sizes) + if !ok { + http.NotFound(w, r) + return + } + etag := fmt.Sprintf("\"public-avatar-%d-%s-%d\"", peer.photo.ID, size.Type, size.Size) + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + data := inline + mimeType := "" + if len(data) == 0 { + chunk, found, err := h.photos.GetFile(r.Context(), domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("photo:%d:%s", peer.photo.ID, size.Type), + Limit: maxPublicAvatarBytes + 1, + }) + if err != nil { + h.logger.Error("Read public avatar blob failed", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Error(err)) + http.Error(w, "avatar read failed", http.StatusInternalServerError) + return + } + if !found || chunk.Total <= 0 || chunk.Total > maxPublicAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total { + h.logger.Warn("Public avatar blob is missing or outside bounds", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Int64("total", chunk.Total), zap.Int("bytes", len(chunk.Bytes))) + http.NotFound(w, r) + return + } + data = chunk.Bytes + mimeType = chunk.MimeType + } + if len(data) == 0 || len(data) > maxPublicAvatarBytes { + http.NotFound(w, r) + return + } + detected := http.DetectContentType(data) + if !safePublicImageType(detected) { + h.logger.Warn("Public avatar blob is not a safe raster image", zap.String("username", username), zap.Int64("photo_id", photoID), zap.String("detected_type", detected), zap.String("stored_type", mimeType)) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", detected) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + if peer.photo.Date > 0 { + w.Header().Set("Last-Modified", time.Unix(int64(peer.photo.Date), 0).UTC().Format(http.TimeFormat)) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) { shortName := strings.TrimSpace(r.PathValue("shortName")) if !validShortNamePath(shortName) { @@ -220,15 +366,364 @@ func (h *handler) publicUsernameURL(username string) string { return h.publicBaseURL + "/" + url.PathEscape(username) } +func (h *handler) publicAvatarURL(username string, photoID int64) string { + return h.publicBaseURL + "/_public/avatar/" + url.PathEscape(username) + "/" + strconv.FormatInt(photoID, 10) +} + +type publicPeerKind string + +const ( + publicPeerUser publicPeerKind = "user" + publicPeerBot publicPeerKind = "bot" + publicPeerChannel publicPeerKind = "channel" + publicPeerSupergroup publicPeerKind = "supergroup" +) + +type publicPeer struct { + kind publicPeerKind + username string + title string + about string + verified bool + memberCount int + photo domain.Photo + hasPhoto bool +} + +func (h *handler) resolvePublicPeer(ctx context.Context, username string) (publicPeer, bool, error) { + var ( + u domain.User + userOK bool + ch domain.Channel + chOK bool + err error + ) + if h.users != nil { + u, userOK, err = h.users.ByUsername(ctx, username) + if err != nil { + return publicPeer{}, false, err + } + } + if h.channels != nil { + ch, chOK, err = h.channels.ResolvePublicChannelUsername(ctx, 0, username) + if err != nil { + return publicPeer{}, false, err + } + } + if userOK && chOK { + return publicPeer{}, false, fmt.Errorf("public username %q has multiple owners", username) + } + if userOK { + return h.publicUserPeer(ctx, username, u) + } + if chOK { + return h.publicChannelPeer(ctx, username, ch) + } + return publicPeer{}, false, nil +} + +func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain.User) (publicPeer, bool, error) { + if u.ID == 0 || !strings.EqualFold(strings.TrimSpace(u.Username), requested) || !validUsernamePath(u.Username) { + return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested) + } + title := strings.TrimSpace(u.FirstName + " " + u.LastName) + if title == "" { + title = u.Username + } + if err := validatePublicPeerText(title, u.About); err != nil { + return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", u.Username, err) + } + about := strings.TrimSpace(u.About) + photoKind := domain.ProfilePhotoKindProfile + if !u.Bot && h.privacy != nil { + visible, err := h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyAbout) + if err != nil { + return publicPeer{}, false, fmt.Errorf("evaluate public about privacy: %w", err) + } + if !visible { + about = "" + } + visible, err = h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyProfilePhoto) + if err != nil { + return publicPeer{}, false, fmt.Errorf("evaluate public profile photo privacy: %w", err) + } + if !visible { + photoKind = domain.ProfilePhotoKindFallback + } + } + peer := publicPeer{ + kind: publicPeerUser, + username: u.Username, + title: title, + about: about, + verified: u.Verified, + } + if u.Bot { + peer.kind = publicPeerBot + } + if h.photos != nil { + photo, found, err := h.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, u.ID, photoKind) + if err != nil { + return publicPeer{}, false, fmt.Errorf("load public user photo: %w", err) + } + if found && photo.ID != 0 { + if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable { + peer.photo, peer.hasPhoto = photo, true + } + } + } + return peer, true, nil +} + +func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch domain.Channel) (publicPeer, bool, error) { + if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) { + return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested) + } + if err := validatePublicPeerText(ch.Title, ch.About); err != nil { + return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err) + } + peer := publicPeer{ + kind: publicPeerChannel, + username: ch.Username, + title: strings.TrimSpace(ch.Title), + about: strings.TrimSpace(ch.About), + verified: ch.Verified, + memberCount: ch.ParticipantsCount, + } + if ch.Megagroup { + peer.kind = publicPeerSupergroup + } + if h.photos != nil && ch.PhotoID != 0 { + photo, found, err := h.photos.GetPhoto(ctx, ch.PhotoID) + if err != nil { + return publicPeer{}, false, fmt.Errorf("load public channel photo: %w", err) + } + if !found { + return publicPeer{}, false, fmt.Errorf("channel %q current photo %d is missing", ch.Username, ch.PhotoID) + } + if photo.ID == ch.PhotoID { + if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable { + peer.photo, peer.hasPhoto = photo, true + } + } else { + return publicPeer{}, false, fmt.Errorf("channel %q photo lookup returned id %d, want %d", ch.Username, photo.ID, ch.PhotoID) + } + } + return peer, true, nil +} + +func validatePublicPeerText(title, about string) error { + if strings.TrimSpace(title) == "" || utf8.RuneCountInString(title) > 256 { + return fmt.Errorf("title is empty or too long") + } + if utf8.RuneCountInString(about) > 4096 { + return fmt.Errorf("about is too long") + } + return nil +} + +func (p publicPeer) buttonLabel() string { + switch p.kind { + case publicPeerBot: + return "Start Bot" + case publicPeerChannel: + return "View Channel" + case publicPeerSupergroup: + return "View Group" + default: + return "Send Message" + } +} + +func (p publicPeer) extra() string { + switch p.kind { + case publicPeerBot: + return "bot" + case publicPeerChannel: + return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "subscriber", "subscribers") + case publicPeerSupergroup: + return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "member", "members") + default: + return "" + } +} + +func (p publicPeer) fallbackDescription() string { + switch p.kind { + case publicPeerBot: + return "Open telesrv to start a chat with this bot." + case publicPeerChannel: + return "Open telesrv to view and join this channel." + case publicPeerSupergroup: + return "Open telesrv to view and join this group." + default: + return "Open telesrv to send a message to @" + p.username + "." + } +} + +func (p publicPeer) initials() string { + words := strings.Fields(p.title) + if len(words) == 0 { + words = []string{p.username} + } + first := []rune(words[0]) + if len(first) == 0 { + return "T" + } + out := []rune{first[0]} + if len(words) > 1 { + last := []rune(words[len(words)-1]) + if len(last) > 0 { + out = append(out, last[0]) + } + } + return strings.ToUpper(string(out)) +} + +func groupedDecimal(n int) string { + if n < 0 { + n = 0 + } + s := strconv.Itoa(n) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + " " + s[i:] + } + return s +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +const ( + maxPublicLinkRawQuery = 2048 + maxPublicLinkParams = 16 + maxPublicLinkValues = 2 + maxPublicLinkValueLen = 512 +) + +func publicResolveQuery(raw string) (url.Values, bool) { + if len(raw) > maxPublicLinkRawQuery { + return nil, false + } + values, err := url.ParseQuery(raw) + if err != nil || len(values) > maxPublicLinkParams { + return nil, false + } + out := make(url.Values, len(values)+1) + for key, items := range values { + if strings.EqualFold(key, "domain") { + continue + } + if !validPublicQueryKey(key) || len(items) > maxPublicLinkValues { + return nil, false + } + for _, value := range items { + if len(value) > maxPublicLinkValueLen || !utf8.ValidString(value) { + return nil, false + } + out.Add(key, value) + } + } + return out, true +} + +func validPublicQueryKey(key string) bool { + if key == "" || len(key) > 32 { + return false + } + for _, r := range key { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + continue + } + return false + } + return true +} + +func bestPublicPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) { + var ( + best domain.PhotoSize + bestBytes []byte + bestScore int64 = -1 + ) + for _, size := range sizes { + if !validPhotoSizeType(size.Type) { + continue + } + var inline []byte + switch size.Kind { + case domain.PhotoSizeKindCached: + if len(size.Bytes) == 0 || len(size.Bytes) > maxPublicAvatarBytes { + continue + } + inline = size.Bytes + case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive: + // Downloadable static raster size. + default: + continue + } + score := int64(size.W) * int64(size.H) + if score <= 0 { + score = int64(size.Size) + } + if score > bestScore { + best, bestBytes, bestScore = size, inline, score + } + } + return best, bestBytes, bestScore >= 0 +} + +func validPhotoSizeType(value string) bool { + if value == "" || len(value) > 8 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + continue + } + return false + } + return true +} + +func safePublicImageType(value string) bool { + switch value { + case "image/jpeg", "image/png", "image/gif", "image/webp": + return true + default: + return false + } +} + +func schemeURLValues(scheme, kind string, values url.Values) string { + return (&url.URL{Scheme: scheme, Host: kind, RawQuery: values.Encode()}).String() +} + +func publicWebAppURL(legacyURL string) string { + return "https://web.telesrv.net/#?tgaddr=" + url.QueryEscape(legacyURL) +} + +func publicSecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} + func normalizePublicBaseURL(raw string) string { - u, err := url.Parse(links.NormalizeBaseURL(raw)) - if err != nil || u.Scheme == "" || u.Host == "" { + normalized, err := links.ValidateBaseURL(raw) + if err != nil { return links.DefaultPublicBaseURL } - u.Path = strings.TrimRight(u.Path, "/") - u.RawQuery = "" - u.Fragment = "" - return strings.TrimRight(u.String(), "/") + return normalized } func validShortNamePath(shortName string) bool { @@ -331,6 +826,130 @@ type pageData struct { AppURLJS template.JS } +type usernamePageData struct { + Title string + Username string + Verified bool + Extra string + Description string + CanonicalURL string + HomeURL string + PhotoURL string + Initials string + ButtonLabel string + AppURL template.URL + LegacyTgURL template.URL + WebURL template.URL + AppURLJS template.JS +} + +func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate") + w.WriteHeader(http.StatusNotFound) + if err := usernameNotFoundTemplate.Execute(w, struct { + Username string + HomeURL string + }{Username: username, HomeURL: h.publicBaseURL + "/"}); err != nil { + h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err)) + } +} + +var usernameLandingTemplate = template.Must(template.New("username-landing").Parse(` + + + + + + {{.Title}} (@{{.Username}}) - telesrv + + + + + + + + + {{if .PhotoURL}}{{end}} + + + + + + {{if .PhotoURL}}{{end}} + + + +
+ ttelesrv +
+
+
{{if .PhotoURL}}{{.Title}} profile photo{{else}}{{end}}
+

{{.Title}}{{if .Verified}}{{end}}

+

@{{.Username}}

+ {{if .Extra}}

{{.Extra}}

{{end}} +

{{.Description}}

+ +

Old test clients only: open with tg://

+
+
+
If you have telesrv, this page can open the chat directly.
+
+ + + +`)) + +var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(` + +Username not found - telesrv + +

Username not found

{{if .Username}}@{{.Username}} is not an active public telesrv username.{{else}}This is not a valid public telesrv username.{{end}}

Back to telesrv
`)) + var landingTemplate = template.Must(template.New("landing").Parse(` diff --git a/internal/web/stickerlinks/server_test.go b/internal/web/stickerlinks/server_test.go index e042a0f2..1d3d6720 100644 --- a/internal/web/stickerlinks/server_test.go +++ b/internal/web/stickerlinks/server_test.go @@ -119,7 +119,7 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/TetrisBot", nil) - NewHandlerWithUsers(fakeResolver{}, users, "http://127.0.0.1:2401").ServeHTTP(rr, req) + NewHandlerWithPublicPeers(fakeResolver{}, users, nil, nil, nil, "http://127.0.0.1:2401").ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) @@ -132,7 +132,10 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) { "http://127.0.0.1:2401/TetrisBot", "telesrv://resolve?domain=TetrisBot", "tg://resolve?domain=TetrisBot", - "start a chat with this bot", + "Start Bot", + "Open telesrv to start a chat with this bot.", + `property="og:title" content="Tetris Bot"`, + `property="al:android:url" content="telesrv://resolve?domain=TetrisBot"`, } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) @@ -143,6 +146,266 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) { } } +func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { + users := fakeUsers{ + "alice": { + ID: 2001, + AccessHash: 987654321, + Phone: "+15551234567", + Username: "Alice", + FirstName: "Alice", + LastName: "Example", + About: "Public bio", + Verified: true, + LastSeenAt: 1700000000, + }, + } + channels := fakeChannels{ + "newsroom": { + ID: 3001, + Username: "NewsRoom", + Title: "News Room", + About: "Public channel description", + Broadcast: true, + ParticipantsCount: 12001, + Verified: true, + PhotoID: 301, + }, + "studygroup": { + ID: 3002, + Username: "StudyGroup", + Title: "Study Group", + About: "A public supergroup", + Megagroup: true, + ParticipantsCount: 1, + }, + } + photos := &fakePhotos{byID: map[int64]domain.Photo{ + 301: {ID: 301, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}}, + }} + handler := NewHandlerWithPublicPeers(fakeResolver{}, users, channels, nil, photos, "https://telesrv.net") + + for _, tc := range []struct { + path string + wants []string + }{ + { + path: "/aLiCe/", + wants: []string{ + "Alice Example", "Public bio", "@Alice", "Send Message", "Verified", + "https://telesrv.net/Alice", "telesrv://resolve?domain=Alice", + }, + }, + { + path: "/NewsRoom", + wants: []string{ + "News Room", "Public channel description", "12 001 subscribers", "View Channel", + "https://telesrv.net/NewsRoom", "telesrv://resolve?domain=NewsRoom", + "/_public/avatar/NewsRoom/301", + }, + }, + { + path: "/StudyGroup", + wants: []string{ + "Study Group", "A public supergroup", "1 member", "View Group", + }, + }, + } { + t.Run(tc.path, func(t *testing.T) { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + for _, want := range tc.wants { + if !strings.Contains(rr.Body.String(), want) { + t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) + } + } + if tc.path == "/aLiCe/" && strings.Count(rr.Body.String(), `

@Alice

`) != 1 { + t.Fatalf("ordinary user username rendered more than once:\n%s", rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "+15551234567") || strings.Contains(rr.Body.String(), "987654321") || strings.Contains(rr.Body.String(), "1700000000") { + t.Fatalf("private protocol fields leaked into page:\n%s", rr.Body.String()) + } + }) + } +} + +func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) { + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + "tetrisbot": {ID: 2001, Username: "TetrisBot", FirstName: "Tetris", Bot: true}, + }, nil, nil, nil, "https://telesrv.net") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/TetrisBot?start=hello&ref=campaign&domain=EvilBot", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{ + "telesrv://resolve?domain=TetrisBot&ref=campaign&start=hello", + "tg://resolve?domain=TetrisBot&ref=campaign&start=hello", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing sanitized query %q:\n%s", want, body) + } + } + if strings.Contains(body, "EvilBot") { + t.Fatalf("caller-controlled domain leaked into page:\n%s", body) + } + + for _, target := range []string{ + "/TetrisBot?bad-key=value", + "/TetrisBot?start=" + strings.Repeat("a", maxPublicLinkValueLen+1), + "/TetrisBot?" + strings.Repeat("a", maxPublicLinkRawQuery+1), + } { + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil)) + if rr.Code != http.StatusRequestURITooLong { + t.Fatalf("%s status = %d, want 414", target, rr.Code) + } + } +} + +func TestHandlerHonorsAnonymousAboutAndPhotoPrivacy(t *testing.T) { + const userID int64 = 2001 + photos := &fakePhotos{ + photos: map[photoLookupKey]domain.Photo{ + {ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: { + ID: 10, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}, + }, + {ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindFallback}: { + ID: 11, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}, + }, + }, + } + privacy := fakeAnonymousPrivacy{ + domain.PrivacyKeyAbout: false, + domain.PrivacyKeyProfilePhoto: false, + } + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + "alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"}, + }, nil, privacy, photos, "https://telesrv.net") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/Alice", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + if strings.Contains(body, "private biography") || strings.Contains(body, "/10") { + t.Fatalf("private about or main photo leaked:\n%s", body) + } + if !strings.Contains(body, "/_public/avatar/Alice/11") { + t.Fatalf("fallback public photo missing:\n%s", body) + } +} + +func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) { + const userID int64 = 2001 + jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01} + photos := &fakePhotos{ + photos: map[photoLookupKey]domain.Photo{ + {ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: { + ID: 99, Date: 1700000000, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: len(jpeg)}}, + }, + }, + files: map[string]domain.FileChunk{ + "photo:99:c": {Bytes: jpeg, MimeType: "image/jpeg", Total: int64(len(jpeg))}, + }, + } + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + "alice": {ID: userID, Username: "Alice", FirstName: "Alice"}, + }, nil, nil, photos, "https://telesrv.net") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil)) + if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "image/jpeg" || rr.Body.String() != string(jpeg) { + t.Fatalf("avatar response status=%d type=%q body=%x", rr.Code, rr.Header().Get("Content-Type"), rr.Body.Bytes()) + } + if rr.Header().Get("ETag") == "" || rr.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("avatar headers = %+v", rr.Header()) + } + etag := rr.Header().Get("ETag") + req := httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil) + req.Header.Set("If-None-Match", etag) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusNotModified || rr.Body.Len() != 0 { + t.Fatalf("conditional avatar status=%d body=%x", rr.Code, rr.Body.Bytes()) + } + + for _, path := range []string{"/_public/avatar/Alice/100", "/_public/avatar/Missing/99"} { + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want 404", path, rr.Code) + } + } +} + +func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) { + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + "sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"}, + }, fakeChannels{ + "sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true}, + }, nil, nil, "https://telesrv.net") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/SharedName", nil)) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("ambiguous owner status = %d, want 500", rr.Code) + } +} + +func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) { + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{}, fakeChannels{}, nil, nil, "https://telesrv.net") + for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusNotFound || !strings.Contains(rr.Body.String(), "Username not found") || !strings.Contains(rr.Body.String(), "noindex,nofollow") { + t.Fatalf("%s status=%d body=%s", path, rr.Code, rr.Body.String()) + } + if strings.Contains(rr.Body.String(), "telesrv://resolve") { + t.Fatalf("not-found page contains a fabricated app link: %s", rr.Body.String()) + } + if rr.Header().Get("Content-Security-Policy") == "" || rr.Header().Get("X-Frame-Options") != "DENY" { + t.Fatalf("not-found security headers = %+v", rr.Header()) + } + } +} + +func TestPublicAvatarRejectsOversizedOrUnsafeBlob(t *testing.T) { + const userID int64 = 2001 + photo := domain.Photo{ + ID: 99, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}, + } + for _, tc := range []struct { + name string + chunk domain.FileChunk + }{ + {name: "oversized", chunk: domain.FileChunk{Bytes: []byte("x"), MimeType: "image/jpeg", Total: maxPublicAvatarBytes + 1}}, + {name: "unsafe mime", chunk: domain.FileChunk{Bytes: []byte(""), MimeType: "image/svg+xml", Total: int64(len(""))}}, + } { + t.Run(tc.name, func(t *testing.T) { + photos := &fakePhotos{ + photos: map[photoLookupKey]domain.Photo{ + {ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: photo, + }, + files: map[string]domain.FileChunk{"photo:99:c": tc.chunk}, + } + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ + "alice": {ID: userID, Username: "Alice", FirstName: "Alice"}, + }, nil, nil, photos, "https://telesrv.net") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rr.Code, rr.Body.String()) + } + }) + } +} + func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) { resolver := fakeResolver{ "emoji_pack": { @@ -167,13 +430,13 @@ func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) { } func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) { - handler := NewHandlerWithUsers(fakeResolver{}, fakeUsers{ + handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{ "alice": { ID: 2001, Username: "Alice", FirstName: "Alice", }, - }, "https://telesrv.net") + }, nil, nil, nil, "https://telesrv.net") for _, path := range []string{ "/addstickers/missing_pack", "/addstickers/bad-name", @@ -181,7 +444,6 @@ func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) { "/addlist/bad!slug", "/addlist/%E4%B8%AD%E6%96%87", "/MissingBot", - "/Alice", "/bad-name-bot", "/1stBot", } { @@ -228,3 +490,47 @@ func (f fakeUsers) ByUsername(_ context.Context, username string) (domain.User, u, ok := f[strings.ToLower(strings.TrimPrefix(username, "@"))] return u, ok, nil } + +type fakeChannels map[string]domain.Channel + +func (f fakeChannels) ResolvePublicChannelUsername(_ context.Context, _ int64, username string) (domain.Channel, bool, error) { + ch, ok := f[strings.ToLower(strings.TrimPrefix(username, "@"))] + return ch, ok, nil +} + +type fakeAnonymousPrivacy map[domain.PrivacyKey]bool + +func (f fakeAnonymousPrivacy) CanSeeAnonymous(_ context.Context, _ int64, key domain.PrivacyKey) (bool, error) { + visible, ok := f[key] + if !ok { + return true, nil + } + return visible, nil +} + +type photoLookupKey struct { + ownerType domain.PeerType + ownerID int64 + kind domain.ProfilePhotoKind +} + +type fakePhotos struct { + photos map[photoLookupKey]domain.Photo + byID map[int64]domain.Photo + files map[string]domain.FileChunk +} + +func (f *fakePhotos) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) { + photo, ok := f.photos[photoLookupKey{ownerType: ownerType, ownerID: ownerID, kind: kind}] + return photo, ok, nil +} + +func (f *fakePhotos) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { + photo, ok := f.byID[id] + return photo, ok, nil +} + +func (f *fakePhotos) GetFile(_ context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) { + chunk, ok := f.files[req.LocationKey] + return chunk, ok, nil +}