merged with fixes

This commit is contained in:
onysd 2026-09-09 02:49:30 +03:00
parent a9e758b712
commit 2f1818d656
176 changed files with 9000 additions and 907 deletions

View file

@ -119,6 +119,7 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI))) mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI))) mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI))) mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("POST /api/actions/set-account-avatar-video", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarVideoAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI))) mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI))) mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI))) mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
@ -1527,6 +1528,52 @@ func (s *server) handleSetAccountAvatarAPI(w http.ResponseWriter, r *http.Reques
writeCommandResultAPI(w, result, err) writeCommandResultAPI(w, result, err)
} }
type setAccountAvatarVideoAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
UserID int64 `json:"user_id"`
VideoStartTs float64 `json:"video_start_ts"`
}
func (s *server) handleSetAccountAvatarVideoAPI(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarVideoBytes+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
var body setAccountAvatarVideoAPIRequest
dec := json.NewDecoder(strings.NewReader(r.FormValue("metadata")))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid metadata: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAPIError(w, http.StatusBadRequest, "avatar video file is required")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarVideoBytes+1))
if err != nil || len(data) == 0 || int64(len(data)) > admin.MaxAccountAvatarVideoBytes {
writeAPIError(w, http.StatusBadRequest, "avatar video file is empty or too large")
return
}
req := admin.SetAccountAvatarVideoRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-avatar-video"),
UserID: body.UserID,
FileName: header.Filename,
VideoStartTs: body.VideoStartTs,
}
result, err := s.callAdminMultipart(r.Context(), "/v1/accounts/set-avatar-video", req, header.Filename, data)
writeCommandResultAPI(w, result, err)
}
type setUserColorAPIRequest struct { type setUserColorAPIRequest struct {
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
Reason string `json:"reason"` Reason string `json:"reason"`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -242,6 +242,7 @@ export const api = {
gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`, gifCatalogDocumentPreviewURL: (documentID: string) => `/api/gif-catalog/documents/${encodeURIComponent(documentID)}/preview`,
createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }), createStickerSet: (form: FormData) => request<CommandResult>("/api/actions/create-sticker-set", { method: "POST", body: form }),
setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }), setAccountAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar", { method: "POST", body: form }),
setAccountAvatarVideo: (form: FormData) => request<CommandResult>("/api/actions/set-account-avatar-video", { method: "POST", body: form }),
setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }), setChannelAvatar: (form: FormData) => request<CommandResult>("/api/actions/set-channel-avatar", { method: "POST", body: form }),
addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }), addStickerToSet: (form: FormData) => request<CommandResult>("/api/actions/add-sticker-to-set", { method: "POST", body: form }),
gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"), gifCatalog: () => request<GifCatalogListResponse>("/api/gif-catalog"),

View file

@ -13,10 +13,13 @@ type AvatarModalKind = "user" | "channel";
export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) { export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKind; id: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [previewURL, setPreviewURL] = useState(""); const [previewURL, setPreviewURL] = useState("");
const [videoStartTs, setVideoStartTs] = useState("0");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const isVideo = kind === "user" && !!file && file.type.startsWith("video/");
useEffect(() => { useEffect(() => {
if (!file) { if (!file) {
setPreviewURL(""); setPreviewURL("");
@ -29,7 +32,7 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
async function submit() { async function submit() {
if (!file) { if (!file) {
setError("Choose an image file first."); setError("Choose an image or video file first.");
return; return;
} }
if (!reason.trim()) { if (!reason.trim()) {
@ -41,9 +44,13 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
try { try {
const idField = kind === "channel" ? "channel_id" : "user_id"; const idField = kind === "channel" ? "channel_id" : "user_id";
const form = new FormData(); const form = new FormData();
form.set("metadata", JSON.stringify({ command_id: "", reason: reason.trim(), confirm: true, [idField]: id })); const metadata: Record<string, unknown> = { command_id: "", reason: reason.trim(), confirm: true, [idField]: id };
if (isVideo) {
metadata.video_start_ts = Number(videoStartTs) || 0;
}
form.set("metadata", JSON.stringify(metadata));
form.set("file", file, file.name); form.set("file", file, file.name);
const result = kind === "channel" ? await api.setChannelAvatar(form) : await api.setAccountAvatar(form); const result = kind === "channel" ? await api.setChannelAvatar(form) : isVideo ? await api.setAccountAvatarVideo(form) : await api.setAccountAvatar(form);
if (result.error) { if (result.error) {
setError(result.error); setError(result.error);
return; return;
@ -71,11 +78,32 @@ export function AvatarModal({ kind, id, onClose, onDone }: { kind: AvatarModalKi
</div> </div>
<div className="command-body"> <div className="command-body">
<label className={`gift-file-picker ${file ? "has-file" : ""}`}> <label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /> <input
{previewURL ? <img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} /> : <ImagePlus size={22} />} type="file"
<span className="gift-file-copy"><span className="gift-field-label">{"New avatar"}</span><strong>{file ? file.name : "Choose a JPEG, PNG, or WebP image"}</strong></span> accept={kind === "user" ? "image/png,image/jpeg,image/webp,video/mp4" : "image/png,image/jpeg,image/webp"}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
{previewURL ? (
isVideo ? (
<video className="gift-file-icon" src={previewURL} style={{ objectFit: "cover" }} muted loop autoPlay />
) : (
<img className="gift-file-icon" src={previewURL} alt="" style={{ objectFit: "cover" }} />
)
) : (
<ImagePlus size={22} />
)}
<span className="gift-file-copy">
<span className="gift-field-label">{"New avatar"}</span>
<strong>{file ? file.name : kind === "user" ? "Choose a JPEG, PNG, WebP image, or MP4 video" : "Choose a JPEG, PNG, or WebP image"}</strong>
</span>
<span className="gift-file-action">{file ? "Change file" : "Choose file"}</span> <span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label> </label>
{isVideo && (
<label className="gift-reason-field">
<span>{"Video start (seconds)"}</span>
<input type="number" min="0" step="0.1" value={videoStartTs} onChange={(event) => setVideoStartTs(event.target.value)} />
</label>
)}
<label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label> <label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this avatar is being changed"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
</div> </div>

View file

@ -0,0 +1,4 @@
-- No-op: see 0181_sticker_set_system_key_unique.up.sql. The real schema
-- change (and its reversal) lives in
-- 20260901000006_sticker_set_system_key_unique.down.sql.
SELECT 1;

View file

@ -0,0 +1,12 @@
-- No-op placeholder. This migration's real content (sticker_sets.system_key
-- uniqueness) already applies via 20260901000006_sticker_set_system_key_unique
-- (identical SQL, applied earlier under our fork's own timestamp-based
-- migration numbering). The version number 181 itself must still exist as a
-- migration, though: internal/store/postgres/postgres.go's Migrate() calls
-- m.Migrate(phoneIdentityPredecessorVersion) (=181) to step a fresh database
-- to exactly this version before running the Go-side phone-identity
-- canonicalization pass, and golang-migrate requires that version to be a
-- real, reachable migration file. Do not reapply the sticker_sets change here
-- -- 20260901000006 already owns it, and repeating it would fail on an
-- already-unique index for any install that runs both.
SELECT 1;

View file

@ -0,0 +1,3 @@
-- E.164 canonicalization is intentionally irreversible: the removed national
-- trunk presentation is not part of the account identity.
SELECT 1;

View file

@ -0,0 +1,5 @@
-- The country-aware data rewrite is executed transactionally by
-- postgres.canonicalizeStoredPhoneIdentities before this durable version marker.
-- Keeping it in Go avoids incorrect SQL-only stripping of significant leading
-- zeroes (for example Italian fixed-line numbers).
SELECT 1;

View file

@ -0,0 +1,8 @@
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM private_messages WHERE reply_external <> '{}'::jsonb)
OR EXISTS (SELECT 1 FROM message_boxes WHERE reply_external <> '{}'::jsonb) THEN
RAISE EXCEPTION 'cannot discard durable external reply snapshots';
END IF;
END $$;
ALTER TABLE message_boxes DROP COLUMN reply_external;
ALTER TABLE private_messages DROP COLUMN reply_external;

View file

@ -0,0 +1,7 @@
ALTER TABLE private_messages ADD COLUMN reply_external jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE message_boxes ADD COLUMN reply_external jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE private_messages ADD CONSTRAINT private_messages_reply_external_object
CHECK (jsonb_typeof(reply_external) = 'object' AND octet_length(reply_external::text) <= 1048576);
ALTER TABLE message_boxes ADD CONSTRAINT message_boxes_reply_external_object
CHECK (jsonb_typeof(reply_external) = 'object' AND octet_length(reply_external::text) <= 1048576);

3
go.mod
View file

@ -15,6 +15,7 @@ require (
github.com/klauspost/compress v1.19.1 github.com/klauspost/compress v1.19.1
github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/minio/minio-go/v7 v7.2.1 github.com/minio/minio-go/v7 v7.2.1
github.com/nyaruka/phonenumbers v1.8.1
github.com/pion/datachannel v1.6.2 github.com/pion/datachannel v1.6.2
github.com/pion/dtls/v3 v3.1.5 github.com/pion/dtls/v3 v3.1.5
github.com/pion/ice/v4 v4.3.0 github.com/pion/ice/v4 v4.3.0
@ -58,7 +59,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect
@ -99,6 +99,7 @@ require (
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.48.0 // indirect golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
rsc.io/qr v0.2.0 // indirect rsc.io/qr v0.2.0 // indirect

View file

@ -35,6 +35,7 @@ const (
ActionSetPhone = "account.set_phone" ActionSetPhone = "account.set_phone"
ActionSetLoginEmail = "account.set_login_email" ActionSetLoginEmail = "account.set_login_email"
ActionSetAccountAvatar = "account.set_avatar" ActionSetAccountAvatar = "account.set_avatar"
ActionSetAccountAvatarVideo = "account.set_avatar_video"
ActionSetChannelAvatar = "channel.set_avatar" ActionSetChannelAvatar = "channel.set_avatar"
ActionSetChannelUsername = "channel.set_username" ActionSetChannelUsername = "channel.set_username"
ActionSetChannelSettings = "channel.set_settings" ActionSetChannelSettings = "channel.set_settings"
@ -288,6 +289,9 @@ type AvatarResolver interface {
// preview before CreateAvatarFromBytes actually materializes the avatar. // preview before CreateAvatarFromBytes actually materializes the avatar.
ValidateAvatarUpload(data []byte) bool ValidateAvatarUpload(data []byte) bool
CreateAvatarFromBytes(ctx context.Context, data []byte, ownerUserID int64) (domain.Photo, error) 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) 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 // 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 // current avatar, which is denormalized on the channel row as a bare
@ -873,6 +877,14 @@ type SetAccountAvatarRequest struct {
Data []byte `json:"-"` 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 { type SetChannelAvatarRequest struct {
CommandMeta CommandMeta
ChannelID int64 `json:"channel_id"` 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 // SetChannelAvatar force-sets a channel's avatar from raw uploaded image
// bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as // bytes, reusing the same avatar rendition pipeline (s/a/c sizes) as
// SetAccountAvatar, but attaching the resulting photo directly to the // 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. // (SetAccountAvatar) a user's profile photo through the admin console.
const MaxAccountAvatarBytes = 4 << 20 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 // AccountAvatar returns an account's current profile photo bytes and detected
// MIME type, mirroring internal/web's public avatar serving (same size // MIME type, mirroring internal/web's public avatar serving (same size
// selection and safe-image-type checks) so the admin console shows exactly // selection and safe-image-type checks) so the admin console shows exactly

View file

@ -56,6 +56,7 @@ type Service interface {
SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error) SetPhone(ctx context.Context, req admin.SetPhoneRequest) (admin.CommandResult, error)
SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error) SetLoginEmail(ctx context.Context, req admin.SetLoginEmailRequest) (admin.CommandResult, error)
SetAccountAvatar(ctx context.Context, req admin.SetAccountAvatarRequest) (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) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, string, bool, error)
SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error) SetChannelAvatar(ctx context.Context, req admin.SetChannelAvatarRequest) (admin.CommandResult, error)
SetUserColor(ctx context.Context, req admin.SetUserColorRequest) (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-phone", s.authenticated(s.handleSetPhone))
mux.HandleFunc("POST /v1/accounts/set-login-email", s.authenticated(s.handleSetLoginEmail)) 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", 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-color", s.authenticated(s.handleSetUserColor))
mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus)) mux.HandleFunc("POST /v1/accounts/set-emoji-status", s.authenticated(s.handleSetUserEmojiStatus))
mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions)) 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) { func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountAvatarRequest 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 return
} }
if !decodeMultipartMetadata(w, r, &req) { if !decodeMultipartMetadata(w, r, &req) {
@ -403,6 +405,18 @@ func (s *Server) handleSetAccountAvatar(w http.ResponseWriter, r *http.Request)
writeCommandResult(w, result, err) 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) { func (s *Server) handleChannelAvatar(w http.ResponseWriter, r *http.Request) {
channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) channelID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || channelID <= 0 { 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) { func (s *Server) handleSetChannelAvatar(w http.ResponseWriter, r *http.Request) {
var req admin.SetChannelAvatarRequest 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 return
} }
if !decodeMultipartMetadata(w, r, &req) { 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 // decodeAvatarUpload parses a multipart avatar-upload form shared by the
// account and channel avatar endpoints, reading the uploaded file into data. // 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 { // maxBytes is the caller's own ceiling (MaxAccountAvatarBytes for a static
r.Body = http.MaxBytesReader(w, r.Body, admin.MaxAccountAvatarBytes+(1<<20)) // 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 { if err := r.ParseMultipartForm(1 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error()) writeError(w, http.StatusBadRequest, "invalid multipart form: "+err.Error())
return false return false
@ -454,8 +470,8 @@ func (s *Server) decodeAvatarUpload(w http.ResponseWriter, r *http.Request, file
return false return false
} }
defer file.Close() defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, admin.MaxAccountAvatarBytes+1)) raw, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil || len(raw) == 0 || int64(len(raw)) > admin.MaxAccountAvatarBytes { if err != nil || len(raw) == 0 || int64(len(raw)) > maxBytes {
writeError(w, http.StatusBadRequest, "avatar file is empty or too large") writeError(w, http.StatusBadRequest, "avatar file is empty or too large")
return false return false
} }

View file

@ -364,6 +364,10 @@ func (fakeService) SetAccountAvatar(_ context.Context, req admin.SetAccountAvata
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil 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) { func (fakeService) ChannelAvatar(_ context.Context, _ int64) ([]byte, string, bool, error) {
return nil, "", false, nil return nil, "", false, nil
} }

View file

@ -96,7 +96,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8]
} }
executeAt := now.Add(accountDeletionDelay) executeAt := now.Add(accountDeletionDelay)
message := fmt.Sprintf( 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), url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken),
) )
pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{

View file

@ -51,7 +51,7 @@ const (
) )
func botFatherHelpText() string { 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: You can control me by sending these commands:

View file

@ -24,11 +24,11 @@ const (
) )
func chatBotHelpText() string { func chatBotHelpText() string {
return chatBotHelpPrefix + branding.ProductName + chatBotHelpSuffix return chatBotHelpPrefix + branding.ProductName() + chatBotHelpSuffix
} }
func chatBotInstruction() string { 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 ( const (

View file

@ -46,7 +46,7 @@ const (
) )
func stickersBotHelpText() string { 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 /newpack to create a sticker pack.\n" +
"Send /newemoji to create a custom emoji pack.\n" + "Send /newemoji to create a custom emoji pack.\n" +
"Send /addsticker to add an item to one of your packs.\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() return internalReply()
} }
if cmd == stickersBotCmdDel { 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 { 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) shortName := normalizeStickersBotShortName(raw)
if shortName == "" || strings.HasPrefix(shortName, "/") { 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}) set, _, found, err := s.stickers.ResolveStickerSet(ctx, domain.StickerSetRef{Kind: domain.StickerSetRefByShortName, ShortName: shortName})
if err != nil { if err != nil {
@ -556,7 +556,7 @@ func (s *Service) listStickersBotPacks(ctx context.Context, userID int64) botRep
func stickersBotStepPrompt(state domain.BotChatState) botReply { func stickersBotStepPrompt(state domain.BotChatState) botReply {
switch state.Step { switch state.Step {
case stickersBotStepSet: 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: case stickersBotStepTitle:
return botReply{Text: "Send a title for this pack, or /cancel."} return botReply{Text: "Send a title for this pack, or /cancel."}
case stickersBotStepDocument: case stickersBotStepDocument:

View file

@ -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. 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 { 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 /start - what a third-party mark is and who grants it
/verify - apply for the mark /verify - apply for the mark
@ -1125,7 +1125,7 @@ func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierS
b.WriteString("\n\nWhy:\n") b.WriteString("\n\nWhy:\n")
b.WriteString(state.Draft[verifierDraftReason]) b.WriteString(state.Draft[verifierDraftReason])
b.WriteString("\n\nThis is a third-party mark, not the official ") 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.") 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() return b.String()
} }
@ -1174,7 +1174,7 @@ func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) {
switch req.Status { switch req.Status {
case domain.CustomVerificationApproved: 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.", 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: case domain.CustomVerificationRejected:
text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label) 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 != "" { if reason := strings.TrimSpace(req.DecisionReason); reason != "" {

View file

@ -115,7 +115,7 @@ const (
) )
func verifyBotStartText() string { 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: Before you apply, check that the subject of the application:
- is a channel, supergroup or bot with a public @username; - 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 { func verifyBotHelpText() string {
return `I collect official ` + branding.ProductName + ` verification applications. return `I collect official ` + branding.ProductName() + ` verification applications.
/new - file a verification application /new - file a verification application
/status - list your applications and their status /status - list your applications and their status

View file

@ -42,7 +42,7 @@ func (s *Service) SeedDefaultVerifier(ctx context.Context) (bool, error) {
if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{ if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{
BotID: domain.VerifierBotUserID, BotID: domain.VerifierBotUserID,
IconDocumentID: icon.DocumentID, IconDocumentID: icon.DocumentID,
CompanyName: branding.ProductName, CompanyName: branding.ProductName(),
DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.", DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.",
CanModifyCustomDescription: false, CanModifyCustomDescription: false,
Enabled: true, Enabled: true,

View file

@ -189,7 +189,9 @@ func (c *blobBytesCache) get(key string) ([]byte, bool) {
if el, ok := c.m[key]; ok { if el, ok := c.m[key]; ok {
c.ll.MoveToFront(el) c.ll.MoveToFront(el)
entry := el.Value.(*blobBytesEntry) 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 return nil, false
} }

View file

@ -1,6 +1,11 @@
package files package files
import "sync/atomic" import (
"io"
"sync/atomic"
"telesrv/internal/domain"
)
// SpaceGuard bounds how much more may be written to the permanent blob // SpaceGuard bounds how much more may be written to the permanent blob
// backend. LocalDiskSpaceGuard checks real OS free disk bytes; // 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) Allow(int64) (bool, error) { return true, nil }
func (NoopSpaceGuard) Usage() (int64, int64, bool) { return 0, 0, false } 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 // LocalDiskSpaceGuard rejects writes once cached free disk bytes fall below
// minFreeBytes (<=0 disables the check). The free-bytes figure is // minFreeBytes (<=0 disables the check). The free-bytes figure is
// refreshed by DiskUsageWorker, not recomputed per call, to avoid a statfs // refreshed by DiskUsageWorker, not recomputed per call, to avoid a statfs

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

View file

@ -188,11 +188,51 @@ func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file do
return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup}) 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) { 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) body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil { if err != nil {
return domain.Photo{}, err 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 { if body.Size == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid return domain.Photo{}, domain.ErrPhotoInvalid
} }
@ -230,18 +270,11 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
Date: int(time.Now().Unix()), Date: int(time.Now().Unix()),
DCID: s.dc, DCID: s.dc,
Sizes: sizes, Sizes: sizes,
OwnerUserID: file.OwnerUserID, OwnerUserID: ownerUserID,
} }
if err := s.media.PutPhoto(ctx, photo); err != nil { if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err 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 return photo, nil
} }

View file

@ -15,6 +15,7 @@ import (
"time" "time"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store"
) )
// fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。 // fakeMediaStore 是 store.MediaStore 的内存替身,用于在无 PG 时验证 seed 导入器。
@ -29,20 +30,27 @@ type fakeMediaStore struct {
webPages map[int64]domain.MessageWebPage webPages map[int64]domain.MessageWebPage
seedState map[string]string seedState map[string]string
receipts map[string]domain.UploadedMediaReceipt receipts map[string]domain.UploadedMediaReceipt
// profilePhotos[ownerID|kind] 保存某 owner 当前 profile/fallback 照片引用。
profilePhotos map[string]domain.ProfilePhotoRef
} }
func newFakeMediaStore() *fakeMediaStore { func newFakeMediaStore() *fakeMediaStore {
return &fakeMediaStore{ return &fakeMediaStore{
blobs: map[string]domain.FileBlob{}, blobs: map[string]domain.FileBlob{},
docs: map[int64]domain.Document{}, docs: map[int64]domain.Document{},
photos: map[int64]domain.Photo{}, photos: map[int64]domain.Photo{},
sets: map[int64]domain.StickerSet{}, sets: map[int64]domain.StickerSet{},
parts: map[string][]domain.UploadPart{}, parts: map[string][]domain.UploadPart{},
seedState: map[string]string{}, seedState: map[string]string{},
receipts: map[string]domain.UploadedMediaReceipt{}, 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 { func fakeUploadReceiptKey(ownerUserID, fileID int64) string {
return fmt.Sprintf("%d/%d", ownerUserID, fileID) return fmt.Sprintf("%d/%d", ownerUserID, fileID)
} }
@ -414,29 +422,101 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
defer f.mu.Unlock() defer f.mu.Unlock()
return len(f.reactions), nil 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 return nil
} }
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) { func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
return 0, false, nil 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) { func (f *fakeMediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
return map[int64]domain.ProfilePhotoRef{}, nil return f.CurrentProfilePhotosKind(ctx, ownerType, ids, domain.ProfilePhotoKindProfile)
} }
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
return map[int64]domain.ProfilePhotoRef{}, nil 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) { func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
return nil, 0, nil 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) { func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
return nil, 0, nil 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) { func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
return nil, nil 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) { func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) {
return nil, nil 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) { func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {

View file

@ -256,7 +256,9 @@ func TestMaxUploadFileBytesUnlimitedByDefault(t *testing.T) {
type countingUploadPartBackend struct { type countingUploadPartBackend struct {
*LocalFS *LocalFS
getUploadPartCalls int getUploadPartCalls int
putUploadPartCalls int
deleteUploadPartCalls int
} }
func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) { 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) 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) { func newUploadPartTestService(t *testing.T, media *fakeMediaStore, quota domain.UploadPartQuota) (*Service, *LocalFS) {
t.Helper() t.Helper()
blobs, err := NewLocalFS(t.TempDir()) blobs, err := NewLocalFS(t.TempDir())

View file

@ -54,7 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt
packs: packs, packs: packs,
packCache: newLangPackCache(maxBytes, maxEntries), packCache: newLangPackCache(maxBytes, maxEntries),
languageCache: newLanguageListCache(languageEntries), languageCache: newLanguageListCache(languageEntries),
publicBaseURL: branding.DefaultPublicURL, publicBaseURL: branding.PublicBaseURL(),
} }
} }

View file

@ -71,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore
creds: creds, creds: creds,
challenges: challenges, challenges: challenges,
rpID: rpID, rpID: rpID,
rpName: branding.ProductName, rpName: branding.ProductName(),
dcID: dcID, dcID: dcID,
challengeTTL: defaultChallengeTTL, challengeTTL: defaultChallengeTTL,
now: time.Now, now: time.Now,

View file

@ -18,9 +18,11 @@ import (
// 强制重新拉取 p/g而不是信任本地缓存。用于失效任何账号本地可能缓存的陈旧/错误 // 强制重新拉取 p/g而不是信任本地缓存。用于失效任何账号本地可能缓存的陈旧/错误
// p/g例如账号早年间对接过其它后端、缓存版本号恰好等于当时的 DHConfigVersion // p/g例如账号早年间对接过其它后端、缓存版本号恰好等于当时的 DHConfigVersion
// 此后再也不会刷新——版本号是纯常量,服务端自己永远不会主动使旧缓存过期)。 // 此后再也不会刷新——版本号是纯常量,服务端自己永远不会主动使旧缓存过期)。
// 本次从 1→2 是为诊断一例「A 拨 B 接通即断key fingerprint/Ga hash 不合)」而提升, // 早先从 1→2 是为诊断一例「A 拨 B 接通即断key fingerprint/Ga hash 不合)」而提升。
// 与本次通话 bug 排查同批次的服务端改动一起看。 // 上游又发现了同类问题的另一诱因:私有 DC 客户端若带着同为某个旧 version、但来自
const DHConfigVersion = 2 // 另一配置 profile 的缓存,服务端错误返回 NotModified 会让密聊两端用不同 p/g
// 这里直接取上游更新、更高的版本号,一次性使两类陈旧缓存都失效。
const DHConfigVersion = 20260811
// DHG 是 DH generator。与官方一致取 3TDesktop MTP::IsPrimeAndGood 对 // DHG 是 DH generator。与官方一致取 3TDesktop MTP::IsPrimeAndGood 对
// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径DrKLO native 同。 // 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径DrKLO native 同。

View file

@ -487,43 +487,7 @@ func dedupNonZero(ids []int64) []int64 {
} }
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool { func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID { return domain.EvaluatePrivacy(rules, ctx)
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
} }
func ValidKey(key domain.PrivacyKey) bool { func ValidKey(key domain.PrivacyKey) bool {
@ -588,52 +552,6 @@ func validateRules(rules []domain.PrivacyRule) error {
return nil 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 { func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules {
return domain.PrivacyRules{ return domain.PrivacyRules{
OwnerUserID: ownerUserID, OwnerUserID: ownerUserID,

View file

@ -193,6 +193,12 @@ func (s *Service) PrivacyBaseUsers(ctx context.Context, userIDs []int64) ([]doma
return s.loadBaseUsersByIDs(ctx, userIDs) 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 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。 // ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。
func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) {
if currentUserID == 0 { if currentUserID == 0 {

View file

@ -6,44 +6,133 @@
package branding package branding
import ( import (
"fmt"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"sync/atomic"
"unicode"
"telesrv/internal/links"
) )
const ( // Config is the deployment-wide, user-visible product identity. It is loaded
ProductName = "OwpenGram" // once during process startup; protocol identifiers and client detection
ProductUsername = "owpengram" // tokens deliberately remain outside this structure.
DesktopAppName = "OwpenGram Desktop" type Config struct {
AndroidAppName = "OwpenGram Android" ProductName string
IOSAppName = "OwpenGram iOS" ProductUsername string
MacOSAppName = "OwpenGram macOS" DesktopAppName string
WebAAppName = "OwpenGram Web A" AndroidAppName string
WebKAppName = "OwpenGram Web K" IOSAppName string
PremiumName = "OwpenGram Premium" MacOSAppName string
StarsName = "OwpenGram Stars" WebAAppName string
DefaultPublicURL = "https://owpengram.org" 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. // ClientAppName returns the branded display name for a stored client platform.
// Stored detection tokens remain unchanged; this is only used at presentation // Stored detection tokens remain unchanged; this is only used at presentation
// boundaries such as account.getAuthorizations. // boundaries such as account.getAuthorizations.
func ClientAppName(platform string) string { func ClientAppName(platform string) string {
cfg := Current()
switch strings.ToLower(strings.TrimSpace(platform)) { switch strings.ToLower(strings.TrimSpace(platform)) {
case "android": case "android":
return AndroidAppName return cfg.AndroidAppName
case "ios": case "ios":
return IOSAppName return cfg.IOSAppName
case "macos": case "macos":
return MacOSAppName return cfg.MacOSAppName
case "telegram-tt", "weba": case "telegram-tt", "weba":
return WebAAppName return cfg.WebAAppName
case "tweb", "webk": case "tweb", "webk":
return WebKAppName return cfg.WebKAppName
case "tdesktop", "desktop", "windows": case "tdesktop", "desktop", "windows":
return DesktopAppName return cfg.DesktopAppName
default: default:
return ProductName return cfg.ProductName
} }
} }
@ -78,18 +167,49 @@ func UserVisibleText(value, publicBaseURL string) string {
if technicalIDRE.MatchString(value) { if technicalIDRE.MatchString(value) {
return value return value
} }
return officialBrandRE.ReplaceAllString(value, ProductName) return officialBrandRE.ReplaceAllString(value, ProductName())
} }
func publicDestination(raw string) (string, string) { func publicDestination(raw string) (string, string) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/") raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" { if raw == "" {
raw = DefaultPublicURL raw = PublicBaseURL()
} }
parsed, err := url.Parse(raw) parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" { if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" {
raw = DefaultPublicURL raw = PublicBaseURL()
parsed, _ = url.Parse(raw) parsed, _ = url.Parse(raw)
} }
return raw, parsed.Host 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
}

View file

@ -46,13 +46,14 @@ func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) {
} }
func TestClientPresentationNames(t *testing.T) { func TestClientPresentationNames(t *testing.T) {
cfg := Current()
for platform, want := range map[string]string{ for platform, want := range map[string]string{
"tdesktop": DesktopAppName, "tdesktop": cfg.DesktopAppName,
"android": AndroidAppName, "android": cfg.AndroidAppName,
"ios": IOSAppName, "ios": cfg.IOSAppName,
"macos": MacOSAppName, "macos": cfg.MacOSAppName,
"telegram-tt": WebAAppName, "telegram-tt": cfg.WebAAppName,
"tweb": WebKAppName, "tweb": cfg.WebKAppName,
} { } {
if got := ClientAppName(platform); got != want { if got := ClientAppName(platform); got != want {
t.Fatalf("ClientAppName(%q) = %q, want %q", 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) 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")
}
})
}
}

View file

@ -349,7 +349,7 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass
if req != nil && req.Hash == emptyStickerSetHash { if req != nil && req.Hash == emptyStickerSetHash {
return &tg.MessagesStickerSetNotModified{} return &tg.MessagesStickerSetNotModified{}
} }
title, shortName := branding.ProductName+" Empty Sticker Set", "owpengram_empty" title, shortName := branding.ProductName()+" Empty Sticker Set", "owpengram_empty"
if req != nil { if req != nil {
switch set := req.Stickerset.(type) { switch set := req.Stickerset.(type) {
case *tg.InputStickerSetAnimatedEmoji: case *tg.InputStickerSetAnimatedEmoji:

View file

@ -994,7 +994,7 @@ func Load() (Config, error) {
SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""), SMTPUsername: envOr("TELESRV_SMTP_USERNAME", ""),
SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""), SMTPPassword: envOr("TELESRV_SMTP_PASSWORD", ""),
SMTPFrom: envOr("TELESRV_SMTP_FROM", ""), 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"))), SMTPTLSMode: strings.ToLower(strings.TrimSpace(envOr("TELESRV_SMTP_TLS", "starttls"))),
SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second), SMTPTimeout: envDurationOr("TELESRV_SMTP_TIMEOUT", 10*time.Second),
LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"),

View file

@ -4,6 +4,9 @@ import (
"errors" "errors"
"strings" "strings"
"time" "time"
"unicode"
"github.com/nyaruka/phonenumbers"
) )
var ( var (
@ -260,38 +263,105 @@ func MaskEmail(email string) string {
return name[:1] + "***" + name[len(name)-1:] + email[at:] return name[:1] + "***" + name[len(name)-1:] + email[at:]
} }
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤 // virtualLoginPhoneMinDigits/virtualLoginPhoneMaxDigits bound the "888"-prefixed
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。 // virtual login identity range NormalizePhone accepts without going through
// // libphonenumber (real E.164 numbers never start with 888). This is a login
// Email-signup 合成号码EncodeEmailPhone 生成,"888" 前缀 + 至少一个字母)是唯一例外: // identity concept only -- distinct from, and independent of, ownership of any
// 原样保留(仅 lower+trim不剥离字母——否则 DecodeEmailPhone 会因编码内容被剥空而 // purchasable collectible-phone asset with the same digit shape.
// 永远解不出邮箱。真实手机号恒为纯数字,不含字母,故这个判定不会误伤任何真实号码。 const (
func NormalizePhone(phone string) string { virtualLoginPhoneMinDigits = 7
if IsEmailSignupPhone(phone) { virtualLoginPhoneMaxDigits = 15
return strings.ToLower(strings.TrimSpace(phone)) )
}
// 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 var b strings.Builder
b.Grow(len(phone)) b.Grow(len(phone))
seenDigit := false
seenPlus := false
for _, r := range phone { for _, r := range phone {
if r >= '0' && r <= '9' { switch {
case r >= '0' && r <= '9':
b.WriteRune(r) 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() return b.String()
} }
// ValidPhone 校验 NormalizePhone 后的持久化形态:真实手机号是 5-200 位纯数字; // NormalizePhone returns the one persisted login identity. Virtual +888
// email-signup 合成号码额外允许小写字母EncodeEmailPhone 的转义字符集)。 // identities are independent of the collectible-phone registry and accept
// 上限与 users.phone 列宽一致;当前开发登录/改号链路不强制精确 E.164 长度, // 7-15 canonical digits. Ordinary international numbers use E.164 digits
// 但拒绝空串、非法字符和会截断的超长输入。 // without the leading '+'. Their parsing is deliberately country-aware so a
func ValidPhone(phone string) bool { // national trunk prefix is removed only where the numbering plan says it is a
if len(phone) < 5 || len(phone) > 200 { // prefix. For example, both +98 0998 167 9461 and +98 998 167 9461 become
return false // 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) { 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 { for _, r := range phone {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') { if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') {
return false return false
@ -299,10 +369,6 @@ func ValidPhone(phone string) bool {
} }
return true return true
} }
for _, r := range phone { canonical := NormalizePhone(phone)
if r < '0' || r > '9' { return canonical != "" && canonical == phone
return false
}
}
return true
} }

View file

@ -43,4 +43,7 @@ type AuthKeyClientInfo struct {
SystemVersion string SystemVersion string
APIID int APIID int
AppVersion string AppVersion string
// IP 是最近一次会话建立的客户端对端地址host-only。只做 metadata 级别的
// 合并刷新,绝不当作登录/绑定的身份证据,也不会触碰 created_at。
IP string
} }

View file

@ -104,8 +104,13 @@ func TestNewEmailSignupDisplayPhoneHonorsConfiguredPrefix(t *testing.T) {
if !strings.HasPrefix(phone, prefix) { if !strings.HasPrefix(phone, prefix) {
t.Fatalf("phone %q missing configured prefix %q", phone, prefix) t.Fatalf("phone %q missing configured prefix %q", phone, prefix)
} }
if !ValidPhone(phone) { // A display phone is cosmetic only (see assignEmailSignupDisplayPhone --
t.Fatalf("phone %q fails ValidPhone", phone) // 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)
} }
} }
} }

View file

@ -41,7 +41,7 @@ func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!") got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
if got != "Hello from OwpenGram!" { 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") SetOfficialSystemUserDisplayName("Custom Server")

View file

@ -49,7 +49,8 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
// MediaSearchRequest 是共享媒体标签页分页查询的入参messages.search 媒体过滤分支)。 // MediaSearchRequest 是共享媒体标签页分页查询的入参messages.search 媒体过滤分支)。
// Categories 是该标签页映射到的基础类别并集PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。 // Categories 是该标签页映射到的基础类别并集PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
// 分页对齐历史语义OffsetID 为游标(返回 id 严格小于它、AddOffset 为额外偏移、MaxID/MinID 为闭区间。 // OffsetID 定位第一条严格更旧的消息;负 AddOffset 向更新侧取数(可含游标本身)。
// MaxID/MinID、MaxDate/MinDate 均为开区间;计数忽略分页偏移。
type MediaSearchRequest struct { type MediaSearchRequest struct {
Categories []MediaCategory Categories []MediaCategory
Query string Query string

View file

@ -35,7 +35,7 @@ const (
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default. // MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
MaxMessageReplyQuoteLength = 1024 MaxMessageReplyQuoteLength = 1024
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id. // 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 limits styled text entity vectors in message text and quotes.
MaxMessageEntityCount = 256 MaxMessageEntityCount = 256
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。 // MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
@ -81,7 +81,7 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
return ErrReplyMessageIDInvalid return ErrReplyMessageIDInvalid
} }
// story 回复StoryID>0不携带 MessageID/TopMessageID普通回复至少有其一。 // 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 return ErrReplyMessageIDInvalid
} }
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset { if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
@ -250,6 +250,9 @@ type MessageReply struct {
QuoteText string QuoteText string
QuoteEntities []MessageEntity QuoteEntities []MessageEntity
QuoteOffset int 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 为 0Peer 为 story 作者, // StoryID > 0 表示这是一条对 story 的回复评论MessageID 为 0Peer 为 story 作者,
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。 // 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
StoryID int StoryID int
@ -287,6 +290,11 @@ type MessageFilter struct {
MaxID int MaxID int
MinID int MinID int
Hash int64 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 与 // PinnedOnly 仅返回置顶消息messages.search filterPinned 与
// userFull.pinned_msg_id 的查询路径)。 // userFull.pinned_msg_id 的查询路径)。
PinnedOnly bool PinnedOnly bool
@ -716,11 +724,13 @@ type PinPrivateMessageRequest struct {
Pinned bool Pinned bool
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时), // PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。 // 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
PmOneside bool PmOneside bool
Silent bool Silent bool
Date int // RecipientBlocked applies the normal private-service delivery policy.
OriginAuthKeyID [8]byte RecipientBlocked bool
OriginSessionID int64 Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
} }
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。 // PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。

View file

@ -26,6 +26,7 @@ var (
// undisclosed delivery and make a retry impossible to reconcile. // undisclosed delivery and make a retry impossible to reconcile.
ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous") ErrLoginCodeDeliveryCommitAmbiguous = errors.New("login code delivery commit ambiguous")
ErrReplyMessageIDInvalid = errors.New("reply message id invalid") ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
ErrQuoteTextInvalid = errors.New("quote text invalid")
ErrChatForwardsRestricted = errors.New("chat forwards restricted") ErrChatForwardsRestricted = errors.New("chat forwards restricted")
ErrNoForwardsRequestExpired = errors.New("no forwards request expired") ErrNoForwardsRequestExpired = errors.New("no forwards request expired")
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH收藏夹子会话置顶 // ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH收藏夹子会话置顶

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

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

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

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

View file

@ -9,6 +9,9 @@ import (
const ( const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。 // OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
OfficialSystemUserID int64 = 777000 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 // OfficialSystemUserPhotoID/AccessHash 是该账号头像 photo 的固定 id
// 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致, // 与 files.Service.SeedOfficialSystemAvatar 种子写入的行保持一致,
// 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。 // 确保跨重启后 OfficialSystemUser() 引用的 photo id 稳定不变。
@ -108,7 +111,7 @@ func SetOfficialSystemUserAvatar(dcID int, stripped []byte) {
} }
// officialSystemUserDisplayName overrides OfficialSystemUser's FirstName -- // 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 // once at startup from the operator's Server Settings -> Server identity
// name, if any. Deliberately only the display name, not Username: the // name, if any. Deliberately only the display name, not Username: the
// account's @username is a stable, addressable identifier other things may // 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 // SetOfficialSystemUserDisplayName records the operator's custom server
// name for the official system account (777000), read once at startup from // name for the official system account (777000), read once at startup from
// Server Settings -> Server identity. Pass "" to fall back to // 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. // override above uses.
func SetOfficialSystemUserDisplayName(name string) { func SetOfficialSystemUserDisplayName(name string) {
officialSystemUserDisplayName = strings.TrimSpace(name) officialSystemUserDisplayName = strings.TrimSpace(name)
@ -126,7 +129,7 @@ func SetOfficialSystemUserDisplayName(name string) {
// officialSystemDisplayName returns the official system account's current // officialSystemDisplayName returns the official system account's current
// effective display name: the operator's custom override if set via // 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 // OfficialSystemUser (777000's FirstName) and the login-welcome-message
// {{server_name}} placeholder (see login_welcome_template.go) so both stay // {{server_name}} placeholder (see login_welcome_template.go) so both stay
// consistent with each other. // consistent with each other.
@ -134,7 +137,7 @@ func officialSystemDisplayName() string {
if officialSystemUserDisplayName != "" { if officialSystemUserDisplayName != "" {
return officialSystemUserDisplayName return officialSystemUserDisplayName
} }
return branding.ProductName return branding.ProductName()
} }
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时 // botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
@ -223,11 +226,11 @@ func OfficialSystemUser() User {
// only the default snapshot; startup reconciliation and the memory backend use // only the default snapshot; startup reconciliation and the memory backend use
// these helpers so custom deployments do not expose stale "telesrv" text. // these helpers so custom deployments do not expose stale "telesrv" text.
func ChatBotDescription() string { func ChatBotDescription() string {
return "Chat with the configured " + branding.ProductName + " AI provider." return "Chat with the configured " + branding.ProductName() + " AI provider."
} }
func StickersBotDescription() string { 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 结尾属种子例外(与官方一致)。 // BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。

File diff suppressed because it is too large Load diff

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

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

View 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()
}

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

View 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:
}
}

View file

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/hex" "encoding/hex"
"errors" "errors"
"net"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -81,12 +82,12 @@ type Conn struct {
salt int64 salt int64
key crypto.AuthKey key crypto.AuthKey
outbound chan outboundOp outbound chan *outboundOp
outboundControl chan outboundOp outboundControl chan *outboundOp
// Critical RPC results (session/difference convergence) and large bulk // Critical RPC results (session/difference convergence) and large bulk
// responses have independent bounded lanes. The actor remains the sole writer. // responses have independent bounded lanes. The actor remains the sole writer.
outboundCritical chan outboundOp outboundCritical chan *outboundOp
outboundBulk chan outboundOp outboundBulk chan *outboundOp
outboundStop chan struct{} outboundStop chan struct{}
outboundDone chan struct{} outboundDone chan struct{}
outboundClose sync.Once outboundClose sync.Once
@ -104,10 +105,14 @@ type Conn struct {
// Encoded MTProto service frames and control vectors use independent headroom: pong, // 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 // 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. // full. Content-related control frames keep this budget while pending for resend.
outboundControlTrackedBudget *outboundTrackedBudget outboundControlTrackedBudget *outboundTrackedBudget
outboundControlBudgetOnce sync.Once outboundControlBudgetOnce sync.Once
outboundScratchPool *outboundScratchPool outboundCriticalTrackedBudget *outboundTrackedBudget
outboundScratchOnce sync.Once outboundCriticalBudgetOnce sync.Once
outboundScratchPool *outboundScratchPool
outboundScratchOnce sync.Once
outboundOpPool *outboundOpPool
outboundReplayBodyPool *outboundReplayBodyPool
// outboundState outlives this physical Conn generation. A replacement // outboundState outlives this physical Conn generation. A replacement
// physical connection for the same auth key/session reuses it. // physical connection for the same auth key/session reuses it.
outboundState *outboundState outboundState *outboundState
@ -160,6 +165,9 @@ type Conn struct {
// 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。 // 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。
rpcRootCtx context.Context rpcRootCtx context.Context
rpcMaxInflight int rpcMaxInflight int
// remoteAddr 是 MTProto 连接的对端地址(来自 net.Conn.RemoteAddr仅保留 host
// 部分;绑定设备授权时写入 authorizations.ip便于在 admin 面板看到登录 IP。
remoteAddr string
// sentContentMessages is retained only for standalone construction tests. // sentContentMessages is retained only for standalone construction tests.
// Server connections allocate seq_no from logical-session outboundState. // Server connections allocate seq_no from logical-session outboundState.
@ -419,3 +427,21 @@ func (c *Conn) ReceivesUpdates() bool { return c.receivesUpdates.Load() }
// SetReceivesUpdates 设置该连接是否接收主动推送的 updates。 // SetReceivesUpdates 设置该连接是否接收主动推送的 updates。
// 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。 // 登录后的主连接在 updates.getState/getDifference 建立同步基线后置为 true。
func (c *Conn) SetReceivesUpdates(v bool) { c.receivesUpdates.Store(v) } 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 返回连接的对端 IPhost 部分),未设置时为空字符串。
func (c *Conn) clientIP() string { return c.remoteAddr }

View file

@ -22,8 +22,8 @@ func TestPushSkipsConnReboundToOtherUser(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: sid, sessionID: sid,
authKeyID: [8]byte{authKey}, authKeyID: [8]byte{authKey},
outbound: make(chan outboundOp, 4), outbound: make(chan *outboundOp, 4),
outboundControl: make(chan outboundOp, 4), outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.userID.Store(userA) c.userID.Store(userA)

View file

@ -27,6 +27,7 @@ import (
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/observability/dbtrace" "telesrv/internal/observability/dbtrace"
"telesrv/internal/postresponse" "telesrv/internal/postresponse"
"telesrv/internal/rpcresult"
"telesrv/internal/store" "telesrv/internal/store"
) )
@ -40,6 +41,7 @@ type connState struct {
createdFloor int64 createdFloor int64
seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req seen map[int64]clientMsgRecord // 已处理的 client msg_id用于幂等和 msgs_state_req
order []int64 order []int64
orderHead int
minSeen int64 minSeen int64
maxSeen int64 maxSeen int64
// maxContentMsgID/maxContentSeqNo 是已接受 content 消息的 msg_id / seq_no 高水位, // 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 // 直接复用 current.key/current.salt 解密。任何 provisional 在 claim 建立后、发 required
// control 前都会最终回查 AuthKeyStore使外部撤销与 activation 线性化。 // control 前都会最终回查 AuthKeyStore使外部撤销与 activation 线性化。
// plain 是 serveConn 持有的复用明文缓冲frame 的 slice 仅在下一帧解密前有效。 // 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 key crypto.AuthKey
var serverSalt int64 var serverSalt int64
var authKeyExpiresAt int var authKeyExpiresAt int
@ -161,6 +163,8 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
} else { } else {
current = s.newConn(tc, key, frame.sessionID, serverSalt) current = s.newConn(tc, key, frame.sessionID, serverSalt)
} }
// 记录对端 IP供绑定设备授权时写入 authorizations.ipadmin 面板可见)。
current.setRemoteAddrStr(remote)
current.authKeyExpiresAt = authKeyExpiresAt current.authKeyExpiresAt = authKeyExpiresAt
// Same-session evidence is restored as explicit; auth-key metadata is only // Same-session evidence is restored as explicit; auth-key metadata is only
// an inherited default and can be corrected by the next invokeWithLayer. // an inherited default and can be corrected by the next invokeWithLayer.
@ -904,7 +908,7 @@ func (s *Server) publishRPCResult(
priority := rpcResultPriority(method, encoded) priority := rpcResultPriority(method, encoded)
encoded.priority = priority encoded.priority = priority
if metrics, ok := s.metrics.(RPCResultMetrics); ok { 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 visible := encoded.compressed || priority == outboundPriorityCritical || priority == outboundPriorityBulk
return priority, visible return priority, visible
@ -916,21 +920,26 @@ func (s *Server) publishRPCResult(
// re-execution hidden behind a local capacity error. // re-execution hidden behind a local capacity error.
retainForReplay := func(encoded *encodedOutboundMessage, admissionErr error) error { retainForReplay := func(encoded *encodedOutboundMessage, admissionErr error) error {
if s == nil || s.rpcResults == nil || c == nil || encoded == nil || reqMsgID == 0 { 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") return errors.New("rpc result receipt ledger is unavailable")
} }
priority, visible := prepareEncoded(encoded) priority, visible := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() { if owner != nil && !owner.HandOff() {
encoded.releaseBulkCredit()
return ErrRPCResultFlightInvalid return ErrRPCResultFlightInvalid
} }
started := time.Now() started := time.Now()
encoded.markReplayable() encoded.markReplayable()
encoded.releaseBulkCredit()
// Complete may expose terminal execution only after the old connection // Complete may expose terminal execution only after the old connection
// is irreversibly unable to accept another same-generation request. // is irreversibly unable to accept another same-generation request.
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
s.completeRPCResult(c, reqMsgID, encoded, false) s.completeRPCResult(c, reqMsgID, encoded, false)
latency := time.Since(started) latency := time.Since(started)
if metrics, ok := s.metrics.(RPCResultMetrics); ok { 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 resultLogLevel := zap.DebugLevel
if visible { if visible {
@ -941,14 +950,15 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", encoded.writtenRequestID()), zap.Int64("delivered_req_msg_id", encoded.writtenRequestID()),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID), 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)) zap.String("priority", priority.String()), zap.Error(admissionErr))
} }
return nil return nil
} }
encoded, reserved, retained, err := s.encodeRPCResultReservedWithHandoffContext( methodPriority := rpcMethodPriority(method)
prepareCtx, c, reqMsgID, result, retainForReplay, encoded, reserved, retained, err := s.encodeRPCResultReservedWithPriorityAndHandoffContext(
prepareCtx, c, reqMsgID, result, methodPriority, retainForReplay,
) )
if retained { if retained {
return err return err
@ -958,11 +968,14 @@ func (s *Server) publishRPCResult(
return err return err
} }
if err != nil { if err != nil {
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
provider.exactRPCBulkCredit().release()
}
s.log.Warn("Encode RPC result failed; publishing INTERNAL", s.log.Warn("Encode RPC result failed; publishing INTERNAL",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err)) zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
afterDelivered = nil afterDelivered = nil
encoded, reserved, retained, err = s.encodeRPCResultReservedWithHandoffContext( encoded, reserved, retained, err = s.encodeRPCResultReservedWithPriorityAndHandoffContext(
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, retainForReplay, prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, methodPriority, retainForReplay,
) )
if retained { if retained {
return err return err
@ -973,6 +986,9 @@ func (s *Server) publishRPCResult(
} }
} }
if encoded == nil || reserved == nil { if encoded == nil || reserved == nil {
if provider, ok := result.(interface{ exactRPCBulkCredit() *outboundBulkCredit }); ok {
provider.exactRPCBulkCredit().release()
}
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
return errors.New("rpc result encode completed without tracked retention") return errors.New("rpc result encode completed without tracked retention")
} }
@ -981,6 +997,7 @@ func (s *Server) publishRPCResult(
defer reserved.release() defer reserved.release()
priority, _ := prepareEncoded(encoded) priority, _ := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() { if owner != nil && !owner.HandOff() {
encoded.releaseBulkCredit()
return ErrRPCResultFlightInvalid return ErrRPCResultFlightInvalid
} }
@ -989,7 +1006,7 @@ func (s *Server) publishRPCResult(
latency := time.Since(egressStarted) latency := time.Since(egressStarted)
deliveredReqMsgID := encoded.writtenRequestID() deliveredReqMsgID := encoded.writtenRequestID()
if metrics, ok := s.metrics.(RPCResultMetrics); ok { 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 { if deliveryErr != nil {
encoded.markReplayable() encoded.markReplayable()
@ -1000,7 +1017,7 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", deliveredReqMsgID), zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID), 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)) zap.Error(deliveryErr))
} }
return return
@ -1012,7 +1029,7 @@ func (s *Server) publishRPCResult(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", deliveredReqMsgID), zap.Int64("delivered_req_msg_id", deliveredReqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID), 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)) 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 { if checked := s.log.Check(zap.DebugLevel, "RPC result admitted"); checked != nil {
checked.Write( checked.Write(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), 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())) zap.Bool("gzip", encoded.compressed), zap.String("priority", priority.String()))
} }
return nil return nil
@ -1086,7 +1103,7 @@ func (s *Server) sendReplayedRPCResultWithHook(
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
return errors.New("nil replayed rpc_result") 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 { if err != nil {
c.failOutboundBudget(err) c.failOutboundBudget(err)
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
@ -1222,6 +1239,19 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
reqMsgID int64, reqMsgID int64,
result bin.Encoder, result bin.Encoder,
handoff rpcResultRetentionHandoff, 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) { ) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
@ -1237,7 +1267,10 @@ func (s *Server) encodeRPCResultReservedWithHandoffContext(
if err != nil { if err != nil {
return err 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) bytes := len(encoded.body)
if budget.reserve(bytes) { if budget.reserve(bytes) {
reserved = &outboundBodyReservation{budget: budget, bytes: 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) 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 // Encode the ordinary exact/no-gzip path directly behind the rpc_result
// prefix. This avoids both the old generated Prepare snapshot and another // prefix. This avoids both the old generated Prepare snapshot and another
// full-body copy merely to prepend the 12-byte envelope. // 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) return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(innerBody)+12, maxOutboundBodyBytes)
} }
wireInner, compressed, err := encodeAdaptiveRPCResultInner(ctx, nil, innerBody) wireInner := innerBody
if err != nil { compressed := false
return nil, fmt.Errorf("compress rpc result: %w", err) 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 { if len(wireInner) > maxOutboundBodyBytes-12 {
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(wireInner)+12, maxOutboundBodyBytes) 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, typeID: proto.ResultTypeID, body: body, reqMsgID: reqMsgID,
compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(0), compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(0),
layer: layerBinding, layerInvariant: layerInvariantResult, layer: layerBinding, layerInvariant: layerInvariantResult,
replaySource: replaySource, innerDigest: sha256.Sum256(innerBody), logicalBytes: len(body), bulkCredit: bulkCredit,
}, nil }, nil
} }
@ -1620,18 +1667,23 @@ func (cs *connState) trackInbound(msgID int64, seqNo int32, content, service boo
cs.maxContentSeqNo = seqNo 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 { if msgID < cs.minSeen {
cs.minSeen = msgID cs.minSeen = msgID
} }
if msgID > cs.maxSeen { if msgID > cs.maxSeen {
cs.maxSeen = msgID cs.maxSeen = msgID
} }
if len(cs.order) > maxTrackedClientMsgIDs { if evicted != 0 {
oldest := cs.order[0] delete(cs.seen, evicted)
cs.order = cs.order[1:] if evicted == cs.minSeen || evicted == cs.maxSeen {
delete(cs.seen, oldest)
if oldest == cs.minSeen || oldest == cs.maxSeen {
cs.recomputeRange() cs.recomputeRange()
} }
} }

View file

@ -19,8 +19,8 @@ func TestRunFlushDiscardsBatchOnIdentitySwitch(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: sessionID, sessionID: sessionID,
authKeyID: raw, authKeyID: raw,
outbound: make(chan outboundOp, 4), outbound: make(chan *outboundOp, 4),
outboundControl: make(chan outboundOp, 4), outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
sm.Register(c) sm.Register(c)

View file

@ -2098,7 +2098,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
if dependencies.failed || len(dependencies.waiters) != 1 { if dependencies.failed || len(dependencies.waiters) != 1 {
t.Fatalf("dependencies before completion = %+v", dependencies) t.Fatalf("dependencies before completion = %+v", dependencies)
} }
gate := newLayerRPCExecutionGate(c, dependencies) gate := newLayerRPCExecutionGate(c, dependencies, nil, nil)
if gate == nil || gate.runnable() { if gate == nil || gate.runnable() {
t.Fatal("dependency gate was runnable before business completion") t.Fatal("dependency gate was runnable before business completion")
} }
@ -2123,7 +2123,7 @@ func TestLayerRPCDependencyGateUsesBusinessOutcome(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
dependencies := s.layerRPCDependencies(c, 304, missing) 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() { 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()) t.Fatalf("missing dependency gate = deps:%+v runnable:%v success:%v", dependencies, gate.runnable(), gate.success())
} }

View file

@ -8,6 +8,8 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"telesrv/internal/transport"
) )
// ErrInboundRPCQueueFull 表示 inbound RPC 已触达单连接或进程级预算。 // ErrInboundRPCQueueFull 表示 inbound RPC 已触达单连接或进程级预算。
@ -940,6 +942,11 @@ func (c *Conn) runInboundRPC(task inboundRPC) {
c.metrics.InboundRPCStarted(task.method, now.Sub(task.enqueuedAt)) c.metrics.InboundRPCStarted(task.method, now.Sub(task.enqueuedAt))
ctx := task.ctx ctx := task.ctx
// 把连接对端 IP 作为中立传输元数据注入,绑定设备授权时写 authorizations.ip。
// Edge 只产生传输事实;由 RPC 层的 transport.ClientIPFrom 消费,避免反向依赖。
if ip := c.clientIP(); ip != "" {
ctx = transport.WithClientIP(ctx, ip)
}
if task.run != nil { if task.run != nil {
_ = task.run(ctx) _ = task.run(ctx)
} }

View file

@ -13,6 +13,7 @@ import (
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/observability/dbtrace" "telesrv/internal/observability/dbtrace"
"telesrv/internal/postresponse" "telesrv/internal/postresponse"
"telesrv/internal/rpcresult"
) )
// layerRPCResultEncoder keeps the generated result bound to the immutable // layerRPCResultEncoder keeps the generated result bound to the immutable
@ -22,6 +23,8 @@ import (
type layerRPCResultEncoder struct { type layerRPCResultEncoder struct {
call tlprofile.Call call tlprofile.Call
result tlprofile.Result result tlprofile.Result
source rpcresult.ReplaySource
bulk *outboundBulkCredit
} }
func (e *layerRPCResultEncoder) Encode(b *bin.Buffer) error { 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 { type exactLayerRPCResultEncoder interface {
bin.Encoder bin.Encoder
exactLayerRPCResultBinding() outboundLayerBinding exactLayerRPCResultBinding() outboundLayerBinding
@ -90,7 +107,11 @@ func bindAdmittedLayerRPCResult(request tlprofile.Admission, result tlprofile.Re
if result.Prepared().Identity() != request.Prepared().Identity() { if result.Prepared().Identity() != request.Prepared().Identity() {
return nil, errLayerRPCResultIdentityMismatch 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( func (s *Server) newInboundLayerRPCTask(
@ -104,7 +125,15 @@ func (s *Server) newInboundLayerRPCTask(
owner *rpcResultOwnerLease, owner *rpcResultOwnerLease,
) inboundRPC { ) inboundRPC {
wireSize := request.Prepared().WireSize() 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() { timeoutResponse := func() {
writeTimeout := c.writeTimeout writeTimeout := c.writeTimeout
if writeTimeout <= 0 || writeTimeout > 5*time.Second { if writeTimeout <= 0 || writeTimeout > 5*time.Second {
@ -126,6 +155,8 @@ func (s *Server) newInboundLayerRPCTask(
size: wireSize, size: wireSize,
onTimeout: timeoutResponse, onTimeout: timeoutResponse,
release: func() { release: func() {
bulkExecution.release()
bulkLease.releaseIfOwned()
if owner != nil && owner.Abort() { if owner != nil && owner.Abort() {
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
} }
@ -137,7 +168,7 @@ func (s *Server) newInboundLayerRPCTask(
ErrorCode: 500, ErrorMessage: "MSG_WAIT_FAILED", ErrorCode: 500, ErrorMessage: "MSG_WAIT_FAILED",
}, nil) }, 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{ fields := []zap.Field{
zap.Int64("msg_id", msgID), zap.String("auth_key_id", c.authKeyHex), zap.Int64("msg_id", msgID), zap.String("auth_key_id", c.authKeyHex),
zap.Int64("session_id", c.sessionID), zap.Error(err), zap.Int64("session_id", c.sessionID), zap.Error(err),
@ -161,11 +192,15 @@ func layerRPCTimeoutMessage(gate *inboundRPCGate) string {
return "RPC_TIMEOUT" return "RPC_TIMEOUT"
} }
func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inboundRPCGate { func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet, bulk *outboundBulkCreditLease, execution *bulkRPCAdmission) *inboundRPCGate {
if len(dependencies.waiters) == 0 && !dependencies.failed { if len(dependencies.waiters) == 0 && !dependencies.failed && bulk == nil && execution == nil {
return 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 { if dependencies.failed {
gate.failed.Store(true) gate.failed.Store(true)
} }
@ -174,6 +209,11 @@ func newLayerRPCExecutionGate(c *Conn, dependencies layerRPCDependencySet) *inbo
gate.resolve(false) 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. // Release the subscriber-installation sentinel.
gate.resolve(true) gate.resolve(true)
return gate return gate
@ -202,6 +242,7 @@ func (s *Server) handleAdmittedLayerRPC(
method string, method string,
request tlprofile.Admission, request tlprofile.Admission,
owner *rpcResultOwnerLease, owner *rpcResultOwnerLease,
bulkLease *outboundBulkCreditLease,
) error { ) error {
if s.layerRPC == nil { if s.layerRPC == nil {
return s.publishAdmittedLayerRPCResult(c, msgID, method, owner, false, &mt.RPCError{ return s.publishAdmittedLayerRPCResult(c, msgID, method, owner, false, &mt.RPCError{
@ -219,6 +260,9 @@ func (s *Server) handleAdmittedLayerRPC(
var exact *layerRPCResultEncoder var exact *layerRPCResultEncoder
if err == nil && result != nil { if err == nil && result != nil {
exact, err = bindAdmittedLayerRPCResult(request, result) exact, err = bindAdmittedLayerRPCResult(request, result)
if err == nil && exact != nil && bulkLease != nil {
exact.bulk = bulkLease.transfer()
}
} }
dur := s.clock.Now().Sub(start) dur := s.clock.Now().Sub(start)
s.metrics.RPCHandled(effectiveMethod, dur, err) s.metrics.RPCHandled(effectiveMethod, dur, err)

View file

@ -101,7 +101,7 @@ func TestProjectionFailureCachesInternalWithoutRepeatingBusiness(t *testing.T) {
} }
if err := s.handleAdmittedLayerRPC( if err := s.handleAdmittedLayerRPC(
context.Background(), c, reqMsgID, claim.admissionSeq, context.Background(), c, reqMsgID, claim.admissionSeq,
"help.getConfig", request, claim.owner, "help.getConfig", request, claim.owner, nil,
); err != nil { ); err != nil {
t.Fatalf("publish projection failure: %v", err) t.Fatalf("publish projection failure: %v", err)
} }

File diff suppressed because it is too large Load diff

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

View file

@ -215,13 +215,13 @@ func TestSendRequiredControlQueueDeadlineTerminatesAndReturnsBudget(t *testing.T
writeTimeout: time.Second, writeTimeout: time.Second,
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20), outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
outboundControlTrackedBudget: controlBudget, outboundControlTrackedBudget: controlBudget,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
// No actor is running and the bounded control queue is full, so the parent // No actor is running and the bounded control queue is full, so the parent
// deadline must cover queue admission and make the failure terminal. // 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) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel() defer cancel()

View file

@ -4,12 +4,15 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors" "errors"
"io" "io"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"unsafe"
"github.com/iamxvbaba/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/crypto" "github.com/iamxvbaba/td/crypto"
@ -20,6 +23,17 @@ import (
"github.com/iamxvbaba/td/transport" "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 { type failAfterTransport struct {
failAt atomic.Int32 failAt atomic.Int32
sends atomic.Int32 sends atomic.Int32
@ -448,6 +462,9 @@ func newOutboundTestConn(t *testing.T, tr transport.Conn, budget *outboundTracke
} }
func TestOutboundQueueBackingUsesSmallConfigurableBounds(t *testing.T) { 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) { t.Run("defaults", func(t *testing.T) {
c := &Conn{metrics: NopMetrics{}} c := &Conn{metrics: NopMetrics{}}
c.startOutbound() 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) { func TestOutboundOptionsDefaults(t *testing.T) {
opts := Options{} opts := Options{}
opts.setDefaults() opts.setDefaults()
@ -486,13 +561,17 @@ func TestOutboundOptionsDefaults(t *testing.T) {
if opts.OutboundTrackedGlobalMaxBytes != 512<<20 { if opts.OutboundTrackedGlobalMaxBytes != 512<<20 {
t.Fatalf("outbound tracked default = %d, want %d", 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) { func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
srv := New(Options{ srv := New(Options{
OutboundQueueSize: 7, OutboundQueueSize: 7,
OutboundControlQueueSize: 3, OutboundControlQueueSize: 3,
OutboundTrackedGlobalMaxBytes: 20, OutboundTrackedGlobalMaxBytes: 20,
OutboundCriticalGlobalMaxBytes: 30,
}) })
var rawKey crypto.Key var rawKey crypto.Key
key := rawKey.WithID() key := rawKey.WithID()
@ -513,6 +592,12 @@ func TestServerNewConnectionsShareOutboundBudgetAndQueueLimits(t *testing.T) {
if got := srv.outboundTrackedBudget.maxBytes; got != 20 { if got := srv.outboundTrackedBudget.maxBytes; got != 20 {
t.Fatalf("server outbound tracked max = %d, want 20", got) 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) { 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) { func TestOutboundStateEvictionReturnsTrackedBudget(t *testing.T) {
budget := newOutboundTrackedBudget(64) budget := newOutboundTrackedBudget(64)
state := newOutboundStateWithLimits(budget, 2, 8) state := newOutboundStateWithLimits(budget, 2, 8)
@ -991,11 +1232,11 @@ func TestOutboundStateReleasesMixedBodyAndControlBudgets(t *testing.T) {
func TestSendBestEffortQueueFullBehavior(t *testing.T) { func TestSendBestEffortQueueFullBehavior(t *testing.T) {
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)} c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
c.outbound = make(chan outboundOp, 1) c.outbound = make(chan *outboundOp, 1)
c.outboundControl = make(chan outboundOp, 1) c.outboundControl = make(chan *outboundOp, 1)
c.outboundStop = make(chan struct{}) c.outboundStop = make(chan struct{})
// 占满普通队列,模拟出站拥塞。 // 占满普通队列,模拟出站拥塞。
c.outbound <- outboundOp{} c.outbound <- &outboundOp{}
if err := c.SendBestEffort(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}, 0); err != ErrOutboundQueueFull { 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) 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) { func TestSendAsyncControlQueueBoundary(t *testing.T) {
c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)} c := &Conn{metrics: NopMetrics{}, outboundTrackedBudget: newOutboundTrackedBudget(1 << 20)}
c.outbound = make(chan outboundOp, 1) c.outbound = make(chan *outboundOp, 1)
c.outboundControl = make(chan outboundOp, 1) c.outboundControl = make(chan *outboundOp, 1)
c.outboundStop = make(chan struct{}) 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 { if err := c.SendAsync(context.Background(), proto.MessageFromServer, &mt.MsgsAck{}); err != nil {
t.Fatalf("SendAsync on full control queue: %v", err) t.Fatalf("SendAsync on full control queue: %v", err)

View file

@ -325,16 +325,24 @@ func encodeAdaptiveRPCResultInner(ctx context.Context, stop <-chan struct{}, inn
// Android. These bootstrap barriers must pass background prefetch regardless of // Android. These bootstrap barriers must pass background prefetch regardless of
// platform or their own encoded size. // platform or their own encoded size.
func rpcResultPriority(method string, encoded *encodedOutboundMessage) outboundPriority { 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 base := method
if i := strings.IndexByte(base, '#'); i >= 0 { if i := strings.IndexByte(base, '#'); i >= 0 {
base = base[:i] base = base[:i]
} }
switch base { switch base {
case "updates.getDifference", "updates.getChannelDifference", "updates.getState", case "updates.getDifference", "updates.getChannelDifference", "updates.getState",
"messages.getDialogs", "messages.getPinnedDialogs": "messages.getDialogs", "messages.getPinnedDialogs",
"auth.bindTempAuthKey", "help.getConfig", "users.getUsers":
return outboundPriorityCritical return outboundPriorityCritical
} }
return classifyOutboundPriority(encoded, false) return outboundPriorityNormal
} }
func (p outboundPriority) String() string { func (p outboundPriority) String() string {

View file

@ -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) { func TestEncodeRPCResultReservedDropsBodyOnBudgetTimeout(t *testing.T) {
const maxBytes = 1 << 20 const maxBytes = 1 << 20
budget := newOutboundTrackedBudget(maxBytes) budget := newOutboundTrackedBudget(maxBytes)

View file

@ -23,10 +23,10 @@ func TestRPCResultCloneReservationIsOneShotUnderReleaseRace(t *testing.T) {
} }
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)} reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
start := make(chan struct{}) start := make(chan struct{})
taken := make(chan outboundOp, 1) taken := make(chan *outboundOp, 1)
go func() { go func() {
<-start <-start
op, _ := reserved.take(encoded) op, _ := reserved.take(encoded, fallbackOutboundOpPool)
taken <- op taken <- op
}() }()
released := make(chan struct{}) released := make(chan struct{})
@ -53,7 +53,7 @@ func TestRPCResultReservationReleaseWinsAdmissionRollback(t *testing.T) {
t.Fatal("reserve body") t.Fatal("reserve body")
} }
reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)} reserved := &outboundBodyReservation{budget: budget, bytes: len(encoded.body)}
op, err := reserved.take(encoded) op, err := reserved.take(encoded, fallbackOutboundOpPool)
if err != nil { if err != nil {
t.Fatalf("take reservation: %v", err) 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 // 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. // the raw op charge instead of resurrecting an owner nobody will release.
reserved.release() reserved.release()
if !reserved.reclaim(&op) { if !reserved.reclaim(op) {
t.Fatal("reclaim actor reservation") t.Fatal("reclaim actor reservation")
} }
if got := budget.snapshot(); got != 0 { if got := budget.snapshot(); got != 0 {
@ -175,7 +175,7 @@ func TestOutboundActorRetargetRequiresSecondBodyReservation(t *testing.T) {
terminalBytes = budget.snapshot() terminalBytes = budget.snapshot()
}, },
} }
err := c.handleOutboundSend(state, op) err := c.handleOutboundSend(state, &op)
op.finish(outboundResult{err: err}) op.finish(outboundResult{err: err})
if !errors.Is(terminalErr, ErrOutboundTrackedBudget) { if !errors.Is(terminalErr, ErrOutboundTrackedBudget) {
t.Fatalf("retarget terminal error = %v, want %v", terminalErr, ErrOutboundTrackedBudget) t.Fatalf("retarget terminal error = %v, want %v", terminalErr, ErrOutboundTrackedBudget)
@ -228,7 +228,7 @@ func TestOutboundActorRetargetTransfersOnlyReplacementToPending(t *testing.T) {
terminalBytes = budget.snapshot() terminalBytes = budget.snapshot()
}, },
} }
err := c.handleOutboundSend(state, op) err := c.handleOutboundSend(state, &op)
op.finish(outboundResult{err: err}) op.finish(outboundResult{err: err})
if terminalErr != nil { if terminalErr != nil {
t.Fatalf("retarget terminal error: %v", terminalErr) t.Fatalf("retarget terminal error: %v", terminalErr)

View file

@ -1135,5 +1135,5 @@ func (s *Server) publishRewrappedRPCResult(
s.log.Info("RPC init rewrap result replay delivered", s.log.Info("RPC init rewrap result replay delivered",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID), 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()))
} }

View file

@ -38,6 +38,8 @@ type RuntimeSnapshot struct {
OutboundTrackedMaxBytes int64 OutboundTrackedMaxBytes int64
OutboundControlBytes int64 OutboundControlBytes int64
OutboundControlMaxBytes int64 OutboundControlMaxBytes int64
OutboundCriticalBytes int64
OutboundCriticalMaxBytes int64
OutboundWriteBytes int64 OutboundWriteBytes int64
OutboundWriteMaxBytes int64 OutboundWriteMaxBytes int64
RPCExecutionOwners int64 RPCExecutionOwners int64
@ -190,6 +192,10 @@ func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
result.OutboundControlBytes = s.outboundControlBudget.snapshot() result.OutboundControlBytes = s.outboundControlBudget.snapshot()
result.OutboundControlMaxBytes = s.outboundControlBudget.maxBytes 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 { if s.outboundScratchPool != nil && s.outboundScratchPool.budget != nil {
result.OutboundWriteBytes = s.outboundScratchPool.snapshot() result.OutboundWriteBytes = s.outboundScratchPool.snapshot()
result.OutboundWriteMaxBytes = s.outboundScratchPool.budget.maxBytes result.OutboundWriteMaxBytes = s.outboundScratchPool.budget.maxBytes

View file

@ -330,27 +330,21 @@ type Options struct {
// 阻断连接维持消息。可靠响应无法 tracking 时终止该连接durable best-effort update // 阻断连接维持消息。可靠响应无法 tracking 时终止该连接durable best-effort update
// 则只丢在线加速并由 difference 恢复。 // 则只丢在线加速并由 difference 恢复。
OutboundTrackedGlobalMaxBytes int64 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. // OutboundWriteGlobalMaxBytes bounds concurrent encrypted wire/codec/obfuscation scratch.
// Scratch is shared and pooled across connections; default 512 MiB. // Scratch is shared and pooled across connections; default 512 MiB.
OutboundWriteGlobalMaxBytes int64 OutboundWriteGlobalMaxBytes int64
// DC 是本 server 的 DC ID。默认 2。 // DC 是本 server 的 DC ID。默认 2。
DC int DC int
// StrictDC turns on exact DC-ID validation for the permanent-key exchange // StrictDC enables DC-label validation during key exchange. It is false by
// (default off = lenient). telesrv is always a single physical backend — // default: this single physical backend accepts every wire int32 label for
// there is no real multi-DC federation behind it — but the OwpenGram // permanent and temporary keys, and the label never changes auth-key
// client forks intentionally run in "single-server backend" mode, where // persistence, session identity, or business state. When enabled,
// dc_id 1..5 all alias to this one server (see owpengram_servers.cpp / // permanent labels must equal DC and temporary labels may equal +/-DC.
// ApplyServerToDcOptions in the desktop client) so that any old data // This diagnostic switch does not itself provide multi-DC isolation.
// 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 bool StrictDC bool
// RSAKey 是 server RSA 私钥用于密钥交换。nil 时无法完成握手。 // RSAKey 是 server RSA 私钥用于密钥交换。nil 时无法完成握手。
RSAKey *rsa.PrivateKey RSAKey *rsa.PrivateKey
@ -458,6 +452,9 @@ func (o *Options) setDefaults() {
if o.OutboundTrackedGlobalMaxBytes <= 0 { if o.OutboundTrackedGlobalMaxBytes <= 0 {
o.OutboundTrackedGlobalMaxBytes = defaultOutboundTrackedMaxBytes o.OutboundTrackedGlobalMaxBytes = defaultOutboundTrackedMaxBytes
} }
if o.OutboundCriticalGlobalMaxBytes <= 0 {
o.OutboundCriticalGlobalMaxBytes = defaultOutboundCriticalMaxBytes
}
if o.OutboundWriteGlobalMaxBytes <= 0 { if o.OutboundWriteGlobalMaxBytes <= 0 {
o.OutboundWriteGlobalMaxBytes = defaultOutboundWriteMaxBytes o.OutboundWriteGlobalMaxBytes = defaultOutboundWriteMaxBytes
} }
@ -518,13 +515,17 @@ type Server struct {
rpcQueueSize int rpcQueueSize int
rpcTimeout time.Duration rpcTimeout time.Duration
rpcScheduler *inboundRPCScheduler rpcScheduler *inboundRPCScheduler
bulkRPCScheduler *bulkRPCScheduler
rpcDeliveryHooks *rpcDeliveryHookExecutor rpcDeliveryHooks *rpcDeliveryHookExecutor
frameBudget *inboundFrameBudget frameBudget *inboundFrameBudget
outboundQueueSize int outboundQueueSize int
outboundControlQueueSize int outboundControlQueueSize int
outboundTrackedBudget *outboundTrackedBudget outboundTrackedBudget *outboundTrackedBudget
outboundControlBudget *outboundTrackedBudget outboundControlBudget *outboundTrackedBudget
outboundCriticalBudget *outboundTrackedBudget
outboundScratchPool *outboundScratchPool outboundScratchPool *outboundScratchPool
outboundOpPool *outboundOpPool
outboundReplayBodyPool *outboundReplayBodyPool
dc int dc int
strictDC bool strictDC bool
@ -581,17 +582,20 @@ func New(opts Options) *Server {
rpcQueueSize: opts.RPCQueueSize, rpcQueueSize: opts.RPCQueueSize,
rpcTimeout: opts.RPCTimeout, rpcTimeout: opts.RPCTimeout,
rpcScheduler: newInboundRPCScheduler(opts.RPCGlobalWorkers, opts.RPCGlobalMaxTasks, opts.RPCGlobalMaxBytes), rpcScheduler: newInboundRPCScheduler(opts.RPCGlobalWorkers, opts.RPCGlobalMaxTasks, opts.RPCGlobalMaxBytes),
bulkRPCScheduler: newBulkRPCScheduler(max(1, opts.RPCGlobalWorkers/2)),
rpcDeliveryHooks: newRPCDeliveryHookExecutor(opts.RPCDeliveryHookWorkers, opts.RPCDeliveryHookMaxPending), rpcDeliveryHooks: newRPCDeliveryHookExecutor(opts.RPCDeliveryHookWorkers, opts.RPCDeliveryHookMaxPending),
frameBudget: newInboundFrameBudget(opts.InboundFrameGlobalMaxBytes), frameBudget: newInboundFrameBudget(opts.InboundFrameGlobalMaxBytes),
outboundQueueSize: opts.OutboundQueueSize, outboundQueueSize: opts.OutboundQueueSize,
outboundControlQueueSize: opts.OutboundControlQueueSize, outboundControlQueueSize: opts.OutboundControlQueueSize,
outboundTrackedBudget: newOutboundTrackedBudget(opts.OutboundTrackedGlobalMaxBytes), outboundTrackedBudget: newOutboundTrackedBudget(opts.OutboundTrackedGlobalMaxBytes),
outboundControlBudget: newOutboundTrackedBudget(defaultOutboundControlMaxBytes), outboundControlBudget: newOutboundTrackedBudget(defaultOutboundControlMaxBytes),
outboundCriticalBudget: newOutboundTrackedBudget(opts.OutboundCriticalGlobalMaxBytes),
outboundScratchPool: newOutboundScratchPool(opts.OutboundWriteGlobalMaxBytes), outboundScratchPool: newOutboundScratchPool(opts.OutboundWriteGlobalMaxBytes),
outboundOpPool: newOutboundOpPool(defaultOutboundOpPoolSize),
outboundReplayBodyPool: newOutboundReplayBodyPool(defaultOutboundReplayBodyClasses),
dc: opts.DC, dc: opts.DC,
strictDC: opts.StrictDC, strictDC: opts.StrictDC,
key: exchange.PrivateKey{RSA: opts.RSAKey}, key: exchange.PrivateKey{RSA: opts.RSAKey},
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
authKeys: opts.AuthKeys, authKeys: opts.AuthKeys,
conns: conns, conns: conns,
rpc: opts.legacyRPC, rpc: opts.legacyRPC,
@ -612,6 +616,7 @@ func New(opts Options) *Server {
}), }),
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks), rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes), admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
pubKeyPEM: rsaPublicKeyPEM(opts.RSAKey),
} }
if opts.IdentityDir != "" { if opts.IdentityDir != "" {
server.identityStore = identity.NewStore(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 { func (s *Server) buildConn(tc transport.Conn, lease *physicalTransportLease, key crypto.AuthKey, sessionID, salt int64) *Conn {
c := &Conn{ c := &Conn{
transport: tc, transport: tc,
transportLease: lease, transportLease: lease,
writer: tc, writer: tc,
cipher: s.cipher, cipher: s.cipher,
msgID: proto.NewMessageIDGen(s.clock.Now), msgID: proto.NewMessageIDGen(s.clock.Now),
writeTimeout: s.writeTimeout, writeTimeout: s.writeTimeout,
metrics: s.metrics, metrics: s.metrics,
now: s.clock.Now, now: s.clock.Now,
authKeyID: key.ID, authKeyID: key.ID,
authKeyHex: hex.EncodeToString(key.ID[:]), authKeyHex: hex.EncodeToString(key.ID[:]),
sessionID: sessionID, sessionID: sessionID,
salt: salt, salt: salt,
key: key, key: key,
createdAt: s.clock.Now(), createdAt: s.clock.Now(),
outboundQueueSize: s.outboundQueueSize, outboundQueueSize: s.outboundQueueSize,
outboundControlQueueSize: s.outboundControlQueueSize, outboundControlQueueSize: s.outboundControlQueueSize,
outboundTrackedBudget: s.outboundTrackedBudget, outboundTrackedBudget: s.outboundTrackedBudget,
outboundControlTrackedBudget: s.outboundControlBudget, outboundControlTrackedBudget: s.outboundControlBudget,
outboundScratchPool: s.outboundScratchPool, outboundCriticalTrackedBudget: s.outboundCriticalBudget,
rpcDeliveryHooks: s.rpcDeliveryHooks, outboundScratchPool: s.outboundScratchPool,
outboundOpPool: s.outboundOpPool,
outboundReplayBodyPool: s.outboundReplayBodyPool,
rpcDeliveryHooks: s.rpcDeliveryHooks,
rpcResultAcked: func(conn *Conn, reqMsgID int64) { rpcResultAcked: func(conn *Conn, reqMsgID int64) {
// The sole outbound actor invokes this only after resolving a client // The sole outbound actor invokes this only after resolving a client
// msgs_ack server msg_id through its tracked resend frame. The actor has // 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.rpcDeliveryHooks.stop(rpcCloseWaitTimeout)
defer s.conns.releaseAllLogicalSessions() defer s.conns.releaseAllLogicalSessions()
defer s.rpcScheduler.stop(rpcCloseWaitTimeout) defer s.rpcScheduler.stop(rpcCloseWaitTimeout)
defer s.bulkRPCScheduler.close()
// 只在最外层 listener 包一次,确保 same-port mux 的 sniff/HTTP upgrade 也计入 // 只在最外层 listener 包一次,确保 same-port mux 的 sniff/HTTP upgrade 也计入
// raw admission而不是等连接已经分流后才计数。 // raw admission而不是等连接已经分流后才计数。
ln = s.observeRawAccepts(s.admission.wrapListener(ln)) 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 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) { if errors.Is(err, errActivationAuthKeyRejected) {
// handleEncrypted writes -404 while its activation claim still owns the // handleEncrypted writes -404 while its activation claim still owns the
// physical writer, then its deferred abort removes/closes the claim. // physical writer, then its deferred abort removes/closes the claim.

View file

@ -77,7 +77,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
cs := newConnState() cs := newConnState()
var plain bin.Buffer 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 { if err != nil {
t.Fatalf("first bad salt: %v", err) t.Fatalf("first bad salt: %v", err)
} }
@ -92,7 +92,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( secondWrong, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, sessionID, secondID, 3, 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 { if err != nil {
t.Fatalf("second bad salt: %v", err) t.Fatalf("second bad salt: %v", err)
} }
@ -123,7 +123,7 @@ func TestBadServerSaltRetainsOneProvisionalConnUntilCorrected(t *testing.T) {
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, sessionID, firstID, 1, 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 { if err != nil {
t.Fatalf("corrected retry: %v", err) t.Fatalf("corrected retry: %v", err)
} }
@ -171,7 +171,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
} }
cs := newConnState() cs := newConnState()
var plain bin.Buffer 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 { if err != nil {
t.Fatalf("activate first session: %v", err) t.Fatalf("activate first session: %v", err)
} }
@ -185,7 +185,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( wrongFrame, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, wrongSalt, serverSalt, secondSID, secondID, 1, 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 { if err != nil {
t.Fatalf("new session bad salt: %v", err) t.Fatalf("new session bad salt: %v", err)
} }
@ -205,7 +205,7 @@ func TestWrongSaltSessionChangeTransfersPhysicalOwnership(t *testing.T) {
corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest( corrected, _ := encryptedRPCFrameWithAuthoritativeSaltForBarrierTest(
t, key, serverSalt, serverSalt, secondSID, secondID, 1, 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 { if err != nil {
t.Fatalf("activate transferred session: %v", err) t.Fatalf("activate transferred session: %v", err)
} }
@ -318,7 +318,7 @@ func TestHandleEncryptedRequiredSessionBarrierPrecedesStateRegistrationAndRPC(t
} }
done := make(chan result, 1) done := make(chan result, 1)
go func() { 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} done <- result{conn: conn, err: err}
}() }()
@ -394,7 +394,7 @@ func TestHandleEncryptedRequiredSessionBarrierFailureIsAtomic(t *testing.T) {
done := make(chan error, 1) done := make(chan error, 1)
go func() { 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 done <- err
}() }()
select { select {
@ -458,7 +458,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
firstTransport := &collectingSessionTransport{} firstTransport := &collectingSessionTransport{}
firstState := newConnState() firstState := newConnState()
var firstPlain bin.Buffer 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 { if err != nil {
t.Fatalf("first handleEncrypted: %v", err) t.Fatalf("first handleEncrypted: %v", err)
} }
@ -478,7 +478,7 @@ func TestCrossConnectionInflightRPCHasOneBusinessOwnerAndReplaysResult(t *testin
} }
secondDone := make(chan handleResult, 1) secondDone := make(chan handleResult, 1)
go func() { 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} secondDone <- handleResult{conn: conn, err: handleErr}
}() }()
@ -566,7 +566,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
firstTransport := &collectingSessionTransport{} firstTransport := &collectingSessionTransport{}
firstState := newConnState() firstState := newConnState()
var firstPlain bin.Buffer 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 { if err != nil {
t.Fatalf("first handleEncrypted: %v", err) t.Fatalf("first handleEncrypted: %v", err)
} }
@ -580,7 +580,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
secondTransport := &collectingSessionTransport{} secondTransport := &collectingSessionTransport{}
secondState := newConnState() secondState := newConnState()
var secondPlain bin.Buffer 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) { if err != nil && !errors.Is(err, ErrConnClosed) {
t.Fatalf("second handleEncrypted: %v", err) t.Fatalf("second handleEncrypted: %v", err)
} }
@ -605,7 +605,7 @@ func TestCrossConnectionInflightAbortRetriesOnlyAfterOldOwnerStops(t *testing.T)
thirdTransport := &collectingSessionTransport{} thirdTransport := &collectingSessionTransport{}
thirdState := newConnState() thirdState := newConnState()
var thirdPlain bin.Buffer 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 { if err != nil {
t.Fatalf("third handleEncrypted: %v", err) t.Fatalf("third handleEncrypted: %v", err)
} }

View file

@ -219,8 +219,8 @@ func TestSessionManagerBestEffortFanoutPreparesOncePerProfile(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: int64(i + 1), sessionID: int64(i + 1),
authKeyID: [8]byte{byte(i + 1)}, authKeyID: [8]byte{byte(i + 1)},
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
metrics: NopMetrics{}, metrics: NopMetrics{},
} }
@ -270,8 +270,8 @@ func TestSessionManagerMixedLayerFanoutUsesProfileBoundBodies(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: int64(profile), sessionID: int64(profile),
authKeyID: authKeyID, authKeyID: authKeyID,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
metrics: NopMetrics{}, metrics: NopMetrics{},
} }
@ -390,11 +390,11 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
authKeyID: [8]byte{byte(i + 1)}, authKeyID: [8]byte{byte(i + 1)},
transport: tr, transport: tr,
metrics: NopMetrics{}, metrics: NopMetrics{},
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.outbound <- outboundOp{} c.outbound <- &outboundOp{}
c.userID.Store(userID) c.userID.Store(userID)
c.userIDResolved.Store(true) c.userIDResolved.Store(true)
c.receivesUpdates.Store(true) c.receivesUpdates.Store(true)
@ -409,8 +409,8 @@ func TestSessionManagerBestEffortFanoutUsesOneBudgetAndDropsOnlySlowConsumers(t
sessionID: 99, sessionID: 99,
authKeyID: [8]byte{99}, authKeyID: [8]byte{99},
metrics: NopMetrics{}, metrics: NopMetrics{},
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
healthy.userID.Store(userID) healthy.userID.Store(userID)
@ -656,8 +656,8 @@ func TestForceCloseBatchTimeoutStillClosesProducerAndRPCGates(t *testing.T) {
c := &Conn{ c := &Conn{
transport: tr, transport: tr,
metrics: NopMetrics{}, metrics: NopMetrics{},
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second) c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)
@ -743,8 +743,8 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
sessionID: sessionID, sessionID: sessionID,
metrics: NopMetrics{}, metrics: NopMetrics{},
transport: transport, transport: transport,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.receivesUpdates.Store(true) c.receivesUpdates.Store(true)
@ -752,7 +752,7 @@ func TestPushToUserAuthKeyUsesOneDeadlineAndDropsOnlySlowPFSConnections(t *testi
t.Fatalf("freeze profile: %v", err) t.Fatalf("freeze profile: %v", err)
} }
if queueFull { if queueFull {
c.outbound <- outboundOp{} c.outbound <- &outboundOp{}
} }
sm.Register(c) sm.Register(c)
sm.BindAuthKeyForSession(raw, sessionID, business) sm.BindAuthKeyForSession(raw, sessionID, business)
@ -961,8 +961,8 @@ func TestPushToSessionForAuthKeyImmediateBypassesReadinessQueue(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: 42, sessionID: 42,
authKeyID: raw, authKeyID: raw,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
if err := c.FreezeLayerProfile(tlprofile.Profile227); err != nil { if err := c.FreezeLayerProfile(tlprofile.Profile227); err != nil {
@ -1006,8 +1006,8 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
c := &Conn{ c := &Conn{
authKeyID: key.authKeyID, authKeyID: key.authKeyID,
sessionID: key.sessionID, sessionID: key.sessionID,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
metrics: NopMetrics{}, metrics: NopMetrics{},
outboundTrackedBudget: newOutboundTrackedBudget(1 << 20), outboundTrackedBudget: newOutboundTrackedBudget(1 << 20),
@ -1049,7 +1049,7 @@ func TestSessionManagerWithholdsUpdatesReadinessUntilExactProfile(t *testing.T)
} }
c.membershipsSynced.Store(true) c.membershipsSynced.Store(true)
sm.SetReceivesUpdatesForAuthKey(key.authKeyID, key.sessionID, true) sm.SetReceivesUpdatesForAuthKey(key.authKeyID, key.sessionID, true)
var op outboundOp var op *outboundOp
select { select {
case op = <-c.outbound: case op = <-c.outbound:
case <-time.After(time.Second): case <-time.After(time.Second):
@ -1210,8 +1210,8 @@ func TestPendingFlushGlobalBodyPressureDoesNotTerminateHealthyConnection(t *test
c := &Conn{ c := &Conn{
authKeyID: key.authKeyID, authKeyID: key.authKeyID,
sessionID: key.sessionID, sessionID: key.sessionID,
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
metrics: NopMetrics{}, metrics: NopMetrics{},
outboundTrackedBudget: newOutboundTrackedBudget(1), outboundTrackedBudget: newOutboundTrackedBudget(1),

View file

@ -22,8 +22,8 @@ func TestTerminalFailurePathsCloseGatesBeforeBlockingTransportClose(t *testing.T
c := &Conn{ c := &Conn{
transport: tr, transport: tr,
metrics: NopMetrics{}, metrics: NopMetrics{},
outbound: make(chan outboundOp, 1), outbound: make(chan *outboundOp, 1),
outboundControl: make(chan outboundOp, 1), outboundControl: make(chan *outboundOp, 1),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.startInboundRPCScheduler(scheduler, 1, 1, time.Second) c.startInboundRPCScheduler(scheduler, 1, 1, time.Second)

View file

@ -224,8 +224,8 @@ func TestContainerInvalidSequenceTailIsAtomic(t *testing.T) {
cs := newConnState() cs := newConnState()
c := &Conn{ c := &Conn{
metrics: NopMetrics{}, metrics: NopMetrics{},
outbound: make(chan outboundOp, 4), outbound: make(chan *outboundOp, 4),
outboundControl: make(chan outboundOp, 4), outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
var acks []int64 var acks []int64

View file

@ -21,8 +21,8 @@ func TestPushTransientSkipsNotReadySession(t *testing.T) {
c := &Conn{ c := &Conn{
sessionID: 7, sessionID: 7,
authKeyID: [8]byte{7}, authKeyID: [8]byte{7},
outbound: make(chan outboundOp, 4), outbound: make(chan *outboundOp, 4),
outboundControl: make(chan outboundOp, 4), outboundControl: make(chan *outboundOp, 4),
outboundStop: make(chan struct{}), outboundStop: make(chan struct{}),
} }
c.userID.Store(userID) c.userID.Store(userID)
@ -63,7 +63,7 @@ func TestPushTransientCompatibleSkipsUnavailableAndUnknownProfiles(t *testing.T)
makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn { makeConn := func(sessionID int64, profile tlprofile.Profile, known bool) *Conn {
c := &Conn{ c := &Conn{
sessionID: sessionID, authKeyID: [8]byte{byte(sessionID)}, 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{}), outboundStop: make(chan struct{}),
} }
c.userID.Store(userID) c.userID.Store(userID)

View file

@ -227,9 +227,12 @@ func (r *Registry) add(name string, value uint64, labels ...Label) {
} }
func (r *Registry) addGauge(name string, delta int64, labels ...Label) { func (r *Registry) addGauge(name string, delta int64, labels ...Label) {
if r == nil || delta == 0 { if r == nil {
return 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) r.gauge(newSeriesKey(name, labels...)).value.Add(delta)
} }

View file

@ -43,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string
Hash: aiComposeToneWebPageHash(tone), Hash: aiComposeToneWebPageHash(tone),
Date: int(now.Unix()), Date: int(now.Unix()),
Type: aiComposeToneWebPageType, Type: aiComposeToneWebPageType,
SiteName: branding.ProductName, SiteName: branding.ProductName(),
Title: tone.Title, Title: tone.Title,
Description: tone.Prompt, Description: tone.Prompt,
ComposeToneEmojiID: tone.EmojiID, ComposeToneEmojiID: tone.EmojiID,

View file

@ -41,8 +41,18 @@ func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.Upda
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrPersistentTimestamp) { 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() 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) return nil, channelInvalidErr(err)
} }
diff, err = r.enrichChannelDifferenceStrict(ctx, userID, diff) diff, err = r.enrichChannelDifferenceStrict(ctx, userID, diff)

View file

@ -9,6 +9,7 @@ import (
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/transport"
) )
type ctxKey int type ctxKey int
@ -303,3 +304,15 @@ func invokeWithoutUpdatesFrom(ctx context.Context) bool {
v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool) v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool)
return v 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)
}

View file

@ -16,6 +16,9 @@ func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization {
a.AppVersion = ci.AppVersion a.AppVersion = ci.AppVersion
a.APIID = ci.APIID a.APIID = ci.APIID
} }
if ip, ok := ClientIPFrom(ctx); ok {
a.IP = ip
}
return a return a
} }

View file

@ -346,7 +346,7 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
} }
return &tg.MessageReplyStoryHeader{Peer: peer, StoryID: m.ReplyTo.StoryID} 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 return nil
} }
header := &tg.MessageReplyHeader{} header := &tg.MessageReplyHeader{}
@ -364,6 +364,18 @@ func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass {
header.SetReplyToPeerID(peer) 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 != "" { if m.ReplyTo.QuoteText != "" {
header.SetQuote(true) header.SetQuote(true)
header.SetQuoteText(m.ReplyTo.QuoteText) header.SetQuoteText(m.ReplyTo.QuoteText)

View file

@ -62,6 +62,8 @@ func peersListEmptyErr() error { return tgerr.New(400, "PEERS_LIST_EMPTY") }
// peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。 // peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。
func peerIDInvalidErr() error { return tgerr.New(400, "PEER_ID_INVALID") } 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 parentPeerInvalidErr() error { return tgerr.New(400, "PARENT_PEER_INVALID") }
func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_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 maxDateInvalidErr() error { return tgerr.New(400, "MAX_DATE_INVALID") }
func fileEmptyErr() error { return tgerr.New(400, "FILE_EMPTY") } func fileEmptyErr() error { return tgerr.New(400, "FILE_EMPTY") }
func quoteTextInvalidErr() error { return tgerr.New(400, "QUOTE_TEXT_INVALID") }
// signInErr 把登录业务错误映射为客户端可识别的 rpc_error。 // signInErr 把登录业务错误映射为客户端可识别的 rpc_error。
func signInErr(err error) error { func signInErr(err error) error {
switch { switch {

View file

@ -25,7 +25,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) {
}, nil }, nil
}) })
registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) { 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) { registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) {
return r.onHelpSaveAppLog(ctx) 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. // dead payment URLs. All six TL fields are mandatory.
func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) {
promo := &tg.HelpPremiumPromo{ promo := &tg.HelpPremiumPromo{
StatusText: branding.PremiumName + " is not active on this account.", StatusText: branding.PremiumName() + " is not active on this account.",
StatusEntities: []tg.MessageEntityClass{}, StatusEntities: []tg.MessageEntityClass{},
VideoSections: []string{}, VideoSections: []string{},
Videos: []tg.DocumentClass{}, Videos: []tg.DocumentClass{},
@ -207,7 +207,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm
} }
if u.PremiumActiveAt(r.clock.Now().Unix()) { if u.PremiumActiveAt(r.clock.Now().Unix()) {
until := time.Unix(int64(u.PremiumUntil), 0) 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 { if r.deps.PremiumPromo != nil {
catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx) catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx)

View file

@ -12,12 +12,48 @@ import (
"github.com/iamxvbaba/td/tlprofile" "github.com/iamxvbaba/td/tlprofile"
compatandroid "telesrv/internal/compat/android" compatandroid "telesrv/internal/compat/android"
"telesrv/internal/observability/dbtrace" "telesrv/internal/observability/dbtrace"
"telesrv/internal/rpcresult"
) )
type layerWrappersAppliedKey struct{} type layerWrappersAppliedKey struct{}
type layerAdmissionSequenceKey struct{} type layerAdmissionSequenceKey struct{}
type layerRPCProfileEvidenceFreshKey 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 // WithLayerRPCProfileEvidenceFresh records whether an admitted request's
// explicit selector is inside MTProto's mutable msg_id freshness window. A // explicit selector is inside MTProto's mutable msg_id freshness window. A
// stale selector still owns its immutable request/result codec, but wrapper // stale selector still owns its immutable request/result codec, but wrapper
@ -278,7 +314,16 @@ func (r *Router) DispatchAdmitted(
} }
dbBefore := dbtrace.SnapshotFromContext(ctx) dbBefore := dbtrace.SnapshotFromContext(ctx)
start := time.Now() 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) dur := time.Since(start)
dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore) dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore)
fields := append([]zap.Field{ fields := append([]zap.Field{

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

View file

@ -514,7 +514,7 @@ func (r *Router) forwardFromPeerAndSources(ctx context.Context, userID int64, in
} }
return fromPeer, sources, nil return fromPeer, sources, nil
} }
fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) fromPeer, err := r.checkedMessageReadPeer(ctx, userID, input, false)
return fromPeer, nil, err 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 { if fromPeer.Type != domain.PeerTypeUser || fromPeer.ID == 0 {
return nil, domain.ErrMessageIDInvalid 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) state, err := svc.GetPrivateNoForwards(ctx, userID, fromPeer.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -771,6 +773,8 @@ func messageForwardErr(err error) error {
return messageIDInvalidErr() return messageIDInvalidErr()
case errors.Is(err, domain.ErrChatForwardsRestricted): case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr() return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrQuoteTextInvalid):
return quoteTextInvalidErr()
case errors.Is(err, domain.ErrReplyMessageIDInvalid): case errors.Is(err, domain.ErrReplyMessageIDInvalid):
return replyMessageIDInvalidErr() return replyMessageIDInvalidErr()
case errors.Is(err, domain.ErrMessageRandomIDDuplicate): case errors.Is(err, domain.ErrMessageRandomIDDuplicate):

View file

@ -806,10 +806,13 @@ func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.Messag
return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users} return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users}
} }
func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) { func (r *Router) messageFilterFromHistoryRequest(ctx context.Context, userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, error) {
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) if err := validateMessageReadBounds(req.Limit, req.OffsetID, req.MaxID, req.MinID); err != nil {
if !ok { return domain.MessageFilter{}, err
return domain.MessageFilter{}, false }
peer, err := r.checkedMessageReadPeer(ctx, userID, req.Peer, false)
if err != nil {
return domain.MessageFilter{}, err
} }
limit := req.Limit limit := req.Limit
if limit > 50 { if limit > 50 {
@ -825,10 +828,13 @@ func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesG
MaxID: req.MaxID, MaxID: req.MaxID,
MinID: req.MinID, MinID: req.MinID,
Hash: req.Hash, Hash: req.Hash,
}, true }, nil
} }
func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int64, req *tg.MessagesSearchRequest) (domain.MessageFilter, error) { 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 limit := req.Limit
if limit > 500 { if limit > 500 {
limit = 500 limit = 500
@ -840,6 +846,7 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
MaxDate: req.MaxDate, MaxDate: req.MaxDate,
AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset),
Limit: limit, Limit: limit,
CountOnly: req.Limit == 0,
MaxID: req.MaxID, MaxID: req.MaxID,
MinID: req.MinID, MinID: req.MinID,
Hash: req.Hash, Hash: req.Hash,
@ -850,10 +857,21 @@ func (r *Router) messageFilterFromSearchRequest(ctx context.Context, userID int6
filter.PhoneCallsOnly = true filter.PhoneCallsOnly = true
filter.MissedPhoneCallsOnly = phoneCalls.Missed 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.HasPeer = true
filter.Peer = peer 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() savedReactions, hasSavedReactions := req.GetSavedReaction()
// An empty optional vector carries no reaction-filtering semantics. Some TL // An empty optional vector carries no reaction-filtering semantics. Some TL
// clients emit flags.3 with a zero-length vector on ordinary peer searches. // clients emit flags.3 with a zero-length vector on ordinary peer searches.

View file

@ -580,7 +580,7 @@ func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) {
Hash: 99, 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{ req := &tg.MessagesGetHistoryRequest{
Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash},
Limit: 20, Limit: 20,

View file

@ -206,7 +206,7 @@ func TestSavedReactionTagHashMatchesClientShape(t *testing.T) {
func TestMessageFilterFromSearchRequestParsesSavedTagsAndPeer(t *testing.T) { func TestMessageFilterFromSearchRequestParsesSavedTagsAndPeer(t *testing.T) {
const userID = int64(1000000001) 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{ req := &tg.MessagesSearchRequest{
Peer: &tg.InputPeerSelf{}, Peer: &tg.InputPeerSelf{},
Q: "needle", Q: "needle",

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

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

View file

@ -611,9 +611,9 @@ func (r *Router) registerMessages(d *tlprofile.Dispatcher) {
if err != nil { if err != nil {
return nil, internalErr() return nil, internalErr()
} }
filter, ok := r.messageFilterFromHistoryRequest(userID, req) filter, err := r.messageFilterFromHistoryRequest(ctx, userID, req)
if !ok { if err != nil {
return messagesNotModifiedOrEmpty(req.Hash), nil return nil, err
} }
if filter.Peer.Type == domain.PeerTypeChannel { if filter.Peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil { if r.deps.Channels == nil {

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

View file

@ -8,6 +8,7 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/iamxvbaba/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain" "telesrv/internal/domain"
) )
@ -273,6 +274,10 @@ func messageSendErr(err error) error {
return randomIDDuplicateErr() return randomIDDuplicateErr()
case errors.Is(err, domain.ErrMessageEmpty): case errors.Is(err, domain.ErrMessageEmpty):
return messageEmptyErr() return messageEmptyErr()
case errors.Is(err, domain.ErrChatForwardsRestricted):
return chatForwardsRestrictedErr()
case errors.Is(err, domain.ErrQuoteTextInvalid):
return quoteTextInvalidErr()
default: default:
return internalErr() return internalErr()
} }
@ -330,8 +335,11 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d
} }
replyPeer := peer replyPeer := peer
if inputPeer, ok := reply.GetReplyToPeerID(); ok { 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 err != nil {
if tgerr.Is(err, "INTERNAL_SERVER_ERROR") {
return nil, err
}
return nil, replyMessageIDInvalidErr() return nil, replyMessageIDInvalidErr()
} }
replyPeer = parsed replyPeer = parsed

View file

@ -51,7 +51,9 @@ type presenceTracker struct {
byUser map[int64]map[presenceSessionKey]domain.UserStatus byUser map[int64]map[presenceSessionKey]domain.UserStatus
// offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它, // offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它,
// 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。 // 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。
offlineTimers map[int64]*time.Timer offlineTimers map[int64]*time.Timer
offlineRunning int64
lastSeenDirectRunning int64
} }
func newPresenceTracker() *presenceTracker { func newPresenceTracker() *presenceTracker {
@ -62,8 +64,8 @@ func newPresenceTracker() *presenceTracker {
} }
} }
// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己 // armOfflineTimer replaces the user's pending grace timer. Stop cannot join
// 从 map 移除再执行 firefire 内部会查在线态做最终去抖),全程不持 p.mu 调用 fire。 // an already-started AfterFunc; the callback must still prove timer ownership.
func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) { func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) {
if p == nil || userID == 0 { if p == nil || userID == 0 {
return return
@ -75,12 +77,24 @@ func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire fu
if old := p.offlineTimers[userID]; old != nil { if old := p.offlineTimers[userID]; old != nil {
old.Stop() old.Stop()
} }
p.offlineTimers[userID] = time.AfterFunc(d, func() { var timer *time.Timer
timer = time.AfterFunc(d, func() {
p.mu.Lock() p.mu.Lock()
if p.offlineTimers[userID] != timer {
p.mu.Unlock()
return
}
delete(p.offlineTimers, userID) delete(p.offlineTimers, userID)
p.offlineRunning++
p.mu.Unlock() p.mu.Unlock()
defer func() {
p.mu.Lock()
p.offlineRunning--
p.mu.Unlock()
}()
fire() fire()
}) })
p.offlineTimers[userID] = timer
p.mu.Unlock() p.mu.Unlock()
} }
@ -419,8 +433,13 @@ func (r *Router) persistReservedLastSeenAsync(
lastSeenAt int, lastSeenAt int,
) { ) {
bgCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second) 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() { go func() {
defer cancel() defer cancel()
defer r.presence.changeDirectLastSeenRunning(-1)
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
r.log.Error("Update user last seen panicked", zap.Int64("user_id", userID), zap.Any("panic", rec)) 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 退出路径上,同步做 // 去抖宽限后在后台带超时执行——连接关闭发生在 serveConn 退出路径上,同步做
// DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。 // DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。
func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) { 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) r.forgetClientSessionInfo(rawAuthKeyID, sessionID)
if userID == 0 { if userID == 0 {
return return
@ -497,7 +522,9 @@ func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, l
if !lastForUser { if !lastForUser {
return return
} }
disconnectedAt := int(r.clock.Now().Unix()) if disconnectedAt <= 0 {
disconnectedAt = int(r.clock.Now().Unix())
}
// 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer避免断连风暴堆积。 // 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer避免断连风暴堆积。
r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() { r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() {
r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt) r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt)

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@ -71,6 +72,7 @@ type presenceLastSeenBatchDispatcher struct {
gate sync.RWMutex gate sync.RWMutex
accepting bool accepting bool
pending atomic.Int64
} }
func newPresenceLastSeenBatchDispatcher( func newPresenceLastSeenBatchDispatcher(
@ -89,6 +91,7 @@ func newPresenceLastSeenBatchDispatcher(
if metrics == nil { if metrics == nil {
metrics = NopMetrics{} metrics = NopMetrics{}
} }
metrics.PresenceLastSeenPending(0)
return &presenceLastSeenBatchDispatcher{ return &presenceLastSeenBatchDispatcher{
updater: updater, updater: updater,
cfg: cfg, cfg: cfg,
@ -108,18 +111,23 @@ func (d *presenceLastSeenBatchDispatcher) submit(update store.UserLastSeenUpdate
if !d.accepting { if !d.accepting {
return errPresenceLastSeenBatchStopped return errPresenceLastSeenBatchStopped
} }
d.metrics.PresenceLastSeenPending(1) d.changePending(1)
select { select {
case d.queue <- update: case d.queue <- update:
d.metrics.PresenceLastSeenSubmitted() d.metrics.PresenceLastSeenSubmitted()
return nil return nil
default: default:
d.metrics.PresenceLastSeenPending(-1) d.changePending(-1)
d.metrics.PresenceLastSeenOverflow() d.metrics.PresenceLastSeenOverflow()
return errPresenceLastSeenBatchFull return errPresenceLastSeenBatchFull
} }
} }
func (d *presenceLastSeenBatchDispatcher) changePending(delta int) {
d.pending.Add(int64(delta))
d.metrics.PresenceLastSeenPending(delta)
}
func (d *presenceLastSeenBatchDispatcher) stopAccepting() { func (d *presenceLastSeenBatchDispatcher) stopAccepting() {
if d == nil { if d == nil {
return return
@ -223,7 +231,7 @@ func (d *presenceLastSeenBatchDispatcher) executeWithRetry(
cancel() cancel()
d.metrics.PresenceLastSeenBatch(len(updates), time.Since(started), err) d.metrics.PresenceLastSeenBatch(len(updates), time.Since(started), err)
if err == nil { if err == nil {
d.metrics.PresenceLastSeenPending(-rawCount) d.changePending(-rawCount)
return true return true
} }
if attempt == 1 || attempt&(attempt-1) == 0 { if attempt == 1 || attempt&(attempt-1) == 0 {
@ -286,6 +294,6 @@ func (d *presenceLastSeenBatchDispatcher) reportDrainDropped(count int) {
return return
} }
d.metrics.PresenceLastSeenDrainDropped(count) d.metrics.PresenceLastSeenDrainDropped(count)
d.metrics.PresenceLastSeenPending(-count) d.changePending(-count)
d.log.Error("presence last-seen shutdown drain expired", zap.Int("updates", count)) d.log.Error("presence last-seen shutdown drain expired", zap.Int("updates", count))
} }

View file

@ -3,6 +3,8 @@ package rpc
import ( import (
"context" "context"
"errors" "errors"
"net/http/httptest"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
@ -10,9 +12,27 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/store" "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 { type capturePresenceLastSeenUpdater struct {
mu sync.Mutex mu sync.Mutex
calls [][]store.UserLastSeenUpdate calls [][]store.UserLastSeenUpdate

Some files were not shown because too many files have changed in this diff Show more