941 lines
30 KiB
Go
941 lines
30 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
const (
|
|
ActionSetAccountFrozen = "account.set_frozen"
|
|
ActionGrantPremium = "account.grant_premium"
|
|
ActionGrantStars = "account.grant_stars"
|
|
ActionSetVerified = "account.set_verified"
|
|
ActionSetChannelVerified = "channel.set_verified"
|
|
ActionRevokeSessions = "account.revoke_sessions"
|
|
ActionDeletePrivateMessages = "messages.delete_private_messages"
|
|
ActionDeletePrivateHistory = "messages.delete_private_history"
|
|
|
|
maxCommandIDLength = 128
|
|
maxActorLength = 128
|
|
maxReasonLength = 1000
|
|
maxHistoryBatches = 100
|
|
maxPremiumMonths = 120
|
|
maxStarsGrant = 1_000_000_000
|
|
maxFreezeAppealURLLength = 2048
|
|
)
|
|
|
|
type CommandRepository interface {
|
|
BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error)
|
|
FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error)
|
|
}
|
|
|
|
type RestrictionStore interface {
|
|
GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error)
|
|
SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error)
|
|
}
|
|
|
|
type AuthService interface {
|
|
ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error)
|
|
ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
|
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
|
}
|
|
|
|
type AuthKeyRevoker interface {
|
|
RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error
|
|
}
|
|
|
|
type UsersService interface {
|
|
AdminUser(ctx context.Context, userID int64) (domain.User, bool, error)
|
|
GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error)
|
|
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
|
|
}
|
|
|
|
type StarsService interface {
|
|
Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error)
|
|
}
|
|
|
|
type StarsNotifier interface {
|
|
NotifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error
|
|
}
|
|
|
|
type UserNotifier interface {
|
|
NotifyUserChanged(ctx context.Context, u domain.User) error
|
|
}
|
|
|
|
type ChannelsService interface {
|
|
GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error)
|
|
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
|
|
}
|
|
|
|
type ChannelNotifier interface {
|
|
NotifyChannelChanged(ctx context.Context, ch domain.Channel) error
|
|
}
|
|
|
|
type MessagesService interface {
|
|
GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error)
|
|
GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error)
|
|
DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error)
|
|
DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error)
|
|
}
|
|
|
|
type Dependencies struct {
|
|
Commands CommandRepository
|
|
Restrictions RestrictionStore
|
|
Auth AuthService
|
|
Revoker AuthKeyRevoker
|
|
Users UsersService
|
|
Stars StarsService
|
|
StarsNotifier StarsNotifier
|
|
UserNotifier UserNotifier
|
|
Channels ChannelsService
|
|
ChannelNotifier ChannelNotifier
|
|
Messages MessagesService
|
|
Now func() time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
commands CommandRepository
|
|
restrictions RestrictionStore
|
|
auth AuthService
|
|
revoker AuthKeyRevoker
|
|
users UsersService
|
|
stars StarsService
|
|
starsNotifier StarsNotifier
|
|
userNotifier UserNotifier
|
|
channels ChannelsService
|
|
channelNotifier ChannelNotifier
|
|
messages MessagesService
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewService(deps Dependencies) *Service {
|
|
s := &Service{now: time.Now}
|
|
return s.Configure(deps)
|
|
}
|
|
|
|
func (s *Service) Configure(deps Dependencies) *Service {
|
|
if deps.Commands != nil {
|
|
s.commands = deps.Commands
|
|
}
|
|
if deps.Restrictions != nil {
|
|
s.restrictions = deps.Restrictions
|
|
}
|
|
if deps.Auth != nil {
|
|
s.auth = deps.Auth
|
|
}
|
|
if deps.Revoker != nil {
|
|
s.revoker = deps.Revoker
|
|
}
|
|
if deps.Users != nil {
|
|
s.users = deps.Users
|
|
}
|
|
if deps.Stars != nil {
|
|
s.stars = deps.Stars
|
|
}
|
|
if deps.StarsNotifier != nil {
|
|
s.starsNotifier = deps.StarsNotifier
|
|
}
|
|
if deps.UserNotifier != nil {
|
|
s.userNotifier = deps.UserNotifier
|
|
}
|
|
if deps.Channels != nil {
|
|
s.channels = deps.Channels
|
|
}
|
|
if deps.ChannelNotifier != nil {
|
|
s.channelNotifier = deps.ChannelNotifier
|
|
}
|
|
if deps.Messages != nil {
|
|
s.messages = deps.Messages
|
|
}
|
|
if deps.Now != nil {
|
|
s.now = deps.Now
|
|
}
|
|
if s.now == nil {
|
|
s.now = time.Now
|
|
}
|
|
return s
|
|
}
|
|
|
|
type CommandMeta struct {
|
|
CommandID string `json:"command_id"`
|
|
Actor string `json:"actor"`
|
|
Reason string `json:"reason"`
|
|
DryRun bool `json:"dry_run"`
|
|
}
|
|
|
|
type CommandResult struct {
|
|
CommandID string `json:"command_id"`
|
|
Action string `json:"action"`
|
|
Status string `json:"status"`
|
|
AlreadyExecuted bool `json:"already_executed"`
|
|
DryRun bool `json:"dry_run"`
|
|
TargetUserID int64 `json:"target_user_id,omitempty"`
|
|
TargetPeer domain.Peer `json:"target_peer,omitempty"`
|
|
Message string `json:"message"`
|
|
Details map[string]any `json:"details,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type SetAccountFrozenRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Frozen bool `json:"frozen"`
|
|
Until time.Time `json:"freeze_until,omitempty"`
|
|
AppealURL string `json:"freeze_appeal_url,omitempty"`
|
|
}
|
|
|
|
type GrantPremiumRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Months int `json:"months"`
|
|
}
|
|
|
|
type GrantStarsRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
|
|
type SetVerifiedRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
type SetChannelVerifiedRequest struct {
|
|
CommandMeta
|
|
ChannelID int64 `json:"channel_id"`
|
|
Verified bool `json:"verified"`
|
|
}
|
|
|
|
type RevokeSessionsRequest struct {
|
|
CommandMeta
|
|
UserID int64 `json:"user_id"`
|
|
Hash int64 `json:"hash,omitempty"`
|
|
KeepHash int64 `json:"keep_hash,omitempty"`
|
|
RevokeAll bool `json:"revoke_all,omitempty"`
|
|
}
|
|
|
|
type DeletePrivateMessagesRequest struct {
|
|
CommandMeta
|
|
OwnerUserID int64 `json:"owner_user_id"`
|
|
Peer domain.Peer `json:"peer"`
|
|
IDs []int `json:"ids"`
|
|
Revoke bool `json:"revoke"`
|
|
}
|
|
|
|
type DeletePrivateHistoryRequest struct {
|
|
CommandMeta
|
|
OwnerUserID int64 `json:"owner_user_id"`
|
|
Peer domain.Peer `json:"peer"`
|
|
MaxID int `json:"max_id,omitempty"`
|
|
MinDate int `json:"min_date,omitempty"`
|
|
MaxDate int `json:"max_date,omitempty"`
|
|
JustClear bool `json:"just_clear,omitempty"`
|
|
Revoke bool `json:"revoke"`
|
|
MaxBatches int `json:"max_batches,omitempty"`
|
|
}
|
|
|
|
// AccountFreeze returns the durable account-level freeze state. A missing row
|
|
// is the only non-frozen default; invalid active rows are rejected by the
|
|
// store/schema instead of normalized on read.
|
|
func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
|
if s == nil || s.restrictions == nil || userID == 0 {
|
|
return domain.AccountFreeze{}, false, nil
|
|
}
|
|
freeze, found, err := s.restrictions.GetAccountFreeze(ctx, userID)
|
|
if err != nil || !found {
|
|
return freeze, found, err
|
|
}
|
|
if err := validateAccountFreeze(freeze); err != nil {
|
|
return domain.AccountFreeze{}, false, fmt.Errorf("invalid durable account freeze for user %d: %w", userID, err)
|
|
}
|
|
return freeze, true, nil
|
|
}
|
|
|
|
func validateAccountFreeze(freeze domain.AccountFreeze) error {
|
|
if !freeze.Frozen {
|
|
if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" {
|
|
return fmt.Errorf("inactive freeze retains client-visible state")
|
|
}
|
|
return nil
|
|
}
|
|
if freeze.Since.IsZero() || freeze.Until.IsZero() || !freeze.Until.After(freeze.Since) ||
|
|
freeze.Since.Unix() <= 0 || freeze.Until.Unix() > math.MaxInt32 {
|
|
return fmt.Errorf("active freeze has invalid since/until")
|
|
}
|
|
if len(freeze.AppealURL) > maxFreezeAppealURLLength {
|
|
return fmt.Errorf("active freeze appeal URL is too long")
|
|
}
|
|
parsed, err := url.ParseRequestURI(freeze.AppealURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return fmt.Errorf("active freeze has invalid appeal URL")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CanSendMessages(ctx context.Context, userID int64) error {
|
|
freeze, found, err := s.AccountFreeze(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found && freeze.Frozen {
|
|
return domain.ErrUserFrozen
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if s == nil || s.restrictions == nil {
|
|
return CommandResult{}, fmt.Errorf("admin restriction store is not configured")
|
|
}
|
|
now := s.now().UTC()
|
|
appealURL := strings.TrimSpace(req.AppealURL)
|
|
if req.Frozen {
|
|
if req.Until.IsZero() || req.Until.Unix() > math.MaxInt32 {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be a non-zero int32 Unix timestamp")
|
|
}
|
|
if len(appealURL) > maxFreezeAppealURLLength {
|
|
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be <= %d bytes", maxFreezeAppealURLLength)
|
|
}
|
|
parsed, err := url.ParseRequestURI(appealURL)
|
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return CommandResult{}, fmt.Errorf("freeze_appeal_url must be an absolute HTTP(S) URL")
|
|
}
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
// Keep this time-relative check inside runCommand: a completed command ID
|
|
// must remain replayable after its deadline, while a new stale request is
|
|
// recorded as failed and cannot mutate the restriction row.
|
|
if req.Frozen && !req.Until.After(now) {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be in the future")
|
|
}
|
|
prev, found, err := s.restrictions.GetAccountFreeze(ctx, req.UserID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
next := domain.AccountFreeze{
|
|
UserID: req.UserID,
|
|
Frozen: req.Frozen,
|
|
Reason: req.Reason,
|
|
Actor: req.Actor,
|
|
CommandID: req.CommandID,
|
|
}
|
|
if req.Frozen {
|
|
next.Since = now
|
|
if found && prev.Frozen {
|
|
next.Since = prev.Since
|
|
}
|
|
next.Until = req.Until.UTC()
|
|
next.AppealURL = appealURL
|
|
if !next.Until.After(next.Since) {
|
|
return CommandResult{}, fmt.Errorf("freeze_until must be after freeze_since")
|
|
}
|
|
}
|
|
wouldChange := !found || prev.Frozen != next.Frozen ||
|
|
!prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) ||
|
|
prev.AppealURL != next.AppealURL
|
|
details := map[string]any{
|
|
"previous_frozen": found && prev.Frozen,
|
|
"new_frozen": req.Frozen,
|
|
"would_change": wouldChange,
|
|
}
|
|
if req.Frozen {
|
|
details["freeze_since"] = next.Since.Format(time.RFC3339)
|
|
details["freeze_until"] = next.Until.Format(time.RFC3339)
|
|
details["freeze_appeal_url"] = next.AppealURL
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.restrictions.SetAccountFreeze(ctx, next)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339)
|
|
return CommandResult{Message: "account freeze updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if req.Months < 0 || req.Months > maxPremiumMonths {
|
|
return CommandResult{}, fmt.Errorf("months must be between 0 and %d", maxPremiumMonths)
|
|
}
|
|
if s == nil || s.users == nil {
|
|
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionGrantPremium, 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
|
|
}
|
|
if u.Bot {
|
|
return CommandResult{}, domain.ErrPremiumBotUnsupported
|
|
}
|
|
details := premiumCommandDetails(u, req.Months, s.now())
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.GrantPremium(ctx, req.UserID, req.Months)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_premium_until"] = updated.PremiumUntil
|
|
details["updated_premium_active"] = updated.PremiumActiveAt(s.now().Unix())
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
msg := "premium updated"
|
|
if req.Months == 0 {
|
|
msg = "premium cleared"
|
|
}
|
|
return CommandResult{Message: msg, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) GrantStars(ctx context.Context, req GrantStarsRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if req.Amount <= 0 || req.Amount > maxStarsGrant {
|
|
return CommandResult{}, fmt.Errorf("amount must be between 1 and %d", maxStarsGrant)
|
|
}
|
|
if s == nil || s.users == nil || s.stars == nil {
|
|
return CommandResult{}, fmt.Errorf("admin stars dependencies are not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionGrantStars, 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{
|
|
"amount": req.Amount,
|
|
"username": u.Username,
|
|
"phone": u.Phone,
|
|
"would_credit": true,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
balance, err := s.stars.Credit(ctx, req.UserID, req.Amount, domain.StarsReasonAdjust, domain.Peer{}, "Admin Stars grant", req.Reason)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_balance"] = balance.Balance
|
|
details["starting_grant_applied"] = balance.Granted
|
|
if err := s.notifyStarsBalanceChanged(ctx, balance); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "stars granted", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if s == nil || s.users == nil {
|
|
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetVerified, 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{
|
|
"previous_verified": u.Verified,
|
|
"new_verified": req.Verified,
|
|
"would_change": u.Verified != req.Verified,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.users.SetVerified(ctx, req.UserID, req.Verified)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_verified"] = updated.Verified
|
|
if err := s.notifyUserChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "verified updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) {
|
|
if req.ChannelID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("channel_id is required")
|
|
}
|
|
if s == nil || s.channels == nil {
|
|
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
|
|
}
|
|
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelVerified, 0, target, req, func() (CommandResult, error) {
|
|
ch, err := s.channels.GetChannelByID(ctx, req.ChannelID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if ch.Monoforum || (!ch.Broadcast && !ch.Megagroup) {
|
|
return CommandResult{}, domain.ErrChannelInvalid
|
|
}
|
|
details := map[string]any{
|
|
"title": ch.Title,
|
|
"username": ch.Username,
|
|
"broadcast": ch.Broadcast,
|
|
"megagroup": ch.Megagroup,
|
|
"previous_verified": ch.Verified,
|
|
"new_verified": req.Verified,
|
|
"would_change": ch.Verified != req.Verified,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
updated, err := s.channels.SetVerified(ctx, req.ChannelID, req.Verified)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["updated_verified"] = updated.Verified
|
|
if err := s.notifyChannelChanged(ctx, updated); err != nil {
|
|
details["notify_error"] = err.Error()
|
|
}
|
|
return CommandResult{Message: "channel verified updated", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest) (CommandResult, error) {
|
|
if req.UserID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("user_id is required")
|
|
}
|
|
if s == nil || s.auth == nil || s.revoker == nil {
|
|
return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured")
|
|
}
|
|
if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) {
|
|
return CommandResult{}, fmt.Errorf("choose one revoke mode")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
|
|
items, err := s.auth.ListAuthorizations(ctx, req.UserID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
targets, keep, err := revokeTargets(items, req)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details := map[string]any{
|
|
"target_hashes": authorizationHashes(targets),
|
|
"target_count": len(targets),
|
|
"keep_hash": keep.Hash,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
var revoked []domain.Authorization
|
|
if req.Hash != 0 {
|
|
deleted, found, err := s.auth.ResetAuthorization(ctx, req.UserID, req.Hash)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if found {
|
|
revoked = append(revoked, deleted)
|
|
}
|
|
} else {
|
|
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
revoked = append(revoked, deleted...)
|
|
}
|
|
for _, a := range revoked {
|
|
if err := s.revoker.RevokeAuthorizationAuthKey(ctx, a.AuthKeyID, req.UserID); err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
}
|
|
details["revoked_hashes"] = authorizationHashes(revoked)
|
|
details["revoked_count"] = len(revoked)
|
|
return CommandResult{Message: "sessions revoked", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) DeletePrivateMessages(ctx context.Context, req DeletePrivateMessagesRequest) (CommandResult, error) {
|
|
ids, err := normalizeIDs(req.IDs)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
req.IDs = ids
|
|
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
|
|
}
|
|
if s == nil || s.messages == nil {
|
|
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateMessages, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
|
|
list, err := s.messages.GetMessages(ctx, req.OwnerUserID, req.IDs)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
found, missing, err := validatePrivateMessageSelection(req.OwnerUserID, req.Peer, req.IDs, list.Messages)
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details := map[string]any{
|
|
"requested_ids": req.IDs,
|
|
"found_ids": found,
|
|
"missing_ids": missing,
|
|
"revoke": req.Revoke,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
if len(missing) > 0 {
|
|
return CommandResult{}, fmt.Errorf("messages not found for owner/peer: %v", missing)
|
|
}
|
|
res, err := s.messages.DeleteMessages(ctx, req.OwnerUserID, domain.DeleteMessagesRequest{
|
|
OwnerUserID: req.OwnerUserID,
|
|
IDs: req.IDs,
|
|
Revoke: req.Revoke,
|
|
Date: int(s.now().Unix()),
|
|
})
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details["deleted"] = summarizeDeleteResult(res)
|
|
details["changed"] = res.Changed()
|
|
return CommandResult{Message: "messages deleted", Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHistoryRequest) (CommandResult, error) {
|
|
if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 {
|
|
return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required")
|
|
}
|
|
if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || req.MinDate < 0 || req.MaxDate < 0 {
|
|
return CommandResult{}, domain.ErrMessageIDInvalid
|
|
}
|
|
if req.MaxBatches <= 0 {
|
|
req.MaxBatches = 10
|
|
}
|
|
if req.MaxBatches > maxHistoryBatches {
|
|
return CommandResult{}, fmt.Errorf("max_batches exceeds %d", maxHistoryBatches)
|
|
}
|
|
if s == nil || s.messages == nil {
|
|
return CommandResult{}, fmt.Errorf("admin message dependency is not configured")
|
|
}
|
|
return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateHistory, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) {
|
|
preview, err := s.messages.GetHistory(ctx, req.OwnerUserID, domain.MessageFilter{
|
|
HasPeer: true,
|
|
Peer: req.Peer,
|
|
MaxID: req.MaxID,
|
|
Limit: 50,
|
|
})
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
details := map[string]any{
|
|
"preview_ids": messageIDs(preview.Messages),
|
|
"preview_count": len(preview.Messages),
|
|
"batch_limit": domain.MaxDeleteHistoryBatch,
|
|
"max_batches": req.MaxBatches,
|
|
"revoke": req.Revoke,
|
|
"just_clear": req.JustClear,
|
|
"date_range_filter": req.MinDate != 0 || req.MaxDate != 0,
|
|
}
|
|
if req.DryRun {
|
|
return CommandResult{Message: "dry-run completed", Details: details}, nil
|
|
}
|
|
totalDeleted := 0
|
|
ownerBatches := make([]any, 0, req.MaxBatches)
|
|
offset := 0
|
|
for batch := 0; batch < req.MaxBatches; batch++ {
|
|
res, err := s.messages.DeleteHistory(ctx, req.OwnerUserID, domain.DeleteHistoryRequest{
|
|
OwnerUserID: req.OwnerUserID,
|
|
Peer: req.Peer,
|
|
MaxID: req.MaxID,
|
|
MinDate: req.MinDate,
|
|
MaxDate: req.MaxDate,
|
|
JustClear: req.JustClear,
|
|
Revoke: req.Revoke,
|
|
Date: int(s.now().Unix()),
|
|
})
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
self := res.Self()
|
|
totalDeleted += len(self.MessageIDs)
|
|
ownerBatches = append(ownerBatches, summarizeDeleteResult(res)...)
|
|
offset = res.Offset
|
|
if res.Offset == 0 {
|
|
break
|
|
}
|
|
}
|
|
details["deleted_count"] = totalDeleted
|
|
details["deleted"] = ownerBatches
|
|
details["has_more"] = offset != 0
|
|
msg := "history deleted"
|
|
if offset != 0 {
|
|
msg = "history partially deleted; run another command to continue"
|
|
}
|
|
return CommandResult{Message: msg, Details: details}, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) {
|
|
if s == nil || s.commands == nil {
|
|
return CommandResult{}, fmt.Errorf("admin command store is not configured")
|
|
}
|
|
meta.CommandID = strings.TrimSpace(meta.CommandID)
|
|
meta.Actor = strings.TrimSpace(meta.Actor)
|
|
meta.Reason = strings.TrimSpace(meta.Reason)
|
|
if meta.CommandID == "" || len(meta.CommandID) > maxCommandIDLength {
|
|
return CommandResult{}, fmt.Errorf("command_id is required and must be <= %d bytes", maxCommandIDLength)
|
|
}
|
|
if meta.Actor == "" || len(meta.Actor) > maxActorLength {
|
|
return CommandResult{}, fmt.Errorf("actor is required and must be <= %d bytes", maxActorLength)
|
|
}
|
|
if meta.Reason == "" || len(meta.Reason) > maxReasonLength {
|
|
return CommandResult{}, fmt.Errorf("reason is required and must be <= %d bytes", maxReasonLength)
|
|
}
|
|
requestJSON, err := json.Marshal(request)
|
|
if err != nil {
|
|
return CommandResult{}, fmt.Errorf("marshal admin request: %w", err)
|
|
}
|
|
cmd, created, err := s.commands.BeginCommand(ctx, domain.AdminCommand{
|
|
CommandID: meta.CommandID,
|
|
Actor: meta.Actor,
|
|
Action: action,
|
|
TargetUserID: targetUserID,
|
|
TargetPeer: targetPeer,
|
|
DryRun: meta.DryRun,
|
|
Reason: meta.Reason,
|
|
RequestJSON: requestJSON,
|
|
Status: domain.AdminCommandRunning,
|
|
CreatedAt: s.now(),
|
|
})
|
|
if err != nil {
|
|
return CommandResult{}, err
|
|
}
|
|
if !created {
|
|
return resultFromCommand(cmd), nil
|
|
}
|
|
result, opErr := fn()
|
|
result.CommandID = meta.CommandID
|
|
result.Action = action
|
|
result.DryRun = meta.DryRun
|
|
result.TargetUserID = targetUserID
|
|
result.TargetPeer = targetPeer
|
|
status := domain.AdminCommandCompleted
|
|
if opErr != nil {
|
|
status = domain.AdminCommandFailed
|
|
result.Status = string(status)
|
|
result.Error = opErr.Error()
|
|
if result.Message == "" {
|
|
result.Message = "command failed"
|
|
}
|
|
} else {
|
|
result.Status = string(status)
|
|
}
|
|
resultJSON, marshalErr := json.Marshal(result)
|
|
if marshalErr != nil {
|
|
return result, fmt.Errorf("marshal admin result: %w", marshalErr)
|
|
}
|
|
errorText := ""
|
|
if opErr != nil {
|
|
errorText = opErr.Error()
|
|
}
|
|
if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil {
|
|
return result, err
|
|
}
|
|
return result, opErr
|
|
}
|
|
|
|
func resultFromCommand(cmd domain.AdminCommand) CommandResult {
|
|
var result CommandResult
|
|
if len(cmd.ResultJSON) > 0 {
|
|
if err := json.Unmarshal(cmd.ResultJSON, &result); err == nil {
|
|
result.AlreadyExecuted = true
|
|
return result
|
|
}
|
|
}
|
|
result = CommandResult{
|
|
CommandID: cmd.CommandID,
|
|
Action: cmd.Action,
|
|
Status: string(cmd.Status),
|
|
AlreadyExecuted: true,
|
|
DryRun: cmd.DryRun,
|
|
TargetUserID: cmd.TargetUserID,
|
|
TargetPeer: cmd.TargetPeer,
|
|
Message: "command already exists",
|
|
Error: cmd.Error,
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error {
|
|
if s == nil || s.userNotifier == nil {
|
|
return nil
|
|
}
|
|
return s.userNotifier.NotifyUserChanged(ctx, u)
|
|
}
|
|
|
|
func (s *Service) notifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error {
|
|
if s == nil || s.starsNotifier == nil {
|
|
return nil
|
|
}
|
|
return s.starsNotifier.NotifyStarsBalanceChanged(ctx, balance)
|
|
}
|
|
|
|
func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error {
|
|
if s == nil || s.channelNotifier == nil {
|
|
return nil
|
|
}
|
|
return s.channelNotifier.NotifyChannelChanged(ctx, ch)
|
|
}
|
|
|
|
func premiumCommandDetails(u domain.User, months int, now time.Time) map[string]any {
|
|
active := u.PremiumActiveAt(now.Unix())
|
|
base := now
|
|
if active {
|
|
base = time.Unix(int64(u.PremiumUntil), 0)
|
|
}
|
|
projected := 0
|
|
if months > 0 {
|
|
projected = int(base.AddDate(0, months, 0).Unix())
|
|
}
|
|
return map[string]any{
|
|
"previous_premium_until": u.PremiumUntil,
|
|
"previous_premium_active": active,
|
|
"months": months,
|
|
"new_premium_until": projected,
|
|
"would_change": months > 0 || u.PremiumUntil != 0,
|
|
}
|
|
}
|
|
|
|
func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]domain.Authorization, domain.Authorization, error) {
|
|
if req.Hash != 0 {
|
|
for _, a := range items {
|
|
if a.Hash == req.Hash {
|
|
return []domain.Authorization{a}, domain.Authorization{}, nil
|
|
}
|
|
}
|
|
return nil, domain.Authorization{}, nil
|
|
}
|
|
var keep domain.Authorization
|
|
if req.KeepHash != 0 {
|
|
found := false
|
|
for _, a := range items {
|
|
if a.Hash == req.KeepHash {
|
|
keep = a
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, domain.Authorization{}, fmt.Errorf("keep_hash authorization not found")
|
|
}
|
|
}
|
|
targets := make([]domain.Authorization, 0, len(items))
|
|
for _, a := range items {
|
|
if req.KeepHash != 0 && a.Hash == req.KeepHash {
|
|
continue
|
|
}
|
|
targets = append(targets, a)
|
|
}
|
|
return targets, keep, nil
|
|
}
|
|
|
|
func authorizationHashes(items []domain.Authorization) []int64 {
|
|
out := make([]int64, 0, len(items))
|
|
for _, a := range items {
|
|
out = append(out, a.Hash)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
func normalizeIDs(ids []int) ([]int, error) {
|
|
if len(ids) == 0 {
|
|
return nil, domain.ErrMessageIDInvalid
|
|
}
|
|
if len(ids) > domain.MaxDeleteMessageIDs {
|
|
return nil, fmt.Errorf("too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs)
|
|
}
|
|
seen := make(map[int]struct{}, len(ids))
|
|
out := make([]int, 0, len(ids))
|
|
for _, id := range ids {
|
|
if id <= 0 || id > domain.MaxMessageBoxID {
|
|
return nil, domain.ErrMessageIDInvalid
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
sort.Ints(out)
|
|
return out, nil
|
|
}
|
|
|
|
func validatePrivateMessageSelection(ownerUserID int64, peer domain.Peer, ids []int, messages []domain.Message) ([]int, []int, error) {
|
|
foundSet := make(map[int]domain.Message, len(messages))
|
|
for _, msg := range messages {
|
|
foundSet[msg.ID] = msg
|
|
if msg.OwnerUserID != ownerUserID || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peer.ID {
|
|
return nil, nil, domain.ErrMessageIDInvalid
|
|
}
|
|
}
|
|
found := make([]int, 0, len(messages))
|
|
missing := make([]int, 0)
|
|
for _, id := range ids {
|
|
if _, ok := foundSet[id]; ok {
|
|
found = append(found, id)
|
|
continue
|
|
}
|
|
missing = append(missing, id)
|
|
}
|
|
return found, missing, nil
|
|
}
|
|
|
|
func summarizeDeleteResult(res domain.DeleteMessagesResult) []any {
|
|
out := make([]any, 0, len(res.Deleted))
|
|
for _, item := range res.Deleted {
|
|
ids := append([]int(nil), item.MessageIDs...)
|
|
sort.Ints(ids)
|
|
out = append(out, map[string]any{
|
|
"user_id": item.UserID,
|
|
"message_ids": ids,
|
|
"pts": item.Event.Pts,
|
|
"pts_count": item.Event.PtsCount,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func messageIDs(messages []domain.Message) []int {
|
|
out := make([]int, 0, len(messages))
|
|
for _, msg := range messages {
|
|
out = append(out, msg.ID)
|
|
}
|
|
return out
|
|
}
|