feat: sync iOS compatibility support
This commit is contained in:
parent
1f646ef024
commit
50803a604c
32 changed files with 871 additions and 78 deletions
|
|
@ -56,7 +56,7 @@ codebase.
|
||||||
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
|
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
|
||||||
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
|
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
|
||||||
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |
|
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |
|
||||||
| ✅ | Desktop, Android, and Web focus | Telegram Desktop is the primary target, with Android and Web compatibility paths actively covered by the same server. |
|
| ✅ | Desktop, Android, iOS, and Web focus | Telegram Desktop is the primary target, with Android, iOS, and Web compatibility paths actively covered by the same server. |
|
||||||
|
|
||||||
Some items are compatibility-first or experimental, but they are real open
|
Some items are compatibility-first or experimental, but they are real open
|
||||||
server code, not hidden product-only features. The next step is making these
|
server code, not hidden product-only features. The next step is making these
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
|
||||||
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
|
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
|
||||||
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
|
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
|
||||||
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |
|
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |
|
||||||
| ✅ | Desktop、Android 与 Web 兼容 | Telegram Desktop 是第一目标,Android 与 Web 兼容路径也由同一套 server 持续覆盖。 |
|
| ✅ | Desktop、Android、iOS 与 Web 兼容 | Telegram Desktop 是第一目标,Android、iOS 与 Web 兼容路径也由同一套 server 持续覆盖。 |
|
||||||
|
|
||||||
其中一部分能力仍是兼容优先或实验性质,但它们都是真实开放的 server 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。
|
其中一部分能力仍是兼容优先或实验性质,但它们都是真实开放的 server 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。
|
||||||
|
|
||||||
|
|
|
||||||
2
deploy/migrations/0084_geolive_media_index.down.sql
Normal file
2
deploy/migrations/0084_geolive_media_index.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
DELETE FROM message_box_media WHERE category = 10;
|
||||||
|
DELETE FROM channel_message_media WHERE category = 10;
|
||||||
15
deploy/migrations/0084_geolive_media_index.up.sql
Normal file
15
deploy/migrations/0084_geolive_media_index.up.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- messages.getRecentLocations 复用共享媒体 seek index,避免在 peer 历史上扫描 JSONB。
|
||||||
|
-- category=10 是服务端内部 geo_live 类别,不暴露给普通 messages.search filter。
|
||||||
|
INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date)
|
||||||
|
SELECT owner_user_id, box_id, peer_id, 10, message_date
|
||||||
|
FROM message_boxes
|
||||||
|
WHERE NOT deleted
|
||||||
|
AND media ->> 'kind' = 'geo_live'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO channel_message_media (channel_id, id, category, message_date)
|
||||||
|
SELECT channel_id, id, 10, message_date
|
||||||
|
FROM channel_messages
|
||||||
|
WHERE NOT deleted
|
||||||
|
AND media ->> 'kind' = 'geo_live'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- No historical client identity data was changed.
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Reserved after local development applied version 85. Client identity must
|
||||||
|
-- converge only from a fresh initConnection; historical rows are not inferred.
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -1126,14 +1126,20 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
|
||||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
|
if err := s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
|
||||||
Layer: info.Layer,
|
Layer: info.Layer,
|
||||||
DeviceModel: info.DeviceModel,
|
DeviceModel: info.DeviceModel,
|
||||||
Platform: info.Platform,
|
Platform: info.Platform,
|
||||||
SystemVersion: info.SystemVersion,
|
SystemVersion: info.SystemVersion,
|
||||||
APIID: info.APIID,
|
APIID: info.APIID,
|
||||||
AppVersion: info.AppVersion,
|
AppVersion: info.AppVersion,
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.auths != nil {
|
||||||
|
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
|
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,48 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
keys := memory.NewAuthKeyStore()
|
||||||
|
authz := memory.NewAuthorizationStore()
|
||||||
|
key := testAuthKey(0x23)
|
||||||
|
saveAuthKey(t, keys, key)
|
||||||
|
if err := authz.Bind(ctx, domain.Authorization{
|
||||||
|
AuthKeyID: key.ID,
|
||||||
|
UserID: 1780243200,
|
||||||
|
Platform: "unknown",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("bind authorization: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, nil, "12345")
|
||||||
|
|
||||||
|
info := domain.AuthKeyClientInfo{
|
||||||
|
Layer: 227,
|
||||||
|
DeviceModel: "iPhone Simulator",
|
||||||
|
Platform: "ios",
|
||||||
|
SystemVersion: "26.5",
|
||||||
|
APIID: 1,
|
||||||
|
AppVersion: "12.8 (10000)",
|
||||||
|
}
|
||||||
|
if err := svc.UpdateAuthKeyClientInfo(ctx, key.ID, info); err != nil {
|
||||||
|
t.Fatalf("update auth key client info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
storedKey, found, err := keys.Get(ctx, key.ID)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("get auth key: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
storedAuth, found, err := authz.ByAuthKey(ctx, key.ID)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("get authorization: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if storedKey.Platform != "ios" || storedAuth.Platform != "ios" ||
|
||||||
|
storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel ||
|
||||||
|
storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion {
|
||||||
|
t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||||
|
|
|
||||||
14
internal/compat/ios/minimal.go
Normal file
14
internal/compat/ios/minimal.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package ios
|
||||||
|
|
||||||
|
import "github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
// NoAppUpdate is the bounded answer used until telesrv has an application
|
||||||
|
// release catalog. It makes iOS keep its installed build and retry on its
|
||||||
|
// normal schedule instead of retrying a failed RPC.
|
||||||
|
func NoAppUpdate() tg.HelpAppUpdateClass {
|
||||||
|
return &tg.HelpNoAppUpdate{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceLockedUpdated acknowledges the client-side autolock report. telesrv
|
||||||
|
// currently has no push-notification privacy state to persist from this hint.
|
||||||
|
func DeviceLockedUpdated() bool { return true }
|
||||||
|
|
@ -18,6 +18,9 @@ const (
|
||||||
MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true(视频消息)
|
MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true(视频消息)
|
||||||
MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage
|
MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage
|
||||||
MediaCategoryPoll MediaCategory = 9 // media.kind=poll
|
MediaCategoryPoll MediaCategory = 9 // media.kind=poll
|
||||||
|
// MediaCategoryGeoLive 是 messages.getRecentLocations 的内部索引类别。
|
||||||
|
// 它不映射到普通共享媒体筛选器,只用于按 peer 有界读取 live-location 消息。
|
||||||
|
MediaCategoryGeoLive MediaCategory = 10 // media.kind=geo_live
|
||||||
)
|
)
|
||||||
|
|
||||||
// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。
|
// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。
|
||||||
|
|
@ -84,6 +87,8 @@ func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []Me
|
||||||
}
|
}
|
||||||
case MessageMediaKindPoll:
|
case MessageMediaKindPoll:
|
||||||
add(MediaCategoryPoll)
|
add(MediaCategoryPoll)
|
||||||
|
case MessageMediaKindGeoLive:
|
||||||
|
add(MediaCategoryGeoLive)
|
||||||
case MessageMediaKindWebPage:
|
case MessageMediaKindWebPage:
|
||||||
add(MediaCategoryURL)
|
add(MediaCategoryURL)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ func TestClassifyMediaCategories(t *testing.T) {
|
||||||
{"nil media no entities", nil, nil, []MediaCategory{}},
|
{"nil media no entities", nil, nil, []MediaCategory{}},
|
||||||
{"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}},
|
{"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}},
|
||||||
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
|
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
|
||||||
|
{"live location", &MessageMedia{Kind: MessageMediaKindGeoLive, GeoLive: &MessageGeoLive{}}, nil, []MediaCategory{MediaCategoryGeoLive}},
|
||||||
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
|
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
|
||||||
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
|
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
|
||||||
{"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}},
|
{"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package mtprotoedge
|
package mtprotoedge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
|
@ -69,6 +70,17 @@ type serverExchangeCompat struct {
|
||||||
commitKey func(context.Context, exchange.ServerExchangeResult) error
|
commitKey func(context.Context, exchange.ServerExchangeResult) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pqInnerDataTempTypeID uint32 = 0x3c6a84d4
|
||||||
|
|
||||||
|
// compatPQInnerData is the normalized handshake input accepted at the MTProto
|
||||||
|
// edge. gotd v0.158.0 does not generate p_q_inner_data_temp#3c6a84d4, which is
|
||||||
|
// still emitted by Telegram-iOS for PFS temporary auth keys.
|
||||||
|
type compatPQInnerData struct {
|
||||||
|
Data mt.PQInnerData
|
||||||
|
Temp bool
|
||||||
|
ExpiresIn int
|
||||||
|
}
|
||||||
|
|
||||||
func (s serverExchangeCompat) run(ctx context.Context) (exchange.ServerExchangeResult, error) {
|
func (s serverExchangeCompat) run(ctx context.Context) (exchange.ServerExchangeResult, error) {
|
||||||
wrapKeyNotFound := func(err error) error {
|
wrapKeyNotFound := func(err error) error {
|
||||||
return exchangeError(codec.CodeAuthKeyNotFound, err)
|
return exchangeError(codec.CodeAuthKeyNotFound, err)
|
||||||
|
|
@ -119,28 +131,36 @@ SendResPQ:
|
||||||
|
|
||||||
var innerData mt.PQInnerData
|
var innerData mt.PQInnerData
|
||||||
{
|
{
|
||||||
|
if dhParams.DH.Nonce != req.Nonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params nonce does not match req_pq")
|
||||||
|
}
|
||||||
|
if dhParams.DH.ServerNonce != serverNonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params server_nonce does not match resPQ")
|
||||||
|
}
|
||||||
|
if dhParams.DH.PublicKeyFingerprint != s.key.Fingerprint() {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params public key fingerprint does not match server key")
|
||||||
|
}
|
||||||
|
|
||||||
r, err := crypto.DecodeRSAPad(dhParams.DH.EncryptedData, s.key.RSA)
|
r, err := crypto.DecodeRSAPad(dhParams.DH.EncryptedData, s.key.RSA)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
|
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
|
||||||
}
|
}
|
||||||
b.ResetTo(r)
|
b.ResetTo(r)
|
||||||
|
|
||||||
d, err := mt.DecodePQInnerData(b)
|
d, generated, err := decodeCompatPQInnerData(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return exchange.ServerExchangeResult{}, err
|
return exchange.ServerExchangeResult{}, err
|
||||||
}
|
}
|
||||||
if err := s.validatePQInnerDataDC(d); err != nil {
|
if generated != nil {
|
||||||
|
if err := s.validatePQInnerDataDC(generated); err != nil {
|
||||||
|
return exchange.ServerExchangeResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validatePQInnerData(d, req, dhParams.DH, serverNonce, pq); err != nil {
|
||||||
return exchange.ServerExchangeResult{}, err
|
return exchange.ServerExchangeResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
innerData = mt.PQInnerData{
|
innerData = d.Data
|
||||||
Pq: d.GetPq(),
|
|
||||||
P: d.GetP(),
|
|
||||||
Q: d.GetQ(),
|
|
||||||
Nonce: d.GetNonce(),
|
|
||||||
ServerNonce: d.GetServerNonce(),
|
|
||||||
NewNonce: d.GetNewNonce(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dhPrime, err := s.rng.DhPrime()
|
dhPrime, err := s.rng.DhPrime()
|
||||||
|
|
@ -188,6 +208,12 @@ SendResPQ:
|
||||||
return exchange.ServerExchangeResult{}, err
|
return exchange.ServerExchangeResult{}, err
|
||||||
}
|
}
|
||||||
s.log.Debug("Received client SetClientDHParamsRequest")
|
s.log.Debug("Received client SetClientDHParamsRequest")
|
||||||
|
if clientDhParams.Nonce != req.Nonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("set_client_DH_params nonce does not match req_pq")
|
||||||
|
}
|
||||||
|
if clientDhParams.ServerNonce != serverNonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("set_client_DH_params server_nonce does not match resPQ")
|
||||||
|
}
|
||||||
|
|
||||||
decrypted, err := crypto.DecryptExchangeAnswer(clientDhParams.EncryptedData, key, iv)
|
decrypted, err := crypto.DecryptExchangeAnswer(clientDhParams.EncryptedData, key, iv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -200,6 +226,12 @@ SendResPQ:
|
||||||
if err := clientInnerData.Decode(b); err != nil {
|
if err := clientInnerData.Decode(b); err != nil {
|
||||||
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
|
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
|
||||||
}
|
}
|
||||||
|
if clientInnerData.Nonce != req.Nonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("client_DH_inner_data nonce does not match req_pq")
|
||||||
|
}
|
||||||
|
if clientInnerData.ServerNonce != serverNonce {
|
||||||
|
return exchange.ServerExchangeResult{}, gofaster.New("client_DH_inner_data server_nonce does not match resPQ")
|
||||||
|
}
|
||||||
|
|
||||||
gB := big.NewInt(0).SetBytes(clientInnerData.GB)
|
gB := big.NewInt(0).SetBytes(clientInnerData.GB)
|
||||||
var authKey crypto.Key
|
var authKey crypto.Key
|
||||||
|
|
@ -235,6 +267,68 @@ SendResPQ:
|
||||||
return serverResult, nil
|
return serverResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeCompatPQInnerData(b *bin.Buffer) (compatPQInnerData, mt.PQInnerDataClass, error) {
|
||||||
|
id, err := b.PeekID()
|
||||||
|
if err != nil {
|
||||||
|
return compatPQInnerData{}, nil, err
|
||||||
|
}
|
||||||
|
if id == pqInnerDataTempTypeID {
|
||||||
|
if err := b.ConsumeID(pqInnerDataTempTypeID); err != nil {
|
||||||
|
return compatPQInnerData{}, nil, err
|
||||||
|
}
|
||||||
|
var data mt.PQInnerData
|
||||||
|
if err := data.DecodeBare(b); err != nil {
|
||||||
|
return compatPQInnerData{}, nil, fmt.Errorf("decode p_q_inner_data_temp: %w", err)
|
||||||
|
}
|
||||||
|
expiresIn, err := b.Int()
|
||||||
|
if err != nil {
|
||||||
|
return compatPQInnerData{}, nil, fmt.Errorf("decode p_q_inner_data_temp expires_in: %w", err)
|
||||||
|
}
|
||||||
|
return compatPQInnerData{Data: data, Temp: true, ExpiresIn: expiresIn}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
generated, err := mt.DecodePQInnerData(b)
|
||||||
|
if err != nil {
|
||||||
|
return compatPQInnerData{}, nil, err
|
||||||
|
}
|
||||||
|
result := compatPQInnerData{Data: mt.PQInnerData{
|
||||||
|
Pq: generated.GetPq(),
|
||||||
|
P: generated.GetP(),
|
||||||
|
Q: generated.GetQ(),
|
||||||
|
Nonce: generated.GetNonce(),
|
||||||
|
ServerNonce: generated.GetServerNonce(),
|
||||||
|
NewNonce: generated.GetNewNonce(),
|
||||||
|
}}
|
||||||
|
if temp, ok := generated.(*mt.PQInnerDataTempDC); ok {
|
||||||
|
result.Temp = true
|
||||||
|
result.ExpiresIn = temp.ExpiresIn
|
||||||
|
}
|
||||||
|
return result, generated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePQInnerData(d compatPQInnerData, req compatReqPQ, dh mt.ReqDHParamsRequest, serverNonce bin.Int128, pq *big.Int) error {
|
||||||
|
if d.Data.Nonce != req.Nonce {
|
||||||
|
return gofaster.New("p_q_inner_data nonce does not match req_pq")
|
||||||
|
}
|
||||||
|
if d.Data.ServerNonce != serverNonce {
|
||||||
|
return gofaster.New("p_q_inner_data server_nonce does not match resPQ")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(d.Data.Pq, pq.Bytes()) {
|
||||||
|
return gofaster.New("p_q_inner_data pq does not match resPQ")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(d.Data.P, dh.P) || !bytes.Equal(d.Data.Q, dh.Q) {
|
||||||
|
return gofaster.New("p_q_inner_data factors do not match req_DH_params")
|
||||||
|
}
|
||||||
|
product := new(big.Int).Mul(new(big.Int).SetBytes(d.Data.P), new(big.Int).SetBytes(d.Data.Q))
|
||||||
|
if product.Cmp(pq) != 0 {
|
||||||
|
return gofaster.New("p_q_inner_data factors do not multiply to pq")
|
||||||
|
}
|
||||||
|
if d.Temp && d.ExpiresIn <= 0 {
|
||||||
|
return gofaster.New("p_q_inner_data temporary key expires_in must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s serverExchangeCompat) validatePQInnerDataDC(d mt.PQInnerDataClass) error {
|
func (s serverExchangeCompat) validatePQInnerDataDC(d mt.PQInnerDataClass) error {
|
||||||
switch innerDataDC := d.(type) {
|
switch innerDataDC := d.(type) {
|
||||||
case *mt.PQInnerDataDC:
|
case *mt.PQInnerDataDC:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package mtprotoedge
|
package mtprotoedge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -366,6 +368,99 @@ func TestKeyExchangeRejectsWrongNegativeTempDC(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeCompatPQInnerDataTemp(t *testing.T) {
|
||||||
|
want := mt.PQInnerData{
|
||||||
|
Pq: []byte{0x0f},
|
||||||
|
P: []byte{0x03},
|
||||||
|
Q: []byte{0x05},
|
||||||
|
Nonce: bin.Int128{1, 2, 3},
|
||||||
|
ServerNonce: bin.Int128{4, 5, 6},
|
||||||
|
NewNonce: bin.Int256{7, 8, 9},
|
||||||
|
}
|
||||||
|
b := new(bin.Buffer)
|
||||||
|
b.PutID(pqInnerDataTempTypeID)
|
||||||
|
if err := want.EncodeBare(b); err != nil {
|
||||||
|
t.Fatalf("encode bare: %v", err)
|
||||||
|
}
|
||||||
|
b.PutInt(86400)
|
||||||
|
|
||||||
|
got, generated, err := decodeCompatPQInnerData(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if generated != nil {
|
||||||
|
t.Fatalf("generated class = %T, want nil for iOS temp compatibility type", generated)
|
||||||
|
}
|
||||||
|
if !got.Temp || got.ExpiresIn != 86400 {
|
||||||
|
t.Fatalf("temp metadata = (%v, %d), want (true, 86400)", got.Temp, got.ExpiresIn)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got.Data.Pq, want.Pq) || !bytes.Equal(got.Data.P, want.P) || !bytes.Equal(got.Data.Q, want.Q) ||
|
||||||
|
got.Data.Nonce != want.Nonce || got.Data.ServerNonce != want.ServerNonce || got.Data.NewNonce != want.NewNonce {
|
||||||
|
t.Fatalf("decoded data = %+v, want %+v", got.Data, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCompatPQInnerDataTempRejectsTruncatedData(t *testing.T) {
|
||||||
|
b := new(bin.Buffer)
|
||||||
|
b.PutID(pqInnerDataTempTypeID)
|
||||||
|
b.PutBytes([]byte{0x0f})
|
||||||
|
if _, _, err := decodeCompatPQInnerData(b); err == nil {
|
||||||
|
t.Fatal("truncated p_q_inner_data_temp decoded successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePQInnerDataInvariants(t *testing.T) {
|
||||||
|
nonce := bin.Int128{1}
|
||||||
|
serverNonce := bin.Int128{2}
|
||||||
|
pq := big.NewInt(15)
|
||||||
|
valid := compatPQInnerData{
|
||||||
|
Data: mt.PQInnerData{
|
||||||
|
Pq: pq.Bytes(),
|
||||||
|
P: []byte{3},
|
||||||
|
Q: []byte{5},
|
||||||
|
Nonce: nonce,
|
||||||
|
ServerNonce: serverNonce,
|
||||||
|
},
|
||||||
|
Temp: true,
|
||||||
|
ExpiresIn: 86400,
|
||||||
|
}
|
||||||
|
req := compatReqPQ{Nonce: nonce}
|
||||||
|
dh := mt.ReqDHParamsRequest{P: []byte{3}, Q: []byte{5}}
|
||||||
|
if err := validatePQInnerData(valid, req, dh, serverNonce, pq); err != nil {
|
||||||
|
t.Fatalf("valid inner data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*compatPQInnerData)
|
||||||
|
}{
|
||||||
|
{name: "nonce", mutate: func(d *compatPQInnerData) { d.Data.Nonce = bin.Int128{9} }},
|
||||||
|
{name: "server nonce", mutate: func(d *compatPQInnerData) { d.Data.ServerNonce = bin.Int128{9} }},
|
||||||
|
{name: "pq", mutate: func(d *compatPQInnerData) { d.Data.Pq = []byte{21} }},
|
||||||
|
{name: "outer factors", mutate: func(d *compatPQInnerData) { d.Data.P = []byte{5} }},
|
||||||
|
{name: "factor product", mutate: func(d *compatPQInnerData) { d.Data.P = []byte{2}; dh.P = []byte{2} }},
|
||||||
|
{name: "expiry", mutate: func(d *compatPQInnerData) { d.ExpiresIn = 0 }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
candidate := valid
|
||||||
|
candidate.Data.Pq = bytes.Clone(valid.Data.Pq)
|
||||||
|
candidate.Data.P = bytes.Clone(valid.Data.P)
|
||||||
|
candidate.Data.Q = bytes.Clone(valid.Data.Q)
|
||||||
|
localDH := dh
|
||||||
|
if tt.name == "factor product" {
|
||||||
|
candidate.Data.P = []byte{2}
|
||||||
|
localDH.P = []byte{2}
|
||||||
|
} else {
|
||||||
|
tt.mutate(&candidate)
|
||||||
|
}
|
||||||
|
if err := validatePQInnerData(candidate, req, localDH, serverNonce, pq); err == nil {
|
||||||
|
t.Fatal("invalid inner data validated successfully")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) {
|
func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) {
|
||||||
const dc = 2
|
const dc = 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
ioscompat "telesrv/internal/compat/ios"
|
||||||
"telesrv/internal/compat/tdesktop"
|
"telesrv/internal/compat/tdesktop"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -19,6 +20,12 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) {
|
||||||
d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) {
|
d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
})
|
})
|
||||||
|
d.OnAccountUpdateDeviceLocked(func(ctx context.Context, period int) (bool, error) {
|
||||||
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||||
|
return false, internalErr()
|
||||||
|
}
|
||||||
|
return ioscompat.DeviceLockedUpdated(), nil
|
||||||
|
})
|
||||||
d.OnAccountSendChangePhoneCode(r.onAccountSendChangePhoneCode)
|
d.OnAccountSendChangePhoneCode(r.onAccountSendChangePhoneCode)
|
||||||
d.OnAccountChangePhone(r.onAccountChangePhone)
|
d.OnAccountChangePhone(r.onAccountChangePhone)
|
||||||
d.OnAccountCheckUsername(r.onAccountCheckUsername)
|
d.OnAccountCheckUsername(r.onAccountCheckUsername)
|
||||||
|
|
|
||||||
|
|
@ -916,6 +916,11 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
|
||||||
t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0])
|
t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0])
|
||||||
}
|
}
|
||||||
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
|
||||||
|
if _, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
|
||||||
|
Peer: forumPeer,
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "TOPICS_EMPTY") {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID empty topics err = %v, want TOPICS_EMPTY", err)
|
||||||
|
}
|
||||||
forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{
|
forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{
|
||||||
Peer: forumPeer,
|
Peer: forumPeer,
|
||||||
Limit: 10,
|
Limit: 10,
|
||||||
|
|
@ -1001,15 +1006,25 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
|
||||||
if !foundCreated {
|
if !foundCreated {
|
||||||
t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID)
|
t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID)
|
||||||
}
|
}
|
||||||
|
missingTopicID := topicID + 1000
|
||||||
forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
|
forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
|
||||||
Peer: forumPeer,
|
Peer: forumPeer,
|
||||||
Topics: []int{forumGeneralTopicID, topicID},
|
Topics: []int{topicID, missingTopicID, forumGeneralTopicID, topicID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("messages.getForumTopicsByID with created topic: %v", err)
|
t.Fatalf("messages.getForumTopicsByID with created topic: %v", err)
|
||||||
}
|
}
|
||||||
if forumTopicsByID.Count != 2 || len(forumTopicsByID.Topics) != 2 || len(forumTopicsByID.Messages) == 0 {
|
if forumTopicsByID.Count != 3 || len(forumTopicsByID.Topics) != 3 || len(forumTopicsByID.Messages) == 0 {
|
||||||
t.Fatalf("messages.getForumTopicsByID with created topic = %+v, want General + created topic", forumTopicsByID)
|
t.Fatalf("messages.getForumTopicsByID with created/missing/duplicate topics = %+v, want three unique results", forumTopicsByID)
|
||||||
|
}
|
||||||
|
if topic, ok := forumTopicsByID.Topics[0].(*tg.ForumTopic); !ok || topic.ID != topicID {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID result[0] = %T %+v, want live topic %d", forumTopicsByID.Topics[0], forumTopicsByID.Topics[0], topicID)
|
||||||
|
}
|
||||||
|
if topic, ok := forumTopicsByID.Topics[1].(*tg.ForumTopicDeleted); !ok || topic.ID != missingTopicID {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID result[1] = %T %+v, want deleted placeholder %d", forumTopicsByID.Topics[1], forumTopicsByID.Topics[1], missingTopicID)
|
||||||
|
}
|
||||||
|
if topic, ok := forumTopicsByID.Topics[2].(*tg.ForumTopic); !ok || topic.ID != forumGeneralTopicID {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID result[2] = %T %+v, want General", forumTopicsByID.Topics[2], forumTopicsByID.Topics[2])
|
||||||
}
|
}
|
||||||
topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0}
|
topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0}
|
||||||
topicReply.SetTopMsgID(topicID)
|
topicReply.SetTopMsgID(topicID)
|
||||||
|
|
@ -1217,6 +1232,19 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
|
||||||
if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 {
|
if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 {
|
||||||
t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete)
|
t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete)
|
||||||
}
|
}
|
||||||
|
deletedByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
|
||||||
|
Peer: forumPeer,
|
||||||
|
Topics: []int{topicID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID after delete topic: %v", err)
|
||||||
|
}
|
||||||
|
if deletedByID.Count != 1 || len(deletedByID.Topics) != 1 {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID after delete = %+v, want one deleted placeholder", deletedByID)
|
||||||
|
}
|
||||||
|
if topic, ok := deletedByID.Topics[0].(*tg.ForumTopicDeleted); !ok || topic.ID != topicID {
|
||||||
|
t.Fatalf("messages.getForumTopicsByID after delete result = %T %+v, want deleted topic %d", deletedByID.Topics[0], deletedByID.Topics[0], topicID)
|
||||||
|
}
|
||||||
antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true})
|
antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("toggle antispam: %v", err)
|
t.Fatalf("toggle antispam: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,13 @@ var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`)
|
||||||
type ClientType string
|
type ClientType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ClientTypeUnknown ClientType = "unknown"
|
ClientTypeUnknown ClientType = "unknown"
|
||||||
ClientTypeTDesktop ClientType = "tdesktop"
|
ClientTypeTDesktop ClientType = "tdesktop"
|
||||||
ClientTypeAndroid ClientType = "android"
|
ClientTypeAndroid ClientType = "android"
|
||||||
|
ClientTypeIOS ClientType = "ios"
|
||||||
|
ClientTypeMacOS ClientType = "macos"
|
||||||
|
ClientTypeTWeb ClientType = "tweb"
|
||||||
|
ClientTypeTelegramTT ClientType = "telegram-tt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientInfo 是 initConnection 携带的客户端信息。
|
// ClientInfo 是 initConnection 携带的客户端信息。
|
||||||
|
|
@ -53,6 +57,10 @@ type ClientInfo struct {
|
||||||
LangPack string
|
LangPack string
|
||||||
LangCode string
|
LangCode string
|
||||||
Type ClientType
|
Type ClientType
|
||||||
|
// typeResolved distinguishes current-connection classification from raw
|
||||||
|
// metadata restored from storage. A persisted unknown remains unknown until
|
||||||
|
// a fresh initConnection supplies authoritative wire evidence.
|
||||||
|
typeResolved bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。
|
// WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。
|
||||||
|
|
@ -88,22 +96,32 @@ func ClientTypeFrom(ctx context.Context) ClientType {
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeClientInfo(info ClientInfo) ClientInfo {
|
func normalizeClientInfo(info ClientInfo) ClientInfo {
|
||||||
if !knownClientType(info.Type) {
|
info.Type = detectClientType(info)
|
||||||
info.Type = detectClientType(info)
|
info.typeResolved = true
|
||||||
}
|
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
func (info ClientInfo) ClientType() ClientType {
|
func (info ClientInfo) ClientType() ClientType {
|
||||||
if knownClientType(info.Type) {
|
if info.typeResolved {
|
||||||
return info.Type
|
if knownClientType(info.Type) {
|
||||||
|
return info.Type
|
||||||
|
}
|
||||||
|
return ClientTypeUnknown
|
||||||
}
|
}
|
||||||
return detectClientType(info)
|
return detectClientType(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreClientInfo(info ClientInfo) ClientInfo {
|
||||||
|
if !knownClientType(info.Type) {
|
||||||
|
info.Type = ClientTypeUnknown
|
||||||
|
}
|
||||||
|
info.typeResolved = true
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
func knownClientType(t ClientType) bool {
|
func knownClientType(t ClientType) bool {
|
||||||
switch t {
|
switch t {
|
||||||
case ClientTypeTDesktop, ClientTypeAndroid:
|
case ClientTypeTDesktop, ClientTypeAndroid, ClientTypeIOS, ClientTypeMacOS, ClientTypeTWeb, ClientTypeTelegramTT:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
@ -118,25 +136,55 @@ func clientTypeFromAPIID(apiID int) ClientType {
|
||||||
return ClientTypeAndroid
|
return ClientTypeAndroid
|
||||||
case 2040, 17349, 611335:
|
case 2040, 17349, 611335:
|
||||||
return ClientTypeTDesktop
|
return ClientTypeTDesktop
|
||||||
|
case 8:
|
||||||
|
return ClientTypeIOS
|
||||||
|
case 2496, 1025907:
|
||||||
|
return ClientTypeTWeb
|
||||||
default:
|
default:
|
||||||
return ClientTypeUnknown
|
return ClientTypeUnknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectClientType(info ClientInfo) ClientType {
|
func detectClientType(info ClientInfo) ClientType {
|
||||||
if t := clientTypeFromAPIID(info.APIID); t != ClientTypeUnknown {
|
// Wire evidence wins over stored/explicit type and API id. In particular,
|
||||||
|
// local TWeb builds may reuse api_id=2040 (the official TDesktop id), while
|
||||||
|
// lang_pack=webk and a browser UA unambiguously identify the web client.
|
||||||
|
if t := clientTypeFromStrongEvidence(info); t != ClientTypeUnknown {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
if strings.EqualFold(info.LangPack, string(ClientTypeAndroid)) {
|
if knownClientType(info.Type) {
|
||||||
return ClientTypeAndroid
|
return info.Type
|
||||||
}
|
}
|
||||||
if strings.EqualFold(info.LangPack, string(ClientTypeTDesktop)) {
|
return clientTypeFromAPIID(info.APIID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientTypeFromStrongEvidence(info ClientInfo) ClientType {
|
||||||
|
langPack := strings.ToLower(strings.TrimSpace(info.LangPack))
|
||||||
|
switch langPack {
|
||||||
|
case "weba":
|
||||||
|
return ClientTypeTelegramTT
|
||||||
|
case "web", "webk":
|
||||||
|
return ClientTypeTWeb
|
||||||
|
case string(ClientTypeAndroid):
|
||||||
|
return ClientTypeAndroid
|
||||||
|
case string(ClientTypeIOS):
|
||||||
|
return ClientTypeIOS
|
||||||
|
case string(ClientTypeMacOS):
|
||||||
|
return ClientTypeMacOS
|
||||||
|
case string(ClientTypeTDesktop):
|
||||||
return ClientTypeTDesktop
|
return ClientTypeTDesktop
|
||||||
}
|
}
|
||||||
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
||||||
switch {
|
switch {
|
||||||
|
case strings.Contains(client, "mozilla/"), strings.Contains(client, "applewebkit/"),
|
||||||
|
strings.Contains(client, "telegram web"), strings.Contains(client, "webogram"):
|
||||||
|
return ClientTypeTWeb
|
||||||
case strings.Contains(client, "android"), androidSDKVersionRE.MatchString(client):
|
case strings.Contains(client, "android"), androidSDKVersionRE.MatchString(client):
|
||||||
return ClientTypeAndroid
|
return ClientTypeAndroid
|
||||||
|
case strings.Contains(client, "iphone"), strings.Contains(client, "ipad"),
|
||||||
|
strings.Contains(client, "ipod"), strings.Contains(client, "ipados"),
|
||||||
|
strings.Contains(client, "ios "):
|
||||||
|
return ClientTypeIOS
|
||||||
case strings.Contains(client, "tdesktop"), strings.Contains(client, "desktop"):
|
case strings.Contains(client, "tdesktop"), strings.Contains(client, "desktop"):
|
||||||
return ClientTypeTDesktop
|
return ClientTypeTDesktop
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@ func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSI
|
||||||
|
|
||||||
func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") }
|
func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") }
|
||||||
func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") }
|
func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") }
|
||||||
|
func topicsEmptyErr() error { return tgerr.New(400, "TOPICS_EMPTY") }
|
||||||
|
|
||||||
// randomIDEmptyErr 表示发送消息缺少 random_id。
|
// randomIDEmptyErr 表示发送消息缺少 random_id。
|
||||||
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }
|
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
androidcompat "telesrv/internal/compat/android"
|
androidcompat "telesrv/internal/compat/android"
|
||||||
|
ioscompat "telesrv/internal/compat/ios"
|
||||||
"telesrv/internal/compat/tdesktop"
|
"telesrv/internal/compat/tdesktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,6 +22,12 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) {
|
||||||
d.OnHelpGetInviteText(func(ctx context.Context) (*tg.HelpInviteText, error) {
|
d.OnHelpGetInviteText(func(ctx context.Context) (*tg.HelpInviteText, error) {
|
||||||
return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil
|
return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil
|
||||||
})
|
})
|
||||||
|
d.OnHelpGetAppUpdate(func(ctx context.Context, source string) (tg.HelpAppUpdateClass, error) {
|
||||||
|
if _, _, err := r.currentUserID(ctx); err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
return ioscompat.NoAppUpdate(), nil
|
||||||
|
})
|
||||||
d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) {
|
d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) {
|
||||||
if r.deps.Help == nil {
|
if r.deps.Help == nil {
|
||||||
return tdesktop.AppConfig(hash), nil
|
return tdesktop.AppConfig(hash), nil
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,14 @@ func langPackFromClient(ctx context.Context) string {
|
||||||
return string(ClientTypeAndroid)
|
return string(ClientTypeAndroid)
|
||||||
case ClientTypeTDesktop:
|
case ClientTypeTDesktop:
|
||||||
return string(ClientTypeTDesktop)
|
return string(ClientTypeTDesktop)
|
||||||
|
case ClientTypeIOS:
|
||||||
|
return string(ClientTypeIOS)
|
||||||
|
case ClientTypeMacOS:
|
||||||
|
return string(ClientTypeMacOS)
|
||||||
|
case ClientTypeTWeb:
|
||||||
|
return "webk"
|
||||||
|
case ClientTypeTelegramTT:
|
||||||
|
return "weba"
|
||||||
}
|
}
|
||||||
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
|
||||||
if strings.Contains(client, "android") {
|
if strings.Contains(client, "android") {
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,9 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
|
if len(req.Topics) == 0 {
|
||||||
|
return nil, topicsEmptyErr()
|
||||||
|
}
|
||||||
if len(req.Topics) > maxForumTopicIDs {
|
if len(req.Topics) > maxForumTopicIDs {
|
||||||
return nil, limitInvalidErr()
|
return nil, limitInvalidErr()
|
||||||
}
|
}
|
||||||
|
|
@ -332,11 +335,18 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
|
||||||
return nil, channelForumMissingErr()
|
return nil, channelForumMissingErr()
|
||||||
}
|
}
|
||||||
includeGeneral := false
|
includeGeneral := false
|
||||||
|
requestedIDs := make([]int, 0, len(req.Topics))
|
||||||
ids := make([]int, 0, len(req.Topics))
|
ids := make([]int, 0, len(req.Topics))
|
||||||
|
seen := make(map[int]struct{}, len(req.Topics))
|
||||||
for _, topicID := range req.Topics {
|
for _, topicID := range req.Topics {
|
||||||
if topicID <= 0 || topicID > domain.MaxMessageBoxID {
|
if topicID <= 0 || topicID > domain.MaxMessageBoxID {
|
||||||
return nil, messageIDInvalidErr()
|
return nil, messageIDInvalidErr()
|
||||||
}
|
}
|
||||||
|
if _, ok := seen[topicID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[topicID] = struct{}{}
|
||||||
|
requestedIDs = append(requestedIDs, topicID)
|
||||||
if topicID == forumGeneralTopicID {
|
if topicID == forumGeneralTopicID {
|
||||||
includeGeneral = true
|
includeGeneral = true
|
||||||
continue
|
continue
|
||||||
|
|
@ -350,7 +360,34 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
|
||||||
return nil, forumTopicError(err)
|
return nil, forumTopicError(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil
|
return r.forumTopicsByIDResponse(ctx, userID, view, list, includeGeneral, requestedIDs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forumTopicsByIDResponse keeps the request's unique ID order and returns one
|
||||||
|
// constructor for every requested topic. Telegram clients use
|
||||||
|
// forumTopicDeleted as a positive deletion/missing confirmation; silently
|
||||||
|
// omitting an ID leaves their local thread state stale and causes repeat reads.
|
||||||
|
func (r *Router) forumTopicsByIDResponse(ctx context.Context, userID int64, view domain.ChannelView, list domain.ChannelForumTopicList, includeGeneral bool, requestedIDs []int) *tg.MessagesForumTopics {
|
||||||
|
response := r.forumTopicsResponse(ctx, userID, view, list, includeGeneral)
|
||||||
|
live := make(map[int]tg.ForumTopicClass, len(response.Topics))
|
||||||
|
for _, topic := range response.Topics {
|
||||||
|
switch topic := topic.(type) {
|
||||||
|
case *tg.ForumTopic:
|
||||||
|
live[topic.ID] = topic
|
||||||
|
case *tg.ForumTopicDeleted:
|
||||||
|
live[topic.ID] = topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Topics = make([]tg.ForumTopicClass, 0, len(requestedIDs))
|
||||||
|
for _, topicID := range requestedIDs {
|
||||||
|
if topic, ok := live[topicID]; ok {
|
||||||
|
response.Topics = append(response.Topics, topic)
|
||||||
|
} else {
|
||||||
|
response.Topics = append(response.Topics, &tg.ForumTopicDeleted{ID: topicID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Count = len(response.Topics)
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) {
|
func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) {
|
||||||
|
|
|
||||||
57
internal/rpc/messages_recent_locations.go
Normal file
57
internal/rpc/messages_recent_locations.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
|
||||||
|
"telesrv/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// onMessagesGetRecentLocations returns the peer's newest live-location
|
||||||
|
// messages through the existing media seek indexes. Expired/stopped items are
|
||||||
|
// deliberately retained: iOS tags every messageMediaGeoLive in its local
|
||||||
|
// history and applies the active-period check in PeerLiveLocationsContext.
|
||||||
|
func (r *Router) onMessagesGetRecentLocations(ctx context.Context, req *tg.MessagesGetRecentLocationsRequest) (tg.MessagesMessagesClass, error) {
|
||||||
|
if req == nil || req.Limit < 0 || req.Limit > maxSearchResultsLimit {
|
||||||
|
return nil, limitInvalidErr()
|
||||||
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input, ok := req.Peer.(*tg.InputPeerUser); ok && input != nil {
|
||||||
|
if err := r.validateInputUser(ctx, &tg.InputUser{UserID: input.UserID, AccessHash: input.AccessHash}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search := domain.MediaSearchRequest{
|
||||||
|
Categories: []domain.MediaCategory{domain.MediaCategoryGeoLive},
|
||||||
|
Limit: req.Limit,
|
||||||
|
}
|
||||||
|
if peer.Type == domain.PeerTypeChannel {
|
||||||
|
if r.deps.Channels == nil {
|
||||||
|
return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
|
||||||
|
}
|
||||||
|
history, err := r.deps.Channels.SearchChannelMedia(ctx, userID, peer.ID, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, channelInvalidErr(err)
|
||||||
|
}
|
||||||
|
history = r.enrichChannelHistory(ctx, userID, history)
|
||||||
|
r.trackChannelInterest(ctx, userID, peer.ID)
|
||||||
|
return r.tgChannelHistoryMessages(ctx, userID, history), nil
|
||||||
|
}
|
||||||
|
r.clearChannelInterest(ctx, userID)
|
||||||
|
if r.deps.Messages == nil {
|
||||||
|
return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
|
||||||
|
}
|
||||||
|
list, err := r.deps.Messages.SearchPrivateMedia(ctx, userID, peer.ID, search)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
|
||||||
|
}
|
||||||
61
internal/rpc/messages_recent_locations_test.go
Normal file
61
internal/rpc/messages_recent_locations_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMessagesGetRecentLocationsReturnsOnlyGeoLive(t *testing.T) {
|
||||||
|
r, owner, friend := newMediaTestRouter(t)
|
||||||
|
ctx := WithUserID(context.Background(), owner.ID)
|
||||||
|
peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
|
||||||
|
|
||||||
|
if _, err := r.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
|
||||||
|
Peer: peer, Message: "not a location", RandomID: 73001,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("send text: %v", err)
|
||||||
|
}
|
||||||
|
want := sendTestLiveLocation(t, r, owner.ID, peer, 73002, 900)
|
||||||
|
|
||||||
|
result, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{Peer: peer, Limit: 20})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getRecentLocations: %v", err)
|
||||||
|
}
|
||||||
|
var messages []tg.MessageClass
|
||||||
|
switch value := result.(type) {
|
||||||
|
case *tg.MessagesMessages:
|
||||||
|
messages = value.Messages
|
||||||
|
case *tg.MessagesMessagesSlice:
|
||||||
|
messages = value.Messages
|
||||||
|
default:
|
||||||
|
t.Fatalf("getRecentLocations = %T", result)
|
||||||
|
}
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("recent location messages = %d, want 1", len(messages))
|
||||||
|
}
|
||||||
|
got, ok := messages[0].(*tg.Message)
|
||||||
|
if !ok || got.ID != want.ID {
|
||||||
|
t.Fatalf("recent location = %#v, want id %d", messages[0], want.ID)
|
||||||
|
}
|
||||||
|
if _, ok := got.Media.(*tg.MessageMediaGeoLive); !ok {
|
||||||
|
t.Fatalf("recent location media = %T, want MessageMediaGeoLive", got.Media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesGetRecentLocationsValidatesLimitAndAccessHash(t *testing.T) {
|
||||||
|
r, owner, friend := newMediaTestRouter(t)
|
||||||
|
ctx := WithUserID(context.Background(), owner.ID)
|
||||||
|
if _, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{
|
||||||
|
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Limit: maxSearchResultsLimit + 1,
|
||||||
|
}); err == nil || !tgerr.Is(err, "LIMIT_INVALID") {
|
||||||
|
t.Fatalf("oversized limit err = %v, want LIMIT_INVALID", err)
|
||||||
|
}
|
||||||
|
if _, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{
|
||||||
|
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, Limit: 20,
|
||||||
|
}); err == nil || !tgerr.Is(err, "USER_ID_INVALID") {
|
||||||
|
t.Fatalf("bad access hash err = %v, want USER_ID_INVALID", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -294,6 +294,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
|
||||||
}
|
}
|
||||||
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
|
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
|
||||||
})
|
})
|
||||||
|
d.OnMessagesGetRecentLocations(r.onMessagesGetRecentLocations)
|
||||||
d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) {
|
d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) {
|
||||||
id, _ := AuthKeyIDFrom(ctx)
|
id, _ := AuthKeyIDFrom(ctx)
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
|
|
||||||
|
|
@ -716,9 +716,15 @@ func (r *Router) rememberClientAPIID(ctx context.Context, apiID int) {
|
||||||
if apiID == 0 {
|
if apiID == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
info := ClientInfo{APIID: apiID, Type: clientTypeFromAPIID(apiID)}
|
info := normalizeClientInfo(ClientInfo{APIID: apiID, Type: clientTypeFromAPIID(apiID)})
|
||||||
sessionInfo := clientSessionInfo{clientInfo: info, hasClientInfo: true}
|
sessionInfo := clientSessionInfo{clientInfo: info, hasClientInfo: true}
|
||||||
r.rememberClientSessionInfo(ctx, sessionInfo)
|
r.rememberClientSessionInfo(ctx, sessionInfo)
|
||||||
|
// api_id is weak evidence. If initConnection (or durable restoration) has
|
||||||
|
// already supplied stronger client metadata, persist that effective session
|
||||||
|
// fact instead of letting auth.sendCode overwrite it on an id collision.
|
||||||
|
if effective, ok, _ := r.clientSessionInfo(ctx); ok {
|
||||||
|
sessionInfo = effective
|
||||||
|
}
|
||||||
r.persistAuthKeyClientInfo(ctx, sessionInfo)
|
r.persistAuthKeyClientInfo(ctx, sessionInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1103,7 +1109,7 @@ func clientSessionInfoFromAuthKeyClientInfo(item domain.AuthKeyClientInfo, curre
|
||||||
Type: ClientType(item.Platform),
|
Type: ClientType(item.Platform),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
info.clientInfo = normalizeClientInfo(info.clientInfo)
|
info.clientInfo = restoreClientInfo(info.clientInfo)
|
||||||
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
|
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
|
||||||
info.clientInfo.DeviceModel != "" ||
|
info.clientInfo.DeviceModel != "" ||
|
||||||
info.clientInfo.SystemVersion != "" ||
|
info.clientInfo.SystemVersion != "" ||
|
||||||
|
|
@ -1162,7 +1168,7 @@ func clientSessionInfoFromAuthorizationRecord(item domain.Authorization, current
|
||||||
Type: ClientType(item.Platform),
|
Type: ClientType(item.Platform),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
info.clientInfo = normalizeClientInfo(info.clientInfo)
|
info.clientInfo = restoreClientInfo(info.clientInfo)
|
||||||
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
|
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
|
||||||
info.clientInfo.DeviceModel != "" ||
|
info.clientInfo.DeviceModel != "" ||
|
||||||
info.clientInfo.SystemVersion != "" ||
|
info.clientInfo.SystemVersion != "" ||
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,52 @@ func TestDispatchPersistsPreLoginClientMetadataFromSendCodeAPIID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendCodeAPIIDDoesNotOverwriteStrongTWebIdentity(t *testing.T) {
|
||||||
|
auth := &captureAuthService{}
|
||||||
|
rawAuthKeyID := [8]byte{0x34, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
|
||||||
|
const sessionID = int64(8103956954238395545)
|
||||||
|
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
initReq := &tg.InvokeWithLayerRequest{
|
||||||
|
Layer: currentClientLayer,
|
||||||
|
Query: &tg.InitConnectionRequest{
|
||||||
|
APIID: 2040,
|
||||||
|
DeviceModel: "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36",
|
||||||
|
SystemVersion: "Win32",
|
||||||
|
AppVersion: "2.2",
|
||||||
|
SystemLangCode: "en-US",
|
||||||
|
LangPack: "webk",
|
||||||
|
LangCode: "en",
|
||||||
|
Query: &tg.HelpGetConfigRequest{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var initBuf bin.Buffer
|
||||||
|
if err := initReq.Encode(&initBuf); err != nil {
|
||||||
|
t.Fatalf("encode init request: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &initBuf); err != nil {
|
||||||
|
t.Fatalf("dispatch init request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendCode bin.Buffer
|
||||||
|
if err := (&tg.AuthSendCodeRequest{
|
||||||
|
PhoneNumber: "+8618800000021",
|
||||||
|
APIID: 2040,
|
||||||
|
APIHash: "tweb-local",
|
||||||
|
Settings: tg.CodeSettings{},
|
||||||
|
}).Encode(&sendCode); err != nil {
|
||||||
|
t.Fatalf("encode auth.sendCode: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &sendCode); err != nil {
|
||||||
|
t.Fatalf("dispatch auth.sendCode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := auth.authKeyClientInfos[rawAuthKeyID]
|
||||||
|
if persisted.Platform != string(ClientTypeTWeb) || persisted.DeviceModel != initReq.Query.(*tg.InitConnectionRequest).DeviceModel {
|
||||||
|
t.Fatalf("API id fallback overwrote strong TWeb identity: %+v", persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDispatchRestoresPreLoginAndroidMetadataFromAuthKey(t *testing.T) {
|
func TestDispatchRestoresPreLoginAndroidMetadataFromAuthKey(t *testing.T) {
|
||||||
core, logs := observer.New(zap.DebugLevel)
|
core, logs := observer.New(zap.DebugLevel)
|
||||||
authKeyID := [8]byte{0x22, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
|
authKeyID := [8]byte{0x22, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
|
||||||
|
|
@ -460,33 +506,96 @@ func TestInvokeWithLayerPersistsClientLayerUpgrade(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientTypeDetectsAndroidSDKVersion(t *testing.T) {
|
func TestClientTypeDetectionUsesStrongEvidenceBeforeAPIID(t *testing.T) {
|
||||||
info := normalizeClientInfo(ClientInfo{
|
tests := []struct {
|
||||||
DeviceModel: "GooglePixel 9a",
|
name string
|
||||||
SystemVersion: "SDK 36",
|
info ClientInfo
|
||||||
AppVersion: "12.7.3 (67509) pbeta",
|
want ClientType
|
||||||
})
|
}{
|
||||||
if got := info.ClientType(); got != ClientTypeAndroid {
|
{
|
||||||
t.Fatalf("client type = %s, want %s", got, ClientTypeAndroid)
|
name: "iOS 12.8 simulator",
|
||||||
|
info: ClientInfo{APIID: 1, DeviceModel: "iPhone Simulator", SystemVersion: "26.5", AppVersion: "12.8 (10000)", LangPack: "ios"},
|
||||||
|
want: ClientTypeIOS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "restored iOS without lang pack",
|
||||||
|
info: ClientInfo{DeviceModel: "iPhone 16 Pro", SystemVersion: "18.5", Type: ClientTypeUnknown},
|
||||||
|
want: ClientTypeIOS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TWeb WebK",
|
||||||
|
info: ClientInfo{APIID: 1025907, DeviceModel: "Mozilla/5.0 Chrome/138.0", SystemVersion: "Win32", LangPack: "webk"},
|
||||||
|
want: ClientTypeTWeb,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "telegram-tt WebA",
|
||||||
|
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 Chrome/150.0", SystemVersion: "Windows", AppVersion: "12.0.32 A", LangPack: "weba"},
|
||||||
|
want: ClientTypeTelegramTT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TWeb borrowed TDesktop API id",
|
||||||
|
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 AppleWebKit/537.36", SystemVersion: "Win32", Type: ClientTypeTDesktop},
|
||||||
|
want: ClientTypeTWeb,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile TWeb is not native Android",
|
||||||
|
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36", LangPack: "webk"},
|
||||||
|
want: ClientTypeTWeb,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Android SDK version",
|
||||||
|
info: ClientInfo{DeviceModel: "GooglePixel 9a", SystemVersion: "SDK 36", AppVersion: "12.7.3 (67509) pbeta"},
|
||||||
|
want: ClientTypeAndroid,
|
||||||
|
},
|
||||||
|
{name: "DrKLO API fallback", info: ClientInfo{APIID: 4}, want: ClientTypeAndroid},
|
||||||
|
{name: "TDesktop API fallback", info: ClientInfo{APIID: 2040}, want: ClientTypeTDesktop},
|
||||||
|
{name: "official iOS API fallback", info: ClientInfo{APIID: 8}, want: ClientTypeIOS},
|
||||||
|
{name: "official TWeb API fallback", info: ClientInfo{APIID: 2496}, want: ClientTypeTWeb},
|
||||||
|
{name: "macOS lang pack", info: ClientInfo{LangPack: "macos"}, want: ClientTypeMacOS},
|
||||||
|
{name: "stored known type", info: ClientInfo{Type: ClientTypeIOS}, want: ClientTypeIOS},
|
||||||
|
{
|
||||||
|
name: "gotd remains unknown",
|
||||||
|
info: ClientInfo{DeviceModel: "go1.26.2", SystemVersion: "windows", AppVersion: "v0.144.0"},
|
||||||
|
want: ClientTypeUnknown,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := normalizeClientInfo(tt.info).ClientType(); got != tt.want {
|
||||||
|
t.Fatalf("client type = %s, want %s", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info = normalizeClientInfo(ClientInfo{
|
func TestRestoredUnknownClientInfoIsNotReclassifiedFromHistoricalFields(t *testing.T) {
|
||||||
DeviceModel: "go1.26.2",
|
info := restoreClientInfo(ClientInfo{
|
||||||
SystemVersion: "windows",
|
APIID: 2040,
|
||||||
AppVersion: "v0.144.0",
|
DeviceModel: "Mozilla/5.0 AppleWebKit/537.36",
|
||||||
|
SystemVersion: "Windows",
|
||||||
|
AppVersion: "12.0.32 A",
|
||||||
|
Type: ClientTypeUnknown,
|
||||||
})
|
})
|
||||||
if got := info.ClientType(); got != ClientTypeUnknown {
|
if got := info.ClientType(); got != ClientTypeUnknown {
|
||||||
t.Fatalf("gotd test client type = %s, want %s", got, ClientTypeUnknown)
|
t.Fatalf("restored historical client type = %s, want unknown until fresh initConnection", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
info = normalizeClientInfo(ClientInfo{APIID: 4})
|
info = restoreClientInfo(ClientInfo{
|
||||||
if got := info.ClientType(); got != ClientTypeAndroid {
|
DeviceModel: "GooglePixel 9a",
|
||||||
t.Fatalf("DrKLO api_id=4 client type = %s, want %s", got, ClientTypeAndroid)
|
SystemVersion: "SDK 36",
|
||||||
|
Type: ClientTypeUnknown,
|
||||||
|
})
|
||||||
|
if got := info.ClientType(); got != ClientTypeUnknown {
|
||||||
|
t.Fatalf("restored historical Android client type = %s, want unknown until fresh initConnection", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
info = normalizeClientInfo(ClientInfo{APIID: 2040})
|
info = restoreClientInfo(ClientInfo{
|
||||||
if got := info.ClientType(); got != ClientTypeTDesktop {
|
DeviceModel: "Mozilla/5.0 AppleWebKit/537.36",
|
||||||
t.Fatalf("TDesktop api_id=2040 client type = %s, want %s", got, ClientTypeTDesktop)
|
AppVersion: "12.0.32 A",
|
||||||
|
Type: ClientTypeTelegramTT,
|
||||||
|
})
|
||||||
|
if got := info.ClientType(); got != ClientTypeTelegramTT {
|
||||||
|
t.Fatalf("restored persisted telegram-tt client type = %s, want %s", got, ClientTypeTelegramTT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1092,6 +1201,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
||||||
{name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}},
|
{name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}},
|
||||||
{name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}},
|
{name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}},
|
||||||
{name: "help.getInviteText", req: &tg.HelpGetInviteTextRequest{}},
|
{name: "help.getInviteText", req: &tg.HelpGetInviteTextRequest{}},
|
||||||
|
{name: "help.getAppUpdate", req: &tg.HelpGetAppUpdateRequest{}},
|
||||||
{name: "auth.initPasskeyLogin", req: &tg.AuthInitPasskeyLoginRequest{APIID: 4, APIHash: "test"}},
|
{name: "auth.initPasskeyLogin", req: &tg.AuthInitPasskeyLoginRequest{APIID: 4, APIHash: "test"}},
|
||||||
{name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}},
|
{name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}},
|
||||||
{name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}},
|
{name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}},
|
||||||
|
|
@ -1124,6 +1234,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
||||||
{name: "account.getSavedRingtones", req: &tg.AccountGetSavedRingtonesRequest{}},
|
{name: "account.getSavedRingtones", req: &tg.AccountGetSavedRingtonesRequest{}},
|
||||||
{name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}},
|
{name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}},
|
||||||
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
|
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
|
||||||
|
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
|
||||||
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
|
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
|
||||||
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
|
||||||
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
|
||||||
|
|
@ -1155,6 +1266,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
|
||||||
{name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
{name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
||||||
{name: "messages.setChatWallPaper", req: &tg.MessagesSetChatWallPaperRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Wallpaper: &tg.InputWallPaperNoFile{ID: 930000000000000000}}},
|
{name: "messages.setChatWallPaper", req: &tg.MessagesSetChatWallPaperRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Wallpaper: &tg.InputWallPaperNoFile{ID: 930000000000000000}}},
|
||||||
{name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}},
|
{name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}},
|
||||||
|
{name: "messages.getRecentLocations", req: &tg.MessagesGetRecentLocationsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}},
|
||||||
{name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
{name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
|
||||||
{name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}},
|
{name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}},
|
||||||
{name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}},
|
{name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package rpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"hash/fnv"
|
||||||
|
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
@ -24,15 +26,16 @@ func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int)
|
||||||
if len(reactions) == 0 {
|
if len(reactions) == 0 {
|
||||||
return tdesktop.AvailableReactions(hash), nil
|
return tdesktop.AvailableReactions(hash), nil
|
||||||
}
|
}
|
||||||
catalogHash := availableReactionsHash(reactions)
|
|
||||||
if hash == catalogHash {
|
|
||||||
return &tg.MessagesAvailableReactionsNotModified{}, nil
|
|
||||||
}
|
|
||||||
docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions))
|
docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
return tgAvailableReactions(reactions, documentsByID(docs), catalogHash), nil
|
docByID := documentsByID(docs)
|
||||||
|
catalogHash := availableReactionsHash(reactions, docByID)
|
||||||
|
if hash == catalogHash {
|
||||||
|
return &tg.MessagesAvailableReactionsNotModified{}, nil
|
||||||
|
}
|
||||||
|
return tgAvailableReactions(reactions, docByID, catalogHash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onMessagesGetAvailableEffects 返回消息发送特效目录(全局静态,seed 进内存)。镜像
|
// onMessagesGetAvailableEffects 返回消息发送特效目录(全局静态,seed 进内存)。镜像
|
||||||
|
|
@ -457,24 +460,34 @@ func documentsByID(docs []domain.Document) map[int64]domain.Document {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// availableReactionsHash 用 reaction 的核心字段算稳定 hash(供 *NotModified 缓存判定)。
|
// availableReactionsHash covers every domain field that contributes to the TL
|
||||||
func availableReactionsHash(reactions []domain.AvailableReaction) int {
|
// response, including the embedded documents. A repaired file reference,
|
||||||
values := make([]int64, 0, len(reactions)*10)
|
// attribute, thumbnail, title, or emoji must invalidate clients which cached an
|
||||||
|
// older response; hashing only document ids leaves those clients permanently on
|
||||||
|
// stale resources after a seed repair.
|
||||||
|
func availableReactionsHash(reactions []domain.AvailableReaction, docByID map[int64]domain.Document) int {
|
||||||
|
h := fnv.New32a()
|
||||||
for _, r := range reactions {
|
for _, r := range reactions {
|
||||||
values = append(values,
|
encoded, _ := json.Marshal(r)
|
||||||
int64(len([]rune(r.Reaction))),
|
_, _ = h.Write(encoded)
|
||||||
boolHashValue(r.Inactive),
|
_, _ = h.Write([]byte{0xff})
|
||||||
boolHashValue(r.Premium),
|
for _, id := range r.DocumentIDs() {
|
||||||
r.StaticIconID,
|
doc, ok := docByID[id]
|
||||||
r.AppearAnimationID,
|
if !ok {
|
||||||
r.SelectAnimationID,
|
// Missing mandatory/optional documents are part of the response as
|
||||||
r.ActivateAnimationID,
|
// documentEmpty{id}; keep that state hashable as well.
|
||||||
r.EffectAnimationID,
|
doc.ID = id
|
||||||
r.AroundAnimationID,
|
}
|
||||||
r.CenterIconID,
|
encoded, _ = json.Marshal(doc)
|
||||||
)
|
_, _ = h.Write(encoded)
|
||||||
|
_, _ = h.Write([]byte{0xfe})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return int(tdesktopCountHash(values) & 0x7fffffff)
|
sum := int(h.Sum32() & 0x7fffffff)
|
||||||
|
if sum == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {
|
func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {
|
||||||
|
|
|
||||||
|
|
@ -565,6 +565,42 @@ func TestMessagesGetAvailableReactionsNotModified(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMessagesGetAvailableReactionsDocumentRepairInvalidatesHash(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
reactions := []domain.AvailableReaction{{
|
||||||
|
Reaction: "❤", Title: "Heart", StaticIconID: 101, AppearAnimationID: 102,
|
||||||
|
SelectAnimationID: 103, ActivateAnimationID: 104, EffectAnimationID: 105,
|
||||||
|
}}
|
||||||
|
docs := map[int64]domain.Document{}
|
||||||
|
for _, id := range reactions[0].DocumentIDs() {
|
||||||
|
docs[id] = domain.Document{ID: id, AccessHash: id + 1000, DCID: 2, MimeType: "application/x-tgsticker", Size: 10}
|
||||||
|
}
|
||||||
|
files := &fakeFiles{reactions: reactions, docs: docs}
|
||||||
|
r := &Router{deps: Deps{Files: files}}
|
||||||
|
|
||||||
|
first, err := r.onMessagesGetAvailableReactions(ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first getAvailableReactions: %v", err)
|
||||||
|
}
|
||||||
|
oldHash := first.(*tg.MessagesAvailableReactions).Hash
|
||||||
|
|
||||||
|
repaired := docs[103]
|
||||||
|
repaired.FileReference = []byte("repaired-reference")
|
||||||
|
repaired.Size = 11
|
||||||
|
docs[103] = repaired
|
||||||
|
second, err := r.onMessagesGetAvailableReactions(ctx, oldHash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getAvailableReactions after repair: %v", err)
|
||||||
|
}
|
||||||
|
full, ok := second.(*tg.MessagesAvailableReactions)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("after document repair = %T, want full response", second)
|
||||||
|
}
|
||||||
|
if full.Hash == oldHash {
|
||||||
|
t.Fatalf("document repair kept hash %d", oldHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) {
|
func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) {
|
||||||
doc := tgDocument(domain.Document{
|
doc := tgDocument(domain.Document{
|
||||||
ID: 100,
|
ID: 100,
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ func (r *Router) registerUpdates(d *tg.ServerDispatcher) {
|
||||||
|
|
||||||
// onUpdatesGetState 处理 updates.getState。TDesktop 与 DrKLO 的启动路径把它当作
|
// onUpdatesGetState 处理 updates.getState。TDesktop 与 DrKLO 的启动路径把它当作
|
||||||
// 「从当前快照开始同步」的显式 baseline:返回账号当前连续水位并推进该设备 observed。
|
// 「从当前快照开始同步」的显式 baseline:返回账号当前连续水位并推进该设备 observed。
|
||||||
// 对无法识别的客户端仍返回同一 current state,但不把尚未被客户端带回的服务端快照
|
// 对尚未审计 baseline 语义的客户端仍返回同一 current state,但不把尚未被客户端带回
|
||||||
// 记成 observed;这保留 durable difference tail,避免把 TDesktop/DrKLO 的兼容例外
|
// 的服务端快照记成 observed;这保留 durable difference tail,避免把 TDesktop/DrKLO
|
||||||
// 扩散成所有客户端都能跨过未实际确认事件的 retention 后门。
|
// 的兼容例外扩散成所有客户端都能跨过未实际确认事件的 retention 后门。
|
||||||
func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) {
|
func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) {
|
||||||
id, _ := AuthKeyIDFrom(ctx)
|
id, _ := AuthKeyIDFrom(ctx)
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
|
@ -35,7 +35,7 @@ func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error
|
||||||
} else {
|
} else {
|
||||||
st, err = r.deps.Updates.CurrentState(ctx, userID)
|
st, err = r.deps.Updates.CurrentState(ctx, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
r.log.Warn("updates.getState returned current snapshot without advancing observed baseline for unknown client",
|
r.log.Warn("updates.getState returned current snapshot without advancing observed baseline for client without audited baseline policy",
|
||||||
r.contextLogFields(ctx)...)
|
r.contextLogFields(ctx)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ type AuthorizationStore interface {
|
||||||
Bind(ctx context.Context, a domain.Authorization) error
|
Bind(ctx context.Context, a domain.Authorization) error
|
||||||
ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
|
ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
|
||||||
UpdateLayer(ctx context.Context, authKeyID [8]byte, layer int) error
|
UpdateLayer(ctx context.Context, authKeyID [8]byte, layer int) error
|
||||||
|
// UpdateClientInfo 合并更新已绑定授权的客户端元数据,使设备列表与 auth key 协商事实一致。
|
||||||
|
UpdateClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error
|
||||||
ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error)
|
ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error)
|
||||||
Delete(ctx context.Context, authKeyID [8]byte) error
|
Delete(ctx context.Context, authKeyID [8]byte) error
|
||||||
DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,34 @@ func (s *AuthorizationStore) UpdateLayer(_ context.Context, id [8]byte, layer in
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
if a, ok := s.m[id]; ok {
|
||||||
|
if info.Layer > 0 {
|
||||||
|
a.Layer = info.Layer
|
||||||
|
}
|
||||||
|
if info.DeviceModel != "" {
|
||||||
|
a.DeviceModel = info.DeviceModel
|
||||||
|
}
|
||||||
|
if info.Platform != "" {
|
||||||
|
a.Platform = info.Platform
|
||||||
|
}
|
||||||
|
if info.SystemVersion != "" {
|
||||||
|
a.SystemVersion = info.SystemVersion
|
||||||
|
}
|
||||||
|
if info.APIID != 0 {
|
||||||
|
a.APIID = info.APIID
|
||||||
|
}
|
||||||
|
if info.AppVersion != "" {
|
||||||
|
a.AppVersion = info.AppVersion
|
||||||
|
}
|
||||||
|
a.ActiveAt = time.Now()
|
||||||
|
s.m[id] = a
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if a, ok := s.m[id]; ok {
|
if a, ok := s.m[id]; ok {
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,25 @@ UPDATE authorizations SET layer = $2, active_at = now() WHERE auth_key_id = $1`,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
|
||||||
|
if _, err := s.db.Exec(ctx, `
|
||||||
|
UPDATE authorizations SET
|
||||||
|
layer = CASE WHEN $2 > 0 THEN $2 ELSE layer END,
|
||||||
|
device_model = CASE WHEN $3 <> '' THEN $3 ELSE device_model END,
|
||||||
|
platform = CASE WHEN $4 <> '' THEN $4 ELSE platform END,
|
||||||
|
system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END,
|
||||||
|
api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END,
|
||||||
|
app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END,
|
||||||
|
active_at = now()
|
||||||
|
WHERE auth_key_id = $1`,
|
||||||
|
authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform,
|
||||||
|
info.SystemVersion, int32(info.APIID), info.AppVersion,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("update authorization client info: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkPasswordPassed 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
// MarkPasswordPassed 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||||||
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
|
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
|
||||||
if _, err := s.db.Exec(ctx, `
|
if _, err := s.db.Exec(ctx, `
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,49 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
|
||||||
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
|
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := createRevokeTestUser(t, ctx, pool, "client-info")
|
||||||
|
id := revokeTestAuthKeyID(0xb1)
|
||||||
|
keys := NewAuthKeyStore(pool)
|
||||||
|
auths := NewAuthorizationStore(pool)
|
||||||
|
saveRevokeTestAuthKey(t, ctx, keys, id)
|
||||||
|
if err := auths.Bind(ctx, domain.Authorization{
|
||||||
|
AuthKeyID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Hash: 9201,
|
||||||
|
Platform: "unknown",
|
||||||
|
DeviceModel: "legacy",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("bind authorization: %v", err)
|
||||||
|
}
|
||||||
|
if err := auths.UpdateClientInfo(ctx, id, domain.AuthKeyClientInfo{
|
||||||
|
Layer: 227,
|
||||||
|
DeviceModel: "iPhone Simulator",
|
||||||
|
Platform: "ios",
|
||||||
|
SystemVersion: "26.5",
|
||||||
|
APIID: 1,
|
||||||
|
AppVersion: "12.8 (10000)",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update client info: %v", err)
|
||||||
|
}
|
||||||
|
// Empty/zero values are a partial update and must not erase strong metadata.
|
||||||
|
if err := auths.UpdateClientInfo(ctx, id, domain.AuthKeyClientInfo{AppVersion: "12.8.1"}); err != nil {
|
||||||
|
t.Fatalf("partial update client info: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, found, err := auths.ByAuthKey(ctx, id)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("get authorization: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if got.Layer != 227 || got.DeviceModel != "iPhone Simulator" || got.Platform != "ios" ||
|
||||||
|
got.SystemVersion != "26.5" || got.APIID != 1 || got.AppVersion != "12.8.1" {
|
||||||
|
t.Fatalf("merged client info = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
|
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)
|
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue