feat: sync public links and phone change updates

This commit is contained in:
A 2026-07-10 22:01:44 +08:00
parent 41c7f1d018
commit da04c0fa6a
53 changed files with 3029 additions and 111 deletions

View file

@ -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 /<username>, 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.

View file

@ -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. |

View file

@ -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 回复钩子。 |

View file

@ -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)
}

View file

@ -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;

View file

@ -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[])
);

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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。返回

View file

@ -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())

View file

@ -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.changePhoneRPC 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 使用:协议语义是客户端宣告「从现在开始同步」,启动期的

View file

@ -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) != ""
}

View file

@ -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 == "" {

View file

@ -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`)

View file

@ -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
}

View file

@ -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
}

View file

@ -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 idBool 为 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,

View file

@ -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
}

View file

@ -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 {

View file

@ -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)

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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") }

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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,

View file

@ -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(&currentPhone); 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
}

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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),

View file

@ -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 keyJSON 仅作为稳定的长度分隔编码输入。
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
}

View file

@ -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)
}
}

View file

@ -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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0e1621">
<title>{{.Title}} (@{{.Username}}) - telesrv</title>
<meta name="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="telesrv">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
{{if .PhotoURL}}<meta property="og:image" content="{{.PhotoURL}}">{{end}}
<meta property="al:android:url" content="{{.AppURL}}">
<meta property="al:ios:url" content="{{.AppURL}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.Title}}">
<meta name="twitter:description" content="{{.Description}}">
{{if .PhotoURL}}<meta name="twitter:image" content="{{.PhotoURL}}">{{end}}
<style>
:root { color-scheme: dark; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100svh; color: #f5f8fb; background:
radial-gradient(circle at 50% -20%, rgba(50, 161, 255, .25), transparent 42%), #0e1621; }
.shell { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; }
.brand { display: flex; align-items: center; gap: 10px; width: fit-content; margin: 28px auto 0; color: #dceeff;
font-size: 17px; font-weight: 700; letter-spacing: .01em; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white;
background: linear-gradient(145deg, #52b8ff, #168de2); box-shadow: 0 8px 24px rgba(31, 151, 232, .3); }
main { display: grid; place-items: center; padding: 32px 18px; }
.card { width: min(100%, 420px); padding: 34px 30px 28px; text-align: center; border: 1px solid rgba(255,255,255,.08);
border-radius: 24px; background: rgba(23, 33, 43, .92); box-shadow: 0 28px 90px rgba(0,0,0,.34); backdrop-filter: blur(18px); }
.avatar { display: grid; place-items: center; width: 112px; height: 112px; margin: 0 auto 22px; overflow: hidden;
border-radius: 50%; background: linear-gradient(145deg, #47b7ff, #167bc1); box-shadow: 0 16px 44px rgba(10, 112, 183, .3); }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.initials { font-size: 38px; font-weight: 750; letter-spacing: -.04em; color: white; }
h1 { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 0; font-size: clamp(25px, 7vw, 32px);
line-height: 1.18; letter-spacing: -.025em; overflow-wrap: anywhere; }
.verified { display: inline-grid; flex: 0 0 auto; place-items: center; width: 21px; height: 21px; border-radius: 50%;
color: #fff; background: #3aa8f7; font-size: 13px; font-weight: 900; }
.username { margin: 8px 0 0; color: #67bff9; font-size: 16px; overflow-wrap: anywhere; }
.extra { margin: 7px 0 0; color: #91a3b5; font-size: 14px; }
.description { margin: 22px auto 0; color: #c5d0da; font-size: 15px; line-height: 1.55; white-space: pre-line;
overflow-wrap: anywhere; }
.actions { display: grid; gap: 11px; margin-top: 28px; }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 20px; border-radius: 13px;
font-size: 15px; font-weight: 720; text-decoration: none; transition: transform .16s ease, background .16s ease; }
.button:hover { transform: translateY(-1px); }
.primary { color: #fff; background: linear-gradient(135deg, #31a9f5, #168de2); box-shadow: 0 10px 28px rgba(22,141,226,.25); }
.secondary { color: #a9dafa; background: rgba(72, 164, 226, .12); border: 1px solid rgba(89, 180, 241, .15); }
.legacy { margin: 18px 0 0; color: #718395; font-size: 12px; }
.legacy a { color: #83bddd; text-decoration: none; }
footer { padding: 0 18px 26px; color: #657789; font-size: 12px; text-align: center; }
@media (max-width: 480px) {
.brand { margin-top: 20px; }
main { padding: 24px 14px; align-items: start; }
.card { padding: 28px 22px 24px; border-radius: 20px; }
.avatar { width: 96px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
</style>
</head>
<body>
<div class="shell">
<a class="brand" href="{{.HomeURL}}" aria-label="telesrv home"><span class="brand-mark">t</span><span>telesrv</span></a>
<main>
<article class="card">
<div class="avatar">{{if .PhotoURL}}<img src="{{.PhotoURL}}" alt="{{.Title}} profile photo" width="112" height="112">{{else}}<span class="initials" aria-hidden="true">{{.Initials}}</span>{{end}}</div>
<h1><span>{{.Title}}</span>{{if .Verified}}<span class="verified" title="Verified" aria-label="Verified"></span>{{end}}</h1>
<p class="username">@{{.Username}}</p>
{{if .Extra}}<p class="extra">{{.Extra}}</p>{{end}}
<p class="description">{{.Description}}</p>
<div class="actions">
<a class="button primary" href="{{.AppURL}}">{{.ButtonLabel}}</a>
<a class="button secondary" href="{{.WebURL}}">Open in Web</a>
</div>
<p class="legacy">Old test clients only: <a href="{{.LegacyTgURL}}">open with tg://</a></p>
</article>
</main>
<footer>If you have telesrv, this page can open the chat directly.</footer>
</div>
<script>window.setTimeout(function () { window.location.href = {{.AppURLJS}}; }, 250);</script>
</body>
</html>
`))
var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>Username not found - telesrv</title>
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:#0e1621;color:#f5f8fb}.card{width:min(100%,420px);padding:34px 28px;border:1px solid rgba(255,255,255,.08);border-radius:22px;background:#17212b;text-align:center}h1{margin:0 0 12px;font-size:26px}p{margin:0;color:#9fb0bf;line-height:1.55;overflow-wrap:anywhere}a{display:inline-block;margin-top:24px;color:#67bff9;text-decoration:none}</style>
</head><body><main class="card"><h1>Username not found</h1><p>{{if .Username}}@{{.Username}} is not an active public telesrv username.{{else}}This is not a valid public telesrv username.{{end}}</p><a href="{{.HomeURL}}">Back to telesrv</a></main></body></html>`))
var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype html>
<html lang="en">
<head>

View file

@ -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(), `<p class="username">@Alice</p>`) != 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&amp;ref=campaign&amp;start=hello",
"tg://resolve?domain=TetrisBot&amp;ref=campaign&amp;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("<svg></svg>"), MimeType: "image/svg+xml", Total: int64(len("<svg></svg>"))}},
} {
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
}