feat: sync public links and phone change updates
This commit is contained in:
parent
41c7f1d018
commit
da04c0fa6a
53 changed files with 3029 additions and 111 deletions
185
internal/app/account/phone_change.go
Normal file
185
internal/app/account/phone_change.go
Normal 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
|
||||
}
|
||||
196
internal/app/account/phone_change_test.go
Normal file
196
internal/app/account/phone_change_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
71
internal/app/auth/change_phone_resend_test.go
Normal file
71
internal/app/auth/change_phone_resend_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)。返回
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package updates
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
|
|
@ -113,6 +114,23 @@ func (s *Service) ConfirmedState(ctx context.Context, authKeyID [8]byte, userID
|
|||
return st, found, nil
|
||||
}
|
||||
|
||||
// ConfirmEvent 把一个已在其它业务事务中原子提交的事件标记为当前设备已消费。
|
||||
// 典型调用是 account.changePhone:RPC result 已携最新 User,当前设备不应再收
|
||||
// updateUserPhone,但仍需把设备确认水位推进到该事件 pts。
|
||||
func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error {
|
||||
if event.UserID != 0 && event.UserID != userID {
|
||||
return fmt.Errorf("confirm update event user mismatch: event=%d caller=%d", event.UserID, userID)
|
||||
}
|
||||
if event.Pts <= 0 {
|
||||
return nil
|
||||
}
|
||||
date := event.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0})
|
||||
}
|
||||
|
||||
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。
|
||||
//
|
||||
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的
|
||||
|
|
|
|||
14
internal/compat/android/suggestions.go
Normal file
14
internal/compat/android/suggestions.go
Normal 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) != ""
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
18
internal/domain/phone_change.go
Normal file
18
internal/domain/phone_change.go
Normal 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
|
||||
}
|
||||
|
|
@ -27,7 +27,10 @@ const (
|
|||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL
|
||||
// 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。
|
||||
UpdateEventUserPhone UpdateEventType = "user_phone"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
|
||||
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
|
||||
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
|
||||
|
|
@ -77,6 +80,7 @@ type UpdateEvent struct {
|
|||
Peer Peer
|
||||
Peers []Peer
|
||||
Bool bool
|
||||
Phone string
|
||||
Settings PeerSettings
|
||||
MessageIDs []int
|
||||
MaxID int
|
||||
|
|
@ -122,6 +126,7 @@ func (e UpdateEvent) LacksWirePts() bool {
|
|||
UpdateEventDialogUnreadMark,
|
||||
UpdateEventPeerSettings,
|
||||
UpdateEventPeerStoryBlocked,
|
||||
UpdateEventUserPhone,
|
||||
UpdateEventDialogFilter,
|
||||
UpdateEventDialogFilterOrder,
|
||||
UpdateEventDialogFilters,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
89
internal/rpc/account_phone.go
Normal file
89
internal/rpc/account_phone.go
Normal 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
|
||||
}
|
||||
128
internal/rpc/account_phone_rpc_test.go
Normal file
128
internal/rpc/account_phone_rpc_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
56
internal/rpc/help_dismiss_suggestion_test.go
Normal file
56
internal/rpc/help_dismiss_suggestion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
internal/store/memory/code_test.go
Normal file
92
internal/store/memory/code_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
72
internal/store/memory/phone_change.go
Normal file
72
internal/store/memory/phone_change.go
Normal 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
|
||||
}
|
||||
13
internal/store/phone_change.go
Normal file
13
internal/store/phone_change.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
112
internal/store/postgres/phone_change.go
Normal file
112
internal/store/postgres/phone_change.go
Normal 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(¤tPhone); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PhoneChangeResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("lock user for phone change: %w", err)
|
||||
}
|
||||
if currentPhone == req.Phone {
|
||||
row, err := qtx.GetUserByID(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("reload unchanged phone user: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("commit unchanged phone: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.PhoneChangeResult{User: userFromModel(row)}, nil
|
||||
}
|
||||
|
||||
row, err := qtx.UpdateUserPhone(ctx, sqlcgen.UpdateUserPhoneParams{ID: req.UserID, Phone: req.Phone})
|
||||
if err != nil {
|
||||
if isUniqueConstraint(err, "users_phone_unique_idx") {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PhoneChangeResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("update user phone: %w", err)
|
||||
}
|
||||
date := req.Date
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
event := domain.UpdateEvent{
|
||||
UserID: req.UserID,
|
||||
Type: domain.UpdateEventUserPhone,
|
||||
Date: date,
|
||||
Phone: req.Phone,
|
||||
PtsCount: 1,
|
||||
}
|
||||
event.Pts, err = reserveUserPts(ctx, tx, req.UserID, event.PtsCount)
|
||||
if err != nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("reserve phone change pts: %w", err)
|
||||
}
|
||||
if err := appendUserUpdateEvent(ctx, tx, qtx, req.UserID, event); err != nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("append phone change event: %w", err)
|
||||
}
|
||||
if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{
|
||||
TargetUserID: req.UserID,
|
||||
Pts: int32(event.Pts),
|
||||
EventType: string(event.Type),
|
||||
ExcludeAuthKeyID: authKeyIDToInt64(req.ExcludeAuthKeyID),
|
||||
ExcludeSessionID: req.ExcludeSessionID,
|
||||
}); err != nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("enqueue phone change dispatch: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
if isUniqueConstraint(err, "users_phone_unique_idx") {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("commit phone change: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.PhoneChangeResult{User: userFromModel(row), Event: event, Changed: true}, nil
|
||||
}
|
||||
86
internal/store/postgres/phone_change_integration_test.go
Normal file
86
internal/store/postgres/phone_change_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package redisstore
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -22,14 +24,80 @@ func NewCodeStore(c *redis.Client) *CodeStore {
|
|||
return &CodeStore{c: c}
|
||||
}
|
||||
|
||||
func codeKey(hash string) string { return "phonecode:" + hash }
|
||||
const codeKeyPrefix = "phonecode:"
|
||||
|
||||
func codeKey(hash string) string { return codeKeyPrefix + hash }
|
||||
|
||||
func codeScopeKey(scope store.PhoneCodeScope) string {
|
||||
// 不把手机号/auth key 明文放进 Redis key;JSON 仅作为稳定的长度分隔编码输入。
|
||||
raw, _ := json.Marshal(scope)
|
||||
digest := sha256.Sum256(raw)
|
||||
return "phonecodescope:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
const rotateScopedCodeScript = `
|
||||
local old_hash = redis.call('GET', KEYS[2])
|
||||
if old_hash and old_hash ~= ARGV[1] then
|
||||
redis.call('DEL', ARGV[4] .. old_hash)
|
||||
end
|
||||
local ttl_ms = tonumber(ARGV[3])
|
||||
if ttl_ms and ttl_ms > 0 then
|
||||
redis.call('PSETEX', KEYS[1], ttl_ms, ARGV[2])
|
||||
redis.call('PSETEX', KEYS[2], ttl_ms, ARGV[1])
|
||||
else
|
||||
redis.call('SET', KEYS[1], ARGV[2])
|
||||
redis.call('SET', KEYS[2], ARGV[1])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
const deleteScopedCodeScript = `
|
||||
redis.call('DEL', KEYS[1])
|
||||
if redis.call('GET', KEYS[2]) == ARGV[1] then
|
||||
redis.call('DEL', KEYS[2])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
const consumeScopedCodeScript = `
|
||||
if redis.call('GET', KEYS[2]) ~= ARGV[1] then
|
||||
return false
|
||||
end
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
redis.call('DEL', KEYS[2])
|
||||
return false
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
redis.call('DEL', KEYS[2])
|
||||
return raw
|
||||
`
|
||||
|
||||
func (s *CodeStore) Set(ctx context.Context, hash string, code store.PhoneCode, ttl time.Duration) error {
|
||||
v, err := json.Marshal(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal phone code: %w", err)
|
||||
}
|
||||
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
|
||||
scope := code.Scope()
|
||||
if !scope.Valid() {
|
||||
if err := s.c.Set(ctx, codeKey(hash), v, ttl).Err(); err != nil {
|
||||
return fmt.Errorf("redis set phone code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ttlMillis := ttl.Milliseconds()
|
||||
if ttl > 0 && ttlMillis == 0 {
|
||||
ttlMillis = 1
|
||||
}
|
||||
if err := s.c.Eval(
|
||||
ctx,
|
||||
rotateScopedCodeScript,
|
||||
[]string{codeKey(hash), codeScopeKey(scope)},
|
||||
hash,
|
||||
string(v),
|
||||
ttlMillis,
|
||||
codeKeyPrefix,
|
||||
).Err(); err != nil {
|
||||
return fmt.Errorf("redis set phone code: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -52,7 +120,7 @@ func (s *CodeStore) Get(ctx context.Context, hash string) (store.PhoneCode, bool
|
|||
|
||||
func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCode) error {
|
||||
key := codeKey(hash)
|
||||
ttl, err := s.c.TTL(ctx, key).Result()
|
||||
ttl, err := s.c.PTTL(ctx, key).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis ttl phone code: %w", err)
|
||||
}
|
||||
|
|
@ -70,5 +138,57 @@ func (s *CodeStore) Update(ctx context.Context, hash string, code store.PhoneCod
|
|||
}
|
||||
|
||||
func (s *CodeStore) Del(ctx context.Context, hash string) error {
|
||||
return s.c.Del(ctx, codeKey(hash)).Err()
|
||||
key := codeKey(hash)
|
||||
raw, err := s.c.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return s.c.Del(ctx, key).Err()
|
||||
}
|
||||
return fmt.Errorf("redis get phone code for delete: %w", err)
|
||||
}
|
||||
var code store.PhoneCode
|
||||
if err := json.Unmarshal(raw, &code); err != nil {
|
||||
return fmt.Errorf("unmarshal phone code for delete: %w", err)
|
||||
}
|
||||
scope := code.Scope()
|
||||
if !scope.Valid() {
|
||||
return s.c.Del(ctx, key).Err()
|
||||
}
|
||||
if err := s.c.Eval(ctx, deleteScopedCodeScript, []string{key, codeScopeKey(scope)}, hash).Err(); err != nil {
|
||||
return fmt.Errorf("redis delete scoped phone code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CodeStore) ConsumeScoped(ctx context.Context, hash string, scope store.PhoneCodeScope) (store.PhoneCode, bool, error) {
|
||||
if !scope.Valid() {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
result, err := s.c.Eval(
|
||||
ctx,
|
||||
consumeScopedCodeScript,
|
||||
[]string{codeKey(hash), codeScopeKey(scope)},
|
||||
hash,
|
||||
).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: %w", err)
|
||||
}
|
||||
if result == nil {
|
||||
return store.PhoneCode{}, false, nil
|
||||
}
|
||||
raw, ok := result.(string)
|
||||
if !ok {
|
||||
return store.PhoneCode{}, false, fmt.Errorf("redis consume scoped phone code: unexpected result %T", result)
|
||||
}
|
||||
var code store.PhoneCode
|
||||
if err := json.Unmarshal([]byte(raw), &code); err != nil {
|
||||
return store.PhoneCode{}, false, fmt.Errorf("unmarshal consumed phone code: %w", err)
|
||||
}
|
||||
if code.Scope() != scope {
|
||||
return store.PhoneCode{}, false, fmt.Errorf("consumed phone code scope mismatch")
|
||||
}
|
||||
return code, true, nil
|
||||
}
|
||||
|
|
|
|||
83
internal/store/redisstore/code_integration_test.go
Normal file
83
internal/store/redisstore/code_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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&ref=campaign&start=hello",
|
||||
"tg://resolve?domain=TetrisBot&ref=campaign&start=hello",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("body missing sanitized query %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "EvilBot") {
|
||||
t.Fatalf("caller-controlled domain leaked into page:\n%s", body)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"/TetrisBot?bad-key=value",
|
||||
"/TetrisBot?start=" + strings.Repeat("a", maxPublicLinkValueLen+1),
|
||||
"/TetrisBot?" + strings.Repeat("a", maxPublicLinkRawQuery+1),
|
||||
} {
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if rr.Code != http.StatusRequestURITooLong {
|
||||
t.Fatalf("%s status = %d, want 414", target, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHonorsAnonymousAboutAndPhotoPrivacy(t *testing.T) {
|
||||
const userID int64 = 2001
|
||||
photos := &fakePhotos{
|
||||
photos: map[photoLookupKey]domain.Photo{
|
||||
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: {
|
||||
ID: 10, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
|
||||
},
|
||||
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindFallback}: {
|
||||
ID: 11, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
|
||||
},
|
||||
},
|
||||
}
|
||||
privacy := fakeAnonymousPrivacy{
|
||||
domain.PrivacyKeyAbout: false,
|
||||
domain.PrivacyKeyProfilePhoto: false,
|
||||
}
|
||||
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
|
||||
"alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"},
|
||||
}, nil, privacy, photos, "https://telesrv.net")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/Alice", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "private biography") || strings.Contains(body, "/10") {
|
||||
t.Fatalf("private about or main photo leaked:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "/_public/avatar/Alice/11") {
|
||||
t.Fatalf("fallback public photo missing:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) {
|
||||
const userID int64 = 2001
|
||||
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01}
|
||||
photos := &fakePhotos{
|
||||
photos: map[photoLookupKey]domain.Photo{
|
||||
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: {
|
||||
ID: 99, Date: 1700000000,
|
||||
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: len(jpeg)}},
|
||||
},
|
||||
},
|
||||
files: map[string]domain.FileChunk{
|
||||
"photo:99:c": {Bytes: jpeg, MimeType: "image/jpeg", Total: int64(len(jpeg))},
|
||||
},
|
||||
}
|
||||
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
|
||||
"alice": {ID: userID, Username: "Alice", FirstName: "Alice"},
|
||||
}, nil, nil, photos, "https://telesrv.net")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil))
|
||||
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "image/jpeg" || rr.Body.String() != string(jpeg) {
|
||||
t.Fatalf("avatar response status=%d type=%q body=%x", rr.Code, rr.Header().Get("Content-Type"), rr.Body.Bytes())
|
||||
}
|
||||
if rr.Header().Get("ETag") == "" || rr.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("avatar headers = %+v", rr.Header())
|
||||
}
|
||||
etag := rr.Header().Get("ETag")
|
||||
req := httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotModified || rr.Body.Len() != 0 {
|
||||
t.Fatalf("conditional avatar status=%d body=%x", rr.Code, rr.Body.Bytes())
|
||||
}
|
||||
|
||||
for _, path := range []string{"/_public/avatar/Alice/100", "/_public/avatar/Missing/99"} {
|
||||
rr = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("%s status = %d, want 404", path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) {
|
||||
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
|
||||
"sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"},
|
||||
}, fakeChannels{
|
||||
"sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true},
|
||||
}, nil, nil, "https://telesrv.net")
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/SharedName", nil))
|
||||
if rr.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("ambiguous owner status = %d, want 500", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) {
|
||||
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{}, fakeChannels{}, nil, nil, "https://telesrv.net")
|
||||
for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} {
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rr.Code != http.StatusNotFound || !strings.Contains(rr.Body.String(), "Username not found") || !strings.Contains(rr.Body.String(), "noindex,nofollow") {
|
||||
t.Fatalf("%s status=%d body=%s", path, rr.Code, rr.Body.String())
|
||||
}
|
||||
if strings.Contains(rr.Body.String(), "telesrv://resolve") {
|
||||
t.Fatalf("not-found page contains a fabricated app link: %s", rr.Body.String())
|
||||
}
|
||||
if rr.Header().Get("Content-Security-Policy") == "" || rr.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("not-found security headers = %+v", rr.Header())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicAvatarRejectsOversizedOrUnsafeBlob(t *testing.T) {
|
||||
const userID int64 = 2001
|
||||
photo := domain.Photo{
|
||||
ID: 99,
|
||||
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
chunk domain.FileChunk
|
||||
}{
|
||||
{name: "oversized", chunk: domain.FileChunk{Bytes: []byte("x"), MimeType: "image/jpeg", Total: maxPublicAvatarBytes + 1}},
|
||||
{name: "unsafe mime", chunk: domain.FileChunk{Bytes: []byte("<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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue