feat: sync account freeze lifecycle
This commit is contained in:
parent
76bfc5100f
commit
47fcf0ea41
40 changed files with 1363 additions and 196 deletions
123
internal/rpc/frozen_gate.go
Normal file
123
internal/rpc/frozen_gate.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Frozen accounts are read-only. Classifying the finite read vocabulary and
|
||||
// failing closed for every other semantic method also covers future handlers:
|
||||
// an unfamiliar mutation cannot silently bypass the account-level gate.
|
||||
var frozenReadOnlyOperationPrefixes = []string{
|
||||
"can", "check", "find", "get", "load", "lookup", "query", "read", "resolve", "search", "translate",
|
||||
}
|
||||
|
||||
var frozenAlwaysBlockedMethods = map[string]struct{}{
|
||||
"channels.deleteMessages": {},
|
||||
"channels.joinChannel": {},
|
||||
"channels.searchPosts": {},
|
||||
}
|
||||
|
||||
// These methods are security/session housekeeping or read acknowledgements
|
||||
// that must remain available in read-only mode. In particular, a frozen user
|
||||
// must be able to log out/delete the account and clients must not enter retry
|
||||
// loops for presence, push registration, or delivery/read acknowledgements.
|
||||
var frozenAllowedMutationNamedMethods = map[string]struct{}{
|
||||
"account.changeAuthorizationSettings": {},
|
||||
"account.deleteAccount": {},
|
||||
"account.registerDevice": {},
|
||||
"account.resetAuthorization": {},
|
||||
"account.resetAuthorizations": {},
|
||||
"account.unregisterDevice": {},
|
||||
"account.updateDeviceLocked": {},
|
||||
"account.updateStatus": {},
|
||||
"messages.readHistory": {},
|
||||
"messages.readMentions": {},
|
||||
"messages.readMessageContents": {},
|
||||
"messages.readReactions": {},
|
||||
"messages.receivedMessages": {},
|
||||
"messages.receivedQueue": {},
|
||||
"messages.reportMessagesDelivery": {},
|
||||
"messages.viewSponsoredMessage": {},
|
||||
"channels.readHistory": {},
|
||||
"channels.readMessageContents": {},
|
||||
"phone.receivedCall": {},
|
||||
"stories.incrementStoryViews": {},
|
||||
}
|
||||
|
||||
func frozenMethodRequiresWriteGate(method string) bool {
|
||||
if _, blocked := frozenAlwaysBlockedMethods[method]; blocked {
|
||||
return true
|
||||
}
|
||||
if _, allowed := frozenAllowedMutationNamedMethods[method]; allowed {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(method, "auth.") {
|
||||
return false
|
||||
}
|
||||
dot := strings.IndexByte(method, '.')
|
||||
if dot < 0 || dot == len(method)-1 {
|
||||
return false
|
||||
}
|
||||
operation := method[dot+1:]
|
||||
for _, prefix := range frozenReadOnlyOperationPrefixes {
|
||||
if strings.HasPrefix(operation, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Router) checkFrozenRPC(ctx context.Context, method string) error {
|
||||
if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) {
|
||||
return nil
|
||||
}
|
||||
userID, authorized := UserIDFrom(ctx)
|
||||
if !authorized || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
if found && freeze.Frozen {
|
||||
return frozenMethodInvalidErr()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkFrozenChannelParticipants implements Telegram's narrower read rule for
|
||||
// the methods documented with FROZEN_PARTICIPANT_MISSING: an account freeze
|
||||
// does not hide joined channels, but it removes public/linked guest preview.
|
||||
func (r *Router) checkFrozenChannelParticipants(ctx context.Context, userID int64, channelIDs ...int64) error {
|
||||
if r == nil || r.deps.AccountFreeze == nil || r.deps.Channels == nil || userID == 0 || len(channelIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
freeze, found, err := r.deps.AccountFreeze.AccountFreeze(ctx, userID)
|
||||
if err != nil {
|
||||
return internalErr()
|
||||
}
|
||||
if !found || !freeze.Frozen {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(channelIDs))
|
||||
for _, channelID := range channelIDs {
|
||||
if channelID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := seen[channelID]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[channelID] = struct{}{}
|
||||
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
|
||||
if err != nil {
|
||||
return channelInvalidErr(err)
|
||||
}
|
||||
if view.Self.UserID != userID || view.Self.Status != domain.ChannelMemberActive || view.Self.Guest {
|
||||
return frozenParticipantMissingErr()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue