merged with fixes
This commit is contained in:
parent
a9e758b712
commit
2f1818d656
176 changed files with 9000 additions and 907 deletions
|
|
@ -35,6 +35,7 @@ const (
|
|||
ActionSetPhone = "account.set_phone"
|
||||
ActionSetLoginEmail = "account.set_login_email"
|
||||
ActionSetAccountAvatar = "account.set_avatar"
|
||||
ActionSetAccountAvatarVideo = "account.set_avatar_video"
|
||||
ActionSetChannelAvatar = "channel.set_avatar"
|
||||
ActionSetChannelUsername = "channel.set_username"
|
||||
ActionSetChannelSettings = "channel.set_settings"
|
||||
|
|
@ -288,6 +289,9 @@ type AvatarResolver interface {
|
|||
// preview before CreateAvatarFromBytes actually materializes the avatar.
|
||||
ValidateAvatarUpload(data []byte) bool
|
||||
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error)
|
||||
// CreateAvatarVideoFromBytes is CreateAvatarFromBytes's video counterpart,
|
||||
// for the admin console's animated-avatar upload.
|
||||
CreateAvatarVideoFromBytes(ctx context.Context, data []byte, ownerUserID int64, videoStartTs float64) (domain.Photo, error)
|
||||
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
||||
// GetPhoto looks up a photo by id directly -- used to read a channel's
|
||||
// current avatar, which is denormalized on the channel row as a bare
|
||||
|
|
@ -873,6 +877,14 @@ type SetAccountAvatarRequest struct {
|
|||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
type SetAccountAvatarVideoRequest struct {
|
||||
CommandMeta
|
||||
UserID int64 `json:"user_id"`
|
||||
FileName string `json:"file_name"`
|
||||
VideoStartTs float64 `json:"video_start_ts"`
|
||||
Data []byte `json:"-"`
|
||||
}
|
||||
|
||||
type SetChannelAvatarRequest struct {
|
||||
CommandMeta
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
|
|
@ -1607,6 +1619,52 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
|
|||
})
|
||||
}
|
||||
|
||||
// SetAccountAvatarVideo is SetAccountAvatar's video counterpart: force-sets a
|
||||
// user's current profile photo from raw uploaded animated-video bytes,
|
||||
// reusing the same still-frame + s/a/c rendition pipeline as
|
||||
// photos.uploadProfilePhoto's video path. Unlike SetAccountAvatar's
|
||||
// ValidateAvatarUpload (an image-header decode), video bytes are only
|
||||
// size-bounded here -- the admin console does not decode video containers.
|
||||
func (s *Service) SetAccountAvatarVideo(ctx context.Context, req SetAccountAvatarVideoRequest) (CommandResult, error) {
|
||||
if req.UserID <= 0 {
|
||||
return CommandResult{}, fmt.Errorf("user_id is required")
|
||||
}
|
||||
if domain.IsSystemUserID(req.UserID) {
|
||||
return CommandResult{}, fmt.Errorf("system user avatar cannot be changed")
|
||||
}
|
||||
if s == nil || s.users == nil || s.photos == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin avatar dependencies are not configured")
|
||||
}
|
||||
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarVideoBytes {
|
||||
return CommandResult{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatarVideo, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
u, found, err := s.users.AdminUser(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return CommandResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return CommandResult{}, domain.ErrUserNotFound
|
||||
}
|
||||
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data), "bot": u.Bot}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "avatar video update validated", Details: details}, nil
|
||||
}
|
||||
photo, err := s.photos.CreateAvatarVideoFromBytes(ctx, req.Data, req.UserID, req.VideoStartTs)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(s.now().Unix())); err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
|
||||
if err := s.notifyUserChanged(ctx, u); err != nil {
|
||||
details["notify_error"] = err.Error()
|
||||
}
|
||||
return CommandResult{Message: "avatar video updated", Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetChannelAvatar force-sets a channel's avatar from raw uploaded image
|
||||
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
|
||||
// SetAccountAvatar, but attaching the resulting photo directly to the
|
||||
|
|
@ -2646,6 +2704,13 @@ func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHis
|
|||
// (SetAccountAvatar) a user's profile photo through the admin console.
|
||||
const MaxAccountAvatarBytes = 4 << 20
|
||||
|
||||
// MaxAccountAvatarVideoBytes bounds SetAccountAvatarVideo uploads. Video
|
||||
// avatars are official Telegram's short (~a few seconds) looping clips, so
|
||||
// their encoded size runs well above a static photo's while still being
|
||||
// bounded -- a real duration/dimension cap would need decoding the video,
|
||||
// which the admin console deliberately does not do.
|
||||
const MaxAccountAvatarVideoBytes = 10 << 20
|
||||
|
||||
// AccountAvatar returns an account's current profile photo bytes and detected
|
||||
// MIME type, mirroring internal/web's public avatar serving (same size
|
||||
// selection and safe-image-type checks) so the admin console shows exactly
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ type Service interface {
|
|||
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
|
||||
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
|
||||
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (admin.CommandResult, error)
|
||||
SetAccountAvatarVideo(ctx context.Context, req admin.SetAccountAvatarVideoRequest) (admin.CommandResult, error)
|
||||
ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error)
|
||||
SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error)
|
||||
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (admin.CommandResult, error)
|
||||
|
|
@ -188,6 +189,7 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/accounts/set-phone", s.authenticated(s.handleSetPhone))
|
||||
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail))
|
||||
mux.HandleFunc("POST /v1/accounts/set-avatar", s.authenticated(s.handleSetAccountAvatar))
|
||||
mux.HandleFunc("POST /v1/accounts/set-avatar-video", s.authenticated(s.handleSetAccountAvatarVideo))
|
||||
mux.HandleFunc("POST /v1/accounts/set-color", s.authenticated(s.handleSetUserColor))
|
||||
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
|
||||
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions))
|
||||
|
|
@ -393,7 +395,7 @@ func (s *Server) handleSetLoginEmail(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetAccountAvatarRequest
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarBytes) {
|
||||
return
|
||||
}
|
||||
if !decodeMultipartMetadata(w, r, &req) {
|
||||
|
|
@ -403,6 +405,18 @@ func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request)
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetAccountAvatarVideo(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetAccountAvatarVideoRequest
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarVideoBytes) {
|
||||
return
|
||||
}
|
||||
if !decodeMultipartMetadata(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.SetAccountAvatarVideo(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || channelID <= 0 {
|
||||
|
|
@ -427,7 +441,7 @@ func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetChannelAvatarRequest
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data) {
|
||||
if !s.decodeAvatarUpload(w, r, &req.FileName, &req.Data, admin.MaxAccountAvatarBytes) {
|
||||
return
|
||||
}
|
||||
if !decodeMultipartMetadata(w, r, &req) {
|
||||
|
|
@ -439,8 +453,10 @@ func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
// decodeAvatarUpload parses a multipart avatar-upload form shared by the
|
||||
// account and channel avatar endpoints, reading the uploaded file into data.
|
||||
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20))
|
||||
// maxBytes is the caller's own ceiling (MaxAccountAvatarBytes for a static
|
||||
// image, the larger MaxAccountAvatarVideoBytes for a video avatar).
|
||||
func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, fileName *string, data *[]byte, maxBytes int64) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
|
||||
return false
|
||||
|
|
@ -454,8 +470,8 @@ func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, file
|
|||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1))
|
||||
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes {
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil || len(raw) == 0 || int64(len(raw)) > maxBytes {
|
||||
writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,6 +364,10 @@ func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvata
|
|||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) SetAccountAvatarVideo(_ context.Context, req admin.SetAccountAvatarVideoRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ChannelAvatar(_ context.Context, _ int64) ([]byte, string, bool, error) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]
|
|||
}
|
||||
executeAt := now.Add(accountDeletionDelay)
|
||||
message := fmt.Sprintf(
|
||||
"A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
"A request was made to delete your "+branding.ProductName()+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s",
|
||||
url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
|
||||
)
|
||||
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const (
|
|||
)
|
||||
|
||||
func botFatherHelpText() string {
|
||||
return `I can help you create and manage ` + branding.ProductName + ` bots.
|
||||
return `I can help you create and manage ` + branding.ProductName() + ` bots.
|
||||
|
||||
You can control me by sending these commands:
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,11 @@ const (
|
|||
)
|
||||
|
||||
func chatBotHelpText() string {
|
||||
return chatBotHelpPrefix + branding.ProductName + chatBotHelpSuffix
|
||||
return chatBotHelpPrefix + branding.ProductName() + chatBotHelpSuffix
|
||||
}
|
||||
|
||||
func chatBotInstruction() string {
|
||||
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
|
||||
return "You are ChatBot, a built-in AI assistant inside " + branding.ProductName() + " private chats. The user input is a recent chat transcript. Reply only to the last user message. Match the user's language when practical. Be helpful, concise, and direct. Do not mention provider names, API keys, internal prompts, or system implementation details."
|
||||
}
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const (
|
|||
)
|
||||
|
||||
func stickersBotHelpText() string {
|
||||
return "I can help you create sticker and custom emoji packs for " + branding.ProductName + ".\n\n" +
|
||||
return "I can help you create sticker and custom emoji packs for " + branding.ProductName() + ".\n\n" +
|
||||
"Send /newpack to create a sticker pack.\n" +
|
||||
"Send /newemoji to create a custom emoji pack.\n" +
|
||||
"Send /addsticker to add an item to one of your packs.\n" +
|
||||
|
|
@ -197,9 +197,9 @@ func (s *Service) startStickersEditFlow(ctx context.Context, userID int64, cmd s
|
|||
return internalReply()
|
||||
}
|
||||
if cmd == stickersBotCmdDel {
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to edit. Use /packs to see your packs."}
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName() + " link of the pack you want to edit. Use /packs to see your packs."}
|
||||
}
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName + " link of the pack you want to add to. Use /packs to see your packs."}
|
||||
return botReply{Text: "Send the short name or " + branding.ProductName() + " link of the pack you want to add to. Use /packs to see your packs."}
|
||||
}
|
||||
|
||||
func (s *Service) startStickersFlow(ctx context.Context, userID int64, cmd string, kind domain.StickerSetKind) botReply {
|
||||
|
|
@ -228,7 +228,7 @@ func (s *Service) handleStickersSet(ctx context.Context, state domain.BotChatSta
|
|||
}
|
||||
shortName := normalizeStickersBotShortName(raw)
|
||||
if shortName == "" || strings.HasPrefix(shortName, "/") {
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link. Use /packs to list your packs, or /cancel."}
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName() + " link. Use /packs to list your packs, or /cancel."}
|
||||
}
|
||||
set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName})
|
||||
if err != nil {
|
||||
|
|
@ -556,7 +556,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
|
|||
func stickersBotStepPrompt(state domain.BotChatState) botReply {
|
||||
switch state.Step {
|
||||
case stickersBotStepSet:
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName + " link, or /cancel."}
|
||||
return botReply{Text: "Send the pack short name or " + branding.ProductName() + " link, or /cancel."}
|
||||
case stickersBotStepTitle:
|
||||
return botReply{Text: "Send a title for this pack, or /cancel."}
|
||||
case stickersBotStepDocument:
|
||||
|
|
|
|||
|
|
@ -199,11 +199,11 @@ func verifierBotWhatText() string {
|
|||
|
||||
A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more.
|
||||
|
||||
It is NOT the official ` + branding.ProductName + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
|
||||
It is NOT the official ` + branding.ProductName() + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.`
|
||||
}
|
||||
|
||||
func verifierBotHelpText() string {
|
||||
return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark.
|
||||
return `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName() + ` checkmark.
|
||||
|
||||
/start - what a third-party mark is and who grants it
|
||||
/verify - apply for the mark
|
||||
|
|
@ -1125,7 +1125,7 @@ func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierS
|
|||
b.WriteString("\n\nWhy:\n")
|
||||
b.WriteString(state.Draft[verifierDraftReason])
|
||||
b.WriteString("\n\nThis is a third-party mark, not the official ")
|
||||
b.WriteString(branding.ProductName)
|
||||
b.WriteString(branding.ProductName())
|
||||
b.WriteString(" checkmark, and I do not decide: an operator reads the application and either grants the mark or refuses it. I will message you here either way.")
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -1174,7 +1174,7 @@ func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) {
|
|||
switch req.Status {
|
||||
case domain.CustomVerificationApproved:
|
||||
return fmt.Sprintf("Application #%d is approved: %s now carries my mark -- my icon before the name and my description in the profile.\n\nThis is a third-party mark, not the official %s checkmark. Send /revoke if you ever want it removed.",
|
||||
req.ID, label, branding.ProductName), true
|
||||
req.ID, label, branding.ProductName()), true
|
||||
case domain.CustomVerificationRejected:
|
||||
text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label)
|
||||
if reason := strings.TrimSpace(req.DecisionReason); reason != "" {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ const (
|
|||
)
|
||||
|
||||
func verifyBotStartText() string {
|
||||
return `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
return `I collect applications for official ` + branding.ProductName() + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed.
|
||||
|
||||
Before you apply, check that the subject of the application:
|
||||
- is a channel, supergroup or bot with a public @username;
|
||||
|
|
@ -129,7 +129,7 @@ Tap the button below, or send /new, to start. Send /help for the full list of co
|
|||
}
|
||||
|
||||
func verifyBotHelpText() string {
|
||||
return `I collect official ` + branding.ProductName + ` verification applications.
|
||||
return `I collect official ` + branding.ProductName() + ` verification applications.
|
||||
|
||||
/new - file a verification application
|
||||
/status - list your applications and their status
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func (s *Service) SeedDefaultVerifier(ctx context.Context) (bool, error) {
|
|||
if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{
|
||||
BotID: domain.VerifierBotUserID,
|
||||
IconDocumentID: icon.DocumentID,
|
||||
CompanyName: branding.ProductName,
|
||||
CompanyName: branding.ProductName(),
|
||||
DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.",
|
||||
CanModifyCustomDescription: false,
|
||||
Enabled: true,
|
||||
|
|
|
|||
|
|
@ -189,7 +189,9 @@ func (c *blobBytesCache) get(key string) ([]byte, bool) {
|
|||
if el, ok := c.m[key]; ok {
|
||||
c.ll.MoveToFront(el)
|
||||
entry := el.Value.(*blobBytesEntry)
|
||||
return append([]byte(nil), entry.bytes...), true
|
||||
// Cache entries are immutable after publication. GetFile returns a
|
||||
// capacity-clipped read-only view of the requested range.
|
||||
return entry.bytes, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package files
|
||||
|
||||
import "sync/atomic"
|
||||
import (
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// SpaceGuard bounds how much more may be written to the permanent blob
|
||||
// backend. LocalDiskSpaceGuard checks real OS free disk bytes;
|
||||
|
|
@ -24,6 +29,41 @@ type NoopSpaceGuard struct{}
|
|||
func (NoopSpaceGuard) Allow(int64) (bool, error) { return true, nil }
|
||||
func (NoopSpaceGuard) Usage() (int64, int64, bool) { return 0, 0, false }
|
||||
|
||||
// requireSpace maps a SpaceGuard rejection to domain.ErrStorageFull. A nil
|
||||
// guard always allows the write.
|
||||
func requireSpace(guard SpaceGuard, additional int64) error {
|
||||
if guard == nil {
|
||||
return nil
|
||||
}
|
||||
ok, err := guard.Allow(additional)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrStorageFull
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// capacityReader stops a streaming permanent write before the backend can
|
||||
// publish an object larger than the current capacity snapshot permits.
|
||||
type capacityReader struct {
|
||||
src io.Reader
|
||||
guard SpaceGuard
|
||||
total int64
|
||||
}
|
||||
|
||||
func (r *capacityReader) Read(p []byte) (int, error) {
|
||||
n, err := r.src.Read(p)
|
||||
if n > 0 {
|
||||
if guardErr := requireSpace(r.guard, r.total+int64(n)); guardErr != nil {
|
||||
return 0, guardErr
|
||||
}
|
||||
r.total += int64(n)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// LocalDiskSpaceGuard rejects writes once cached free disk bytes fall below
|
||||
// minFreeBytes (<=0 disables the check). The free-bytes figure is
|
||||
// refreshed by DiskUsageWorker, not recomputed per call, to avoid a statfs
|
||||
|
|
|
|||
72
internal/app/files/guarded_backend.go
Normal file
72
internal/app/files/guarded_backend.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GuardedBlobBackend applies one capacity policy to every permanent write,
|
||||
// including seeds and non-upload media paths, instead of relying on individual
|
||||
// RPC handlers to remember a check.
|
||||
type GuardedBlobBackend struct {
|
||||
backend BlobBackend
|
||||
guard SpaceGuard
|
||||
}
|
||||
|
||||
func NewGuardedBlobBackend(backend BlobBackend, guard SpaceGuard) *GuardedBlobBackend {
|
||||
return &GuardedBlobBackend{backend: backend, guard: guard}
|
||||
}
|
||||
|
||||
func (g *GuardedBlobBackend) Name() string { return g.backend.Name() }
|
||||
|
||||
func (g *GuardedBlobBackend) Put(ctx context.Context, data []byte) (string, error) {
|
||||
if err := requireSpace(g.guard, int64(len(data))); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g.backend.Put(ctx, data)
|
||||
}
|
||||
|
||||
func (g *GuardedBlobBackend) PutReader(ctx context.Context, r io.Reader) (string, int64, []byte, error) {
|
||||
return g.backend.PutReader(ctx, &capacityReader{src: r, guard: g.guard})
|
||||
}
|
||||
|
||||
func (g *GuardedBlobBackend) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
return g.backend.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (g *GuardedBlobBackend) GetRange(ctx context.Context, key string, offset, limit int64) ([]byte, int64, error) {
|
||||
return g.backend.GetRange(ctx, key, offset, limit)
|
||||
}
|
||||
|
||||
type GuardedUploadPartBackend struct {
|
||||
backend UploadPartBackend
|
||||
guard SpaceGuard
|
||||
}
|
||||
|
||||
func NewGuardedUploadPartBackend(backend UploadPartBackend, guard SpaceGuard) *GuardedUploadPartBackend {
|
||||
return &GuardedUploadPartBackend{backend: backend, guard: guard}
|
||||
}
|
||||
|
||||
func (g *GuardedUploadPartBackend) PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) {
|
||||
if err := requireSpace(g.guard, int64(len(data))); err != nil {
|
||||
return uploadPartObject{}, err
|
||||
}
|
||||
return g.backend.PutUploadPart(ctx, ownerUserID, fileID, part, data)
|
||||
}
|
||||
|
||||
func (g *GuardedUploadPartBackend) GetUploadPart(ctx context.Context, key string) ([]byte, error) {
|
||||
return g.backend.GetUploadPart(ctx, key)
|
||||
}
|
||||
|
||||
func (g *GuardedUploadPartBackend) OpenUploadPart(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return g.backend.OpenUploadPart(ctx, key)
|
||||
}
|
||||
|
||||
func (g *GuardedUploadPartBackend) DeleteUploadPart(ctx context.Context, key string) error {
|
||||
return g.backend.DeleteUploadPart(ctx, key)
|
||||
}
|
||||
|
||||
func (g *GuardedUploadPartBackend) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) {
|
||||
return g.backend.DeleteExpiredUploadParts(ctx, before, limit)
|
||||
}
|
||||
|
|
@ -188,11 +188,51 @@ func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file do
|
|||
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup})
|
||||
}
|
||||
|
||||
// CreateAvatarVideoFromBytes stores already-in-hand animated-video bytes as an
|
||||
// avatar Photo, for callers that skip the chunked upload.saveFilePart
|
||||
// transfer regular clients use (e.g. the admin console, which already has the
|
||||
// full file from a browser upload) -- the video counterpart of
|
||||
// CreateAvatarFromBytes.
|
||||
func (s *Service) CreateAvatarVideoFromBytes(ctx context.Context, data []byte, ownerUserID int64, videoStartTs float64) (domain.Photo, error) {
|
||||
if len(data) == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
objectKey, size, sha256sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if size == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
body := assembledUploadBlob{ObjectKey: objectKey, Size: size, SHA256: sha256sum}
|
||||
return s.createAvatarVideoFromBlob(ctx, body, ownerUserID, videoStartTs, nil)
|
||||
}
|
||||
|
||||
func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
|
||||
body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
photo, err := s.createAvatarVideoFromBlob(ctx, body, file.OwnerUserID, videoStartTs, extraSizes)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
|
||||
s.log.Warn("cleanup assembled avatar video upload parts failed",
|
||||
zap.Int64("owner_user_id", file.OwnerUserID),
|
||||
zap.Int64("file_id", file.FileID),
|
||||
zap.Int64("photo_id", photo.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// createAvatarVideoFromBlob turns an already-durable video blob (from either
|
||||
// the chunked-upload assembly path or a direct in-hand byte slice) into an
|
||||
// avatar Photo. Shared by createAvatarVideoFromUpload and
|
||||
// CreateAvatarVideoFromBytes so the still-frame extraction and photo/blob
|
||||
// record construction stay in exactly one place.
|
||||
func (s *Service) createAvatarVideoFromBlob(ctx context.Context, body assembledUploadBlob, ownerUserID int64, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) {
|
||||
if body.Size == 0 {
|
||||
return domain.Photo{}, domain.ErrPhotoInvalid
|
||||
}
|
||||
|
|
@ -230,18 +270,11 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
|
|||
Date: int(time.Now().Unix()),
|
||||
DCID: s.dc,
|
||||
Sizes: sizes,
|
||||
OwnerUserID: file.OwnerUserID,
|
||||
OwnerUserID: ownerUserID,
|
||||
}
|
||||
if err := s.media.PutPhoto(ctx, photo); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil {
|
||||
s.log.Warn("cleanup assembled avatar video upload parts failed",
|
||||
zap.Int64("owner_user_id", file.OwnerUserID),
|
||||
zap.Int64("file_id", file.FileID),
|
||||
zap.Int64("photo_id", photoID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
|
||||
|
|
@ -29,20 +30,27 @@ type fakeMediaStore struct {
|
|||
webPages map[int64]domain.MessageWebPage
|
||||
seedState map[string]string
|
||||
receipts map[string]domain.UploadedMediaReceipt
|
||||
// profilePhotos[ownerID|kind] 保存某 owner 当前 profile/fallback 照片引用。
|
||||
profilePhotos map[string]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
func newFakeMediaStore() *fakeMediaStore {
|
||||
return &fakeMediaStore{
|
||||
blobs: map[string]domain.FileBlob{},
|
||||
docs: map[int64]domain.Document{},
|
||||
photos: map[int64]domain.Photo{},
|
||||
sets: map[int64]domain.StickerSet{},
|
||||
parts: map[string][]domain.UploadPart{},
|
||||
seedState: map[string]string{},
|
||||
receipts: map[string]domain.UploadedMediaReceipt{},
|
||||
blobs: map[string]domain.FileBlob{},
|
||||
docs: map[int64]domain.Document{},
|
||||
photos: map[int64]domain.Photo{},
|
||||
sets: map[int64]domain.StickerSet{},
|
||||
parts: map[string][]domain.UploadPart{},
|
||||
seedState: map[string]string{},
|
||||
receipts: map[string]domain.UploadedMediaReceipt{},
|
||||
profilePhotos: map[string]domain.ProfilePhotoRef{},
|
||||
}
|
||||
}
|
||||
|
||||
func fakeProfilePhotoKey(ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) string {
|
||||
return fmt.Sprintf("%s:%d:%s", ownerType, ownerID, kind)
|
||||
}
|
||||
|
||||
func fakeUploadReceiptKey(ownerUserID, fileID int64) string {
|
||||
return fmt.Sprintf("%d/%d", ownerUserID, fileID)
|
||||
}
|
||||
|
|
@ -414,29 +422,101 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
|
|||
defer f.mu.Unlock()
|
||||
return len(f.reactions), nil
|
||||
}
|
||||
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
|
||||
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
key := fakeProfilePhotoKey(ownerType, ownerID, kind)
|
||||
existing := f.profilePhotos[key]
|
||||
ref := domain.ProfilePhotoRef{PhotoID: photoID}
|
||||
if p, ok := f.photos[photoID]; ok {
|
||||
ref.DCID = p.DCID
|
||||
ref.Stripped = domain.StrippedFromSizes(p.Sizes)
|
||||
ref.HasVideo = domain.PhotoHasVideo(p.Sizes)
|
||||
}
|
||||
if existing.PhotoID != photoID {
|
||||
f.profilePhotos[key] = ref
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) {
|
||||
return 0, false, nil
|
||||
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
return ref.PhotoID, true, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
func (f *fakeMediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return f.CurrentProfilePhotosKind(ctx, ownerType, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
|
||||
for _, id := range ids {
|
||||
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, id, kind)]; ok {
|
||||
out[id] = ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
|
||||
return nil, 0, nil
|
||||
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var ids []int64
|
||||
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]; ok {
|
||||
ids = append(ids, ref.PhotoID)
|
||||
}
|
||||
return ids, len(ids), nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]domain.Photo, int, error) {
|
||||
return nil, 0, nil
|
||||
func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []domain.Photo
|
||||
if ref, ok := f.profilePhotos[fakeProfilePhotoKey(ownerType, ownerID, kind)]; ok {
|
||||
if p, ok := f.photos[ref.PhotoID]; ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, len(out), nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var deleted []int64
|
||||
key := fakeProfilePhotoKey(ownerType, ownerID, domain.ProfilePhotoKindProfile)
|
||||
if ref, ok := f.profilePhotos[key]; ok {
|
||||
for _, id := range photoIDs {
|
||||
if id == ref.PhotoID {
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
delete(f.profilePhotos, key)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var deleted []int64
|
||||
key := fakeProfilePhotoKey(ownerType, ownerID, kind)
|
||||
if ref, ok := f.profilePhotos[key]; ok {
|
||||
for _, id := range photoIDs {
|
||||
if id == ref.PhotoID {
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
delete(f.profilePhotos, key)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
func (f *fakeMediaStore) WithTx(_ context.Context, fn func(ctx context.Context, txMedia store.MediaStore) error) error {
|
||||
return fn(context.Background(), f)
|
||||
}
|
||||
|
||||
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -256,7 +256,9 @@ func TestMaxUploadFileBytesUnlimitedByDefault(t *testing.T) {
|
|||
|
||||
type countingUploadPartBackend struct {
|
||||
*LocalFS
|
||||
getUploadPartCalls int
|
||||
getUploadPartCalls int
|
||||
putUploadPartCalls int
|
||||
deleteUploadPartCalls int
|
||||
}
|
||||
|
||||
func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) {
|
||||
|
|
@ -264,6 +266,16 @@ func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey
|
|||
return c.LocalFS.GetUploadPart(ctx, objectKey)
|
||||
}
|
||||
|
||||
func (c *countingUploadPartBackend) PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) {
|
||||
c.putUploadPartCalls++
|
||||
return c.LocalFS.PutUploadPart(ctx, ownerUserID, fileID, part, data)
|
||||
}
|
||||
|
||||
func (c *countingUploadPartBackend) DeleteUploadPart(ctx context.Context, objectKey string) error {
|
||||
c.deleteUploadPartCalls++
|
||||
return c.LocalFS.DeleteUploadPart(ctx, objectKey)
|
||||
}
|
||||
|
||||
func newUploadPartTestService(t *testing.T, media *fakeMediaStore, quota domain.UploadPartQuota) (*Service, *LocalFS) {
|
||||
t.Helper()
|
||||
blobs, err := NewLocalFS(t.TempDir())
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
|
|||
packs: packs,
|
||||
packCache: newLangPackCache(maxBytes, maxEntries),
|
||||
languageCache: newLanguageListCache(languageEntries),
|
||||
publicBaseURL: branding.DefaultPublicURL,
|
||||
publicBaseURL: branding.PublicBaseURL(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
|
|||
creds: creds,
|
||||
challenges: challenges,
|
||||
rpID: rpID,
|
||||
rpName: branding.ProductName,
|
||||
rpName: branding.ProductName(),
|
||||
dcID: dcID,
|
||||
challengeTTL: defaultChallengeTTL,
|
||||
now: time.Now,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ import (
|
|||
// 强制重新拉取 p/g,而不是信任本地缓存。用于失效任何账号本地可能缓存的陈旧/错误
|
||||
// p/g(例如账号早年间对接过其它后端、缓存版本号恰好等于当时的 DHConfigVersion,
|
||||
// 此后再也不会刷新——版本号是纯常量,服务端自己永远不会主动使旧缓存过期)。
|
||||
// 本次从 1→2 是为诊断一例「A 拨 B 接通即断(key fingerprint/Ga hash 不合)」而提升,
|
||||
// 与本次通话 bug 排查同批次的服务端改动一起看。
|
||||
const DHConfigVersion = 2
|
||||
// 早先从 1→2 是为诊断一例「A 拨 B 接通即断(key fingerprint/Ga hash 不合)」而提升。
|
||||
// 上游又发现了同类问题的另一诱因:私有 DC 客户端若带着同为某个旧 version、但来自
|
||||
// 另一配置 profile 的缓存,服务端错误返回 NotModified 会让密聊两端用不同 p/g;
|
||||
// 这里直接取上游更新、更高的版本号,一次性使两类陈旧缓存都失效。
|
||||
const DHConfigVersion = 20260811
|
||||
|
||||
// DHG 是 DH generator。与官方一致取 3:TDesktop MTP::IsPrimeAndGood 对
|
||||
// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径,DrKLO native 同。
|
||||
|
|
|
|||
|
|
@ -487,43 +487,7 @@ func dedupNonZero(ids []int64) []int64 {
|
|||
}
|
||||
|
||||
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitDisallowMatches(rule, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitAllowMatches(rule, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return false
|
||||
}
|
||||
case domain.PrivacyRuleAllowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowAll:
|
||||
return false
|
||||
case domain.PrivacyRuleAllowAll:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return domain.EvaluatePrivacy(rules, ctx)
|
||||
}
|
||||
|
||||
func ValidKey(key domain.PrivacyKey) bool {
|
||||
|
|
@ -588,52 +552,6 @@ func validateRules(rules []domain.PrivacyRule) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func explicitDisallowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleDisallowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleDisallowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explicitAllowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleAllowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleAllowCloseFriends:
|
||||
return ctx.ViewerCloseFriend
|
||||
case domain.PrivacyRuleAllowPremium:
|
||||
return ctx.ViewerIsPremium
|
||||
case domain.PrivacyRuleAllowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := set[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules {
|
||||
return domain.PrivacyRules{
|
||||
OwnerUserID: ownerUserID,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,12 @@ func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]doma
|
|||
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// BaseUsersByIDs returns viewer-independent identities for bounded write/read
|
||||
// validation paths without constructing full viewer projections.
|
||||
func (s *Service) BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
return s.loadBaseUsersByIDs(ctx, userIDs)
|
||||
}
|
||||
|
||||
// ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
|
||||
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
|
||||
if currentUserID == 0 {
|
||||
|
|
|
|||
|
|
@ -6,44 +6,133 @@
|
|||
package branding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unicode"
|
||||
|
||||
"telesrv/internal/links"
|
||||
)
|
||||
|
||||
const (
|
||||
ProductName = "OwpenGram"
|
||||
ProductUsername = "owpengram"
|
||||
DesktopAppName = "OwpenGram Desktop"
|
||||
AndroidAppName = "OwpenGram Android"
|
||||
IOSAppName = "OwpenGram iOS"
|
||||
MacOSAppName = "OwpenGram macOS"
|
||||
WebAAppName = "OwpenGram Web A"
|
||||
WebKAppName = "OwpenGram Web K"
|
||||
PremiumName = "OwpenGram Premium"
|
||||
StarsName = "OwpenGram Stars"
|
||||
DefaultPublicURL = "https://owpengram.org"
|
||||
// Config is the deployment-wide, user-visible product identity. It is loaded
|
||||
// once during process startup; protocol identifiers and client detection
|
||||
// tokens deliberately remain outside this structure.
|
||||
type Config struct {
|
||||
ProductName string
|
||||
ProductUsername string
|
||||
DesktopAppName string
|
||||
AndroidAppName string
|
||||
IOSAppName string
|
||||
MacOSAppName string
|
||||
WebAAppName string
|
||||
WebKAppName string
|
||||
PremiumName string
|
||||
StarsName string
|
||||
PublicBaseURL string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultConfig = Config{
|
||||
ProductName: "OwpenGram",
|
||||
ProductUsername: "owpengram",
|
||||
DesktopAppName: "OwpenGram Desktop",
|
||||
AndroidAppName: "OwpenGram Android",
|
||||
IOSAppName: "OwpenGram iOS",
|
||||
MacOSAppName: "OwpenGram macOS",
|
||||
WebAAppName: "OwpenGram Web A",
|
||||
WebKAppName: "OwpenGram Web K",
|
||||
PremiumName: "OwpenGram Premium",
|
||||
StarsName: "OwpenGram Stars",
|
||||
PublicBaseURL: links.DefaultDownloadURL,
|
||||
}
|
||||
configured atomic.Pointer[Config]
|
||||
)
|
||||
|
||||
// DefaultConfig returns a copy of the default product identity.
|
||||
func DefaultConfig() Config { return defaultConfig }
|
||||
|
||||
// Validate normalizes and validates a product identity without installing it.
|
||||
func Validate(cfg Config) (Config, error) {
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *string
|
||||
}{
|
||||
{name: "product name", value: &cfg.ProductName},
|
||||
{name: "desktop app name", value: &cfg.DesktopAppName},
|
||||
{name: "Android app name", value: &cfg.AndroidAppName},
|
||||
{name: "iOS app name", value: &cfg.IOSAppName},
|
||||
{name: "macOS app name", value: &cfg.MacOSAppName},
|
||||
{name: "Web A app name", value: &cfg.WebAAppName},
|
||||
{name: "Web K app name", value: &cfg.WebKAppName},
|
||||
{name: "Premium name", value: &cfg.PremiumName},
|
||||
{name: "Stars name", value: &cfg.StarsName},
|
||||
} {
|
||||
normalized, err := validateDisplayName(*field.value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%s: %w", field.name, err)
|
||||
}
|
||||
*field.value = normalized
|
||||
}
|
||||
cfg.ProductUsername = strings.TrimPrefix(strings.TrimSpace(cfg.ProductUsername), "@")
|
||||
if !validProductUsername(cfg.ProductUsername) {
|
||||
return Config{}, fmt.Errorf("product username must be 5-32 ASCII username characters and start with a letter")
|
||||
}
|
||||
cfg.ProductUsername = strings.ToLower(cfg.ProductUsername)
|
||||
var err error
|
||||
cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("public base URL: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Configure installs the validated process-wide identity before services are
|
||||
// constructed. Readers only ever observe complete immutable snapshots.
|
||||
func Configure(cfg Config) error {
|
||||
normalized, err := Validate(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configured.Store(&normalized)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current returns a copy of the installed product identity.
|
||||
func Current() Config {
|
||||
if cfg := configured.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
func ProductName() string { return Current().ProductName }
|
||||
func ProductUsername() string { return Current().ProductUsername }
|
||||
func PremiumName() string { return Current().PremiumName }
|
||||
func StarsName() string { return Current().StarsName }
|
||||
func PublicBaseURL() string { return Current().PublicBaseURL }
|
||||
|
||||
// ClientAppName returns the branded display name for a stored client platform.
|
||||
// Stored detection tokens remain unchanged; this is only used at presentation
|
||||
// boundaries such as account.getAuthorizations.
|
||||
func ClientAppName(platform string) string {
|
||||
cfg := Current()
|
||||
switch strings.ToLower(strings.TrimSpace(platform)) {
|
||||
case "android":
|
||||
return AndroidAppName
|
||||
return cfg.AndroidAppName
|
||||
case "ios":
|
||||
return IOSAppName
|
||||
return cfg.IOSAppName
|
||||
case "macos":
|
||||
return MacOSAppName
|
||||
return cfg.MacOSAppName
|
||||
case "telegram-tt", "weba":
|
||||
return WebAAppName
|
||||
return cfg.WebAAppName
|
||||
case "tweb", "webk":
|
||||
return WebKAppName
|
||||
return cfg.WebKAppName
|
||||
case "tdesktop", "desktop", "windows":
|
||||
return DesktopAppName
|
||||
return cfg.DesktopAppName
|
||||
default:
|
||||
return ProductName
|
||||
return cfg.ProductName
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,18 +167,49 @@ func UserVisibleText(value, publicBaseURL string) string {
|
|||
if technicalIDRE.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
return officialBrandRE.ReplaceAllString(value, ProductName)
|
||||
return officialBrandRE.ReplaceAllString(value, ProductName())
|
||||
}
|
||||
|
||||
func publicDestination(raw string) (string, string) {
|
||||
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if raw == "" {
|
||||
raw = DefaultPublicURL
|
||||
raw = PublicBaseURL()
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
|
||||
raw = DefaultPublicURL
|
||||
raw = PublicBaseURL()
|
||||
parsed, _ = url.Parse(raw)
|
||||
}
|
||||
return raw, parsed.Host
|
||||
}
|
||||
|
||||
func validateDisplayName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("must not be empty")
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return "", fmt.Errorf("must not exceed 64 characters")
|
||||
}
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) {
|
||||
return "", fmt.Errorf("must not contain control characters")
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validProductUsername(username string) bool {
|
||||
if len(username) < 5 || len(username) > 32 {
|
||||
return false
|
||||
}
|
||||
for i, r := range username {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
case i > 0 && (r >= '0' && r <= '9' || r == '_'):
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,13 +46,14 @@ func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestClientPresentationNames(t *testing.T) {
|
||||
cfg := Current()
|
||||
for platform, want := range map[string]string{
|
||||
"tdesktop": DesktopAppName,
|
||||
"android": AndroidAppName,
|
||||
"ios": IOSAppName,
|
||||
"macos": MacOSAppName,
|
||||
"telegram-tt": WebAAppName,
|
||||
"tweb": WebKAppName,
|
||||
"tdesktop": cfg.DesktopAppName,
|
||||
"android": cfg.AndroidAppName,
|
||||
"ios": cfg.IOSAppName,
|
||||
"macos": cfg.MacOSAppName,
|
||||
"telegram-tt": cfg.WebAAppName,
|
||||
"tweb": cfg.WebKAppName,
|
||||
} {
|
||||
if got := ClientAppName(platform); got != want {
|
||||
t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want)
|
||||
|
|
@ -62,3 +63,55 @@ func TestClientPresentationNames(t *testing.T) {
|
|||
t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureInstallsCompleteBrandSnapshot(t *testing.T) {
|
||||
previous := Current()
|
||||
t.Cleanup(func() {
|
||||
if err := Configure(previous); err != nil {
|
||||
t.Fatalf("restore branding: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := Config{
|
||||
ProductName: "Example Chat",
|
||||
ProductUsername: "@Example_Chat",
|
||||
DesktopAppName: "Example Workstation",
|
||||
AndroidAppName: "Example Droid",
|
||||
IOSAppName: "Example Phone",
|
||||
MacOSAppName: "Example Mac",
|
||||
WebAAppName: "Example Web Alpha",
|
||||
WebKAppName: "Example Web Kappa",
|
||||
PremiumName: "Example Plus",
|
||||
StarsName: "Example Credits",
|
||||
PublicBaseURL: "https://links.example.test/root/",
|
||||
}
|
||||
if err := Configure(cfg); err != nil {
|
||||
t.Fatalf("Configure: %v", err)
|
||||
}
|
||||
if got := Current(); got.ProductUsername != "example_chat" || got.PublicBaseURL != "https://links.example.test/root" {
|
||||
t.Fatalf("Current() = %+v", got)
|
||||
}
|
||||
if got := ClientAppName("android"); got != "Example Droid" {
|
||||
t.Fatalf("ClientAppName(android) = %q", got)
|
||||
}
|
||||
if got := UserVisibleText("Telegram at t.me/example", ""); got != "Example Chat at links.example.test/example" {
|
||||
t.Fatalf("UserVisibleText() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsIncompleteOrUnsafeBranding(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"blank product": func(cfg *Config) { cfg.ProductName = " " },
|
||||
"control": func(cfg *Config) { cfg.StarsName = "bad\nname" },
|
||||
"username": func(cfg *Config) { cfg.ProductUsername = "3bad" },
|
||||
"public URL": func(cfg *Config) { cfg.PublicBaseURL = "file:///tmp/brand" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
mutate(&cfg)
|
||||
if _, err := Validate(cfg); err == nil {
|
||||
t.Fatal("Validate accepted invalid branding")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass
|
|||
if req != nil && req.Hash == emptyStickerSetHash {
|
||||
return &tg.MessagesStickerSetNotModified{}
|
||||
}
|
||||
title, shortName := branding.ProductName+" Empty Sticker Set", "owpengram_empty"
|
||||
title, shortName := branding.ProductName()+" Empty Sticker Set", "owpengram_empty"
|
||||
if req != nil {
|
||||
switch set := req.Stickerset.(type) {
|
||||
case *tg.InputStickerSetAnimatedEmoji:
|
||||
|
|
|
|||
|
|
@ -994,7 +994,7 @@ func Load() (Config, error) {
|
|||
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
|
||||
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
|
||||
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""),
|
||||
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName),
|
||||
SMTPFromName: envOr("TELESRV_SMTP_FROM_NAME", branding.ProductName()),
|
||||
SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
|
||||
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
|
||||
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import (
|
|||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
|
|||
return name[:1] + "***" + name[len(name)-1:] + email[at:]
|
||||
}
|
||||
|
||||
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
|
||||
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
|
||||
//
|
||||
// Email-signup 合成号码(EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外:
|
||||
// 原样保留(仅 lower+trim),不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而
|
||||
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。
|
||||
func NormalizePhone(phone string) string {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
return strings.ToLower(strings.TrimSpace(phone))
|
||||
}
|
||||
// virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
|
||||
// virtual login identity range NormalizePhone accepts without going through
|
||||
// libphonenumber (real E.164 numbers never start with 888). This is a login
|
||||
// identity concept only -- distinct from, and independent of, ownership of any
|
||||
// purchasable collectible-phone asset with the same digit shape.
|
||||
const (
|
||||
virtualLoginPhoneMinDigits = 7
|
||||
virtualLoginPhoneMaxDigits = 15
|
||||
)
|
||||
|
||||
// PhoneDigits removes presentation punctuation from a phone number. It is
|
||||
// intentionally not an identity canonicalizer: callers that select accounts,
|
||||
// issue codes, or persist users must use NormalizePhone and ValidPhone.
|
||||
func PhoneDigits(phone string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
seenDigit := false
|
||||
seenPlus := false
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
seenDigit = true
|
||||
case r == '+':
|
||||
if seenPlus || seenDigit {
|
||||
return ""
|
||||
}
|
||||
seenPlus = true
|
||||
case unicode.IsSpace(r), r == '-', r == '(', r == ')', r == '.', r == '/':
|
||||
// Presentation separators accepted by official clients and contact UIs.
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字;
|
||||
// email-signup 合成号码额外允许小写字母(EncodeEmailPhone 的转义字符集)。
|
||||
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度,
|
||||
// 但拒绝空串、非法字符和会截断的超长输入。
|
||||
func ValidPhone(phone string) bool {
|
||||
if len(phone) < 5 || len(phone) > 200 {
|
||||
return false
|
||||
}
|
||||
// NormalizePhone returns the one persisted login identity. Virtual +888
|
||||
// identities are independent of the collectible-phone registry and accept
|
||||
// 7-15 canonical digits. Ordinary international numbers use E.164 digits
|
||||
// without the leading '+'. Their parsing is deliberately country-aware so a
|
||||
// national trunk prefix is removed only where the numbering plan says it is a
|
||||
// prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
|
||||
// 989981679461, while Italy's significant leading zero in +39 02 ... is retained.
|
||||
//
|
||||
// Email-signup synthetic numbers (EncodeEmailPhone, "888" prefix plus at least
|
||||
// one letter) are a separate exception, kept as-is (lower+trim only, no digit
|
||||
// stripping) -- otherwise DecodeEmailPhone could never recover the email from
|
||||
// an already letter-stripped value. A real phone is always pure digits, so
|
||||
// this check never misclassifies one.
|
||||
//
|
||||
// IsPossibleNumber is the structural gate rather than IsValidNumber. It keeps
|
||||
// syntactically possible reserved/test ranges usable without accepting local
|
||||
// numbers that omit their country calling code or numbers outside E.164's
|
||||
// length/plan metadata.
|
||||
func NormalizePhone(phone string) string {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
return strings.ToLower(strings.TrimSpace(phone))
|
||||
}
|
||||
digits := PhoneDigits(phone)
|
||||
if digits == "" {
|
||||
return ""
|
||||
}
|
||||
// Every syntactically valid +888 virtual number is an independent login
|
||||
// identity; minting or owning the same collectible-phone value is not a
|
||||
// prerequisite. users.phone therefore takes lookup precedence over any
|
||||
// optional collectible alias registry.
|
||||
if len(digits) >= virtualLoginPhoneMinDigits &&
|
||||
len(digits) <= virtualLoginPhoneMaxDigits &&
|
||||
strings.HasPrefix(digits, "888") {
|
||||
return digits
|
||||
}
|
||||
// 42777 is the reserved, non-login phone of the built-in service identity.
|
||||
// It predates the ordinary E.164 user invariant and remains resolvable only
|
||||
// so auth can reject it as a system account instead of treating it as free.
|
||||
if digits == OfficialSystemPhone {
|
||||
return digits
|
||||
}
|
||||
number, err := phonenumbers.Parse("+"+digits, phonenumbers.UNKNOWN_REGION)
|
||||
if err != nil || !phonenumbers.IsPossibleNumber(number) {
|
||||
return ""
|
||||
}
|
||||
canonical := strings.TrimPrefix(phonenumbers.Format(number, phonenumbers.E164), "+")
|
||||
if canonical == "" || len(canonical) > 15 {
|
||||
return ""
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
// ValidPhone reports whether phone is already in the persisted canonical form.
|
||||
// Callers accepting user input normalize first, then validate, so equivalent
|
||||
// international spellings converge before lookup, rate limiting, OTP delivery,
|
||||
// and uniqueness checks. Email-signup synthetic numbers keep their own
|
||||
// lower+trim canonical form (see NormalizePhone).
|
||||
func ValidPhone(phone string) bool {
|
||||
if IsEmailSignupPhone(phone) {
|
||||
if len(phone) < 5 || len(phone) > 200 {
|
||||
return false
|
||||
}
|
||||
for _, r := range phone {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
|
||||
return false
|
||||
|
|
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
|
|||
}
|
||||
return true
|
||||
}
|
||||
for _, r := range phone {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
canonical := NormalizePhone(phone)
|
||||
return canonical != "" && canonical == phone
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,4 +43,7 @@ type AuthKeyClientInfo struct {
|
|||
SystemVersion string
|
||||
APIID int
|
||||
AppVersion string
|
||||
// IP 是最近一次会话建立的客户端对端地址(host-only)。只做 metadata 级别的
|
||||
// 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。
|
||||
IP string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,13 @@ func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) {
|
|||
if !strings.HasPrefix(phone, prefix) {
|
||||
t.Fatalf("phone %q missing configured prefix %q", phone, prefix)
|
||||
}
|
||||
if !ValidPhone(phone) {
|
||||
t.Fatalf("phone %q fails ValidPhone", phone)
|
||||
// A display phone is cosmetic only (see assignEmailSignupDisplayPhone --
|
||||
// it never goes through ValidPhone in production, only a uniqueness
|
||||
// check): random digits after a real prefix essentially never form a
|
||||
// libphonenumber-possible number, so the invariant worth checking here
|
||||
// is "still a plain digit string", not full E.164 validity.
|
||||
if PhoneDigits(phone) != phone {
|
||||
t.Fatalf("phone %q is not a plain digit string", phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
|
|||
|
||||
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
|
||||
if got != "Hello from OwpenGram!" {
|
||||
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
|
||||
t.Fatalf("expected default branding.ProductName() substitution, got %q", got)
|
||||
}
|
||||
|
||||
SetOfficialSystemUserDisplayName("Custom Server")
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
|
|||
|
||||
// MediaSearchRequest 是共享媒体标签页分页查询的入参(messages.search 媒体过滤分支)。
|
||||
// Categories 是该标签页映射到的基础类别并集(PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
|
||||
// 分页对齐历史语义:OffsetID 为游标(返回 id 严格小于它)、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
|
||||
// OffsetID 定位第一条严格更旧的消息;负 AddOffset 向更新侧取数(可含游标本身)。
|
||||
// MaxID/MinID、MaxDate/MinDate 均为开区间;计数忽略分页偏移。
|
||||
type MediaSearchRequest struct {
|
||||
Categories []MediaCategory
|
||||
Query string
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const (
|
|||
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
|
||||
MaxMessageReplyQuoteLength = 1024
|
||||
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
|
||||
MaxMessageReplyQuoteOffset = MaxMessageTextLength
|
||||
MaxMessageReplyQuoteOffset = 2 * MaxMessageTextLength // UTF-16 units, including surrogate pairs.
|
||||
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
|
||||
MaxMessageEntityCount = 256
|
||||
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
|
||||
|
|
@ -81,7 +81,7 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
|
|||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
// story 回复(StoryID>0)不携带 MessageID/TopMessageID;普通回复至少有其一。
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 && reply.External == nil {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
|
||||
|
|
@ -250,6 +250,9 @@ type MessageReply struct {
|
|||
QuoteText string
|
||||
QuoteEntities []MessageEntity
|
||||
QuoteOffset int
|
||||
// External is an immutable source snapshot resolved by the owning store.
|
||||
// It is not client input and does not authorize a source message lookup.
|
||||
External *MessageReplyExternal `json:",omitempty"`
|
||||
// StoryID > 0 表示这是一条对 story 的回复(评论):MessageID 为 0,Peer 为 story 作者,
|
||||
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
|
||||
StoryID int
|
||||
|
|
@ -287,6 +290,11 @@ type MessageFilter struct {
|
|||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
// SenderUserID intersects all other predicates; zero means no sender filter.
|
||||
SenderUserID int64
|
||||
// CountOnly ignores pagination and returns the exact filtered total without
|
||||
// loading message payloads or users. History's default limit is separate.
|
||||
CountOnly bool
|
||||
// PinnedOnly 仅返回置顶消息(messages.search filterPinned 与
|
||||
// userFull.pinned_msg_id 的查询路径)。
|
||||
PinnedOnly bool
|
||||
|
|
@ -716,11 +724,13 @@ type PinPrivateMessageRequest struct {
|
|||
Pinned bool
|
||||
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
|
||||
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
|
||||
PmOneside bool
|
||||
Silent bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
PmOneside bool
|
||||
Silent bool
|
||||
// RecipientBlocked applies the normal private-service delivery policy.
|
||||
RecipientBlocked bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ var (
|
|||
// undisclosed delivery and make a retry impossible to reconcile.
|
||||
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrQuoteTextInvalid = errors.New("quote text invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
|
||||
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶
|
||||
|
|
|
|||
193
internal/domain/message_reply_external.go
Normal file
193
internal/domain/message_reply_external.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxMessageReplyExternalBytes = 1 << 20
|
||||
|
||||
// MessageReplyExternal retains the source at send time, independently of its
|
||||
// later edit/deletion. It never contains another reply or owner-local jump IDs.
|
||||
type MessageReplyExternal struct {
|
||||
From MessageForward `json:"from"`
|
||||
Text string `json:"text"`
|
||||
Entities []MessageEntity `json:"entities,omitempty"`
|
||||
Media *MessageMedia `json:"media,omitempty"`
|
||||
}
|
||||
|
||||
// Apply the same validation when this value is nested in an immutable send
|
||||
// receipt. Otherwise json.Unmarshal there would discard unknown fields or
|
||||
// accept an invalid author even though the message-box decoder rejects it.
|
||||
func (v *MessageReplyExternal) UnmarshalJSON(b []byte) error {
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
type snapshot MessageReplyExternal
|
||||
var out snapshot
|
||||
d := json.NewDecoder(bytes.NewReader(b))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&out); err != nil {
|
||||
return fmt.Errorf("decode external reply: %w", err)
|
||||
}
|
||||
if err := d.Decode(new(any)); err != io.EOF {
|
||||
return fmt.Errorf("external reply trailing JSON")
|
||||
}
|
||||
if _, err := EncodeMessageReplyExternal((*MessageReplyExternal)(&out)); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = MessageReplyExternal(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EncodeMessageReplyExternal(v *MessageReplyExternal) ([]byte, error) {
|
||||
if v == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
if v.From.Date <= 0 || v.From.From.ID <= 0 || (v.From.From.Type != PeerTypeUser && v.From.From.Type != PeerTypeChannel) || v.From.SavedFrom.ID != 0 || v.From.SavedFromMsgID != 0 || len(v.Entities) > MaxMessageEntityCount || !utf8.ValidString(v.Text) || utf8.RuneCountInString(v.Text) > MaxMessageTextLength {
|
||||
return nil, fmt.Errorf("invalid external reply snapshot")
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode external reply: %w", err)
|
||||
}
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func DecodeMessageReplyExternal(b []byte) (*MessageReplyExternal, error) {
|
||||
if len(b) == 0 || bytes.Equal(bytes.TrimSpace(b), []byte("{}")) {
|
||||
return nil, nil
|
||||
}
|
||||
if len(b) > MaxMessageReplyExternalBytes {
|
||||
return nil, fmt.Errorf("external reply snapshot exceeds size bound")
|
||||
}
|
||||
var out MessageReplyExternal
|
||||
if err := out.UnmarshalJSON(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func NewMessageReplyExternal(source Message) (*MessageReplyExternal, error) {
|
||||
v := &MessageReplyExternal{From: MessageForward{From: source.From, Date: source.Date}, Text: source.Body, Entities: source.Entities, Media: source.Media}
|
||||
b, err := EncodeMessageReplyExternal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A codec round trip detaches the source media, including nested slices.
|
||||
return DecodeMessageReplyExternal(b)
|
||||
}
|
||||
|
||||
func ValidateExternalReplyQuote(reply *MessageReply, text string) error {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
if len(reply.QuoteText) > MaxMessageReplyQuoteLength || !utf8.ValidString(reply.QuoteText) || len(reply.QuoteEntities) > MaxMessageEntityCount {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
if reply.QuoteText == "" {
|
||||
if reply.QuoteOffset != 0 || len(reply.QuoteEntities) != 0 {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
source, quote := utf16.Encode([]rune(text)), utf16.Encode([]rune(reply.QuoteText))
|
||||
start := reply.QuoteOffset
|
||||
if start < 0 || start > len(source) || len(quote) > len(source)-start {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
for i, r := range quote {
|
||||
if source[start+i] != r {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
}
|
||||
for _, e := range reply.QuoteEntities {
|
||||
if e.Offset < 0 || e.Length <= 0 || e.Offset > len(quote) || e.Length > len(quote)-e.Offset {
|
||||
return ErrQuoteTextInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CloneMessageReply(in *MessageReply) *MessageReply {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.QuoteEntities = append([]MessageEntity(nil), in.QuoteEntities...)
|
||||
if in.External != nil {
|
||||
x := *in.External
|
||||
x.Entities = append([]MessageEntity(nil), x.Entities...)
|
||||
if x.Media != nil {
|
||||
x.Media = cloneReplyData(reflect.ValueOf(x.Media)).Interface().(*MessageMedia)
|
||||
}
|
||||
out.External = &x
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Clone only our data model. This is not a protocol codec: it neither interprets
|
||||
// TL fields nor converts bytes. Copying nested pointer/slice fields keeps media
|
||||
// added to MessageMedia from silently becoming shared between owner snapshots.
|
||||
func cloneReplyData(v reflect.Value) reflect.Value {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.New(v.Type().Elem())
|
||||
out.Elem().Set(cloneReplyData(v.Elem()))
|
||||
return out
|
||||
case reflect.Slice:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneReplyData(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
case reflect.Map:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.MakeMapWithSize(v.Type(), v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
out.SetMapIndex(iter.Key(), cloneReplyData(iter.Value()))
|
||||
}
|
||||
return out
|
||||
case reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return reflect.Zero(v.Type())
|
||||
}
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(cloneReplyData(v.Elem()))
|
||||
return out
|
||||
case reflect.Struct:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
out.Set(v)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
if v.Type().Field(i).IsExported() {
|
||||
out.Field(i).Set(cloneReplyData(v.Field(i)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case reflect.Array:
|
||||
out := reflect.New(v.Type()).Elem()
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
out.Index(i).Set(cloneReplyData(v.Index(i)))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
92
internal/domain/message_reply_external_test.go
Normal file
92
internal/domain/message_reply_external_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExternalReplySnapshotIsolationAndInvalidPayload(t *testing.T) {
|
||||
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000, Body: "🌕 quote", Media: &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{ID: 7, FileReference: []byte{1, 2, 3}}}}
|
||||
x, err := NewMessageReplyExternal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.Media.Photo.FileReference[0] = 9
|
||||
if x.Media.Photo.FileReference[0] != 1 {
|
||||
t.Fatal("source can mutate persisted snapshot")
|
||||
}
|
||||
r := &MessageReply{External: x}
|
||||
if err := ValidateMessageReplyBounds(r); err != nil {
|
||||
t.Fatal("snapshot-only recipient header rejected", err)
|
||||
}
|
||||
copy := CloneMessageReply(r)
|
||||
copy.External.Media.Photo.FileReference[0] = 8
|
||||
if x.Media.Photo.FileReference[0] != 1 {
|
||||
t.Fatal("owner clone aliases media snapshot")
|
||||
}
|
||||
b, err := EncodeMessageReplyExternal(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := DecodeMessageReplyExternal(b)
|
||||
if err != nil || !reflect.DeepEqual(restored, x) {
|
||||
t.Fatalf("snapshot roundtrip: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"null", "[]", `{"from":{}}`, string(b) + ` {}`, strings.Replace(string(b), `"text":`, `"unrecognized":1,"text":`, 1), strings.Repeat("x", MaxMessageReplyExternalBytes+1)} {
|
||||
if _, err := DecodeMessageReplyExternal([]byte(bad)); err == nil {
|
||||
t.Fatalf("invalid external snapshot accepted: %.50s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReplyQuoteUsesUTF16AndExactSubstring(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text, quote string
|
||||
offset int
|
||||
valid bool
|
||||
}{
|
||||
{"a🌕 quote", "quote", 4, true}, {"a🌕 quote", "quote", 3, false}, {"a🌕 quote", "🌕", 1, true}, {"a🌕 quote", "🌕", 2, false}, {"source", "invented", 0, false}, {"source", "source", 0, true}, {"source", "", 0, true}, {"source", "", 1, false},
|
||||
} {
|
||||
err := ValidateExternalReplyQuote(&MessageReply{QuoteText: tc.quote, QuoteOffset: tc.offset}, tc.text)
|
||||
if (err == nil) != tc.valid || (err != nil && !errors.Is(err, ErrQuoteTextInvalid)) {
|
||||
t.Fatalf("%+v: %v", tc, err)
|
||||
}
|
||||
}
|
||||
text := strings.Repeat("🌕", 3000) + "quote"
|
||||
if err := ValidateExternalReplyQuote(&MessageReply{QuoteText: "quote", QuoteOffset: 6000}, text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(&MessageReply{MessageID: 1, QuoteOffset: 6000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReplyDocumentNestedSlicesRemainIndependent(t *testing.T) {
|
||||
source := Message{From: Peer{Type: PeerTypeUser, ID: 42}, Date: 1700000000,
|
||||
Media: &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{
|
||||
ID: 7, FileReference: []byte{1, 2, 3}, MimeType: "audio/ogg",
|
||||
Attributes: []DocumentAttribute{{Kind: DocAttrAudio, Voice: true, Waveform: []byte{4, 5, 6}}},
|
||||
}},
|
||||
}
|
||||
x, err := NewMessageReplyExternal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.Media.Document.FileReference[0] = 9
|
||||
source.Media.Document.Attributes[0].Waveform[0] = 9
|
||||
copy := CloneMessageReply(&MessageReply{External: x})
|
||||
copy.External.Media.Document.Attributes[0].Waveform[1] = 9
|
||||
if !reflect.DeepEqual(x.Media.Document.FileReference, []byte{1, 2, 3}) || !reflect.DeepEqual(x.Media.Document.Attributes[0].Waveform, []byte{4, 5, 6}) {
|
||||
t.Fatal("source or another owner mutated the document snapshot")
|
||||
}
|
||||
raw, err := EncodeMessageReplyExternal(x)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeMessageReplyExternal(raw)
|
||||
if err != nil || !reflect.DeepEqual(got, x) {
|
||||
t.Fatalf("document snapshot roundtrip: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
59
internal/domain/phone_identity_test.go
Normal file
59
internal/domain/phone_identity_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizePhoneUsesCountryAwareE164Identity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "iran redundant national trunk", input: "+98 0998 167 9461", want: "989981679461"},
|
||||
{name: "iran canonical", input: "+98 998 167 9461", want: "989981679461"},
|
||||
{name: "iran wire digits redundant trunk", input: "9809981679461", want: "989981679461"},
|
||||
{name: "italy significant leading zero", input: "+39 02 1234 5678", want: "390212345678"},
|
||||
{name: "china presentation", input: "+86 (188) 0000-0000", want: "8618800000000"},
|
||||
{name: "possible reserved NANP range", input: "+1 555 000 0001", want: "15550000001"},
|
||||
{name: "local number without country", input: "09981679461", want: ""},
|
||||
{name: "letters are not separators", input: "+98abc9981679461", want: ""},
|
||||
{name: "international prefix is not country code", input: "00989981679461", want: ""},
|
||||
{name: "reserved system identity", input: OfficialSystemPhone, want: OfficialSystemPhone},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := NormalizePhone(test.input); got != test.want {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want %q", test.input, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneAcceptsVirtual888LoginIdentityRange(t *testing.T) {
|
||||
for input, want := range map[string]string{
|
||||
"+888 12-34": "8881234",
|
||||
"8880000": "8880000",
|
||||
"888123456789012": "888123456789012",
|
||||
} {
|
||||
if got := NormalizePhone(input); got != want {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
for _, phone := range []string{"888123", "8881234567890123", "+888abc1234"} {
|
||||
if got := NormalizePhone(phone); got != "" {
|
||||
t.Fatalf("NormalizePhone(%q) = %q, want empty", phone, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPhoneRequiresCanonicalStorageShape(t *testing.T) {
|
||||
for _, phone := range []string{"989981679461", "390212345678", "8618800000000", "15550000001", "8880000", "888123456789012", OfficialSystemPhone} {
|
||||
if !ValidPhone(phone) {
|
||||
t.Fatalf("ValidPhone(%q) = false", phone)
|
||||
}
|
||||
}
|
||||
for _, phone := range []string{"+989981679461", "+8881234", "888123", "8881234567890123", "9809981679461", "09981679461", "", "+98abc9981679461"} {
|
||||
if ValidPhone(phone) {
|
||||
t.Fatalf("ValidPhone(%q) = true", phone)
|
||||
}
|
||||
}
|
||||
}
|
||||
89
internal/domain/privacy_evaluate.go
Normal file
89
internal/domain/privacy_evaluate.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package domain
|
||||
|
||||
import "slices"
|
||||
|
||||
func EvaluatePrivacy(rules PrivacyRules, ctx PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitDisallowMatches(rule, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitAllowMatches(rule, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return false
|
||||
}
|
||||
case PrivacyRuleAllowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowAll:
|
||||
return false
|
||||
case PrivacyRuleAllowAll:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func explicitDisallowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleDisallowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case PrivacyRuleDisallowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case PrivacyRuleDisallowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explicitAllowMatches(rule PrivacyRule, ctx PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case PrivacyRuleAllowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case PrivacyRuleAllowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case PrivacyRuleAllowCloseFriends:
|
||||
return ctx.ViewerCloseFriend
|
||||
case PrivacyRuleAllowPremium:
|
||||
return ctx.ViewerIsPremium
|
||||
case PrivacyRuleAllowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := set[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import (
|
|||
const (
|
||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||
OfficialSystemUserID int64 = 777000
|
||||
// OfficialSystemPhone is a reserved service identity, not an ordinary E.164
|
||||
// login number. Auth must recognize and reject it before account lookup.
|
||||
OfficialSystemPhone = "42777"
|
||||
// OfficialSystemUserPhotoID/AccessHash 是该账号头像 photo 的固定 id,
|
||||
// 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致,
|
||||
// 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。
|
||||
|
|
@ -108,7 +111,7 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
|
|||
}
|
||||
|
||||
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName --
|
||||
// empty means "use branding.ProductName" (the compile-time default), set
|
||||
// empty means "use branding.ProductName()" (the compile-time default), set
|
||||
// once at startup from the operator's Server Settings -> Server identity
|
||||
// name, if any. Deliberately only the display name, not Username: the
|
||||
// account's @username is a stable, addressable identifier other things may
|
||||
|
|
@ -118,7 +121,7 @@ var officialSystemUserDisplayName string
|
|||
// SetOfficialSystemUserDisplayName records the operator's custom server
|
||||
// name for the official system account (777000), read once at startup from
|
||||
// Server Settings -> Server identity. Pass "" to fall back to
|
||||
// branding.ProductName -- the same "unset -> default" contract the avatar
|
||||
// branding.ProductName() -- the same "unset -> default" contract the avatar
|
||||
// override above uses.
|
||||
func SetOfficialSystemUserDisplayName(name string) {
|
||||
officialSystemUserDisplayName = strings.TrimSpace(name)
|
||||
|
|
@ -126,7 +129,7 @@ func SetOfficialSystemUserDisplayName(name string) {
|
|||
|
||||
// officialSystemDisplayName returns the official system account's current
|
||||
// effective display name: the operator's custom override if set via
|
||||
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
|
||||
// SetOfficialSystemUserDisplayName, else branding.ProductName(). Shared by
|
||||
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
|
||||
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
|
||||
// consistent with each other.
|
||||
|
|
@ -134,7 +137,7 @@ func officialSystemDisplayName() string {
|
|||
if officialSystemUserDisplayName != "" {
|
||||
return officialSystemUserDisplayName
|
||||
}
|
||||
return branding.ProductName
|
||||
return branding.ProductName()
|
||||
}
|
||||
|
||||
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
|
||||
|
|
@ -223,11 +226,11 @@ func OfficialSystemUser() User {
|
|||
// only the default snapshot; startup reconciliation and the memory backend use
|
||||
// these helpers so custom deployments do not expose stale "telesrv" text.
|
||||
func ChatBotDescription() string {
|
||||
return "Chat with the configured " + branding.ProductName + " AI provider."
|
||||
return "Chat with the configured " + branding.ProductName() + " AI provider."
|
||||
}
|
||||
|
||||
func StickersBotDescription() string {
|
||||
return "Create custom sticker and emoji packs for " + branding.ProductName + "."
|
||||
return "Create custom sticker and emoji packs for " + branding.ProductName() + "."
|
||||
}
|
||||
|
||||
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
|
|
|
|||
1393
internal/loadharness/group_media_load_test.go
Normal file
1393
internal/loadharness/group_media_load_test.go
Normal file
File diff suppressed because it is too large
Load diff
129
internal/loadharness/group_media_unit_test.go
Normal file
129
internal/loadharness/group_media_unit_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package loadharness
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
)
|
||||
|
||||
func TestGroupMediaChannelNudgeUsesOnlyMatchingChannelAndMaximumPTS(t *testing.T) {
|
||||
matchingLow := &tg.UpdateChannelTooLong{ChannelID: 71}
|
||||
matchingLow.SetPts(11)
|
||||
matchingHigh := &tg.UpdateChannelTooLong{ChannelID: 71}
|
||||
matchingHigh.SetPts(19)
|
||||
other := &tg.UpdateChannelTooLong{ChannelID: 72}
|
||||
other.SetPts(100)
|
||||
pts, ok := groupMediaChannelNudge(&tg.Updates{Updates: []tg.UpdateClass{other, matchingLow, matchingHigh}}, 71)
|
||||
if !ok || pts != 19 {
|
||||
t.Fatalf("matching channel nudge = pts:%d ok:%v, want 19/true", pts, ok)
|
||||
}
|
||||
withoutPTS := &tg.UpdateChannelTooLong{ChannelID: 71}
|
||||
if pts, ok = groupMediaChannelNudge(&tg.UpdateShort{Update: withoutPTS}, 71); !ok || pts != 0 {
|
||||
t.Fatalf("optional-pts channel nudge = pts:%d ok:%v, want 0/true", pts, ok)
|
||||
}
|
||||
if pts, ok = groupMediaChannelNudge(&tg.Updates{Updates: []tg.UpdateClass{other}}, 71); ok || pts != 0 {
|
||||
t.Fatalf("unrelated channel nudge = pts:%d ok:%v, want 0/false", pts, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaChannelLiveUpdateUsesOnlyMatchingChannelAndMaximumPTS(t *testing.T) {
|
||||
matchingLow := &tg.UpdateNewChannelMessage{
|
||||
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 71}}, Pts: 11, PtsCount: 1,
|
||||
}
|
||||
matchingHigh := &tg.UpdateNewChannelMessage{
|
||||
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 71}}, Pts: 19, PtsCount: 1,
|
||||
}
|
||||
other := &tg.UpdateNewChannelMessage{
|
||||
Message: &tg.Message{PeerID: &tg.PeerChannel{ChannelID: 72}}, Pts: 100, PtsCount: 1,
|
||||
}
|
||||
pts, count := groupMediaChannelLiveUpdate(
|
||||
&tg.Updates{Updates: []tg.UpdateClass{other, matchingLow, matchingHigh}}, 71,
|
||||
)
|
||||
if pts != 19 || count != 2 {
|
||||
t.Fatalf("matching channel live updates = pts:%d count:%d, want 19/2", pts, count)
|
||||
}
|
||||
pts, count = groupMediaChannelLiveUpdate(&tg.UpdateShort{Update: other}, 71)
|
||||
if pts != 0 || count != 0 {
|
||||
t.Fatalf("unrelated channel live update = pts:%d count:%d, want 0/0", pts, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaDifferencePageRequiresMonotonicPTS(t *testing.T) {
|
||||
message := &tg.Message{Message: "marker"}
|
||||
pts, final, messages, updates, err := groupMediaDifferencePage(10, &tg.UpdatesChannelDifference{
|
||||
Pts: 11, Final: false, NewMessages: []tg.MessageClass{message},
|
||||
OtherUpdates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: 71}},
|
||||
})
|
||||
if err != nil || pts != 11 || final || len(messages) != 1 || len(updates) != 1 {
|
||||
t.Fatalf("full difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
|
||||
}
|
||||
pts, final, messages, updates, err = groupMediaDifferencePage(11, &tg.UpdatesChannelDifferenceEmpty{Pts: 11, Final: true})
|
||||
if err != nil || pts != 11 || !final || len(messages) != 0 || len(updates) != 0 {
|
||||
t.Fatalf("empty difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
|
||||
}
|
||||
dialog := &tg.Dialog{}
|
||||
dialog.SetPts(15)
|
||||
pts, final, messages, updates, err = groupMediaDifferencePage(11, &tg.UpdatesChannelDifferenceTooLong{
|
||||
Final: true, Dialog: dialog, Messages: []tg.MessageClass{message},
|
||||
})
|
||||
if err != nil || pts != 15 || !final || len(messages) != 1 || len(updates) != 0 {
|
||||
t.Fatalf("too-long difference page = pts:%d final:%v messages:%d updates:%d err:%v", pts, final, len(messages), len(updates), err)
|
||||
}
|
||||
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifference{Pts: 15, Final: false}); err == nil {
|
||||
t.Fatal("non-final difference page without PTS progress was accepted")
|
||||
}
|
||||
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifferenceEmpty{Pts: 14, Final: true}); err == nil {
|
||||
t.Fatal("difference PTS regression was accepted")
|
||||
}
|
||||
if _, _, _, _, err := groupMediaDifferencePage(15, &tg.UpdatesChannelDifferenceTooLong{Final: false, Dialog: dialog}); err == nil {
|
||||
t.Fatal("non-final channelDifferenceTooLong was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaDifferenceRequestCoalescesMaximumPTS(t *testing.T) {
|
||||
client := &groupMediaClient{differenceWake: make(chan struct{}, 1)}
|
||||
client.requestChannelDifference(12)
|
||||
client.requestChannelDifference(9)
|
||||
client.requestChannelDifference(17)
|
||||
client.requestChannelDifference(0)
|
||||
if !client.differenceRequested.Load() || !client.differenceForce.Load() || client.differenceTargetPts.Load() != 17 || len(client.differenceWake) != 1 {
|
||||
t.Fatalf("coalesced request = requested:%v force:%v pts:%d wakes:%d", client.differenceRequested.Load(), client.differenceForce.Load(), client.differenceTargetPts.Load(), len(client.differenceWake))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaFanoutCountsOnlyDifferenceRecoveredMessages(t *testing.T) {
|
||||
const marker = "telesrv-group-media/run/photo/1"
|
||||
message := func(id int64) *tg.Message {
|
||||
value := &tg.Message{Message: marker}
|
||||
media := &tg.MessageMediaDocument{}
|
||||
media.SetDocument(&tg.Document{ID: id, AccessHash: id + 100, FileReference: []byte{byte(id)}, Size: 4096})
|
||||
value.SetMedia(media)
|
||||
return value
|
||||
}
|
||||
tracker := &groupMediaFanoutTracker{
|
||||
prefix: "telesrv-group-media/run/", members: map[int64]int{101: 0, 102: 1},
|
||||
expected: make(map[string]time.Time), committed: make(map[string]bool),
|
||||
observations: make(map[string]map[int64]groupMediaObservation),
|
||||
}
|
||||
tracker.begin(marker)
|
||||
tracker.observeDifference(101, []tg.MessageClass{message(1)}, nil)
|
||||
tracker.observeDifference(102, nil, []tg.UpdateClass{&tg.UpdateNewChannelMessage{Message: message(2)}})
|
||||
tracker.finish(marker, true)
|
||||
report := tracker.report()
|
||||
if report.Messages != 1 || report.Expected != 2 || report.Observed != 2 || report.Missing != 0 || report.Duplicate != 0 {
|
||||
t.Fatalf("difference fanout report = %+v", report)
|
||||
}
|
||||
canonical := []groupMediaTarget{{Kind: "photo", Marker: marker, Size: 4096, SHA256: sha256.Sum256([]byte("canonical"))}}
|
||||
for index, userID := range []int64{101, 102} {
|
||||
targets, err := tracker.targetsForUser(userID, canonical)
|
||||
if err != nil || len(targets) != 1 || targets[0].SHA256 != canonical[0].SHA256 || targets[0].Location == nil {
|
||||
t.Fatalf("difference targets for user %d = %+v, %v", userID, targets, err)
|
||||
}
|
||||
location, ok := targets[0].Location.(*tg.InputDocumentFileLocation)
|
||||
if !ok || location.ID != int64(index+1) {
|
||||
t.Fatalf("difference target for user %d has location %#v, want document %d", userID, targets[0].Location, index+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
internal/mtprotoedge/bulk_rpc_scheduler.go
Normal file
190
internal/mtprotoedge/bulk_rpc_scheduler.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package mtprotoedge
|
||||
|
||||
import "sync"
|
||||
|
||||
// bulkRPCScheduler limits runnable file handlers before they enter the shared
|
||||
// inbound worker pool. Waiters remain behind inboundRPCGate, so bulk backend
|
||||
// latency cannot occupy every worker needed by bootstrap and control RPCs.
|
||||
type bulkRPCScheduler struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
inUse int
|
||||
waiters []*bulkRPCLease
|
||||
closed bool
|
||||
}
|
||||
|
||||
type bulkRPCLease struct {
|
||||
scheduler *bulkRPCScheduler
|
||||
granted bool
|
||||
released bool
|
||||
notified bool
|
||||
notify func(bool)
|
||||
}
|
||||
|
||||
// bulkRPCAdmission serializes the two bulk prerequisites: a request may enter
|
||||
// the process-wide handler scheduler only after its logical session owns an ACK
|
||||
// window credit. This prevents credit-blocked requests from parking all global
|
||||
// handler slots.
|
||||
type bulkRPCAdmission struct {
|
||||
mu sync.Mutex
|
||||
scheduler *bulkRPCScheduler
|
||||
lease *bulkRPCLease
|
||||
released bool
|
||||
}
|
||||
|
||||
func newBulkRPCScheduler(max int) *bulkRPCScheduler {
|
||||
if max <= 0 {
|
||||
max = 1
|
||||
}
|
||||
return &bulkRPCScheduler{max: max}
|
||||
}
|
||||
|
||||
func (s *bulkRPCScheduler) reserve() *bulkRPCLease {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
lease := &bulkRPCLease{scheduler: s}
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
lease.released = true
|
||||
} else if s.inUse < s.max {
|
||||
s.inUse++
|
||||
lease.granted = true
|
||||
} else {
|
||||
s.waiters = append(s.waiters, lease)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return lease
|
||||
}
|
||||
|
||||
func (l *bulkRPCLease) subscribe(notify func(bool)) {
|
||||
if l == nil || l.scheduler == nil || notify == nil {
|
||||
return
|
||||
}
|
||||
s := l.scheduler
|
||||
s.mu.Lock()
|
||||
l.notify = notify
|
||||
granted, released := l.granted, l.released
|
||||
shouldNotify := (granted || released) && !l.notified
|
||||
if shouldNotify {
|
||||
l.notified = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !shouldNotify {
|
||||
return
|
||||
}
|
||||
if granted {
|
||||
notify(true)
|
||||
} else {
|
||||
notify(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *bulkRPCLease) release() {
|
||||
if l == nil || l.scheduler == nil {
|
||||
return
|
||||
}
|
||||
s := l.scheduler
|
||||
var notifications []func(bool)
|
||||
s.mu.Lock()
|
||||
if l.released {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
l.released = true
|
||||
if l.granted {
|
||||
l.granted = false
|
||||
s.inUse--
|
||||
}
|
||||
for !s.closed && s.inUse < s.max && len(s.waiters) > 0 {
|
||||
next := s.waiters[0]
|
||||
s.waiters[0] = nil
|
||||
s.waiters = s.waiters[1:]
|
||||
if next == nil || next.released {
|
||||
continue
|
||||
}
|
||||
next.granted = true
|
||||
s.inUse++
|
||||
if next.notify != nil && !next.notified {
|
||||
next.notified = true
|
||||
notifications = append(notifications, next.notify)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, notify := range notifications {
|
||||
notify(true)
|
||||
}
|
||||
}
|
||||
|
||||
func newBulkRPCAdmission(scheduler *bulkRPCScheduler) *bulkRPCAdmission {
|
||||
if scheduler == nil {
|
||||
return nil
|
||||
}
|
||||
return &bulkRPCAdmission{scheduler: scheduler}
|
||||
}
|
||||
|
||||
func (a *bulkRPCAdmission) subscribeAfter(credit *outboundBulkCredit, notify func(bool)) {
|
||||
if a == nil || a.scheduler == nil || credit == nil || notify == nil {
|
||||
if notify != nil {
|
||||
notify(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
credit.subscribe(func(success bool) {
|
||||
if !success {
|
||||
notify(false)
|
||||
return
|
||||
}
|
||||
lease := a.scheduler.reserve()
|
||||
a.mu.Lock()
|
||||
if a.released {
|
||||
a.mu.Unlock()
|
||||
lease.release()
|
||||
notify(false)
|
||||
return
|
||||
}
|
||||
a.lease = lease
|
||||
a.mu.Unlock()
|
||||
lease.subscribe(notify)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *bulkRPCAdmission) release() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.released {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
a.released = true
|
||||
lease := a.lease
|
||||
a.lease = nil
|
||||
a.mu.Unlock()
|
||||
lease.release()
|
||||
}
|
||||
|
||||
func (s *bulkRPCScheduler) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
var notifications []func(bool)
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
for _, lease := range s.waiters {
|
||||
if lease == nil || lease.released {
|
||||
continue
|
||||
}
|
||||
lease.released = true
|
||||
if lease.notify != nil && !lease.notified {
|
||||
lease.notified = true
|
||||
notifications = append(notifications, lease.notify)
|
||||
}
|
||||
}
|
||||
s.waiters = nil
|
||||
s.mu.Unlock()
|
||||
for _, notify := range notifications {
|
||||
notify(false)
|
||||
}
|
||||
}
|
||||
92
internal/mtprotoedge/bulk_rpc_scheduler_test.go
Normal file
92
internal/mtprotoedge/bulk_rpc_scheduler_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBulkRPCSchedulerKeepsOverflowBehindGate(t *testing.T) {
|
||||
scheduler := newBulkRPCScheduler(2)
|
||||
first := scheduler.reserve()
|
||||
second := scheduler.reserve()
|
||||
third := scheduler.reserve()
|
||||
woken := make(chan bool, 1)
|
||||
third.subscribe(func(success bool) { woken <- success })
|
||||
select {
|
||||
case <-woken:
|
||||
t.Fatal("overflow bulk handler became runnable before a slot was released")
|
||||
default:
|
||||
}
|
||||
first.release()
|
||||
select {
|
||||
case success := <-woken:
|
||||
if !success {
|
||||
t.Fatal("overflow bulk handler was canceled instead of admitted")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("overflow bulk handler did not become runnable")
|
||||
}
|
||||
second.release()
|
||||
third.release()
|
||||
}
|
||||
|
||||
func TestBulkRPCSchedulerCloseCancelsWaiters(t *testing.T) {
|
||||
scheduler := newBulkRPCScheduler(1)
|
||||
active := scheduler.reserve()
|
||||
waiting := scheduler.reserve()
|
||||
woken := make(chan bool, 1)
|
||||
waiting.subscribe(func(success bool) { woken <- success })
|
||||
scheduler.close()
|
||||
select {
|
||||
case success := <-woken:
|
||||
if success {
|
||||
t.Fatal("closed bulk scheduler granted a waiting handler")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("closed bulk scheduler did not cancel waiter")
|
||||
}
|
||||
active.release()
|
||||
}
|
||||
|
||||
func TestBulkRPCAdmissionDoesNotReserveGlobalSlotBeforeSessionCredit(t *testing.T) {
|
||||
scheduler := newBulkRPCScheduler(1)
|
||||
state := newOutboundStateWithLimits(newOutboundTrackedBudget(1<<20), 128, 1<<20)
|
||||
state.bulkMax = 1
|
||||
activeCredit := state.reserveBulkCredit()
|
||||
waitingCredit := state.reserveBulkCredit()
|
||||
admission := newBulkRPCAdmission(scheduler)
|
||||
woken := make(chan bool, 1)
|
||||
admission.subscribeAfter(waitingCredit.credit, func(success bool) { woken <- success })
|
||||
|
||||
scheduler.mu.Lock()
|
||||
inUseBeforeCredit := scheduler.inUse
|
||||
scheduler.mu.Unlock()
|
||||
if inUseBeforeCredit != 0 {
|
||||
t.Fatalf("credit-blocked request reserved %d global slots, want 0", inUseBeforeCredit)
|
||||
}
|
||||
select {
|
||||
case <-woken:
|
||||
t.Fatal("credit-blocked request became runnable")
|
||||
default:
|
||||
}
|
||||
|
||||
activeCredit.releaseIfOwned()
|
||||
select {
|
||||
case success := <-woken:
|
||||
if !success {
|
||||
t.Fatal("request was canceled after session credit became available")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request did not enter global scheduler after session credit")
|
||||
}
|
||||
scheduler.mu.Lock()
|
||||
inUseAfterCredit := scheduler.inUse
|
||||
scheduler.mu.Unlock()
|
||||
if inUseAfterCredit != 1 {
|
||||
t.Fatalf("admitted request reserved %d global slots, want 1", inUseAfterCredit)
|
||||
}
|
||||
|
||||
admission.release()
|
||||
waitingCredit.releaseIfOwned()
|
||||
state.closeBulkWindow()
|
||||
}
|
||||
29
internal/mtprotoedge/circular_history_test.go
Normal file
29
internal/mtprotoedge/circular_history_test.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package mtprotoedge
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInboundSeenHistoryUsesStableCircularBacking(t *testing.T) {
|
||||
state := newConnState()
|
||||
for id := int64(1); id <= maxTrackedClientMsgIDs; id++ {
|
||||
state.trackInbound(id, int32(id*2+1), true, false, msgStateReceived)
|
||||
}
|
||||
if len(state.order) != maxTrackedClientMsgIDs || len(state.seen) != maxTrackedClientMsgIDs {
|
||||
t.Fatalf("initial seen history = order:%d map:%d", len(state.order), len(state.seen))
|
||||
}
|
||||
backing := &state.order[0]
|
||||
for id := int64(maxTrackedClientMsgIDs + 1); id <= 4*maxTrackedClientMsgIDs; id++ {
|
||||
state.trackInbound(id, int32(id*2+1), true, false, msgStateReceived)
|
||||
}
|
||||
if &state.order[0] != backing {
|
||||
t.Fatal("full seen history replaced its circular backing")
|
||||
}
|
||||
if len(state.order) != maxTrackedClientMsgIDs || len(state.seen) != maxTrackedClientMsgIDs {
|
||||
t.Fatalf("steady seen history = order:%d map:%d", len(state.order), len(state.seen))
|
||||
}
|
||||
if _, ok := state.seenRecord(1); ok {
|
||||
t.Fatal("seen history retained its oldest ID")
|
||||
}
|
||||
if _, ok := state.seenRecord(4 * maxTrackedClientMsgIDs); !ok {
|
||||
t.Fatal("seen history lost its newest ID")
|
||||
}
|
||||
}
|
||||
169
internal/mtprotoedge/client_ip_e2e_test.go
Normal file
169
internal/mtprotoedge/client_ip_e2e_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package mtprotoedge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"github.com/gotd/log/logzap"
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/exchange"
|
||||
"github.com/iamxvbaba/td/session"
|
||||
"github.com/iamxvbaba/td/telegram"
|
||||
"github.com/iamxvbaba/td/telegram/dcs"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/transport"
|
||||
|
||||
"telesrv/internal/app/account"
|
||||
"telesrv/internal/app/auth"
|
||||
"telesrv/internal/app/contacts"
|
||||
"telesrv/internal/app/dialogs"
|
||||
"telesrv/internal/app/help"
|
||||
"telesrv/internal/app/langpack"
|
||||
"telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/rpc"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestClientIPPersistsToAuthorization verifies the real connection-to-persistence
|
||||
// path: the accepted connection's remote IP is carried as neutral transport
|
||||
// metadata by mtprotoedge, flows through RPC routing, and is persisted into the
|
||||
// device authorization (authorizations.ip). It runs a full login over a real
|
||||
// MTProto connection and asserts the stored authorization carries the client's
|
||||
// loopback IP.
|
||||
func TestClientIPPersistsToAuthorization(t *testing.T) {
|
||||
const (
|
||||
dc = 2
|
||||
phone = "+8613800138100"
|
||||
code = "12345"
|
||||
clientIP = "127.0.0.1"
|
||||
)
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("gen rsa: %v", err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tcpAddr := ln.Addr().(*net.TCPAddr)
|
||||
if tcpAddr.IP.String() != clientIP {
|
||||
t.Fatalf("test listener bound to %s, want loopback %s", tcpAddr.IP, clientIP)
|
||||
}
|
||||
|
||||
userStore := memory.NewUserStore()
|
||||
authzStore := memory.NewAuthorizationStore()
|
||||
authKeyStore := memory.NewAuthKeyStore()
|
||||
helpStore := memory.NewHelpStore()
|
||||
if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{
|
||||
Client: "tdesktop", Hash: 1_000_000,
|
||||
JSON: []byte(`{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373"}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed app config: %v", err)
|
||||
}
|
||||
if err := helpStore.UpsertCountries(context.Background(), []domain.Country{
|
||||
{ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{{CountryCode: "1", Prefixes: []string{"1"}}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed countries: %v", err)
|
||||
}
|
||||
langPackStore := memory.NewLangPackStore()
|
||||
if err := langPackStore.UpsertPack(context.Background(), domain.LangPack{
|
||||
LangPack: "tdesktop", LangCode: "en", Version: 1,
|
||||
Strings: []domain.LangPackString{{Key: "lng_language_name", Value: "English"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed langpack: %v", err)
|
||||
}
|
||||
|
||||
deps := rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(authKeyStore), code),
|
||||
Account: account.NewService(memory.NewPasswordStore()),
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore),
|
||||
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
|
||||
Contacts: contacts.NewService(memory.NewContactStore()),
|
||||
Dialogs: dialogs.NewService(memory.NewDialogStore()),
|
||||
LangPack: langpack.NewService(langPackStore),
|
||||
}
|
||||
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
|
||||
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- srv.Serve(ctx, ln) }()
|
||||
|
||||
opts := telegram.Options{
|
||||
PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}},
|
||||
Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}),
|
||||
DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}},
|
||||
Logger: logzap.New(zaptest.NewLogger(t).Named("client")),
|
||||
SessionStorage: &session.StorageMemory{},
|
||||
UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }),
|
||||
}
|
||||
client := telegram.NewClient(1, "hash", opts)
|
||||
|
||||
var newUserID int64
|
||||
if err := client.Run(ctx, func(ctx context.Context) error {
|
||||
raw := tg.NewClient(client)
|
||||
|
||||
sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sentCode, ok := sent.(*tg.AuthSentCode)
|
||||
if !ok {
|
||||
return fmt.Errorf("sendCode result = %T, want *tg.AuthSentCode", sent)
|
||||
}
|
||||
|
||||
if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: sentCode.PhoneCodeHash, PhoneCode: code}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signUpRes, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: sentCode.PhoneCodeHash, FirstName: "IP", LastName: "Test"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authz, ok := signUpRes.(*tg.AuthAuthorization)
|
||||
if !ok {
|
||||
return fmt.Errorf("signUp result = %T, want *tg.AuthAuthorization", signUpRes)
|
||||
}
|
||||
newUser, ok := authz.User.(*tg.User)
|
||||
if !ok {
|
||||
return fmt.Errorf("signUp user = %T, want *tg.User", authz.User)
|
||||
}
|
||||
newUserID = newUser.ID
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("client login flow: %v", err)
|
||||
}
|
||||
|
||||
auths, err := authzStore.ListByUser(ctx, newUserID)
|
||||
if err != nil || len(auths) == 0 {
|
||||
t.Fatalf("authorizations for user %d = %d (err=%v), want >=1", newUserID, len(auths), err)
|
||||
}
|
||||
var gotIP string
|
||||
for _, a := range auths {
|
||||
if a.IP != "" {
|
||||
gotIP = a.IP
|
||||
break
|
||||
}
|
||||
}
|
||||
if gotIP != clientIP {
|
||||
t.Fatalf("persisted authorization IP = %q, want %q", gotIP, clientIP)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
t.Fatalf("server stopped unexpectedly: %v", err)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -81,12 +82,12 @@ type Conn struct {
|
|||
salt int64
|
||||
key crypto.AuthKey
|
||||
|
||||
outbound chan outboundOp
|
||||
outboundControl chan outboundOp
|
||||
outbound chan *outboundOp
|
||||
outboundControl chan *outboundOp
|
||||
// Critical RPC results (session/difference convergence) and large bulk
|
||||
// responses have independent bounded lanes. The actor remains the sole writer.
|
||||
outboundCritical chan outboundOp
|
||||
outboundBulk chan outboundOp
|
||||
outboundCritical chan *outboundOp
|
||||
outboundBulk chan *outboundOp
|
||||
outboundStop chan struct{}
|
||||
outboundDone chan struct{}
|
||||
outboundClose sync.Once
|
||||
|
|
@ -104,10 +105,14 @@ type Conn struct {
|
|||
// Encoded MTProto service frames and control vectors use independent headroom: pong,
|
||||
// new_session_created, bad_msg and msgs_ack must remain admissible when the body budget is
|
||||
// full. Content-related control frames keep this budget while pending for resend.
|
||||
outboundControlTrackedBudget *outboundTrackedBudget
|
||||
outboundControlBudgetOnce sync.Once
|
||||
outboundScratchPool *outboundScratchPool
|
||||
outboundScratchOnce sync.Once
|
||||
outboundControlTrackedBudget *outboundTrackedBudget
|
||||
outboundControlBudgetOnce sync.Once
|
||||
outboundCriticalTrackedBudget *outboundTrackedBudget
|
||||
outboundCriticalBudgetOnce sync.Once
|
||||
outboundScratchPool *outboundScratchPool
|
||||
outboundScratchOnce sync.Once
|
||||
outboundOpPool *outboundOpPool
|
||||
outboundReplayBodyPool *outboundReplayBodyPool
|
||||
// outboundState outlives this physical Conn generation. A replacement
|
||||
// physical connection for the same auth key/session reuses it.
|
||||
outboundState *outboundState
|
||||
|
|
@ -160,6 +165,9 @@ type Conn struct {
|
|||
// 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。
|
||||
rpcRootCtx context.Context
|
||||
rpcMaxInflight int
|
||||
// remoteAddr 是 MTProto 连接的对端地址(来自 net.Conn.RemoteAddr),仅保留 host
|
||||
// 部分;绑定设备授权时写入 authorizations.ip,便于在 admin 面板看到登录 IP。
|
||||
remoteAddr string
|
||||
|
||||
// sentContentMessages is retained only for standalone construction tests.
|
||||
// Server connections allocate seq_no from logical-session outboundState.
|
||||
|
|
@ -419,3 +427,21 @@ func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() }
|
|||
// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。
|
||||
// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。
|
||||
func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) }
|
||||
|
||||
// setRemoteAddrStr 记录 MTProto 连接的对端地址(remote 形如 host:port)。
|
||||
// 只保留 host 部分(去掉端口),用于绑定设备授权时写入 authorizations.ip。
|
||||
func (c *Conn) setRemoteAddrStr(remote string) {
|
||||
if remote == "" {
|
||||
return
|
||||
}
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
// 已经是纯 host(例如 unix socket 或 IPv6 无端口形式)。
|
||||
c.remoteAddr = remote
|
||||
return
|
||||
}
|
||||
c.remoteAddr = host
|
||||
}
|
||||
|
||||
// clientIP 返回连接的对端 IP(host 部分),未设置时为空字符串。
|
||||
func (c *Conn) clientIP() string { return c.remoteAddr }
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ func TestPushSkipsConnReboundToOtherUser(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: sid,
|
||||
authKeyID: [8]byte{authKey},
|
||||
outbound: make(chan outboundOp, 4),
|
||||
outboundControl: make(chan outboundOp, 4),
|
||||
outbound: make(chan *outboundOp, 4),
|
||||
outboundControl: make(chan *outboundOp, 4),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.userID.Store(userA)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/observability/dbtrace"
|
||||
"telesrv/internal/postresponse"
|
||||
"telesrv/internal/rpcresult"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ type connState struct {
|
|||
createdFloor int64
|
||||
seen map[int64]clientMsgRecord // 已处理的 client msg_id,用于幂等和 msgs_state_req
|
||||
order []int64
|
||||
orderHead int
|
||||
minSeen int64
|
||||
maxSeen int64
|
||||
// maxContentMsgID/maxContentSeqNo 是已接受 content 消息的 msg_id / seq_no 高水位,
|
||||
|
|
@ -116,7 +118,7 @@ var errActivationAuthKeyRejected = errors.New("activation auth key no longer exi
|
|||
// 直接复用 current.key/current.salt 解密。任何 provisional 在 claim 建立后、发 required
|
||||
// control 前都会最终回查 AuthKeyStore,使外部撤销与 activation 线性化。
|
||||
// plain 是 serveConn 持有的复用明文缓冲,frame 的 slice 仅在下一帧解密前有效。
|
||||
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
|
||||
func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, remote string, fetchedKey *store.AuthKeyData, b, plain *bin.Buffer) (*Conn, error) {
|
||||
var key crypto.AuthKey
|
||||
var serverSalt int64
|
||||
var authKeyExpiresAt int
|
||||
|
|
@ -161,6 +163,8 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
|
|||
} else {
|
||||
current = s.newConn(tc, key, frame.sessionID, serverSalt)
|
||||
}
|
||||
// 记录对端 IP,供绑定设备授权时写入 authorizations.ip(admin 面板可见)。
|
||||
current.setRemoteAddrStr(remote)
|
||||
current.authKeyExpiresAt = authKeyExpiresAt
|
||||
// Same-session evidence is restored as explicit; auth-key metadata is only
|
||||
// an inherited default and can be corrected by the next invokeWithLayer.
|
||||
|
|
@ -904,7 +908,7 @@ func (s *Server) publishRPCResult(
|
|||
priority := rpcResultPriority(method, encoded)
|
||||
encoded.priority = priority
|
||||
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
|
||||
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, len(encoded.body), encoded.compressed)
|
||||
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, encoded.wireSize(), encoded.compressed)
|
||||
}
|
||||
visible := encoded.compressed || priority == outboundPriorityCritical || priority == outboundPriorityBulk
|
||||
return priority, visible
|
||||
|
|
@ -916,21 +920,26 @@ func (s *Server) publishRPCResult(
|
|||
// re-execution hidden behind a local capacity error.
|
||||
retainForReplay := func(encoded *encodedOutboundMessage, admissionErr error) error {
|
||||
if s == nil || s.rpcResults == nil || c == nil || encoded == nil || reqMsgID == 0 {
|
||||
if encoded != nil {
|
||||
encoded.releaseBulkCredit()
|
||||
}
|
||||
return errors.New("rpc result receipt ledger is unavailable")
|
||||
}
|
||||
priority, visible := prepareEncoded(encoded)
|
||||
if owner != nil && !owner.HandOff() {
|
||||
encoded.releaseBulkCredit()
|
||||
return ErrRPCResultFlightInvalid
|
||||
}
|
||||
started := time.Now()
|
||||
encoded.markReplayable()
|
||||
encoded.releaseBulkCredit()
|
||||
// Complete may expose terminal execution only after the old connection
|
||||
// is irreversibly unable to accept another same-generation request.
|
||||
c.fenceUndeliveredRPCResult()
|
||||
s.completeRPCResult(c, reqMsgID, encoded, false)
|
||||
latency := time.Since(started)
|
||||
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
|
||||
metrics.RPCResultDelivered(method, latency, len(encoded.body), admissionErr)
|
||||
metrics.RPCResultDelivered(method, latency, encoded.wireSize(), admissionErr)
|
||||
}
|
||||
resultLogLevel := zap.DebugLevel
|
||||
if visible {
|
||||
|
|
@ -941,14 +950,15 @@ func (s *Server) publishRPCResult(
|
|||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
|
||||
zap.Int64("delivered_req_msg_id", encoded.writtenRequestID()),
|
||||
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
|
||||
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
|
||||
zap.String("priority", priority.String()), zap.Error(admissionErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded, reserved, retained, err := s.encodeRPCResultReservedWithHandoffContext(
|
||||
prepareCtx, c, reqMsgID, result, retainForReplay,
|
||||
methodPriority := rpcMethodPriority(method)
|
||||
encoded, reserved, retained, err := s.encodeRPCResultReservedWithPriorityAndHandoffContext(
|
||||
prepareCtx, c, reqMsgID, result, methodPriority, retainForReplay,
|
||||
)
|
||||
if retained {
|
||||
return err
|
||||
|
|
@ -958,11 +968,14 @@ func (s *Server) publishRPCResult(
|
|||
return err
|
||||
}
|
||||
if err != nil {
|
||||
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
|
||||
provider.exactRPCBulkCredit().release()
|
||||
}
|
||||
s.log.Warn("Encode RPC result failed; publishing INTERNAL",
|
||||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
|
||||
afterDelivered = nil
|
||||
encoded, reserved, retained, err = s.encodeRPCResultReservedWithHandoffContext(
|
||||
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, retainForReplay,
|
||||
encoded, reserved, retained, err = s.encodeRPCResultReservedWithPriorityAndHandoffContext(
|
||||
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, methodPriority, retainForReplay,
|
||||
)
|
||||
if retained {
|
||||
return err
|
||||
|
|
@ -973,6 +986,9 @@ func (s *Server) publishRPCResult(
|
|||
}
|
||||
}
|
||||
if encoded == nil || reserved == nil {
|
||||
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
|
||||
provider.exactRPCBulkCredit().release()
|
||||
}
|
||||
c.fenceUndeliveredRPCResult()
|
||||
return errors.New("rpc result encode completed without tracked retention")
|
||||
}
|
||||
|
|
@ -981,6 +997,7 @@ func (s *Server) publishRPCResult(
|
|||
defer reserved.release()
|
||||
priority, _ := prepareEncoded(encoded)
|
||||
if owner != nil && !owner.HandOff() {
|
||||
encoded.releaseBulkCredit()
|
||||
return ErrRPCResultFlightInvalid
|
||||
}
|
||||
|
||||
|
|
@ -989,7 +1006,7 @@ func (s *Server) publishRPCResult(
|
|||
latency := time.Since(egressStarted)
|
||||
deliveredReqMsgID := encoded.writtenRequestID()
|
||||
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
|
||||
metrics.RPCResultDelivered(method, latency, len(encoded.body), deliveryErr)
|
||||
metrics.RPCResultDelivered(method, latency, encoded.wireSize(), deliveryErr)
|
||||
}
|
||||
if deliveryErr != nil {
|
||||
encoded.markReplayable()
|
||||
|
|
@ -1000,7 +1017,7 @@ func (s *Server) publishRPCResult(
|
|||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
|
||||
zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
|
||||
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
|
||||
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
|
||||
zap.Error(deliveryErr))
|
||||
}
|
||||
return
|
||||
|
|
@ -1012,7 +1029,7 @@ func (s *Server) publishRPCResult(
|
|||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
|
||||
zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
|
||||
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
|
||||
zap.Int("wire_bytes", encoded.wireSize()), zap.Bool("gzip", encoded.compressed),
|
||||
zap.Duration("egress_latency", latency))
|
||||
}
|
||||
}
|
||||
|
|
@ -1026,7 +1043,7 @@ func (s *Server) publishRPCResult(
|
|||
if checked := s.log.Check(zap.DebugLevel, "RPC result admitted"); checked != nil {
|
||||
checked.Write(
|
||||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
|
||||
zap.Int("wire_bytes", len(encoded.body)), zap.Int("inner_bytes", encoded.uncompressedBytes),
|
||||
zap.Int("wire_bytes", encoded.wireSize()), zap.Int("inner_bytes", encoded.uncompressedBytes),
|
||||
zap.Bool("gzip", encoded.compressed), zap.String("priority", priority.String()))
|
||||
}
|
||||
return nil
|
||||
|
|
@ -1086,7 +1103,7 @@ func (s *Server) sendReplayedRPCResultWithHook(
|
|||
c.fenceUndeliveredRPCResult()
|
||||
return errors.New("nil replayed rpc_result")
|
||||
}
|
||||
attempt, reserved, err := c.cloneRPCResultForRequestReserved(encoded, encoded.reqMsgID, false)
|
||||
attempt, reserved, err := c.cloneRPCResultForRequestReservedContext(ctx, encoded, encoded.reqMsgID, false)
|
||||
if err != nil {
|
||||
c.failOutboundBudget(err)
|
||||
c.fenceUndeliveredRPCResult()
|
||||
|
|
@ -1222,6 +1239,19 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
|
|||
reqMsgID int64,
|
||||
result bin.Encoder,
|
||||
handoff rpcResultRetentionHandoff,
|
||||
) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
|
||||
return s.encodeRPCResultReservedWithPriorityAndHandoffContext(
|
||||
ctx, c, reqMsgID, result, outboundPriorityNormal, handoff,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Server) encodeRPCResultReservedWithPriorityAndHandoffContext(
|
||||
ctx context.Context,
|
||||
c *Conn,
|
||||
reqMsgID int64,
|
||||
result bin.Encoder,
|
||||
priority outboundPriority,
|
||||
handoff rpcResultRetentionHandoff,
|
||||
) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
|
|
@ -1237,7 +1267,10 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
budget := c.outboundMessageBudget(encoded.typeID, false)
|
||||
if priority != outboundPriorityNormal {
|
||||
encoded.priority = priority
|
||||
}
|
||||
budget := c.outboundMessageBudgetForPriority(encoded.typeID, encoded.priority, false)
|
||||
bytes := len(encoded.body)
|
||||
if budget.reserve(bytes) {
|
||||
reserved = &outboundBodyReservation{budget: budget, bytes: bytes}
|
||||
|
|
@ -1286,6 +1319,14 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
|
|||
return nil, fmt.Errorf("bind exact layer rpc result: %w", err)
|
||||
}
|
||||
}
|
||||
var replaySource rpcresult.ReplaySource
|
||||
if provider, ok := result.(interface{ exactRPCReplaySource() rpcresult.ReplaySource }); ok {
|
||||
replaySource = provider.exactRPCReplaySource()
|
||||
}
|
||||
var bulkCredit *outboundBulkCredit
|
||||
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
|
||||
bulkCredit = provider.exactRPCBulkCredit()
|
||||
}
|
||||
// Encode the ordinary exact/no-gzip path directly behind the rpc_result
|
||||
// prefix. This avoids both the old generated Prepare snapshot and another
|
||||
// full-body copy merely to prepend the 12-byte envelope.
|
||||
|
|
@ -1303,9 +1344,14 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
|
|||
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(innerBody)+12, maxOutboundBodyBytes)
|
||||
}
|
||||
|
||||
wireInner, compressed, err := encodeAdaptiveRPCResultInner(ctx, nil, innerBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compress rpc result: %w", err)
|
||||
wireInner := innerBody
|
||||
compressed := false
|
||||
if replaySource == nil {
|
||||
var err error
|
||||
wireInner, compressed, err = encodeAdaptiveRPCResultInner(ctx, nil, innerBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compress rpc result: %w", err)
|
||||
}
|
||||
}
|
||||
if len(wireInner) > maxOutboundBodyBytes-12 {
|
||||
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(wireInner)+12, maxOutboundBodyBytes)
|
||||
|
|
@ -1322,6 +1368,7 @@ func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsg
|
|||
typeID: proto.ResultTypeID, body: body, reqMsgID: reqMsgID,
|
||||
compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(0),
|
||||
layer: layerBinding, layerInvariant: layerInvariantResult,
|
||||
replaySource: replaySource, innerDigest: sha256.Sum256(innerBody), logicalBytes: len(body), bulkCredit: bulkCredit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -1620,18 +1667,23 @@ func (cs *connState) trackInbound(msgID int64, seqNo int32, content, service boo
|
|||
cs.maxContentSeqNo = seqNo
|
||||
}
|
||||
}
|
||||
cs.order = append(cs.order, msgID)
|
||||
var evicted int64
|
||||
if len(cs.order) < maxTrackedClientMsgIDs {
|
||||
cs.order = append(cs.order, msgID)
|
||||
} else {
|
||||
evicted = cs.order[cs.orderHead]
|
||||
cs.order[cs.orderHead] = msgID
|
||||
cs.orderHead = (cs.orderHead + 1) % len(cs.order)
|
||||
}
|
||||
if msgID < cs.minSeen {
|
||||
cs.minSeen = msgID
|
||||
}
|
||||
if msgID > cs.maxSeen {
|
||||
cs.maxSeen = msgID
|
||||
}
|
||||
if len(cs.order) > maxTrackedClientMsgIDs {
|
||||
oldest := cs.order[0]
|
||||
cs.order = cs.order[1:]
|
||||
delete(cs.seen, oldest)
|
||||
if oldest == cs.minSeen || oldest == cs.maxSeen {
|
||||
if evicted != 0 {
|
||||
delete(cs.seen, evicted)
|
||||
if evicted == cs.minSeen || evicted == cs.maxSeen {
|
||||
cs.recomputeRange()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ func TestRunFlushDiscardsBatchOnIdentitySwitch(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: sessionID,
|
||||
authKeyID: raw,
|
||||
outbound: make(chan outboundOp, 4),
|
||||
outboundControl: make(chan outboundOp, 4),
|
||||
outbound: make(chan *outboundOp, 4),
|
||||
outboundControl: make(chan *outboundOp, 4),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
sm.Register(c)
|
||||
|
|
|
|||
|
|
@ -2098,7 +2098,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
|
|||
if dependencies.failed || len(dependencies.waiters) != 1 {
|
||||
t.Fatalf("dependencies before completion = %+v", dependencies)
|
||||
}
|
||||
gate := newLayerRPCExecutionGate(c, dependencies)
|
||||
gate := newLayerRPCExecutionGate(c, dependencies, nil, nil)
|
||||
if gate == nil || gate.runnable() {
|
||||
t.Fatal("dependency gate was runnable before business completion")
|
||||
}
|
||||
|
|
@ -2123,7 +2123,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
dependencies := s.layerRPCDependencies(c, 304, missing)
|
||||
gate := newLayerRPCExecutionGate(c, dependencies)
|
||||
gate := newLayerRPCExecutionGate(c, dependencies, nil, nil)
|
||||
if !dependencies.failed || gate == nil || !gate.runnable() || gate.success() {
|
||||
t.Fatalf("missing dependency gate = deps:%+v runnable:%v success:%v", dependencies, gate.runnable(), gate.success())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/transport"
|
||||
)
|
||||
|
||||
// ErrInboundRPCQueueFull 表示 inbound RPC 已触达单连接或进程级预算。
|
||||
|
|
@ -940,6 +942,11 @@ func (c *Conn) runInboundRPC(task inboundRPC) {
|
|||
|
||||
c.metrics.InboundRPCStarted(task.method, now.Sub(task.enqueuedAt))
|
||||
ctx := task.ctx
|
||||
// 把连接对端 IP 作为中立传输元数据注入,绑定设备授权时写 authorizations.ip。
|
||||
// Edge 只产生传输事实;由 RPC 层的 transport.ClientIPFrom 消费,避免反向依赖。
|
||||
if ip := c.clientIP(); ip != "" {
|
||||
ctx = transport.WithClientIP(ctx, ip)
|
||||
}
|
||||
if task.run != nil {
|
||||
_ = task.run(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tlprofile"
|
||||
"telesrv/internal/observability/dbtrace"
|
||||
"telesrv/internal/postresponse"
|
||||
"telesrv/internal/rpcresult"
|
||||
)
|
||||
|
||||
// layerRPCResultEncoder keeps the generated result bound to the immutable
|
||||
|
|
@ -22,6 +23,8 @@ import (
|
|||
type layerRPCResultEncoder struct {
|
||||
call tlprofile.Call
|
||||
result tlprofile.Result
|
||||
source rpcresult.ReplaySource
|
||||
bulk *outboundBulkCredit
|
||||
}
|
||||
|
||||
func (e *layerRPCResultEncoder) Encode(b *bin.Buffer) error {
|
||||
|
|
@ -45,6 +48,20 @@ func (e *layerRPCResultEncoder) exactLayerRPCResultBinding() outboundLayerBindin
|
|||
}
|
||||
}
|
||||
|
||||
func (e *layerRPCResultEncoder) exactRPCReplaySource() rpcresult.ReplaySource {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.source
|
||||
}
|
||||
|
||||
func (e *layerRPCResultEncoder) exactRPCBulkCredit() *outboundBulkCredit {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.bulk
|
||||
}
|
||||
|
||||
type exactLayerRPCResultEncoder interface {
|
||||
bin.Encoder
|
||||
exactLayerRPCResultBinding() outboundLayerBinding
|
||||
|
|
@ -90,7 +107,11 @@ func bindAdmittedLayerRPCResult(request tlprofile.Admission, result tlprofile.Re
|
|||
if result.Prepared().Identity() != request.Prepared().Identity() {
|
||||
return nil, errLayerRPCResultIdentityMismatch
|
||||
}
|
||||
return &layerRPCResultEncoder{call: request.Call(), result: result}, nil
|
||||
var source rpcresult.ReplaySource
|
||||
if carrier, ok := result.(rpcresult.Carrier); ok {
|
||||
source = carrier.ExactReplaySource()
|
||||
}
|
||||
return &layerRPCResultEncoder{call: request.Call(), result: result, source: source}, nil
|
||||
}
|
||||
|
||||
func (s *Server) newInboundLayerRPCTask(
|
||||
|
|
@ -104,7 +125,15 @@ func (s *Server) newInboundLayerRPCTask(
|
|||
owner *rpcResultOwnerLease,
|
||||
) inboundRPC {
|
||||
wireSize := request.Prepared().WireSize()
|
||||
gate := newLayerRPCExecutionGate(c, dependencies)
|
||||
var bulkLease *outboundBulkCreditLease
|
||||
var bulkExecution *bulkRPCAdmission
|
||||
if method == "upload.getFile" && c != nil && c.outboundState != nil {
|
||||
bulkLease = c.outboundState.reserveBulkCredit()
|
||||
if s.bulkRPCScheduler != nil {
|
||||
bulkExecution = newBulkRPCAdmission(s.bulkRPCScheduler)
|
||||
}
|
||||
}
|
||||
gate := newLayerRPCExecutionGate(c, dependencies, bulkLease, bulkExecution)
|
||||
timeoutResponse := func() {
|
||||
writeTimeout := c.writeTimeout
|
||||
if writeTimeout <= 0 || writeTimeout > 5*time.Second {
|
||||
|
|
@ -126,6 +155,8 @@ func (s *Server) newInboundLayerRPCTask(
|
|||
size: wireSize,
|
||||
onTimeout: timeoutResponse,
|
||||
release: func() {
|
||||
bulkExecution.release()
|
||||
bulkLease.releaseIfOwned()
|
||||
if owner != nil && owner.Abort() {
|
||||
c.fenceUndeliveredRPCResult()
|
||||
}
|
||||
|
|
@ -137,7 +168,7 @@ func (s *Server) newInboundLayerRPCTask(
|
|||
ErrorCode: 500, ErrorMessage: "MSG_WAIT_FAILED",
|
||||
}, nil)
|
||||
}
|
||||
if err := s.handleAdmittedLayerRPC(s.withLayerRPCProfileEvidenceFresh(taskCtx, profileEvidenceFresh), c, msgID, admissionSeq, method, request, owner); err != nil {
|
||||
if err := s.handleAdmittedLayerRPC(s.withLayerRPCProfileEvidenceFresh(taskCtx, profileEvidenceFresh), c, msgID, admissionSeq, method, request, owner, bulkLease); err != nil {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("msg_id", msgID), zap.String("auth_key_id", c.authKeyHex),
|
||||
zap.Int64("session_id", c.sessionID), zap.Error(err),
|
||||
|
|
@ -161,11 +192,15 @@ func layerRPCTimeoutMessage(gate *inboundRPCGate) string {
|
|||
return "RPC_TIMEOUT"
|
||||
}
|
||||
|
||||
func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inboundRPCGate {
|
||||
if len(dependencies.waiters) == 0 && !dependencies.failed {
|
||||
func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet, bulk *outboundBulkCreditLease, execution *bulkRPCAdmission) *inboundRPCGate {
|
||||
if len(dependencies.waiters) == 0 && !dependencies.failed && bulk == nil && execution == nil {
|
||||
return nil
|
||||
}
|
||||
gate := newInboundRPCGate(len(dependencies.waiters), c.wakeInboundRPC)
|
||||
prerequisites := len(dependencies.waiters)
|
||||
if bulk != nil {
|
||||
prerequisites++
|
||||
}
|
||||
gate := newInboundRPCGate(prerequisites, c.wakeInboundRPC)
|
||||
if dependencies.failed {
|
||||
gate.failed.Store(true)
|
||||
}
|
||||
|
|
@ -174,6 +209,11 @@ func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inbo
|
|||
gate.resolve(false)
|
||||
}
|
||||
}
|
||||
if bulk != nil && bulk.credit != nil && execution != nil {
|
||||
execution.subscribeAfter(bulk.credit, gate.resolve)
|
||||
} else if bulk != nil && bulk.credit != nil {
|
||||
bulk.credit.subscribe(gate.resolve)
|
||||
}
|
||||
// Release the subscriber-installation sentinel.
|
||||
gate.resolve(true)
|
||||
return gate
|
||||
|
|
@ -202,6 +242,7 @@ func (s *Server) handleAdmittedLayerRPC(
|
|||
method string,
|
||||
request tlprofile.Admission,
|
||||
owner *rpcResultOwnerLease,
|
||||
bulkLease *outboundBulkCreditLease,
|
||||
) error {
|
||||
if s.layerRPC == nil {
|
||||
return s.publishAdmittedLayerRPCResult(c, msgID, method, owner, false, &mt.RPCError{
|
||||
|
|
@ -219,6 +260,9 @@ func (s *Server) handleAdmittedLayerRPC(
|
|||
var exact *layerRPCResultEncoder
|
||||
if err == nil && result != nil {
|
||||
exact, err = bindAdmittedLayerRPCResult(request, result)
|
||||
if err == nil && exact != nil && bulkLease != nil {
|
||||
exact.bulk = bulkLease.transfer()
|
||||
}
|
||||
}
|
||||
dur := s.clock.Now().Sub(start)
|
||||
s.metrics.RPCHandled(effectiveMethod, dur, err)
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ func TestProjectionFailureCachesInternalWithoutRepeatingBusiness(t *testing.T) {
|
|||
}
|
||||
if err := s.handleAdmittedLayerRPC(
|
||||
context.Background(), c, reqMsgID, claim.admissionSeq,
|
||||
"help.getConfig", request, claim.owner,
|
||||
"help.getConfig", request, claim.owner, nil,
|
||||
); err != nil {
|
||||
t.Fatalf("publish projection failure: %v", err)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
102
internal/mtprotoedge/outbound_replay_body_pool.go
Normal file
102
internal/mtprotoedge/outbound_replay_body_pool.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package mtprotoedge
|
||||
|
||||
// outboundReplayBodyPool owns the short-lived plaintext buffers used to
|
||||
// materialize descriptor-backed rpc_result frames for an exact resend. It is
|
||||
// deliberately bounded and Server-owned: a burst may warm the size classes,
|
||||
// but it cannot make the process retain an unbounded sync.Pool tail.
|
||||
type outboundReplayBodyPool struct {
|
||||
classes []outboundReplayBodyClass
|
||||
}
|
||||
|
||||
type outboundReplayBodyClass struct {
|
||||
size int
|
||||
idle chan []byte
|
||||
}
|
||||
|
||||
type outboundReplayBodyClassSpec struct {
|
||||
size int
|
||||
maxIdle int
|
||||
}
|
||||
|
||||
var defaultOutboundReplayBodyClasses = []outboundReplayBodyClassSpec{
|
||||
{size: 4<<10 + 64, maxIdle: 32},
|
||||
{size: 16<<10 + 64, maxIdle: 32},
|
||||
{size: 64<<10 + 64, maxIdle: 32},
|
||||
{size: 256<<10 + 64, maxIdle: 32},
|
||||
{size: 512<<10 + 64, maxIdle: 32},
|
||||
{size: 1<<20 + 64, maxIdle: 32},
|
||||
{size: 2<<20 + 64, maxIdle: 8},
|
||||
}
|
||||
|
||||
func newOutboundReplayBodyPool(specs []outboundReplayBodyClassSpec) *outboundReplayBodyPool {
|
||||
classes := make([]outboundReplayBodyClass, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.size <= 0 || spec.maxIdle <= 0 {
|
||||
continue
|
||||
}
|
||||
classes = append(classes, outboundReplayBodyClass{
|
||||
size: spec.size,
|
||||
idle: make(chan []byte, spec.maxIdle),
|
||||
})
|
||||
}
|
||||
return &outboundReplayBodyPool{classes: classes}
|
||||
}
|
||||
|
||||
// acquire returns an empty buffer and its owning class. Bodies larger than the
|
||||
// largest reusable class use ordinary GC ownership and return class -1.
|
||||
func (p *outboundReplayBodyPool) acquire(size int) ([]byte, int) {
|
||||
if p == nil || size <= 0 {
|
||||
return nil, -1
|
||||
}
|
||||
for class := range p.classes {
|
||||
bucket := &p.classes[class]
|
||||
if size > bucket.size {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case buf := <-bucket.idle:
|
||||
return buf[:0], class
|
||||
default:
|
||||
return make([]byte, 0, bucket.size), class
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
func (p *outboundReplayBodyPool) release(class int, buf []byte) {
|
||||
if p == nil || class < 0 || class >= len(p.classes) {
|
||||
return
|
||||
}
|
||||
bucket := &p.classes[class]
|
||||
if cap(buf) != bucket.size {
|
||||
return
|
||||
}
|
||||
buf = buf[:0]
|
||||
select {
|
||||
case bucket.idle <- buf:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type outboundReplayBodyLease struct {
|
||||
pool *outboundReplayBodyPool
|
||||
class int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (l *outboundReplayBodyLease) release() {
|
||||
if l == nil || l.pool == nil {
|
||||
return
|
||||
}
|
||||
l.pool.release(l.class, l.buf)
|
||||
*l = outboundReplayBodyLease{}
|
||||
}
|
||||
|
||||
var fallbackOutboundReplayBodyPool = newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses)
|
||||
|
||||
func (c *Conn) replayBodyPool() *outboundReplayBodyPool {
|
||||
if c != nil && c.outboundReplayBodyPool != nil {
|
||||
return c.outboundReplayBodyPool
|
||||
}
|
||||
return fallbackOutboundReplayBodyPool
|
||||
}
|
||||
|
|
@ -215,13 +215,13 @@ func TestSendRequiredControlQueueDeadlineTerminatesAndReturnsBudget(t *testing.T
|
|||
writeTimeout: time.Second,
|
||||
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
|
||||
outboundControlTrackedBudget: controlBudget,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
// No actor is running and the bounded control queue is full, so the parent
|
||||
// deadline must cover queue admission and make the failure terminal.
|
||||
c.outboundControl <- outboundOp{kind: outboundAck}
|
||||
c.outboundControl <- &outboundOp{kind: outboundAck}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
"github.com/iamxvbaba/td/crypto"
|
||||
|
|
@ -20,6 +23,17 @@ import (
|
|||
"github.com/iamxvbaba/td/transport"
|
||||
)
|
||||
|
||||
type staticRPCReplaySource struct {
|
||||
inner []byte
|
||||
}
|
||||
|
||||
func (s *staticRPCReplaySource) EncodeInner(_ context.Context, out *bin.Buffer) error {
|
||||
out.Put(s.inner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*staticRPCReplaySource) RetainedBytes() int { return 128 }
|
||||
|
||||
type failAfterTransport struct {
|
||||
failAt atomic.Int32
|
||||
sends atomic.Int32
|
||||
|
|
@ -448,6 +462,9 @@ func newOutboundTestConn(t *testing.T, tr transport.Conn, budget *outboundTracke
|
|||
}
|
||||
|
||||
func TestOutboundQueueBackingUsesSmallConfigurableBounds(t *testing.T) {
|
||||
if slot, wide := unsafe.Sizeof((*outboundOp)(nil)), unsafe.Sizeof(outboundOp{}); slot >= wide {
|
||||
t.Fatalf("indirect queue slot = %d bytes, wide outbound op = %d bytes", slot, wide)
|
||||
}
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
c := &Conn{metrics: NopMetrics{}}
|
||||
c.startOutbound()
|
||||
|
|
@ -477,6 +494,64 @@ func TestOutboundQueueBackingUsesSmallConfigurableBounds(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestOutboundOpPoolClearsReferencesAndBoundsIdle(t *testing.T) {
|
||||
pool := newOutboundOpPool(1)
|
||||
op := pool.acquire()
|
||||
op.ctx = context.Background()
|
||||
op.msg = &mt.PingRequest{PingID: 1}
|
||||
op.encoded = &encodedOutboundMessage{body: []byte("payload")}
|
||||
op.ids = []int64{1, 2, 3}
|
||||
op.done = make(chan outboundResult, 1)
|
||||
op.terminal = func(error) {}
|
||||
pool.release(op)
|
||||
|
||||
reused := pool.acquire()
|
||||
if reused != op {
|
||||
t.Fatal("idle outbound op was not reused")
|
||||
}
|
||||
if reused.ctx != nil || reused.msg != nil || reused.encoded != nil || reused.ids != nil || reused.done != nil || reused.terminal != nil {
|
||||
t.Fatalf("reused outbound op retained references: %+v", reused)
|
||||
}
|
||||
pool.release(reused)
|
||||
pool.release(&outboundOp{})
|
||||
if got := len(pool.idle); got != 1 {
|
||||
t.Fatalf("idle outbound op count = %d, want bounded 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOutboundOpPool(b *testing.B) {
|
||||
pool := newOutboundOpPool(1)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
op := pool.acquire()
|
||||
op.kind = outboundSend
|
||||
pool.release(op)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundAckHistoryUsesStableCircularBacking(t *testing.T) {
|
||||
state := newOutboundState(newOutboundTrackedBudget(1 << 20))
|
||||
for id := int64(1); id <= maxTrackedAckedMsgIDs; id++ {
|
||||
state.markAcked(id)
|
||||
}
|
||||
if len(state.ackOrder) != maxTrackedAckedMsgIDs || len(state.acked) != maxTrackedAckedMsgIDs {
|
||||
t.Fatalf("initial ack history = order:%d map:%d", len(state.ackOrder), len(state.acked))
|
||||
}
|
||||
backing := &state.ackOrder[0]
|
||||
for id := int64(maxTrackedAckedMsgIDs + 1); id <= 4*maxTrackedAckedMsgIDs; id++ {
|
||||
state.markAcked(id)
|
||||
}
|
||||
if &state.ackOrder[0] != backing {
|
||||
t.Fatal("full ack history replaced its circular backing")
|
||||
}
|
||||
if len(state.ackOrder) != maxTrackedAckedMsgIDs || len(state.acked) != maxTrackedAckedMsgIDs {
|
||||
t.Fatalf("steady ack history = order:%d map:%d", len(state.ackOrder), len(state.acked))
|
||||
}
|
||||
if state.isKnown(1) || !state.isKnown(4*maxTrackedAckedMsgIDs) {
|
||||
t.Fatal("ack history did not evict oldest and retain newest IDs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundOptionsDefaults(t *testing.T) {
|
||||
opts := Options{}
|
||||
opts.setDefaults()
|
||||
|
|
@ -486,13 +561,17 @@ func TestOutboundOptionsDefaults(t *testing.T) {
|
|||
if opts.OutboundTrackedGlobalMaxBytes != 512<<20 {
|
||||
t.Fatalf("outbound tracked default = %d, want %d", opts.OutboundTrackedGlobalMaxBytes, 512<<20)
|
||||
}
|
||||
if opts.OutboundCriticalGlobalMaxBytes != 64<<20 {
|
||||
t.Fatalf("outbound critical default = %d, want %d", opts.OutboundCriticalGlobalMaxBytes, 64<<20)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
|
||||
srv := New(Options{
|
||||
OutboundQueueSize: 7,
|
||||
OutboundControlQueueSize: 3,
|
||||
OutboundTrackedGlobalMaxBytes: 20,
|
||||
OutboundQueueSize: 7,
|
||||
OutboundControlQueueSize: 3,
|
||||
OutboundTrackedGlobalMaxBytes: 20,
|
||||
OutboundCriticalGlobalMaxBytes: 30,
|
||||
})
|
||||
var rawKey crypto.Key
|
||||
key := rawKey.WithID()
|
||||
|
|
@ -513,6 +592,12 @@ func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
|
|||
if got := srv.outboundTrackedBudget.maxBytes; got != 20 {
|
||||
t.Fatalf("server outbound tracked max = %d, want 20", got)
|
||||
}
|
||||
if c1.outboundCriticalTrackedBudget != srv.outboundCriticalBudget || c2.outboundCriticalTrackedBudget != srv.outboundCriticalBudget {
|
||||
t.Fatal("server connections did not receive the shared critical tracking budget")
|
||||
}
|
||||
if got := srv.outboundCriticalBudget.maxBytes; got != 30 {
|
||||
t.Fatalf("server outbound critical max = %d, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptOutboundFrameDecryptsWithGotdCipher(t *testing.T) {
|
||||
|
|
@ -909,6 +994,162 @@ func TestOutboundTrackedBudgetWriteFailureReturnsReservation(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestOutboundStateCompactsImmutableRPCResultAndReplaysExactBody(t *testing.T) {
|
||||
budget := newOutboundTrackedBudget(1 << 20)
|
||||
state := newOutboundStateWithLimits(budget, 64, 1<<20)
|
||||
inner := bytes.Repeat([]byte{0x5a}, 4096)
|
||||
var body bin.Buffer
|
||||
body.PutID(proto.ResultTypeID)
|
||||
body.PutLong(7001)
|
||||
body.Put(inner)
|
||||
wire := body.Raw()
|
||||
if !budget.reserve(len(wire)) {
|
||||
t.Fatal("reserve first-write body")
|
||||
}
|
||||
frame := &outboundFrame{
|
||||
msgID: 9001,
|
||||
seqNo: 1,
|
||||
typeID: proto.ResultTypeID,
|
||||
body: wire,
|
||||
reservedBytes: len(wire),
|
||||
reservationBudget: budget,
|
||||
reqMsgID: 7001,
|
||||
replaySource: &staticRPCReplaySource{inner: append([]byte(nil), inner...)},
|
||||
innerDigest: sha256.Sum256(inner),
|
||||
uncompressedBytes: len(inner),
|
||||
logicalBytes: len(wire),
|
||||
}
|
||||
if err := state.admitReserved(frame); err != nil {
|
||||
t.Fatalf("admit frame: %v", err)
|
||||
}
|
||||
if !state.compactImmutableFrame(frame) {
|
||||
t.Fatal("immutable frame was not compacted")
|
||||
}
|
||||
if frame.body != nil {
|
||||
t.Fatal("compacted frame retained full body")
|
||||
}
|
||||
if got := budget.snapshot(); got != outboundReplayDescriptorCharge {
|
||||
t.Fatalf("retained bytes = %d, want descriptor charge %d", got, outboundReplayDescriptorCharge)
|
||||
}
|
||||
replay, ok := state.rpcResult(7001)
|
||||
if !ok || replay.replaySource == nil || len(replay.body) != 0 {
|
||||
t.Fatalf("replay descriptor = %+v ok=%v", replay, ok)
|
||||
}
|
||||
materialized, err := replay.materializeRPCResultBody(context.Background(), 7001)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize replay: %v", err)
|
||||
}
|
||||
if !bytes.Equal(materialized, wire) {
|
||||
t.Fatal("materialized replay differs from first-write body")
|
||||
}
|
||||
state.ack([]int64{9001})
|
||||
if got := budget.snapshot(); got != 0 {
|
||||
t.Fatalf("retained bytes after ACK = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImmutableRPCResultMaterializesDirectlyIntoScratch(t *testing.T) {
|
||||
inner := bytes.Repeat([]byte{0x6b}, 1<<20)
|
||||
logicalBytes := 12 + len(inner)
|
||||
replay := &encodedOutboundMessage{
|
||||
typeID: proto.ResultTypeID,
|
||||
reqMsgID: 7101,
|
||||
replaySource: &staticRPCReplaySource{inner: inner},
|
||||
innerDigest: sha256.Sum256(inner),
|
||||
uncompressedBytes: len(inner),
|
||||
logicalBytes: logicalBytes,
|
||||
}
|
||||
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: logicalBytes, maxIdle: 1}})
|
||||
scratch, class := pool.acquire(logicalBytes)
|
||||
body, usedScratch, err := replay.materializeRPCResultBodyInto(context.Background(), replay.reqMsgID, scratch)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize replay: %v", err)
|
||||
}
|
||||
if !usedScratch {
|
||||
t.Fatal("descriptor replay did not use the supplied scratch buffer")
|
||||
}
|
||||
if len(body) != logicalBytes || &body[0] != &scratch[:cap(scratch)][0] {
|
||||
t.Fatal("materialized body does not alias the supplied scratch buffer")
|
||||
}
|
||||
if got := int64(binary.LittleEndian.Uint64(body[4:12])); got != replay.reqMsgID {
|
||||
t.Fatalf("materialized req_msg_id = %d, want %d", got, replay.reqMsgID)
|
||||
}
|
||||
pool.release(class, body)
|
||||
if got := len(pool.classes[class].idle); got != 1 {
|
||||
t.Fatalf("idle pooled bodies = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundReplayBodyPoolBoundsIdleBuffers(t *testing.T) {
|
||||
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: 4096, maxIdle: 1}})
|
||||
first, firstClass := pool.acquire(4000)
|
||||
second, secondClass := pool.acquire(4000)
|
||||
if firstClass != 0 || secondClass != 0 || cap(first) != 4096 || cap(second) != 4096 {
|
||||
t.Fatalf("acquired classes/capacities = (%d,%d) (%d,%d)", firstClass, cap(first), secondClass, cap(second))
|
||||
}
|
||||
pool.release(firstClass, first)
|
||||
pool.release(secondClass, second)
|
||||
if got := len(pool.classes[0].idle); got != 1 {
|
||||
t.Fatalf("idle pooled bodies = %d, want bounded at 1", got)
|
||||
}
|
||||
oversized, class := pool.acquire(4097)
|
||||
if oversized != nil || class != -1 {
|
||||
t.Fatalf("oversized acquisition = len:%d class:%d, want GC-owned nil/-1", len(oversized), class)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImmutableRPCResultMaterializePooled(b *testing.B) {
|
||||
inner := bytes.Repeat([]byte{0x6b}, 1<<20)
|
||||
logicalBytes := 12 + len(inner)
|
||||
replay := &encodedOutboundMessage{
|
||||
typeID: proto.ResultTypeID,
|
||||
reqMsgID: 7101,
|
||||
replaySource: &staticRPCReplaySource{inner: inner},
|
||||
innerDigest: sha256.Sum256(inner),
|
||||
uncompressedBytes: len(inner),
|
||||
logicalBytes: logicalBytes,
|
||||
}
|
||||
pool := newOutboundReplayBodyPool([]outboundReplayBodyClassSpec{{size: logicalBytes, maxIdle: 1}})
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(logicalBytes))
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
scratch, class := pool.acquire(logicalBytes)
|
||||
body, usedScratch, err := replay.materializeRPCResultBodyInto(context.Background(), replay.reqMsgID, scratch)
|
||||
if err != nil || !usedScratch {
|
||||
b.Fatalf("materialize replay: used=%v err=%v", usedScratch, err)
|
||||
}
|
||||
pool.release(class, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundBulkACKWindowWakesNextWaiter(t *testing.T) {
|
||||
state := newOutboundStateWithLimits(newOutboundTrackedBudget(1<<20), 128, 1<<20)
|
||||
leasing := make([]*outboundBulkCreditLease, 0, defaultBulkACKWindow+1)
|
||||
for range defaultBulkACKWindow + 1 {
|
||||
leasing = append(leasing, state.reserveBulkCredit())
|
||||
}
|
||||
woken := make(chan bool, 1)
|
||||
leasing[len(leasing)-1].credit.subscribe(func(success bool) { woken <- success })
|
||||
select {
|
||||
case <-woken:
|
||||
t.Fatal("window overflow waiter woke before ACK credit release")
|
||||
default:
|
||||
}
|
||||
leasing[0].releaseIfOwned()
|
||||
select {
|
||||
case success := <-woken:
|
||||
if !success {
|
||||
t.Fatal("window waiter was canceled instead of granted")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("window waiter did not wake after credit release")
|
||||
}
|
||||
for _, lease := range leasing[1:] {
|
||||
lease.releaseIfOwned()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundStateEvictionReturnsTrackedBudget(t *testing.T) {
|
||||
budget := newOutboundTrackedBudget(64)
|
||||
state := newOutboundStateWithLimits(budget, 2, 8)
|
||||
|
|
@ -991,11 +1232,11 @@ func TestOutboundStateReleasesMixedBodyAndControlBudgets(t *testing.T) {
|
|||
|
||||
func TestSendBestEffortQueueFullBehavior(t *testing.T) {
|
||||
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
|
||||
c.outbound = make(chan outboundOp, 1)
|
||||
c.outboundControl = make(chan outboundOp, 1)
|
||||
c.outbound = make(chan *outboundOp, 1)
|
||||
c.outboundControl = make(chan *outboundOp, 1)
|
||||
c.outboundStop = make(chan struct{})
|
||||
// 占满普通队列,模拟出站拥塞。
|
||||
c.outbound <- outboundOp{}
|
||||
c.outbound <- &outboundOp{}
|
||||
|
||||
if err := c.SendBestEffort(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}, 0); err != ErrOutboundQueueFull {
|
||||
t.Fatalf("timeout=0 on full queue: err = %v, want ErrOutboundQueueFull", err)
|
||||
|
|
@ -1027,10 +1268,10 @@ func TestSendBestEffortQueueFullBehavior(t *testing.T) {
|
|||
|
||||
func TestSendAsyncControlQueueBoundary(t *testing.T) {
|
||||
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
|
||||
c.outbound = make(chan outboundOp, 1)
|
||||
c.outboundControl = make(chan outboundOp, 1)
|
||||
c.outbound = make(chan *outboundOp, 1)
|
||||
c.outboundControl = make(chan *outboundOp, 1)
|
||||
c.outboundStop = make(chan struct{})
|
||||
c.outboundControl <- outboundOp{kind: outboundAck}
|
||||
c.outboundControl <- &outboundOp{kind: outboundAck}
|
||||
|
||||
if err := c.SendAsync(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}); err != nil {
|
||||
t.Fatalf("SendAsync on full control queue: %v", err)
|
||||
|
|
|
|||
|
|
@ -325,16 +325,24 @@ func encodeAdaptiveRPCResultInner(ctx context.Context, stop <-chan struct{}, inn
|
|||
// Android. These bootstrap barriers must pass background prefetch regardless of
|
||||
// platform or their own encoded size.
|
||||
func rpcResultPriority(method string, encoded *encodedOutboundMessage) outboundPriority {
|
||||
if priority := rpcMethodPriority(method); priority != outboundPriorityNormal {
|
||||
return priority
|
||||
}
|
||||
return classifyOutboundPriority(encoded, false)
|
||||
}
|
||||
|
||||
func rpcMethodPriority(method string) outboundPriority {
|
||||
base := method
|
||||
if i := strings.IndexByte(base, '#'); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
switch base {
|
||||
case "updates.getDifference", "updates.getChannelDifference", "updates.getState",
|
||||
"messages.getDialogs", "messages.getPinnedDialogs":
|
||||
"messages.getDialogs", "messages.getPinnedDialogs",
|
||||
"auth.bindTempAuthKey", "help.getConfig", "users.getUsers":
|
||||
return outboundPriorityCritical
|
||||
}
|
||||
return classifyOutboundPriority(encoded, false)
|
||||
return outboundPriorityNormal
|
||||
}
|
||||
|
||||
func (p outboundPriority) String() string {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,30 @@ func TestEncodeRPCResultReservedChargesBodyBeforeReturning(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCriticalRPCResultUsesIndependentRetainedBudget(t *testing.T) {
|
||||
ordinary := newOutboundTrackedBudget(1)
|
||||
critical := newOutboundTrackedBudget(1 << 20)
|
||||
c := legacyCanonicalTestConn(t, &Conn{
|
||||
metrics: NopMetrics{},
|
||||
outboundTrackedBudget: ordinary,
|
||||
outboundCriticalTrackedBudget: critical,
|
||||
})
|
||||
s := New(Options{})
|
||||
encoded, reserved, retained, err := s.encodeRPCResultReservedWithPriorityAndHandoffContext(
|
||||
context.Background(), c, 791, exactTestRPCResult(&tg.DataJSON{Data: "bootstrap"}), outboundPriorityCritical, nil,
|
||||
)
|
||||
if err != nil || retained || encoded == nil || reserved == nil {
|
||||
t.Fatalf("critical encode encoded=%p reserved=%p retained=%v err=%v", encoded, reserved, retained, err)
|
||||
}
|
||||
if got := ordinary.snapshot(); got != 0 {
|
||||
t.Fatalf("ordinary budget used by critical result = %d", got)
|
||||
}
|
||||
if got, want := critical.snapshot(), int64(len(encoded.body)); got != want {
|
||||
t.Fatalf("critical budget = %d, want %d", got, want)
|
||||
}
|
||||
reserved.release()
|
||||
}
|
||||
|
||||
func TestEncodeRPCResultReservedDropsBodyOnBudgetTimeout(t *testing.T) {
|
||||
const maxBytes = 1 << 20
|
||||
budget := newOutboundTrackedBudget(maxBytes)
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ func TestRPCResultCloneReservationIsOneShotUnderReleaseRace(t *testing.T) {
|
|||
}
|
||||
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
|
||||
start := make(chan struct{})
|
||||
taken := make(chan outboundOp, 1)
|
||||
taken := make(chan *outboundOp, 1)
|
||||
go func() {
|
||||
<-start
|
||||
op, _ := reserved.take(encoded)
|
||||
op, _ := reserved.take(encoded, fallbackOutboundOpPool)
|
||||
taken <- op
|
||||
}()
|
||||
released := make(chan struct{})
|
||||
|
|
@ -53,7 +53,7 @@ func TestRPCResultReservationReleaseWinsAdmissionRollback(t *testing.T) {
|
|||
t.Fatal("reserve body")
|
||||
}
|
||||
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
|
||||
op, err := reserved.take(encoded)
|
||||
op, err := reserved.take(encoded, fallbackOutboundOpPool)
|
||||
if err != nil {
|
||||
t.Fatalf("take reservation: %v", err)
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ func TestRPCResultReservationReleaseWinsAdmissionRollback(t *testing.T) {
|
|||
// rolls the op back. The rollback must observe the release request and return
|
||||
// the raw op charge instead of resurrecting an owner nobody will release.
|
||||
reserved.release()
|
||||
if !reserved.reclaim(&op) {
|
||||
if !reserved.reclaim(op) {
|
||||
t.Fatal("reclaim actor reservation")
|
||||
}
|
||||
if got := budget.snapshot(); got != 0 {
|
||||
|
|
@ -175,7 +175,7 @@ func TestOutboundActorRetargetRequiresSecondBodyReservation(t *testing.T) {
|
|||
terminalBytes = budget.snapshot()
|
||||
},
|
||||
}
|
||||
err := c.handleOutboundSend(state, op)
|
||||
err := c.handleOutboundSend(state, &op)
|
||||
op.finish(outboundResult{err: err})
|
||||
if !errors.Is(terminalErr, ErrOutboundTrackedBudget) {
|
||||
t.Fatalf("retarget terminal error = %v, want %v", terminalErr, ErrOutboundTrackedBudget)
|
||||
|
|
@ -228,7 +228,7 @@ func TestOutboundActorRetargetTransfersOnlyReplacementToPending(t *testing.T) {
|
|||
terminalBytes = budget.snapshot()
|
||||
},
|
||||
}
|
||||
err := c.handleOutboundSend(state, op)
|
||||
err := c.handleOutboundSend(state, &op)
|
||||
op.finish(outboundResult{err: err})
|
||||
if terminalErr != nil {
|
||||
t.Fatalf("retarget terminal error: %v", terminalErr)
|
||||
|
|
|
|||
|
|
@ -1135,5 +1135,5 @@ func (s *Server) publishRewrappedRPCResult(
|
|||
s.log.Info("RPC init rewrap result replay delivered",
|
||||
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
|
||||
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
|
||||
zap.Int("wire_bytes", len(encoded.body)))
|
||||
zap.Int("wire_bytes", encoded.wireSize()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ type RuntimeSnapshot struct {
|
|||
OutboundTrackedMaxBytes int64
|
||||
OutboundControlBytes int64
|
||||
OutboundControlMaxBytes int64
|
||||
OutboundCriticalBytes int64
|
||||
OutboundCriticalMaxBytes int64
|
||||
OutboundWriteBytes int64
|
||||
OutboundWriteMaxBytes int64
|
||||
RPCExecutionOwners int64
|
||||
|
|
@ -190,6 +192,10 @@ func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
|
|||
result.OutboundControlBytes = s.outboundControlBudget.snapshot()
|
||||
result.OutboundControlMaxBytes = s.outboundControlBudget.maxBytes
|
||||
}
|
||||
if s.outboundCriticalBudget != nil {
|
||||
result.OutboundCriticalBytes = s.outboundCriticalBudget.snapshot()
|
||||
result.OutboundCriticalMaxBytes = s.outboundCriticalBudget.maxBytes
|
||||
}
|
||||
if s.outboundScratchPool != nil && s.outboundScratchPool.budget != nil {
|
||||
result.OutboundWriteBytes = s.outboundScratchPool.snapshot()
|
||||
result.OutboundWriteMaxBytes = s.outboundScratchPool.budget.maxBytes
|
||||
|
|
|
|||
|
|
@ -330,27 +330,21 @@ type Options struct {
|
|||
// 阻断连接维持消息。可靠响应无法 tracking 时终止该连接,durable best-effort update
|
||||
// 则只丢在线加速并由 difference 恢复。
|
||||
OutboundTrackedGlobalMaxBytes int64
|
||||
// OutboundCriticalGlobalMaxBytes is an independent retained-body reserve for
|
||||
// bootstrap/convergence RPC results. Bulk traffic cannot consume it.
|
||||
OutboundCriticalGlobalMaxBytes int64
|
||||
// OutboundWriteGlobalMaxBytes bounds concurrent encrypted wire/codec/obfuscation scratch.
|
||||
// Scratch is shared and pooled across connections; default 512 MiB.
|
||||
OutboundWriteGlobalMaxBytes int64
|
||||
|
||||
// DC 是本 server 的 DC ID。默认 2。
|
||||
DC int
|
||||
// StrictDC turns on exact DC-ID validation for the permanent-key exchange
|
||||
// (default off = lenient). telesrv is always a single physical backend —
|
||||
// there is no real multi-DC federation behind it — but the OwpenGram
|
||||
// client forks intentionally run in "single-server backend" mode, where
|
||||
// dc_id 1..5 all alias to this one server (see owpengram_servers.cpp /
|
||||
// ApplyServerToDcOptions in the desktop client) so that any old data
|
||||
// referencing a specific dc_id still resolves correctly. When tdesktop
|
||||
// adds a new local account it picks its own starting dc_id (its usual
|
||||
// multi-DC load-spreading behavior, unrelated to which physical server
|
||||
// it's actually talking to) — that choice is not guaranteed to equal our
|
||||
// configured DC. Strict validation would reject those accounts with
|
||||
// "-444 wrong dc_id" even though they are connecting to the right (and
|
||||
// only) server; dc_id is a client-side routing label here, not part of
|
||||
// key derivation, so accepting the mismatch does not weaken the exchange.
|
||||
// The switch exists for a hypothetical future real multi-DC deployment.
|
||||
// StrictDC enables DC-label validation during key exchange. It is false by
|
||||
// default: this single physical backend accepts every wire int32 label for
|
||||
// permanent and temporary keys, and the label never changes auth-key
|
||||
// persistence, session identity, or business state. When enabled,
|
||||
// permanent labels must equal DC and temporary labels may equal +/-DC.
|
||||
// This diagnostic switch does not itself provide multi-DC isolation.
|
||||
StrictDC bool
|
||||
// RSAKey 是 server RSA 私钥,用于密钥交换。nil 时无法完成握手。
|
||||
RSAKey *rsa.PrivateKey
|
||||
|
|
@ -458,6 +452,9 @@ func (o *Options) setDefaults() {
|
|||
if o.OutboundTrackedGlobalMaxBytes <= 0 {
|
||||
o.OutboundTrackedGlobalMaxBytes = defaultOutboundTrackedMaxBytes
|
||||
}
|
||||
if o.OutboundCriticalGlobalMaxBytes <= 0 {
|
||||
o.OutboundCriticalGlobalMaxBytes = defaultOutboundCriticalMaxBytes
|
||||
}
|
||||
if o.OutboundWriteGlobalMaxBytes <= 0 {
|
||||
o.OutboundWriteGlobalMaxBytes = defaultOutboundWriteMaxBytes
|
||||
}
|
||||
|
|
@ -518,13 +515,17 @@ type Server struct {
|
|||
rpcQueueSize int
|
||||
rpcTimeout time.Duration
|
||||
rpcScheduler *inboundRPCScheduler
|
||||
bulkRPCScheduler *bulkRPCScheduler
|
||||
rpcDeliveryHooks *rpcDeliveryHookExecutor
|
||||
frameBudget *inboundFrameBudget
|
||||
outboundQueueSize int
|
||||
outboundControlQueueSize int
|
||||
outboundTrackedBudget *outboundTrackedBudget
|
||||
outboundControlBudget *outboundTrackedBudget
|
||||
outboundCriticalBudget *outboundTrackedBudget
|
||||
outboundScratchPool *outboundScratchPool
|
||||
outboundOpPool *outboundOpPool
|
||||
outboundReplayBodyPool *outboundReplayBodyPool
|
||||
|
||||
dc int
|
||||
strictDC bool
|
||||
|
|
@ -581,17 +582,20 @@ func New(opts Options) *Server {
|
|||
rpcQueueSize: opts.RPCQueueSize,
|
||||
rpcTimeout: opts.RPCTimeout,
|
||||
rpcScheduler: newInboundRPCScheduler(opts.RPCGlobalWorkers, opts.RPCGlobalMaxTasks, opts.RPCGlobalMaxBytes),
|
||||
bulkRPCScheduler: newBulkRPCScheduler(max(1, opts.RPCGlobalWorkers/2)),
|
||||
rpcDeliveryHooks: newRPCDeliveryHookExecutor(opts.RPCDeliveryHookWorkers, opts.RPCDeliveryHookMaxPending),
|
||||
frameBudget: newInboundFrameBudget(opts.InboundFrameGlobalMaxBytes),
|
||||
outboundQueueSize: opts.OutboundQueueSize,
|
||||
outboundControlQueueSize: opts.OutboundControlQueueSize,
|
||||
outboundTrackedBudget: newOutboundTrackedBudget(opts.OutboundTrackedGlobalMaxBytes),
|
||||
outboundControlBudget: newOutboundTrackedBudget(defaultOutboundControlMaxBytes),
|
||||
outboundCriticalBudget: newOutboundTrackedBudget(opts.OutboundCriticalGlobalMaxBytes),
|
||||
outboundScratchPool: newOutboundScratchPool(opts.OutboundWriteGlobalMaxBytes),
|
||||
outboundOpPool: newOutboundOpPool(defaultOutboundOpPoolSize),
|
||||
outboundReplayBodyPool: newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses),
|
||||
dc: opts.DC,
|
||||
strictDC: opts.StrictDC,
|
||||
key: exchange.PrivateKey{RSA: opts.RSAKey},
|
||||
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
|
||||
authKeys: opts.AuthKeys,
|
||||
conns: conns,
|
||||
rpc: opts.legacyRPC,
|
||||
|
|
@ -612,6 +616,7 @@ func New(opts Options) *Server {
|
|||
}),
|
||||
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
|
||||
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
|
||||
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
|
||||
}
|
||||
if opts.IdentityDir != "" {
|
||||
server.identityStore = identity.NewStore(opts.IdentityDir)
|
||||
|
|
@ -662,26 +667,29 @@ func (s *Server) newConnWithLease(lease *physicalTransportLease, key crypto.Auth
|
|||
|
||||
func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn {
|
||||
c := &Conn{
|
||||
transport: tc,
|
||||
transportLease: lease,
|
||||
writer: tc,
|
||||
cipher: s.cipher,
|
||||
msgID: proto.NewMessageIDGen(s.clock.Now),
|
||||
writeTimeout: s.writeTimeout,
|
||||
metrics: s.metrics,
|
||||
now: s.clock.Now,
|
||||
authKeyID: key.ID,
|
||||
authKeyHex: hex.EncodeToString(key.ID[:]),
|
||||
sessionID: sessionID,
|
||||
salt: salt,
|
||||
key: key,
|
||||
createdAt: s.clock.Now(),
|
||||
outboundQueueSize: s.outboundQueueSize,
|
||||
outboundControlQueueSize: s.outboundControlQueueSize,
|
||||
outboundTrackedBudget: s.outboundTrackedBudget,
|
||||
outboundControlTrackedBudget: s.outboundControlBudget,
|
||||
outboundScratchPool: s.outboundScratchPool,
|
||||
rpcDeliveryHooks: s.rpcDeliveryHooks,
|
||||
transport: tc,
|
||||
transportLease: lease,
|
||||
writer: tc,
|
||||
cipher: s.cipher,
|
||||
msgID: proto.NewMessageIDGen(s.clock.Now),
|
||||
writeTimeout: s.writeTimeout,
|
||||
metrics: s.metrics,
|
||||
now: s.clock.Now,
|
||||
authKeyID: key.ID,
|
||||
authKeyHex: hex.EncodeToString(key.ID[:]),
|
||||
sessionID: sessionID,
|
||||
salt: salt,
|
||||
key: key,
|
||||
createdAt: s.clock.Now(),
|
||||
outboundQueueSize: s.outboundQueueSize,
|
||||
outboundControlQueueSize: s.outboundControlQueueSize,
|
||||
outboundTrackedBudget: s.outboundTrackedBudget,
|
||||
outboundControlTrackedBudget: s.outboundControlBudget,
|
||||
outboundCriticalTrackedBudget: s.outboundCriticalBudget,
|
||||
outboundScratchPool: s.outboundScratchPool,
|
||||
outboundOpPool: s.outboundOpPool,
|
||||
outboundReplayBodyPool: s.outboundReplayBodyPool,
|
||||
rpcDeliveryHooks: s.rpcDeliveryHooks,
|
||||
rpcResultAcked: func(conn *Conn, reqMsgID int64) {
|
||||
// The sole outbound actor invokes this only after resolving a client
|
||||
// msgs_ack server msg_id through its tracked resend frame. The actor has
|
||||
|
|
@ -708,6 +716,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
|
|||
defer s.rpcDeliveryHooks.stop(rpcCloseWaitTimeout)
|
||||
defer s.conns.releaseAllLogicalSessions()
|
||||
defer s.rpcScheduler.stop(rpcCloseWaitTimeout)
|
||||
defer s.bulkRPCScheduler.close()
|
||||
// 只在最外层 listener 包一次,确保 same-port mux 的 sniff/HTTP upgrade 也计入
|
||||
// raw admission,而不是等连接已经分流后才计数。
|
||||
ln = s.observeRawAccepts(s.admission.wrapListener(ln))
|
||||
|
|
@ -1147,7 +1156,7 @@ func (s *Server) serveConn(ctx context.Context, raw transport.Conn, remote, loca
|
|||
fetchedKey = &d
|
||||
}
|
||||
|
||||
current, err = s.handleEncrypted(ctx, conn, cs, current, fetchedKey, &b, &plain)
|
||||
current, err = s.handleEncrypted(ctx, conn, cs, current, remote, fetchedKey, &b, &plain)
|
||||
if errors.Is(err, errActivationAuthKeyRejected) {
|
||||
// handleEncrypted writes -404 while its activation claim still owns the
|
||||
// physical writer, then its deferred abort removes/closes the claim.
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
|
|||
cs := newConnState()
|
||||
var plain bin.Buffer
|
||||
|
||||
firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstWrong, &plain)
|
||||
firstConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstWrong, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("first bad salt: %v", err)
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
|
|||
secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
|
||||
t, key, wrongSalt, serverSalt, sessionID, secondID, 3,
|
||||
)
|
||||
secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, nil, secondWrong, &plain)
|
||||
secondConn, err := s.handleEncrypted(context.Background(), tr, cs, firstConn, "", nil, secondWrong, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("second bad salt: %v", err)
|
||||
}
|
||||
|
|
@ -123,7 +123,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
|
|||
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
|
||||
t, key, serverSalt, serverSalt, sessionID, firstID, 1,
|
||||
)
|
||||
activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, nil, corrected, &plain)
|
||||
activeConn, err := s.handleEncrypted(context.Background(), tr, cs, secondConn, "", nil, corrected, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("corrected retry: %v", err)
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
|
|||
}
|
||||
cs := newConnState()
|
||||
var plain bin.Buffer
|
||||
oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, firstFrame, &plain)
|
||||
oldConn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, firstFrame, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("activate first session: %v", err)
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
|
|||
wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
|
||||
t, key, wrongSalt, serverSalt, secondSID, secondID, 1,
|
||||
)
|
||||
newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, nil, wrongFrame, &plain)
|
||||
newConn, err := s.handleEncrypted(context.Background(), tr, cs, oldConn, "", nil, wrongFrame, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("new session bad salt: %v", err)
|
||||
}
|
||||
|
|
@ -205,7 +205,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
|
|||
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
|
||||
t, key, serverSalt, serverSalt, secondSID, secondID, 1,
|
||||
)
|
||||
activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, nil, corrected, &plain)
|
||||
activated, err := s.handleEncrypted(context.Background(), tr, cs, newConn, "", nil, corrected, &plain)
|
||||
if err != nil {
|
||||
t.Fatalf("activate transferred session: %v", err)
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ func TestHandleEncryptedRequiredSessionBarrierPrecedesStateRegistrationAndRPC(t
|
|||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
|
||||
conn, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain)
|
||||
done <- result{conn: conn, err: err}
|
||||
}()
|
||||
|
||||
|
|
@ -394,7 +394,7 @@ func TestHandleEncryptedRequiredSessionBarrierFailureIsAtomic(t *testing.T) {
|
|||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.handleEncrypted(context.Background(), tr, cs, nil, &stored, frame, &plain)
|
||||
_, err := s.handleEncrypted(context.Background(), tr, cs, nil, "", &stored, frame, &plain)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
|
|
@ -458,7 +458,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
|
|||
firstTransport := &collectingSessionTransport{}
|
||||
firstState := newConnState()
|
||||
var firstPlain bin.Buffer
|
||||
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
|
||||
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain)
|
||||
if err != nil {
|
||||
t.Fatalf("first handleEncrypted: %v", err)
|
||||
}
|
||||
|
|
@ -478,7 +478,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
|
|||
}
|
||||
secondDone := make(chan handleResult, 1)
|
||||
go func() {
|
||||
conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
|
||||
conn, handleErr := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain)
|
||||
secondDone <- handleResult{conn: conn, err: handleErr}
|
||||
}()
|
||||
|
||||
|
|
@ -566,7 +566,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
|
|||
firstTransport := &collectingSessionTransport{}
|
||||
firstState := newConnState()
|
||||
var firstPlain bin.Buffer
|
||||
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, &stored, firstFrame, &firstPlain)
|
||||
firstConn, err := s.handleEncrypted(context.Background(), firstTransport, firstState, nil, "", &stored, firstFrame, &firstPlain)
|
||||
if err != nil {
|
||||
t.Fatalf("first handleEncrypted: %v", err)
|
||||
}
|
||||
|
|
@ -580,7 +580,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
|
|||
secondTransport := &collectingSessionTransport{}
|
||||
secondState := newConnState()
|
||||
var secondPlain bin.Buffer
|
||||
secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, &stored, secondFrame, &secondPlain)
|
||||
secondConn, err := s.handleEncrypted(context.Background(), secondTransport, secondState, nil, "", &stored, secondFrame, &secondPlain)
|
||||
if err != nil && !errors.Is(err, ErrConnClosed) {
|
||||
t.Fatalf("second handleEncrypted: %v", err)
|
||||
}
|
||||
|
|
@ -605,7 +605,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
|
|||
thirdTransport := &collectingSessionTransport{}
|
||||
thirdState := newConnState()
|
||||
var thirdPlain bin.Buffer
|
||||
thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, &stored, thirdFrame, &thirdPlain)
|
||||
thirdConn, err := s.handleEncrypted(context.Background(), thirdTransport, thirdState, nil, "", &stored, thirdFrame, &thirdPlain)
|
||||
if err != nil {
|
||||
t.Fatalf("third handleEncrypted: %v", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,8 +219,8 @@ func TestSessionManagerBestEffortFanoutPreparesOncePerProfile(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: int64(i + 1),
|
||||
authKeyID: [8]byte{byte(i + 1)},
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
metrics: NopMetrics{},
|
||||
}
|
||||
|
|
@ -270,8 +270,8 @@ func TestSessionManagerMixedLayerFanoutUsesProfileBoundBodies(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: int64(profile),
|
||||
authKeyID: authKeyID,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
metrics: NopMetrics{},
|
||||
}
|
||||
|
|
@ -390,11 +390,11 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
|
|||
authKeyID: [8]byte{byte(i + 1)},
|
||||
transport: tr,
|
||||
metrics: NopMetrics{},
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.outbound <- outboundOp{}
|
||||
c.outbound <- &outboundOp{}
|
||||
c.userID.Store(userID)
|
||||
c.userIDResolved.Store(true)
|
||||
c.receivesUpdates.Store(true)
|
||||
|
|
@ -409,8 +409,8 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
|
|||
sessionID: 99,
|
||||
authKeyID: [8]byte{99},
|
||||
metrics: NopMetrics{},
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
healthy.userID.Store(userID)
|
||||
|
|
@ -656,8 +656,8 @@ func TestForceCloseBatchTimeoutStillClosesProducerAndRPCGates(t *testing.T) {
|
|||
c := &Conn{
|
||||
transport: tr,
|
||||
metrics: NopMetrics{},
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)
|
||||
|
|
@ -743,8 +743,8 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
|
|||
sessionID: sessionID,
|
||||
metrics: NopMetrics{},
|
||||
transport: transport,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.receivesUpdates.Store(true)
|
||||
|
|
@ -752,7 +752,7 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
|
|||
t.Fatalf("freeze profile: %v", err)
|
||||
}
|
||||
if queueFull {
|
||||
c.outbound <- outboundOp{}
|
||||
c.outbound <- &outboundOp{}
|
||||
}
|
||||
sm.Register(c)
|
||||
sm.BindAuthKeyForSession(raw, sessionID, business)
|
||||
|
|
@ -961,8 +961,8 @@ func TestPushToSessionForAuthKeyImmediateBypassesReadinessQueue(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: 42,
|
||||
authKeyID: raw,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
if err := c.FreezeLayerProfile(tlprofile.Profile227); err != nil {
|
||||
|
|
@ -1006,8 +1006,8 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
|
|||
c := &Conn{
|
||||
authKeyID: key.authKeyID,
|
||||
sessionID: key.sessionID,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
metrics: NopMetrics{},
|
||||
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
|
||||
|
|
@ -1049,7 +1049,7 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
|
|||
}
|
||||
c.membershipsSynced.Store(true)
|
||||
sm.SetReceivesUpdatesForAuthKey(key.authKeyID, key.sessionID, true)
|
||||
var op outboundOp
|
||||
var op *outboundOp
|
||||
select {
|
||||
case op = <-c.outbound:
|
||||
case <-time.After(time.Second):
|
||||
|
|
@ -1210,8 +1210,8 @@ func TestPendingFlushGlobalBodyPressureDoesNotTerminateHealthyConnection(t *test
|
|||
c := &Conn{
|
||||
authKeyID: key.authKeyID,
|
||||
sessionID: key.sessionID,
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
metrics: NopMetrics{},
|
||||
outboundTrackedBudget: newOutboundTrackedBudget(1),
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ func TestTerminalFailurePathsCloseGatesBeforeBlockingTransportClose(t *testing.T
|
|||
c := &Conn{
|
||||
transport: tr,
|
||||
metrics: NopMetrics{},
|
||||
outbound: make(chan outboundOp, 1),
|
||||
outboundControl: make(chan outboundOp, 1),
|
||||
outbound: make(chan *outboundOp, 1),
|
||||
outboundControl: make(chan *outboundOp, 1),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)
|
||||
|
|
|
|||
|
|
@ -224,8 +224,8 @@ func TestContainerInvalidSequenceTailIsAtomic(t *testing.T) {
|
|||
cs := newConnState()
|
||||
c := &Conn{
|
||||
metrics: NopMetrics{},
|
||||
outbound: make(chan outboundOp, 4),
|
||||
outboundControl: make(chan outboundOp, 4),
|
||||
outbound: make(chan *outboundOp, 4),
|
||||
outboundControl: make(chan *outboundOp, 4),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
var acks []int64
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
|
|||
c := &Conn{
|
||||
sessionID: 7,
|
||||
authKeyID: [8]byte{7},
|
||||
outbound: make(chan outboundOp, 4),
|
||||
outboundControl: make(chan outboundOp, 4),
|
||||
outbound: make(chan *outboundOp, 4),
|
||||
outboundControl: make(chan *outboundOp, 4),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.userID.Store(userID)
|
||||
|
|
@ -63,7 +63,7 @@ func TestPushTransientCompatibleSkipsUnavailableAndUnknownProfiles(t *testing.T)
|
|||
makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn {
|
||||
c := &Conn{
|
||||
sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)},
|
||||
outbound: make(chan outboundOp, 2), outboundControl: make(chan outboundOp, 2),
|
||||
outbound: make(chan *outboundOp, 2), outboundControl: make(chan *outboundOp, 2),
|
||||
outboundStop: make(chan struct{}),
|
||||
}
|
||||
c.userID.Store(userID)
|
||||
|
|
|
|||
|
|
@ -227,9 +227,12 @@ func (r *Registry) add(name string, value uint64, labels ...Label) {
|
|||
}
|
||||
|
||||
func (r *Registry) addGauge(name string, delta int64, labels ...Label) {
|
||||
if r == nil || delta == 0 {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
// A zero delta still touches the series: callers use it to declare an
|
||||
// idle gauge exists (e.g. XxxPending(0) at startup) so /metrics exposes
|
||||
// "0" immediately instead of omitting the line until first activity.
|
||||
r.gauge(newSeriesKey(name, labels...)).value.Add(delta)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
|
|||
Hash: aiComposeToneWebPageHash(tone),
|
||||
Date: int(now.Unix()),
|
||||
Type: aiComposeToneWebPageType,
|
||||
SiteName: branding.ProductName,
|
||||
SiteName: branding.ProductName(),
|
||||
Title: tone.Title,
|
||||
Description: tone.Prompt,
|
||||
ComposeToneEmojiID: tone.EmojiID,
|
||||
|
|
|
|||
|
|
@ -41,8 +41,18 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
|
|||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrPersistentTimestamp) {
|
||||
r.log.Debug("channel difference cursor rejected",
|
||||
zap.Int64("viewer_user_id", userID),
|
||||
zap.Int64("channel_id", channelID),
|
||||
zap.Int("request_pts", req.Pts))
|
||||
return nil, persistentTimestampInvalidErr()
|
||||
}
|
||||
r.log.Warn("load channel difference failed",
|
||||
zap.Int64("viewer_user_id", userID),
|
||||
zap.Int64("channel_id", channelID),
|
||||
zap.Int("request_pts", req.Pts),
|
||||
zap.Int("limit", req.Limit),
|
||||
zap.Error(err))
|
||||
return nil, channelInvalidErr(err)
|
||||
}
|
||||
diff, err = r.enrichChannelDifferenceStrict(ctx, userID, diff)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/transport"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
|
@ -303,3 +304,15 @@ func invokeWithoutUpdatesFrom(ctx context.Context) bool {
|
|||
v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// WithClientIP 在 ctx 注入客户端连接的对端 IP(来自 MTProto 连接的 RemoteAddr)。
|
||||
// 仅在需要时(绑定设备授权)由 edge 写入,其余 RPC 不依赖它。edge 通过中立
|
||||
// internal/transport 载体写入,这里仅做别名以便 rpc 业务层读取,避免反向依赖。
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
return transport.WithClientIP(ctx, ip)
|
||||
}
|
||||
|
||||
// ClientIPFrom 返回 ctx 中的客户端对端 IP,未设置时 ok=false。
|
||||
func ClientIPFrom(ctx context.Context) (string, bool) {
|
||||
return transport.ClientIPFrom(ctx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization {
|
|||
a.AppVersion = ci.AppVersion
|
||||
a.APIID = ci.APIID
|
||||
}
|
||||
if ip, ok := ClientIPFrom(ctx); ok {
|
||||
a.IP = ip
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
|
|||
}
|
||||
return &tg.MessageReplyStoryHeader{Peer: peer, StoryID: m.ReplyTo.StoryID}
|
||||
}
|
||||
if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 {
|
||||
if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 && m.ReplyTo.External == nil {
|
||||
return nil
|
||||
}
|
||||
header := &tg.MessageReplyHeader{}
|
||||
|
|
@ -364,6 +364,18 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
|
|||
header.SetReplyToPeerID(peer)
|
||||
}
|
||||
}
|
||||
if external := m.ReplyTo.External; external != nil {
|
||||
if from := tgMessageFwdHeader(&external.From); from != nil {
|
||||
header.SetReplyFrom(*from)
|
||||
}
|
||||
if !external.Media.IsZero() {
|
||||
header.SetReplyMedia(tgMessageMedia(external.Media))
|
||||
}
|
||||
if m.ReplyTo.QuoteText == "" && external.Text != "" {
|
||||
header.SetQuoteText(external.Text)
|
||||
header.SetQuoteEntities(tgMessageEntities(external.Entities))
|
||||
}
|
||||
}
|
||||
if m.ReplyTo.QuoteText != "" {
|
||||
header.SetQuote(true)
|
||||
header.SetQuoteText(m.ReplyTo.QuoteText)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ func peersListEmptyErr() error { return tgerr.New(400, "PEERS_LIST_EMPTY") }
|
|||
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
|
||||
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") }
|
||||
|
||||
func fromPeerInvalidErr() error { return tgerr.New(400, "FROM_PEER_INVALID") }
|
||||
|
||||
func parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") }
|
||||
|
||||
func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID") }
|
||||
|
|
@ -442,6 +444,8 @@ func dhGAInvalidErr() error { return tgerr.New(400, "DH_G_A_INVALI
|
|||
func maxDateInvalidErr() error { return tgerr.New(400, "MAX_DATE_INVALID") }
|
||||
func fileEmptyErr() error { return tgerr.New(400, "FILE_EMPTY") }
|
||||
|
||||
func quoteTextInvalidErr() error { return tgerr.New(400, "QUOTE_TEXT_INVALID") }
|
||||
|
||||
// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。
|
||||
func signInErr(err error) error {
|
||||
switch {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
|
|||
}, nil
|
||||
})
|
||||
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) {
|
||||
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil
|
||||
return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName() + "."}, nil
|
||||
})
|
||||
registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
|
||||
return r.onHelpSaveAppLog(ctx)
|
||||
|
|
@ -178,7 +178,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis
|
|||
// dead payment URLs. All six TL fields are mandatory.
|
||||
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
|
||||
promo := &tg.HelpPremiumPromo{
|
||||
StatusText: branding.PremiumName + " is not active on this account.",
|
||||
StatusText: branding.PremiumName() + " is not active on this account.",
|
||||
StatusEntities: []tg.MessageEntityClass{},
|
||||
VideoSections: []string{},
|
||||
Videos: []tg.DocumentClass{},
|
||||
|
|
@ -207,7 +207,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
|
|||
}
|
||||
if u.PremiumActiveAt(r.clock.Now().Unix()) {
|
||||
until := time.Unix(int64(u.PremiumUntil), 0)
|
||||
promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "."
|
||||
promo.StatusText = branding.PremiumName() + " is active until " + until.Format("2006-01-02") + "."
|
||||
}
|
||||
if r.deps.PremiumPromo != nil {
|
||||
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)
|
||||
|
|
|
|||
|
|
@ -12,12 +12,48 @@ import (
|
|||
"github.com/iamxvbaba/td/tlprofile"
|
||||
compatandroid "telesrv/internal/compat/android"
|
||||
"telesrv/internal/observability/dbtrace"
|
||||
"telesrv/internal/rpcresult"
|
||||
)
|
||||
|
||||
type layerWrappersAppliedKey struct{}
|
||||
type layerAdmissionSequenceKey struct{}
|
||||
type layerRPCProfileEvidenceFreshKey struct{}
|
||||
|
||||
type immutableLayerRPCReplaySource struct {
|
||||
call tlprofile.Call
|
||||
source rpcresult.ValueSource
|
||||
}
|
||||
|
||||
func (s *immutableLayerRPCReplaySource) EncodeInner(ctx context.Context, out *bin.Buffer) error {
|
||||
if s == nil || s.source == nil {
|
||||
return fmt.Errorf("immutable layer RPC replay source is unavailable")
|
||||
}
|
||||
value, err := s.source.Value(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.call.EncodeResult(value, out)
|
||||
}
|
||||
|
||||
func (s *immutableLayerRPCReplaySource) RetainedBytes() int {
|
||||
if s == nil || s.source == nil {
|
||||
return 0
|
||||
}
|
||||
return s.source.RetainedBytes() + 128
|
||||
}
|
||||
|
||||
type immutableLayerRPCResult struct {
|
||||
tlprofile.Result
|
||||
source rpcresult.ReplaySource
|
||||
}
|
||||
|
||||
func (r *immutableLayerRPCResult) ExactReplaySource() rpcresult.ReplaySource {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return r.source
|
||||
}
|
||||
|
||||
// WithLayerRPCProfileEvidenceFresh records whether an admitted request's
|
||||
// explicit selector is inside MTProto's mutable msg_id freshness window. A
|
||||
// stale selector still owns its immutable request/result codec, but wrapper
|
||||
|
|
@ -278,7 +314,16 @@ func (r *Router) DispatchAdmitted(
|
|||
}
|
||||
dbBefore := dbtrace.SnapshotFromContext(ctx)
|
||||
start := time.Now()
|
||||
result, err := r.dispatchGeneratedSafely(ctx, method, request)
|
||||
dispatchCtx, replayCapture := rpcresult.WithCapture(ctx)
|
||||
result, err := r.dispatchGeneratedSafely(dispatchCtx, method, request)
|
||||
if err == nil && result != nil {
|
||||
if valueSource := replayCapture.Take(); valueSource != nil {
|
||||
result = &immutableLayerRPCResult{
|
||||
Result: result,
|
||||
source: &immutableLayerRPCReplaySource{call: call, source: valueSource},
|
||||
}
|
||||
}
|
||||
}
|
||||
dur := time.Since(start)
|
||||
dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore)
|
||||
fields := append([]zap.Field{
|
||||
|
|
|
|||
87
internal/rpc/messages_external_reply_test.go
Normal file
87
internal/rpc/messages_external_reply_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestExternalPrivateReplySnapshotWireAndProtection(t *testing.T) {
|
||||
r, store, events, a, b := savedForwardFixture(t)
|
||||
ctx := WithUserID(context.Background(), a.ID)
|
||||
seed, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "a🌕 quote", Date: 1700000000, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{FirstName: "snapshot", PhoneNumber: "123"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, manual := range []bool{false, true} {
|
||||
reply := &tg.InputReplyToMessage{ReplyToMsgID: seed.SenderMessage.ID}
|
||||
reply.SetReplyToPeerID(&tg.InputPeerSelf{})
|
||||
random := int64(2)
|
||||
if manual {
|
||||
random = 3
|
||||
reply.SetQuoteText("quote")
|
||||
reply.SetQuoteOffset(4)
|
||||
}
|
||||
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, Message: "external", RandomID: random}
|
||||
req.SetReplyTo(reply)
|
||||
out, err := r.onMessagesSendMessage(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
|
||||
h := m.ReplyTo.(*tg.MessageReplyHeader)
|
||||
if h.ReplyFrom.FromID.(*tg.PeerUser).UserID != a.ID || h.ReplyFrom.Date != seed.SenderMessage.Date || h.ReplyToMsgID != seed.SenderMessage.ID || h.Quote != manual {
|
||||
t.Fatalf("sender reply=%+v", h)
|
||||
}
|
||||
if manual {
|
||||
if h.QuoteText != "quote" || h.QuoteOffset != 4 {
|
||||
t.Fatal("manual quote")
|
||||
}
|
||||
} else if h.QuoteText != seed.SenderMessage.Body {
|
||||
t.Fatal("external preview text missing")
|
||||
}
|
||||
if h.ReplyMedia.(*tg.MessageMediaContact).FirstName != "snapshot" {
|
||||
t.Fatal("media snapshot")
|
||||
}
|
||||
ownerEvents := savedForwardEvents(t, events, b.ID)[0]
|
||||
recipient := ownerEvents[len(ownerEvents)-1].Message
|
||||
rh := tgMessageReplyHeader(recipient).(*tg.MessageReplyHeader)
|
||||
if _, set := rh.GetReplyToMsgID(); set {
|
||||
t.Fatal("recipient received sender-owned source ID")
|
||||
}
|
||||
if !reflect.DeepEqual(rh.ReplyFrom, h.ReplyFrom) {
|
||||
t.Fatal("external author differs by owner")
|
||||
}
|
||||
if manual {
|
||||
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{seed.SenderMessage.ID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if manual {
|
||||
before := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
replayed, err := r.onMessagesSendMessage(ctx, req)
|
||||
if err != nil || !reflect.DeepEqual(out.(*tg.Updates).Updates, replayed.(*tg.Updates).Updates) || !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatalf("exact replay=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
protected, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 10, Message: "protected", NoForwards: true, Date: 1700000001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply := &tg.InputReplyToMessage{ReplyToMsgID: protected.SenderMessage.ID}
|
||||
reply.SetReplyToPeerID(&tg.InputPeerSelf{})
|
||||
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, Message: "forbidden", RandomID: 11}
|
||||
req.SetReplyTo(reply)
|
||||
before := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
if _, err := r.onMessagesSendMessage(ctx, req); !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") {
|
||||
t.Fatalf("protected source=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatal("rejection wrote event")
|
||||
}
|
||||
}
|
||||
|
|
@ -514,7 +514,7 @@ func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, in
|
|||
}
|
||||
return fromPeer, sources, nil
|
||||
}
|
||||
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
fromPeer, err := r.checkedMessageReadPeer(ctx, userID, input, false)
|
||||
return fromPeer, nil, err
|
||||
}
|
||||
|
||||
|
|
@ -682,7 +682,9 @@ func (r *Router) forwardSourcesFromPrivateMessages(ctx context.Context, userID i
|
|||
if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
|
||||
return nil, domain.ErrMessageIDInvalid
|
||||
}
|
||||
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok {
|
||||
// Saved Messages has no two-user protection state. Its individual source
|
||||
// messages still pass the noforwards check below, including inferred peers.
|
||||
if svc, ok := r.deps.Messages.(PrivateNoForwardsService); ok && fromPeer.ID != userID {
|
||||
state, err := svc.GetPrivateNoForwards(ctx, userID, fromPeer.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -771,6 +773,8 @@ func messageForwardErr(err error) error {
|
|||
return messageIDInvalidErr()
|
||||
case errors.Is(err, domain.ErrChatForwardsRestricted):
|
||||
return chatForwardsRestrictedErr()
|
||||
case errors.Is(err, domain.ErrQuoteTextInvalid):
|
||||
return quoteTextInvalidErr()
|
||||
case errors.Is(err, domain.ErrReplyMessageIDInvalid):
|
||||
return replyMessageIDInvalidErr()
|
||||
case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
|
||||
|
|
|
|||
|
|
@ -806,10 +806,13 @@ func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.Messag
|
|||
return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users}
|
||||
}
|
||||
|
||||
func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) {
|
||||
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
|
||||
if !ok {
|
||||
return domain.MessageFilter{}, false
|
||||
func (r *Router) messageFilterFromHistoryRequest(ctx context.Context, userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, error) {
|
||||
if err := validateMessageReadBounds(req.Limit, req.OffsetID, req.MaxID, req.MinID); err != nil {
|
||||
return domain.MessageFilter{}, err
|
||||
}
|
||||
peer, err := r.checkedMessageReadPeer(ctx, userID, req.Peer, false)
|
||||
if err != nil {
|
||||
return domain.MessageFilter{}, err
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit > 50 {
|
||||
|
|
@ -825,10 +828,13 @@ func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesG
|
|||
MaxID: req.MaxID,
|
||||
MinID: req.MinID,
|
||||
Hash: req.Hash,
|
||||
}, true
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int64, req *tg.MessagesSearchRequest) (domain.MessageFilter, error) {
|
||||
if err := validateMessageReadBounds(req.Limit, req.OffsetID, req.MaxID, req.MinID); err != nil {
|
||||
return domain.MessageFilter{}, err
|
||||
}
|
||||
limit := req.Limit
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
|
|
@ -840,6 +846,7 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
|
|||
MaxDate: req.MaxDate,
|
||||
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
|
||||
Limit: limit,
|
||||
CountOnly: req.Limit == 0,
|
||||
MaxID: req.MaxID,
|
||||
MinID: req.MinID,
|
||||
Hash: req.Hash,
|
||||
|
|
@ -850,10 +857,21 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
|
|||
filter.PhoneCallsOnly = true
|
||||
filter.MissedPhoneCallsOnly = phoneCalls.Missed
|
||||
}
|
||||
if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok {
|
||||
if empty, ok := req.Peer.(*tg.InputPeerEmpty); !ok || empty == nil {
|
||||
peer, err := r.checkedMessageReadPeer(ctx, userID, req.Peer, false)
|
||||
if err != nil {
|
||||
return domain.MessageFilter{}, err
|
||||
}
|
||||
filter.HasPeer = true
|
||||
filter.Peer = peer
|
||||
}
|
||||
if req.FromID != nil {
|
||||
from, err := r.checkedMessageReadPeer(ctx, userID, req.FromID, true)
|
||||
if err != nil {
|
||||
return domain.MessageFilter{}, err
|
||||
}
|
||||
filter.SenderUserID = from.ID
|
||||
}
|
||||
savedReactions, hasSavedReactions := req.GetSavedReaction()
|
||||
// An empty optional vector carries no reaction-filtering semantics. Some TL
|
||||
// clients emit flags.3 with a zero-length vector on ordinary peer searches.
|
||||
|
|
|
|||
|
|
@ -580,7 +580,7 @@ func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) {
|
|||
Hash: 99,
|
||||
},
|
||||
}
|
||||
r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System)
|
||||
r := New(Config{}, Deps{Messages: messages, Users: mapUsersService{users: map[int64]domain.User{domain.OfficialSystemUserID: domain.OfficialSystemUser()}}}, zaptest.NewLogger(t), clock.System)
|
||||
req := &tg.MessagesGetHistoryRequest{
|
||||
Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash},
|
||||
Limit: 20,
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ func TestSavedReactionTagHashMatchesClientShape(t *testing.T) {
|
|||
|
||||
func TestMessageFilterFromSearchRequestParsesSavedTagsAndPeer(t *testing.T) {
|
||||
const userID = int64(1000000001)
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
r := New(Config{}, Deps{Users: mapUsersService{users: map[int64]domain.User{userID + 1: {ID: userID + 1, AccessHash: 1}}}}, zaptest.NewLogger(t), clock.System)
|
||||
req := &tg.MessagesSearchRequest{
|
||||
Peer: &tg.InputPeerSelf{},
|
||||
Q: "needle",
|
||||
|
|
|
|||
83
internal/rpc/messages_read_filter.go
Normal file
83
internal/rpc/messages_read_filter.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type privateSendBaseUserResolver interface {
|
||||
BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error)
|
||||
}
|
||||
|
||||
// Read endpoints retain their existing positive page caps and signed
|
||||
// add_offset semantics. Invalid IDs must not become an unbounded query.
|
||||
func validateMessageReadBounds(limit, offsetID, maxID, minID int) error {
|
||||
if limit < 0 {
|
||||
return limitInvalidErr()
|
||||
}
|
||||
for _, id := range [...]int{offsetID, maxID, minID} {
|
||||
if id < 0 || id > domain.MaxMessageBoxID {
|
||||
return msgIDInvalidErr()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkedMessageReadPeer never treats an invalid reference as global scope.
|
||||
// Only the search parser admits InputPeerEmpty, explicitly. Base identity
|
||||
// lookup avoids loading viewer-specific user projections for count requests.
|
||||
func (r *Router) checkedMessageReadPeer(ctx context.Context, userID int64, input tg.InputPeerClass, sender bool) (domain.Peer, error) {
|
||||
invalid := peerIDInvalidErr
|
||||
if sender {
|
||||
invalid = fromPeerInvalidErr
|
||||
}
|
||||
if inputPeerClassNil(input) {
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
switch peer := input.(type) {
|
||||
case *tg.InputPeerSelf:
|
||||
if userID <= 0 {
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil
|
||||
case *tg.InputPeerUser:
|
||||
if peer.UserID <= 0 {
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
if r.deps.Users == nil {
|
||||
return domain.Peer{}, internalErr()
|
||||
}
|
||||
if resolver, ok := r.deps.Users.(privateSendBaseUserResolver); ok {
|
||||
users, err := resolver.BaseUsersByIDs(ctx, []int64{peer.UserID})
|
||||
if err != nil {
|
||||
return domain.Peer{}, internalErr()
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.ID == peer.UserID && user.AccessHash == peer.AccessHash {
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: peer.UserID}, nil
|
||||
}
|
||||
}
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
user, found, err := r.deps.Users.ByID(ctx, userID, peer.UserID)
|
||||
if err != nil {
|
||||
return domain.Peer{}, internalErr()
|
||||
}
|
||||
if found && user.AccessHash == peer.AccessHash {
|
||||
return domain.Peer{Type: domain.PeerTypeUser, ID: peer.UserID}, nil
|
||||
}
|
||||
return domain.Peer{}, invalid()
|
||||
default:
|
||||
if sender {
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
resolved, ok := r.domainPeerFromInputPeer(userID, input)
|
||||
if !ok || resolved.ID <= 0 {
|
||||
return domain.Peer{}, invalid()
|
||||
}
|
||||
return r.checkedDomainPeerFromInputPeer(ctx, userID, input)
|
||||
}
|
||||
}
|
||||
83
internal/rpc/messages_read_filter_test.go
Normal file
83
internal/rpc/messages_read_filter_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type failingReadUsers struct{ mapUsersService }
|
||||
|
||||
func (failingReadUsers) BaseUsersByIDs(context.Context, []int64) ([]domain.User, error) {
|
||||
return nil, errors.New("identity unavailable")
|
||||
}
|
||||
|
||||
func TestMessageReadInputScope(t *testing.T) {
|
||||
users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{22: {ID: 22, AccessHash: 77}}}}
|
||||
r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := context.Background()
|
||||
invalid := []tg.InputPeerClass{nil, (*tg.InputPeerUser)(nil), &tg.InputPeerEmpty{}, &tg.InputPeerUser{UserID: 0}, &tg.InputPeerUser{UserID: -22}, &tg.InputPeerUser{UserID: 22, AccessHash: 0}, &tg.InputPeerUser{UserID: 22, AccessHash: 78}, &tg.InputPeerUser{UserID: 23, AccessHash: 77}, &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, MsgID: 1, UserID: 22}}
|
||||
for i, p := range invalid {
|
||||
for _, sender := range []bool{false, true} {
|
||||
_, err := r.checkedMessageReadPeer(ctx, 11, p, sender)
|
||||
want := "PEER_ID_INVALID"
|
||||
if sender {
|
||||
want = "FROM_PEER_INVALID"
|
||||
}
|
||||
if !tgerr.Is(err, want) {
|
||||
t.Fatalf("invalid[%d], sender=%t err=%v", i, sender, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range []tg.InputPeerClass{&tg.InputPeerEmpty{}, &tg.InputPeerSelf{}, &tg.InputPeerUser{UserID: 22, AccessHash: 77}} {
|
||||
req := &tg.MessagesSearchRequest{Peer: p, FromID: &tg.InputPeerUser{UserID: 22, AccessHash: 77}, Filter: &tg.InputMessagesFilterEmpty{}}
|
||||
f, err := r.messageFilterFromSearchRequest(ctx, 11, req)
|
||||
_, global := p.(*tg.InputPeerEmpty)
|
||||
if err != nil || f.HasPeer == global || f.SenderUserID != 22 || !f.CountOnly {
|
||||
t.Fatalf("scope=%T filter=%+v err=%v", p, f, err)
|
||||
}
|
||||
}
|
||||
if users.byIDCalls != 0 || users.byIDsCalls != 0 || users.selfCalls != 0 || users.baseByIDsCalls == 0 {
|
||||
t.Fatalf("read validation projected users: %+v", users)
|
||||
}
|
||||
for _, deps := range []Deps{{}, {Users: failingReadUsers{}}} {
|
||||
broken := New(Config{}, deps, zaptest.NewLogger(t), clock.System)
|
||||
_, err := broken.checkedMessageReadPeer(ctx, 11, &tg.InputPeerUser{UserID: 22, AccessHash: 77}, false)
|
||||
if !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
|
||||
t.Fatalf("missing identity boundary err=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReadBoundsAndCaps(t *testing.T) {
|
||||
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := context.Background()
|
||||
for _, tt := range []struct {
|
||||
limit, offset, max, min int
|
||||
want string
|
||||
}{{-1, 0, 0, 0, "LIMIT_INVALID"}, {0, -1, 0, 0, "MSG_ID_INVALID"}, {0, 0, -1, 0, "MSG_ID_INVALID"}, {0, 0, 0, -1, "MSG_ID_INVALID"}, {0, domain.MaxMessageBoxID + 1, 0, 0, "MSG_ID_INVALID"}, {0, 0, domain.MaxMessageBoxID + 1, 0, "MSG_ID_INVALID"}} {
|
||||
_, se := r.messageFilterFromSearchRequest(ctx, 11, &tg.MessagesSearchRequest{Peer: &tg.InputPeerSelf{}, Limit: tt.limit, OffsetID: tt.offset, MaxID: tt.max, MinID: tt.min})
|
||||
_, he := r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: tt.limit, OffsetID: tt.offset, MaxID: tt.max, MinID: tt.min})
|
||||
if !tgerr.Is(se, tt.want) || !tgerr.Is(he, tt.want) {
|
||||
t.Fatalf("bounds %+v search=%v history=%v", tt, se, he)
|
||||
}
|
||||
}
|
||||
s, err := r.messageFilterFromSearchRequest(ctx, 11, &tg.MessagesSearchRequest{Peer: &tg.InputPeerSelf{}, Limit: 501, AddOffset: -2})
|
||||
if err != nil || s.Limit != 500 || s.AddOffset != -2 || s.CountOnly {
|
||||
t.Fatalf("search %+v %v", s, err)
|
||||
}
|
||||
h, err := r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: 501, AddOffset: -2})
|
||||
if err != nil || h.Limit != 50 || h.AddOffset != -2 || h.CountOnly {
|
||||
t.Fatalf("history %+v %v", h, err)
|
||||
}
|
||||
h, err = r.messageFilterFromHistoryRequest(ctx, 11, &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerSelf{}})
|
||||
if err != nil || h.CountOnly {
|
||||
t.Fatalf("default history %+v %v", h, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -611,9 +611,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
|
|||
if err != nil {
|
||||
return nil, internalErr()
|
||||
}
|
||||
filter, ok := r.messageFilterFromHistoryRequest(userID, req)
|
||||
if !ok {
|
||||
return messagesNotModifiedOrEmpty(req.Hash), nil
|
||||
filter, err := r.messageFilterFromHistoryRequest(ctx, userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filter.Peer.Type == domain.PeerTypeChannel {
|
||||
if r.deps.Channels == nil {
|
||||
|
|
|
|||
259
internal/rpc/messages_saved_forward_boundary_test.go
Normal file
259
internal/rpc/messages_saved_forward_boundary_test.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/iamxvbaba/td/clock"
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
"go.uber.org/zap/zaptest"
|
||||
appdialogs "telesrv/internal/app/dialogs"
|
||||
appmessages "telesrv/internal/app/messages"
|
||||
appusers "telesrv/internal/app/users"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func savedForwardFixture(t *testing.T) (*Router, *memory.MessageStore, *memory.UpdateEventStore, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
a, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "15550009501", FirstName: "A"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "15550009502", FirstName: "B"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
events := memory.NewUpdateEventStore()
|
||||
messages.AttachUpdateEventStore(events)
|
||||
r := New(Config{}, Deps{Users: appusers.NewService(users), Dialogs: appdialogs.NewService(dialogs), Messages: appmessages.NewService(messages, dialogs)}, zaptest.NewLogger(t), clock.System)
|
||||
return r, messages, events, a, b
|
||||
}
|
||||
|
||||
func savedForwardEvents(t *testing.T, events *memory.UpdateEventStore, owners ...int64) [][]domain.UpdateEvent {
|
||||
t.Helper()
|
||||
result := make([][]domain.UpdateEvent, len(owners))
|
||||
for i, owner := range owners {
|
||||
var err error
|
||||
result[i], err = events.ListAfter(context.Background(), owner, 0, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestSavedForwardSourceScopeProtectionAndDeletedReplay(t *testing.T) {
|
||||
for _, scope := range []string{"self", "explicit-user", "inferred"} {
|
||||
for _, saved := range []bool{false, true} {
|
||||
for _, protected := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/saved=%v/protected=%v", scope, saved, protected), func(t *testing.T) {
|
||||
r, store, events, a, b := savedForwardFixture(t)
|
||||
ctx := WithUserID(context.Background(), a.ID)
|
||||
seed, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "saved 🌕 quote", NoForwards: protected, Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
to := tg.InputPeerClass(&tg.InputPeerSelf{})
|
||||
targetSender := a.ID
|
||||
if !saved {
|
||||
to = &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}
|
||||
targetSender = b.ID
|
||||
}
|
||||
target, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: targetSender, RecipientUserID: a.ID, RandomID: 2, Message: "target", Date: 1700000001})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
from := tg.InputPeerClass(&tg.InputPeerSelf{})
|
||||
if scope == "explicit-user" {
|
||||
from = &tg.InputPeerUser{UserID: a.ID, AccessHash: a.AccessHash}
|
||||
}
|
||||
if scope == "inferred" {
|
||||
from = &tg.InputPeerEmpty{}
|
||||
}
|
||||
req := &tg.MessagesForwardMessagesRequest{FromPeer: from, ToPeer: to, ID: []int{seed.SenderMessage.ID}, RandomID: []int64{3}}
|
||||
reply := &tg.InputReplyToMessage{ReplyToMsgID: target.RecipientMessage.ID}
|
||||
reply.SetQuoteText("target")
|
||||
req.SetReplyTo(reply)
|
||||
before := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
out, err := r.onMessagesForwardMessages(ctx, req)
|
||||
if protected {
|
||||
if !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") || !reflect.DeepEqual(before, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatalf("protected Saved source err=%v or wrote events", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := out.(*tg.Updates)
|
||||
msg := full.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message)
|
||||
if msg.Message != seed.SenderMessage.Body || msg.FwdFrom.FromID.(*tg.PeerUser).UserID != a.ID || msg.FwdFrom.Date != seed.SenderMessage.Date || msg.ReplyTo.(*tg.MessageReplyHeader).ReplyToMsgID != target.RecipientMessage.ID {
|
||||
t.Fatalf("forward metadata: %+v", msg)
|
||||
}
|
||||
after := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
for i := range after {
|
||||
want := 1
|
||||
if saved && i == 1 {
|
||||
want = 0
|
||||
}
|
||||
if len(after[i])-len(before[i]) != want {
|
||||
t.Fatal("new forward event cardinality")
|
||||
}
|
||||
if want == 1 && after[i][len(after[i])-1].PtsCount != 1 {
|
||||
t.Fatal("new forward PTS count")
|
||||
}
|
||||
}
|
||||
if !saved {
|
||||
received := after[1][len(after[1])-1].Message
|
||||
if received.ReplyTo == nil || received.ReplyTo.MessageID != target.SenderMessage.ID {
|
||||
t.Fatal("recipient reply not mapped")
|
||||
}
|
||||
}
|
||||
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{seed.SenderMessage.ID}, Date: 1700000002}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeReplay := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
replay, err := r.onMessagesForwardMessages(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(full.Updates, replay.(*tg.Updates).Updates) || !reflect.DeepEqual(beforeReplay, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatal("replay changed message or appended event")
|
||||
}
|
||||
fresh := *req
|
||||
fresh.RandomID = []int64{4}
|
||||
if _, err := r.onMessagesForwardMessages(ctx, &fresh); !tgerr.Is(err, "MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("fresh deleted source err=%v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(beforeReplay, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatal("rejected fresh request appended event")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The injected boundary fails before the second send enters the real store.
|
||||
// All successful sends and replay lookups retain the normal service/store path.
|
||||
type failSecondForward struct {
|
||||
*appmessages.Service
|
||||
failRandom int64
|
||||
calls []int64
|
||||
}
|
||||
|
||||
func (s *failSecondForward) SendPrivateText(ctx context.Context, user int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) {
|
||||
s.calls = append(s.calls, req.RandomID)
|
||||
if req.RandomID == s.failRandom {
|
||||
return domain.SendPrivateTextResult{}, errors.New("injected second-send failure")
|
||||
}
|
||||
return s.Service.SendPrivateText(ctx, user, req)
|
||||
}
|
||||
|
||||
func TestSavedForwardPartialCommitRetryAfterCommittedSourceDeleted(t *testing.T) {
|
||||
r, store, events, a, b := savedForwardFixture(t)
|
||||
ctx := WithUserID(context.Background(), a.ID)
|
||||
ids := []int{}
|
||||
for i := int64(1); i <= 2; i++ {
|
||||
source, err := store.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: i, Message: fmt.Sprintf("source-%d", i), Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, source.SenderMessage.ID)
|
||||
}
|
||||
fault := &failSecondForward{Service: r.deps.Messages.(*appmessages.Service), failRandom: 12}
|
||||
r.deps.Messages = fault
|
||||
req := &tg.MessagesForwardMessagesRequest{FromPeer: &tg.InputPeerSelf{}, ToPeer: &tg.InputPeerUser{UserID: b.ID, AccessHash: b.AccessHash}, ID: ids, RandomID: []int64{11, 12}}
|
||||
before := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
if _, err := r.onMessagesForwardMessages(ctx, req); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
|
||||
t.Fatalf("partial send err=%v", err)
|
||||
}
|
||||
after := savedForwardEvents(t, events, a.ID, b.ID)
|
||||
for i := range after {
|
||||
if len(after[i])-len(before[i]) != 1 {
|
||||
t.Fatal("first item must commit before second fails")
|
||||
}
|
||||
}
|
||||
first := after[0][len(after[0])-1].Message
|
||||
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: ids[:1], Date: 1700000001}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before = savedForwardEvents(t, events, a.ID, b.ID)
|
||||
fault.failRandom = 0
|
||||
out, err := r.onMessagesForwardMessages(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(fault.calls, []int64{11, 12, 12}) {
|
||||
t.Fatalf("send calls=%v; committed item must not be re-sent", fault.calls)
|
||||
}
|
||||
full := out.(*tg.Updates)
|
||||
if full.Updates[0].(*tg.UpdateMessageID).ID != first.ID || len(full.Updates) != 4 {
|
||||
t.Fatal("partial retry lost first committed ID")
|
||||
}
|
||||
after = savedForwardEvents(t, events, a.ID, b.ID)
|
||||
for i := range after {
|
||||
if len(after[i])-len(before[i]) != 1 {
|
||||
t.Fatal("retry must only append second item")
|
||||
}
|
||||
}
|
||||
if _, err := r.onMessagesForwardMessages(ctx, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(after, savedForwardEvents(t, events, a.ID, b.ID)) {
|
||||
t.Fatal("full replay appended events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyAndForwardExplicitSourceCredentials(t *testing.T) {
|
||||
for _, method := range []string{"reply", "forward"} {
|
||||
for _, wrong := range []tg.InputPeerClass{&tg.InputPeerUser{UserID: 22, AccessHash: 78}, &tg.InputPeerUser{UserID: 23, AccessHash: 77}, &tg.InputPeerEmpty{}} {
|
||||
t.Run(fmt.Sprintf("%s/%#v", method, wrong), func(t *testing.T) {
|
||||
messages := &captureMessages{}
|
||||
r := New(Config{}, Deps{Messages: messages, Users: mapUsersService{users: map[int64]domain.User{22: {ID: 22, AccessHash: 77}}}}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithUserID(context.Background(), 11)
|
||||
var err error
|
||||
if method == "reply" {
|
||||
req := &tg.MessagesSendMessageRequest{Peer: &tg.InputPeerSelf{}, Message: "reply", RandomID: 91}
|
||||
reply := &tg.InputReplyToMessage{ReplyToMsgID: 7}
|
||||
reply.SetReplyToPeerID(wrong)
|
||||
req.SetReplyTo(reply)
|
||||
_, err = r.onMessagesSendMessage(ctx, req)
|
||||
if !tgerr.Is(err, "REPLY_MESSAGE_ID_INVALID") {
|
||||
t.Fatalf("reply err=%v", err)
|
||||
}
|
||||
} else {
|
||||
_, err = r.onMessagesForwardMessages(ctx, &tg.MessagesForwardMessagesRequest{FromPeer: wrong, ToPeer: &tg.InputPeerSelf{}, ID: []int{7}, RandomID: []int64{92}})
|
||||
// Empty from_peer is permitted only when an owned source can be inferred.
|
||||
want := "PEER_ID_INVALID"
|
||||
if _, ok := wrong.(*tg.InputPeerEmpty); ok {
|
||||
want = "MESSAGE_ID_INVALID"
|
||||
}
|
||||
if !tgerr.Is(err, want) {
|
||||
t.Fatalf("forward err=%v want=%s", err, want)
|
||||
}
|
||||
}
|
||||
if messages.sendReq.RandomID != 0 {
|
||||
t.Fatal("invalid credentials reached write service")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, deps := range []Deps{{}, {Users: failingReadUsers{}}} {
|
||||
r := New(Config{}, deps, zaptest.NewLogger(t), clock.System)
|
||||
reply := &tg.InputReplyToMessage{ReplyToMsgID: 1}
|
||||
reply.SetReplyToPeerID(&tg.InputPeerUser{UserID: 22, AccessHash: 77})
|
||||
if _, err := r.messageReplyFromInput(context.Background(), 11, domain.Peer{Type: domain.PeerTypeUser, ID: 11}, reply); !tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
|
||||
t.Fatalf("identity unavailable err=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"unicode/utf8"
|
||||
|
||||
"github.com/iamxvbaba/td/tg"
|
||||
"github.com/iamxvbaba/td/tgerr"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
|
@ -273,6 +274,10 @@ func messageSendErr(err error) error {
|
|||
return randomIDDuplicateErr()
|
||||
case errors.Is(err, domain.ErrMessageEmpty):
|
||||
return messageEmptyErr()
|
||||
case errors.Is(err, domain.ErrChatForwardsRestricted):
|
||||
return chatForwardsRestrictedErr()
|
||||
case errors.Is(err, domain.ErrQuoteTextInvalid):
|
||||
return quoteTextInvalidErr()
|
||||
default:
|
||||
return internalErr()
|
||||
}
|
||||
|
|
@ -330,8 +335,11 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
|
|||
}
|
||||
replyPeer := peer
|
||||
if inputPeer, ok := reply.GetReplyToPeerID(); ok {
|
||||
parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer)
|
||||
parsed, err := r.checkedMessageReadPeer(ctx, userID, inputPeer, false)
|
||||
if err != nil {
|
||||
if tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
|
||||
return nil, err
|
||||
}
|
||||
return nil, replyMessageIDInvalidErr()
|
||||
}
|
||||
replyPeer = parsed
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ type presenceTracker struct {
|
|||
byUser map[int64]map[presenceSessionKey]domain.UserStatus
|
||||
// offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它,
|
||||
// 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。
|
||||
offlineTimers map[int64]*time.Timer
|
||||
offlineTimers map[int64]*time.Timer
|
||||
offlineRunning int64
|
||||
lastSeenDirectRunning int64
|
||||
}
|
||||
|
||||
func newPresenceTracker() *presenceTracker {
|
||||
|
|
@ -62,8 +64,8 @@ func newPresenceTracker() *presenceTracker {
|
|||
}
|
||||
}
|
||||
|
||||
// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己
|
||||
// 从 map 移除再执行 fire(fire 内部会查在线态做最终去抖),全程不持 p.mu 调用 fire。
|
||||
// armOfflineTimer replaces the user's pending grace timer. Stop cannot join
|
||||
// an already-started AfterFunc; the callback must still prove timer ownership.
|
||||
func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) {
|
||||
if p == nil || userID == 0 {
|
||||
return
|
||||
|
|
@ -75,12 +77,24 @@ func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire fu
|
|||
if old := p.offlineTimers[userID]; old != nil {
|
||||
old.Stop()
|
||||
}
|
||||
p.offlineTimers[userID] = time.AfterFunc(d, func() {
|
||||
var timer *time.Timer
|
||||
timer = time.AfterFunc(d, func() {
|
||||
p.mu.Lock()
|
||||
if p.offlineTimers[userID] != timer {
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(p.offlineTimers, userID)
|
||||
p.offlineRunning++
|
||||
p.mu.Unlock()
|
||||
defer func() {
|
||||
p.mu.Lock()
|
||||
p.offlineRunning--
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
fire()
|
||||
})
|
||||
p.offlineTimers[userID] = timer
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
@ -419,8 +433,13 @@ func (r *Router) persistReservedLastSeenAsync(
|
|||
lastSeenAt int,
|
||||
) {
|
||||
bgCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second)
|
||||
// Keep the direct overflow write visible after its parent callback returns.
|
||||
// The production batch normally handles this work; admission errors retain
|
||||
// their existing explicit log and authoritative write behavior.
|
||||
r.presence.changeDirectLastSeenRunning(1)
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer r.presence.changeDirectLastSeenRunning(-1)
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.log.Error("Update user last seen panicked", zap.Int64("user_id", userID), zap.Any("panic", rec))
|
||||
|
|
@ -488,6 +507,12 @@ func (r *Router) presenceBackgroundContext(ctx context.Context, timeout time.Dur
|
|||
// 去抖宽限后在后台带超时执行——连接关闭发生在 serveConn 退出路径上,同步做
|
||||
// DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。
|
||||
func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) {
|
||||
r.SessionOfflineAt(rawAuthKeyID, sessionID, userID, lastForUser, int(r.clock.Now().Unix()))
|
||||
}
|
||||
|
||||
// SessionOfflineAt preserves the observed disconnect time and is idempotent
|
||||
// for repeated physical-session departure reports.
|
||||
func (r *Router) SessionOfflineAt(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool, disconnectedAt int) {
|
||||
r.forgetClientSessionInfo(rawAuthKeyID, sessionID)
|
||||
if userID == 0 {
|
||||
return
|
||||
|
|
@ -497,7 +522,9 @@ func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, l
|
|||
if !lastForUser {
|
||||
return
|
||||
}
|
||||
disconnectedAt := int(r.clock.Now().Unix())
|
||||
if disconnectedAt <= 0 {
|
||||
disconnectedAt = int(r.clock.Now().Unix())
|
||||
}
|
||||
// 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer),避免断连风暴堆积。
|
||||
r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() {
|
||||
r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -71,6 +72,7 @@ type presenceLastSeenBatchDispatcher struct {
|
|||
|
||||
gate sync.RWMutex
|
||||
accepting bool
|
||||
pending atomic.Int64
|
||||
}
|
||||
|
||||
func newPresenceLastSeenBatchDispatcher(
|
||||
|
|
@ -89,6 +91,7 @@ func newPresenceLastSeenBatchDispatcher(
|
|||
if metrics == nil {
|
||||
metrics = NopMetrics{}
|
||||
}
|
||||
metrics.PresenceLastSeenPending(0)
|
||||
return &presenceLastSeenBatchDispatcher{
|
||||
updater: updater,
|
||||
cfg: cfg,
|
||||
|
|
@ -108,18 +111,23 @@ func (d *presenceLastSeenBatchDispatcher) submit(update store.UserLastSeenUpdate
|
|||
if !d.accepting {
|
||||
return errPresenceLastSeenBatchStopped
|
||||
}
|
||||
d.metrics.PresenceLastSeenPending(1)
|
||||
d.changePending(1)
|
||||
select {
|
||||
case d.queue <- update:
|
||||
d.metrics.PresenceLastSeenSubmitted()
|
||||
return nil
|
||||
default:
|
||||
d.metrics.PresenceLastSeenPending(-1)
|
||||
d.changePending(-1)
|
||||
d.metrics.PresenceLastSeenOverflow()
|
||||
return errPresenceLastSeenBatchFull
|
||||
}
|
||||
}
|
||||
|
||||
func (d *presenceLastSeenBatchDispatcher) changePending(delta int) {
|
||||
d.pending.Add(int64(delta))
|
||||
d.metrics.PresenceLastSeenPending(delta)
|
||||
}
|
||||
|
||||
func (d *presenceLastSeenBatchDispatcher) stopAccepting() {
|
||||
if d == nil {
|
||||
return
|
||||
|
|
@ -223,7 +231,7 @@ func (d *presenceLastSeenBatchDispatcher) executeWithRetry(
|
|||
cancel()
|
||||
d.metrics.PresenceLastSeenBatch(len(updates), time.Since(started), err)
|
||||
if err == nil {
|
||||
d.metrics.PresenceLastSeenPending(-rawCount)
|
||||
d.changePending(-rawCount)
|
||||
return true
|
||||
}
|
||||
if attempt == 1 || attempt&(attempt-1) == 0 {
|
||||
|
|
@ -286,6 +294,6 @@ func (d *presenceLastSeenBatchDispatcher) reportDrainDropped(count int) {
|
|||
return
|
||||
}
|
||||
d.metrics.PresenceLastSeenDrainDropped(count)
|
||||
d.metrics.PresenceLastSeenPending(-count)
|
||||
d.changePending(-count)
|
||||
d.log.Error("presence last-seen shutdown drain expired", zap.Int("updates", count))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package rpc
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
|
@ -10,9 +12,27 @@ import (
|
|||
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestPresenceLastSeenBatchExportsIdlePending(t *testing.T) {
|
||||
registry := obsmetrics.New()
|
||||
updater := &capturePresenceLastSeenUpdater{}
|
||||
d := newPresenceLastSeenBatchDispatcher(updater, presenceLastSeenBatchConfig{}, zaptest.NewLogger(t), registry)
|
||||
if d == nil {
|
||||
t.Fatal("presence owner was not constructed")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
|
||||
if !strings.Contains(recorder.Body.String(), "telesrv_presence_last_seen_pending 0\n") || len(updater.snapshot()) != 0 {
|
||||
t.Fatalf("idle owner must expose zero pending without a last-seen write: %s", recorder.Body.String())
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "telesrv_presence_last_seen_submitted_total") {
|
||||
t.Fatal("idle registration fabricated a last-seen submission")
|
||||
}
|
||||
}
|
||||
|
||||
type capturePresenceLastSeenUpdater struct {
|
||||
mu sync.Mutex
|
||||
calls [][]store.UserLastSeenUpdate
|
||||
|
|
|
|||
39
internal/rpc/presence_timer_generation_test.go
Normal file
39
internal/rpc/presence_timer_generation_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPresenceOfflineExpiredCallbackCannotEraseReplacement(t *testing.T) {
|
||||
p := newPresenceTracker()
|
||||
var fired atomic.Int64
|
||||
p.armOfflineTimer(7, 10*time.Millisecond, func() { fired.Add(1) })
|
||||
p.mu.Lock()
|
||||
old := p.offlineTimers[7]
|
||||
// The real AfterFunc has expired and is waiting for the tracker lock.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if old.Stop() {
|
||||
p.mu.Unlock()
|
||||
t.Fatal("old callback has not started")
|
||||
}
|
||||
// Reproduce the replacement critical section before the old callback
|
||||
// reacquires the lock. No scheduler timing or production test hook.
|
||||
replacement := time.AfterFunc(time.Hour, func() { fired.Add(100) })
|
||||
defer replacement.Stop()
|
||||
p.offlineTimers[7] = replacement
|
||||
p.mu.Unlock()
|
||||
// Synchronize on the callback lock acquisition without holding it.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
p.mu.RLock()
|
||||
current := p.offlineTimers[7]
|
||||
p.mu.RUnlock()
|
||||
if current != replacement || fired.Load() != 0 {
|
||||
t.Fatalf("stale callback: replacement retained=%v, business callbacks=%d", current == replacement, fired.Load())
|
||||
}
|
||||
p.cancelOfflineTimer(7)
|
||||
if replacement.Stop() {
|
||||
t.Fatal("replacement was no longer cancelable through the tracker")
|
||||
}
|
||||
}
|
||||
41
internal/rpc/presence_work.go
Normal file
41
internal/rpc/presence_work.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package rpc
|
||||
|
||||
// PresenceWorkSnapshot measures pending grace callbacks and their last-seen
|
||||
// writers, including online last-seen work in the shared batch. It is not a
|
||||
// durability receipt or an inventory of all router background work.
|
||||
type PresenceWorkSnapshot struct {
|
||||
WaitingTimers int64
|
||||
RunningCallbacks int64
|
||||
DirectWrites int64
|
||||
PendingLastSeen int64
|
||||
}
|
||||
|
||||
// PresenceWorkSnapshot is bounded and identity-free. Holding the tracker lock
|
||||
// across the batch read prevents a false zero at callback -> batch handoff:
|
||||
// submission precedes the callback's locked running-count decrement.
|
||||
func (r *Router) PresenceWorkSnapshot() PresenceWorkSnapshot {
|
||||
if r == nil || r.presence == nil {
|
||||
return PresenceWorkSnapshot{}
|
||||
}
|
||||
p := r.presence
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
s := PresenceWorkSnapshot{
|
||||
WaitingTimers: int64(len(p.offlineTimers)),
|
||||
RunningCallbacks: p.offlineRunning,
|
||||
DirectWrites: p.lastSeenDirectRunning,
|
||||
}
|
||||
if r.lastSeenBatch != nil {
|
||||
s.PendingLastSeen = r.lastSeenBatch.pending.Load()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *presenceTracker) changeDirectLastSeenRunning(delta int64) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.lastSeenDirectRunning += delta
|
||||
p.mu.Unlock()
|
||||
}
|
||||
202
internal/rpc/presence_work_test.go
Normal file
202
internal/rpc/presence_work_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func waitPresenceWork(t *testing.T, r *Router, want PresenceWorkSnapshot) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
got := r.PresenceWorkSnapshot()
|
||||
if got == want {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("presence work = %+v, want %+v", got, want)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWorkRunningCallbackSurvivesReplacementAndCancel(t *testing.T) {
|
||||
r := &Router{presence: newPresenceTracker()}
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
r.presence.armOfflineTimer(7, 0, func() { close(started); <-release })
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("callback did not start")
|
||||
}
|
||||
defer func() {
|
||||
close(release)
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
||||
}()
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1})
|
||||
r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled replacement fired") })
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1, RunningCallbacks: 1})
|
||||
r.presence.cancelOfflineTimer(7)
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{RunningCallbacks: 1})
|
||||
}
|
||||
|
||||
func TestPresenceWorkWaitingCancellationAndOtherDevice(t *testing.T) {
|
||||
r := &Router{presence: newPresenceTracker()}
|
||||
key := presenceSessionKey{sessionID: 2}
|
||||
r.presence.setSessionStatus(key, 7, domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 100})
|
||||
r.SessionOfflineAt([8]byte{}, 1, 7, false, 50)
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
||||
if _, ok := r.presence.statusFor(7, 60); !ok {
|
||||
t.Fatal("departure erased the other device")
|
||||
}
|
||||
r.presence.armOfflineTimer(7, time.Hour, func() { t.Error("canceled timer fired") })
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{WaitingTimers: 1})
|
||||
r.presence.cancelOfflineTimer(7)
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
||||
}
|
||||
|
||||
type heldPresenceBatch struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *heldPresenceBatch) UpdateLastSeenBatch(ctx context.Context, _ []store.UserLastSeenUpdate) error {
|
||||
s.once.Do(func() { close(s.started) })
|
||||
select {
|
||||
case <-s.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWorkCallbackToBatchHasNoUnobservedHandoff(t *testing.T) {
|
||||
u := &heldPresenceBatch{started: make(chan struct{}), release: make(chan struct{})}
|
||||
metrics := &capturePresenceLastSeenMetrics{}
|
||||
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1}, zap.NewNop(), metrics)
|
||||
r := &Router{presence: newPresenceTracker(), lastSeenBatch: d}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { d.Run(ctx); close(done) }()
|
||||
defer func() { close(u.release); cancel(); <-done }()
|
||||
callbackStarted, submit := make(chan struct{}), make(chan struct{})
|
||||
r.presence.armOfflineTimer(7, 0, func() {
|
||||
close(callbackStarted)
|
||||
<-submit
|
||||
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
<-callbackStarted
|
||||
var observing atomic.Bool
|
||||
observing.Store(true)
|
||||
snapshotsDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(snapshotsDone)
|
||||
for observing.Load() {
|
||||
s := r.PresenceWorkSnapshot()
|
||||
if s.RunningCallbacks+s.PendingLastSeen != 1 && s.RunningCallbacks+s.PendingLastSeen != 2 {
|
||||
t.Errorf("unobserved callback handoff: %+v", s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
close(submit)
|
||||
select {
|
||||
case <-u.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("batch did not begin")
|
||||
}
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{PendingLastSeen: 1})
|
||||
observing.Store(false)
|
||||
<-snapshotsDone
|
||||
if d.pending.Load() != metrics.pending.Load() || metrics.pending.Load() != 1 {
|
||||
t.Fatal("snapshot and exported batch pending disagree")
|
||||
}
|
||||
}
|
||||
|
||||
type heldDirectLastSeen struct{ started, release chan struct{} }
|
||||
|
||||
func (u heldDirectLastSeen) UpdateLastSeen(ctx context.Context, _ int64, _ int) error {
|
||||
close(u.started)
|
||||
select {
|
||||
case <-u.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWorkDirectWriteRemainsVisibleAfterParentReturns(t *testing.T) {
|
||||
u := heldDirectLastSeen{make(chan struct{}), make(chan struct{})}
|
||||
r := &Router{presence: newPresenceTracker(), log: zap.NewNop()}
|
||||
r.persistReservedLastSeenAsync(context.Background(), u, 7, 99)
|
||||
<-u.started
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{DirectWrites: 1})
|
||||
close(u.release)
|
||||
waitPresenceWork(t, r, PresenceWorkSnapshot{})
|
||||
}
|
||||
|
||||
func TestPresenceWorkConcurrentArmCancel(t *testing.T) {
|
||||
p := newPresenceTracker()
|
||||
var workers sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
workers.Go(func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
p.armOfflineTimer(int64(j%4+1), time.Hour, func() { t.Error("canceled timer fired") })
|
||||
p.cancelOfflineTimer(int64(j%4 + 1))
|
||||
}
|
||||
})
|
||||
}
|
||||
workers.Wait()
|
||||
for user := int64(1); user <= 4; user++ {
|
||||
p.cancelOfflineTimer(user)
|
||||
}
|
||||
waitPresenceWork(t, &Router{presence: p}, PresenceWorkSnapshot{})
|
||||
}
|
||||
|
||||
func TestPresenceWorkBatchRetryDrainAndOverflowAccounting(t *testing.T) {
|
||||
u := &capturePresenceLastSeenUpdater{failFirst: 1, called: make(chan struct{}, 8)}
|
||||
m := &capturePresenceLastSeenMetrics{}
|
||||
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, QueueSize: 1, DrainTimeout: time.Second}, zap.NewNop(), m)
|
||||
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.submit(store.UserLastSeenUpdate{UserID: 8, LastSeenAt: 100}); !errors.Is(err, errPresenceLastSeenBatchFull) {
|
||||
t.Fatalf("overflow = %v", err)
|
||||
}
|
||||
if d.pending.Load() != 1 || m.pending.Load() != 1 {
|
||||
t.Fatal("rejected work changed pending")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
d.Run(ctx)
|
||||
if d.pending.Load() != 0 || m.pending.Load() != 0 || m.failures.Load() != 1 || m.dropped.Load() != 0 {
|
||||
t.Fatalf("drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceWorkFailedDrainIsCountedNotSuccessful(t *testing.T) {
|
||||
u := &capturePresenceLastSeenUpdater{failFirst: 1000, called: make(chan struct{}, 8)}
|
||||
m := &capturePresenceLastSeenMetrics{}
|
||||
d := newPresenceLastSeenBatchDispatcher(u, presenceLastSeenBatchConfig{MaxSize: 1, DrainTimeout: 10 * time.Millisecond}, zap.NewNop(), m)
|
||||
if err := d.submit(store.UserLastSeenUpdate{UserID: 7, LastSeenAt: 99}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
d.Run(ctx)
|
||||
if d.pending.Load() != 0 || m.pending.Load() != 0 || m.dropped.Load() != 1 || m.failures.Load() < 1 {
|
||||
t.Fatalf("failed drain accounting: pending=%d metric=%d failures=%d dropped=%d", d.pending.Load(), m.pending.Load(), m.failures.Load(), m.dropped.Load())
|
||||
}
|
||||
}
|
||||
|
|
@ -1078,6 +1078,9 @@ func (r *Router) persistAuthKeyClientInfo(ctx context.Context, info clientSessio
|
|||
return
|
||||
}
|
||||
domainInfo := domainAuthKeyClientInfo(info)
|
||||
if ip, ok := ClientIPFrom(ctx); ok {
|
||||
domainInfo.IP = ip
|
||||
}
|
||||
if domainInfo.Layer == 0 && domainInfo.DeviceModel == "" && domainInfo.Platform == "" &&
|
||||
domainInfo.SystemVersion == "" && domainInfo.APIID == 0 && domainInfo.AppVersion == "" {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -508,6 +508,31 @@ func TestObservedClientLayerNeverLeaksAcrossAuthKeySessions(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPersistAuthKeyClientInfoCarriesClientIP(t *testing.T) {
|
||||
authKeyID := [8]byte{0x68, 0x25, 0x7a, 0x09}
|
||||
auth := &captureAuthService{}
|
||||
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{
|
||||
Auth: auth,
|
||||
}, zaptest.NewLogger(t), clock.System)
|
||||
ctx := WithAuthKeyID(
|
||||
WithRawAuthKeyID(WithClientIP(context.Background(), "203.0.113.17"), authKeyID),
|
||||
authKeyID,
|
||||
)
|
||||
|
||||
r.persistAuthKeyClientInfo(ctx, clientSessionInfo{
|
||||
layer: currentClientLayer,
|
||||
hasClientInfo: true,
|
||||
clientInfo: ClientInfo{
|
||||
APIID: 2040, DeviceModel: "Pixel 9", SystemVersion: "SDK 36", AppVersion: "12.8.7",
|
||||
},
|
||||
})
|
||||
|
||||
got := auth.authKeyClientInfos[authKeyID].IP
|
||||
if got != "203.0.113.17" {
|
||||
t.Fatalf("persisted client IP = %q, want %q", got, "203.0.113.17")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeWithLayerPersistsClientLayerUpgrade(t *testing.T) {
|
||||
authKeyID := [8]byte{0x68, 0x25, 0x7a, 0x02}
|
||||
userID := int64(1780269504)
|
||||
|
|
|
|||
|
|
@ -315,6 +315,9 @@ func (s *captureAuthService) UpdateAuthKeyClientInfo(_ context.Context, authKeyI
|
|||
if info.AppVersion != "" {
|
||||
current.AppVersion = info.AppVersion
|
||||
}
|
||||
if info.IP != "" {
|
||||
current.IP = info.IP
|
||||
}
|
||||
s.authKeyClientInfos[authKeyID] = current
|
||||
for i := range s.authorizations {
|
||||
if s.authorizations[i].AuthKeyID != authKeyID {
|
||||
|
|
@ -335,6 +338,9 @@ func (s *captureAuthService) UpdateAuthKeyClientInfo(_ context.Context, authKeyI
|
|||
if info.AppVersion != "" {
|
||||
s.authorizations[i].AppVersion = info.AppVersion
|
||||
}
|
||||
if info.IP != "" {
|
||||
s.authorizations[i].IP = info.IP
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ type mapUsersService struct {
|
|||
|
||||
type countingMapUsersService struct {
|
||||
mapUsersService
|
||||
selfCalls int
|
||||
byIDCalls int
|
||||
byIDsCalls int
|
||||
lastByIDs []int64
|
||||
byIDsBatches [][]int64
|
||||
selfCalls int
|
||||
byIDCalls int
|
||||
byIDsCalls int
|
||||
baseByIDsCalls int
|
||||
lastByIDs []int64
|
||||
byIDsBatches [][]int64
|
||||
}
|
||||
|
||||
func (s staticUsersService) Self(context.Context, int64) (domain.User, error) {
|
||||
|
|
@ -104,6 +105,11 @@ func (s *countingMapUsersService) ByIDs(ctx context.Context, currentUserID int64
|
|||
return s.mapUsersService.ByIDs(ctx, currentUserID, userIDs)
|
||||
}
|
||||
|
||||
func (s *countingMapUsersService) BaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) {
|
||||
s.baseByIDsCalls++
|
||||
return s.mapUsersService.ByIDs(ctx, 1, userIDs)
|
||||
}
|
||||
|
||||
type captureUsersService struct {
|
||||
user domain.User
|
||||
userID int64
|
||||
|
|
|
|||
|
|
@ -782,6 +782,7 @@ func TestMessagesHistoryAndSearchProjectStoriesMaxID(t *testing.T) {
|
|||
}}
|
||||
r := New(Config{}, Deps{
|
||||
Messages: messages,
|
||||
Users: mapUsersService{users: map[int64]domain.User{owner.ID: owner, viewer.ID: viewer}},
|
||||
Stories: appstories.NewService(storyStore),
|
||||
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
|
||||
peer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}
|
||||
|
|
|
|||
|
|
@ -343,6 +343,10 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs,
|
|||
if msg.ReplyTo != nil {
|
||||
addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs)
|
||||
collectMessageEntityUserRefs(msg.ReplyTo.QuoteEntities, userIDs)
|
||||
if external := msg.ReplyTo.External; external != nil {
|
||||
addDomainPeerRef(external.From.From, currentChannelID, userIDs, channelIDs)
|
||||
collectMessagePeerRefs(domain.Message{Media: external.Media, Entities: external.Entities}, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||
|
|
@ -429,6 +433,10 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i
|
|||
if msg.ReplyTo != nil {
|
||||
addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs)
|
||||
collectMessageEntityUserRefs(msg.ReplyTo.QuoteEntities, userIDs)
|
||||
if external := msg.ReplyTo.External; external != nil {
|
||||
addDomainPeerRef(external.From.From, currentChannelID, userIDs, channelIDs)
|
||||
collectMessagePeerRefs(domain.Message{Media: external.Media, Entities: external.Entities}, currentChannelID, userIDs, channelIDs)
|
||||
}
|
||||
}
|
||||
if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 {
|
||||
userIDs[msg.Media.Contact.UserID] = struct{}{}
|
||||
|
|
|
|||
|
|
@ -159,11 +159,6 @@ func clonePeerPtr(in *domain.Peer) *domain.Peer {
|
|||
return &out
|
||||
}
|
||||
|
||||
func cloneMessageReply(in *domain.MessageReply) *domain.MessageReply {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.QuoteEntities = append([]domain.MessageEntity(nil), in.QuoteEntities...)
|
||||
return &out
|
||||
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
|
||||
return domain.CloneMessageReply(reply)
|
||||
}
|
||||
|
|
|
|||
72
internal/rpcresult/replay.go
Normal file
72
internal/rpcresult/replay.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package rpcresult
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/iamxvbaba/td/bin"
|
||||
)
|
||||
|
||||
// ReplaySource rebuilds the exact inner result bytes for one already-executed
|
||||
// RPC from immutable data. Implementations must not rerun authorization,
|
||||
// metadata lookup, or business handlers.
|
||||
type ReplaySource interface {
|
||||
EncodeInner(context.Context, *bin.Buffer) error
|
||||
RetainedBytes() int
|
||||
}
|
||||
|
||||
// Carrier is implemented by a generated result wrapper when a fresh execution
|
||||
// proved that its result can be replayed from immutable data.
|
||||
type Carrier interface {
|
||||
ExactReplaySource() ReplaySource
|
||||
}
|
||||
|
||||
// ValueSource is the RPC-side half of a replay source. It may use canonical
|
||||
// RPC values internally; the edge sees only the encoded ReplaySource above.
|
||||
type ValueSource interface {
|
||||
Value(context.Context) (any, error)
|
||||
RetainedBytes() int
|
||||
}
|
||||
|
||||
type captureKey struct{}
|
||||
|
||||
type Capture struct {
|
||||
mu sync.Mutex
|
||||
source ValueSource
|
||||
}
|
||||
|
||||
func WithCapture(ctx context.Context) (context.Context, *Capture) {
|
||||
capture := &Capture{}
|
||||
return context.WithValue(ctx, captureKey{}, capture), capture
|
||||
}
|
||||
|
||||
// Publish records the one immutable source produced by the admitted terminal.
|
||||
// Wrapper or nested handlers must not replace a source already published by
|
||||
// the same one-shot dispatch.
|
||||
func Publish(ctx context.Context, source ValueSource) bool {
|
||||
if source == nil {
|
||||
return false
|
||||
}
|
||||
capture, _ := ctx.Value(captureKey{}).(*Capture)
|
||||
if capture == nil {
|
||||
return false
|
||||
}
|
||||
capture.mu.Lock()
|
||||
defer capture.mu.Unlock()
|
||||
if capture.source != nil {
|
||||
return false
|
||||
}
|
||||
capture.source = source
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Capture) Take() ValueSource {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
source := c.source
|
||||
c.source = nil
|
||||
c.mu.Unlock()
|
||||
return source
|
||||
}
|
||||
50
internal/store/delivery_effect.go
Normal file
50
internal/store/delivery_effect.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
var ErrDeliveryOutboxRequired = errors.New("durable delivery boundary required")
|
||||
|
||||
// DeliveryEffect is the account-scoped durable update intent built from an
|
||||
// immutable aggregate snapshot. Main persists it into user_update_events and
|
||||
// dispatch_outbox in the aggregate transaction.
|
||||
type DeliveryEffect struct {
|
||||
Kind DeliveryEffectKind
|
||||
TargetUserID int64
|
||||
ExcludeAuthKeyID [8]byte
|
||||
ExcludeSessionID int64
|
||||
Event domain.UpdateEvent
|
||||
}
|
||||
|
||||
type DeliveryEffectKind uint8
|
||||
|
||||
const (
|
||||
DeliveryEffectInvalid DeliveryEffectKind = iota
|
||||
DeliveryEffectAccountPTS
|
||||
)
|
||||
|
||||
type DeliveryEffectsBuilder[T any] func(T) ([]DeliveryEffect, error)
|
||||
|
||||
func AccountPTSDeliveryEffect(userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) DeliveryEffect {
|
||||
return DeliveryEffect{
|
||||
Kind: DeliveryEffectAccountPTS, TargetUserID: userID, Event: event,
|
||||
ExcludeAuthKeyID: excludeAuthKeyID, ExcludeSessionID: excludeSessionID,
|
||||
}
|
||||
}
|
||||
|
||||
func (e DeliveryEffect) Validate() error {
|
||||
if e.Kind != DeliveryEffectAccountPTS || e.TargetUserID <= 0 {
|
||||
return fmt.Errorf("invalid account PTS delivery effect")
|
||||
}
|
||||
if (e.ExcludeAuthKeyID == ([8]byte{})) != (e.ExcludeSessionID == 0) {
|
||||
return fmt.Errorf("delivery effect exclusion requires both raw auth key and session id")
|
||||
}
|
||||
if e.Event.PtsCount <= 0 || e.Event.Pts != 0 {
|
||||
return fmt.Errorf("account PTS delivery effect requires unallocated positive pts_count")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue