merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -0,0 +1,95 @@
package admin
import (
"context"
"strings"
"testing"
"telesrv/internal/domain"
)
type fakeGifCatalogService struct {
entries []domain.GifCatalogEntry
listCalls int
uploadCalls int
createCalls int
}
func (s *fakeGifCatalogService) ValidateGifUpload(_ string, data []byte) (string, bool) {
return "image/gif", strings.HasPrefix(string(data), "GIF89a")
}
func (s *fakeGifCatalogService) AdminUploadGifMaterial(context.Context, string, []byte) (domain.Document, error) {
s.uploadCalls++
return domain.Document{ID: 91}, nil
}
func (s *fakeGifCatalogService) AdminCreateGifCatalogEntry(context.Context, string, int64) (domain.GifCatalogEntry, error) {
s.createCalls++
entry := domain.GifCatalogEntry{ID: 92, DocumentID: 91, Title: "Wave", Enabled: true}
s.entries = append(s.entries, entry)
return entry, nil
}
func (s *fakeGifCatalogService) AdminListGifCatalog(context.Context) ([]domain.GifCatalogEntry, error) {
s.listCalls++
return append([]domain.GifCatalogEntry(nil), s.entries...), nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogEnabled(context.Context, int64, bool) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogSortOrder(context.Context, int64, int) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminSetGifCatalogCategory(context.Context, int64, string) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) AdminAutoCategorizeGifCatalog(context.Context) (int, error) {
return 0, nil
}
func (*fakeGifCatalogService) AdminDeleteUncategorizedGifs(context.Context) (int, int, error) {
return 0, 0, nil
}
func (*fakeGifCatalogService) AdminDeleteGifCatalogEntry(context.Context, int64) (bool, error) {
return true, nil
}
func (*fakeGifCatalogService) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
return domain.FileChunk{}, false, nil
}
func TestCreateGifCatalogEntryReplayAndContentFingerprint(t *testing.T) {
ctx := context.Background()
repo := newMemoryCommandRepo()
gifs := &fakeGifCatalogService{}
svc := NewService(Dependencies{Commands: repo, GifCatalog: gifs, Now: fixedNow})
req := CreateGifCatalogEntryRequest{
CommandMeta: CommandMeta{CommandID: "gif-create-1", Actor: "ops", Reason: "catalog"},
Title: "Wave",
FileName: "wave.gif",
Data: []byte("GIF89a-one"),
}
first, err := svc.CreateGifCatalogEntry(ctx, req)
if err != nil || first.Status != string(domain.AdminCommandCompleted) {
t.Fatalf("first create = %+v err=%v", first, err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("first calls list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
// Even when the catalog has become full, the same completed command must be
// replayed before capacity preflight and must not upload another blob.
gifs.entries = make([]domain.GifCatalogEntry, domain.MaxGifCatalogEntries)
replay, err := svc.CreateGifCatalogEntry(ctx, req)
if err != nil || !replay.AlreadyExecuted {
t.Fatalf("replay = %+v err=%v", replay, err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("replay calls list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
conflict := req
conflict.Data = []byte("GIF89a-two")
if _, err := svc.CreateGifCatalogEntry(ctx, conflict); err == nil || err.Error() != "COMMAND_ID_CONFLICT" {
t.Fatalf("different content with same command id err=%v", err)
}
if gifs.listCalls != 1 || gifs.uploadCalls != 1 || gifs.createCalls != 1 {
t.Fatalf("conflict mutated list/upload/create=%d/%d/%d", gifs.listCalls, gifs.uploadCalls, gifs.createCalls)
}
}

View file

@ -4,6 +4,8 @@ import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -207,10 +209,15 @@ type UsersService interface {
// AccountService carries the login-email factor (account_passwords table),
// a separate concern from UsersService's users-table fields.
type AccountService interface {
// ValidLoginEmail reports whether email is an acceptable login/signup
// email address.
ValidLoginEmail(email string) bool
// SetLoginEmail force-sets a user's login/signup email, no OTP required.
SetLoginEmail(ctx context.Context, userID int64, email string) error
// ClearLoginEmail removes the login email factor entirely.
ClearLoginEmail(ctx context.Context, userID int64) error
// LoginEmail returns a user's current login/signup email, if any.
LoginEmail(ctx context.Context, userID int64) (string, bool, error)
}
// BroadcastService creates and lists system broadcast campaigns (a message
@ -290,6 +297,11 @@ type StickerSetsService interface {
// ValidateStickerMaterialUpload is a pure check (no store writes) so a dry-run
// preview can validate an uploaded file's shape without materializing it.
ValidateStickerMaterialUpload(fileName string, data []byte) (mimeType string, ok bool)
// ValidateAdminCreateStickerSet and ValidateAdminAddStickerToSet are pure
// checks (no store writes), used by a dry-run preview before the
// corresponding Admin* call actually mutates the pack.
ValidateAdminCreateStickerSet(ctx context.Context, title, shortName, emoji string, kind domain.StickerSetKind) error
ValidateAdminAddStickerToSet(ctx context.Context, setID int64, emoji string) error
AdminUploadStickerMaterial(ctx context.Context, fileName string, data []byte) (domain.Document, error)
AdminCreateStickerSet(ctx context.Context, req domain.CreateStickerSetRequest) (domain.StickerSet, []domain.Document, error)
AdminAddStickerToSet(ctx context.Context, setID int64, item domain.StickerSetItemInput) (domain.StickerSet, []domain.Document, error)
@ -695,9 +707,10 @@ type RemoveStickerFromSetRequest struct {
type CreateGifCatalogEntryRequest struct {
CommandMeta
Title string `json:"title"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
Title string `json:"title"`
FileName string `json:"file_name"`
Data []byte `json:"-"`
ContentSHA256 string `json:"content_sha256,omitempty"`
}
type SetGifCatalogEnabledRequest struct {
@ -937,8 +950,9 @@ type TransferCollectibleUsernameRequest struct {
// permanently when Burn is set.
type RevokeCollectibleUsernameRequest struct {
CommandMeta
Username string `json:"username"`
Burn bool `json:"burn"`
Username string `json:"username"`
ExpectedOwnerUserID int64 `json:"expected_owner_user_id,string,omitempty"`
Burn bool `json:"burn"`
}
// DeleteCollectibleUsernameRequest erases a collectible asset entirely. Unlike a
@ -1365,11 +1379,14 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if domain.IsSystemUserID(req.UserID) {
return CommandResult{}, fmt.Errorf("system user profile cannot be changed")
}
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
firstName := strings.TrimSpace(req.FirstName)
lastName := strings.TrimSpace(req.LastName)
req.FirstName = strings.TrimSpace(req.FirstName)
req.LastName = strings.TrimSpace(req.LastName)
return s.runCommand(ctx, req.CommandMeta, ActionSetProfile, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -1379,18 +1396,21 @@ func (s *Service) SetProfile(ctx context.Context, req SetProfileRequest) (Comman
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{
"previous_first_name": u.FirstName, "previous_last_name": u.LastName,
"new_first_name": firstName, "new_last_name": lastName,
"previous_first_name": u.FirstName,
"previous_last_name": u.LastName,
"new_first_name": req.FirstName,
"new_last_name": req.LastName,
"would_change": u.FirstName != req.FirstName || u.LastName != req.LastName,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
return CommandResult{Message: "profile update validated", Details: details}, nil
}
updated, err := s.users.UpdateProfile(ctx, req.UserID, domain.UserProfileUpdate{
FirstName: firstName, HasFirstName: true,
LastName: lastName, HasLastName: true,
FirstName: req.FirstName, HasFirstName: true,
LastName: req.LastName, HasLastName: true,
})
if err != nil {
return CommandResult{}, err
return CommandResult{Details: details}, err
}
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
@ -1409,7 +1429,10 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
if s == nil || s.users == nil {
return CommandResult{}, fmt.Errorf("admin user dependency is not configured")
}
phone := strings.TrimSpace(req.Phone)
req.Phone = domain.NormalizePhone(req.Phone)
if !domain.ValidPhone(req.Phone) {
return CommandResult{}, domain.ErrPhoneNumberInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetPhone, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
@ -1418,15 +1441,22 @@ func (s *Service) SetPhone(ctx context.Context, req SetPhoneRequest) (CommandRes
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
details := map[string]any{"previous_phone": u.Phone, "new_phone": phone}
if u.Bot || domain.IsSystemUserID(u.ID) {
return CommandResult{}, domain.ErrPhoneChangeForbidden
}
details := map[string]any{
"previous_phone": u.Phone,
"new_phone": req.Phone,
"would_change": u.Phone != req.Phone,
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
return CommandResult{Message: "phone update validated", Details: details}, nil
}
updated, err := s.users.SetPhone(ctx, req.UserID, phone)
updated, err := s.users.SetPhone(ctx, req.UserID, req.Phone)
if err != nil {
return CommandResult{}, err
return CommandResult{Details: details}, err
}
details["updated_phone"] = updated.Phone
details["changed"] = u.Phone != updated.Phone
if err := s.notifyUserChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
@ -1442,26 +1472,46 @@ func (s *Service) SetLoginEmail(ctx context.Context, req SetLoginEmailRequest) (
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependency is not configured")
if s == nil || s.users == nil || s.account == nil {
return CommandResult{}, fmt.Errorf("admin account dependencies are not configured")
}
req.Email = strings.TrimSpace(req.Email)
if req.Email != "" && !s.account.ValidLoginEmail(req.Email) {
return CommandResult{}, domain.ErrEmailInvalid
}
email := strings.TrimSpace(req.Email)
return s.runCommand(ctx, req.CommandMeta, ActionSetLoginEmail, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"new_login_email": email}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
}
var err error
if email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, email)
}
u, found, err := s.users.AdminUser(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
if !found {
return CommandResult{}, domain.ErrUserNotFound
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return CommandResult{}, domain.ErrEmailInvalid
}
previous, _, err := s.account.LoginEmail(ctx, req.UserID)
if err != nil {
return CommandResult{}, err
}
details := map[string]any{
"previous_login_email": previous,
"new_login_email": req.Email,
"would_change": !strings.EqualFold(previous, req.Email),
}
if req.DryRun {
return CommandResult{Message: "login email update validated", Details: details}, nil
}
if req.Email == "" {
err = s.account.ClearLoginEmail(ctx, req.UserID)
} else {
err = s.account.SetLoginEmail(ctx, req.UserID, req.Email)
}
if err != nil {
return CommandResult{Details: details}, err
}
message := "login email updated"
if email == "" {
if req.Email == "" {
message = "login email cleared"
}
return CommandResult{Message: message, Details: details}, nil
@ -1475,31 +1525,37 @@ func (s *Service) SetAccountAvatar(ctx context.Context, req SetAccountAvatarRequ
if req.UserID <= 0 {
return CommandResult{}, fmt.Errorf("user_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
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) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetAccountAvatar, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
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 validated", Details: details}, nil
return CommandResult{Message: "avatar update validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, req.UserID)
if err != nil {
return CommandResult{Details: details}, err
}
if _, _, err := s.photos.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, req.UserID, domain.ProfilePhotoKindProfile, photo.ID, int(time.Now().Unix())); err != nil {
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"] = photo.ID
if s.users != nil {
if u, found, uerr := s.users.AdminUser(ctx, req.UserID); uerr == nil && found {
if nerr := s.notifyUserChanged(ctx, u); nerr != nil {
details["notify_error"] = nerr.Error()
}
}
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 updated", Details: details}, nil
})
@ -1514,20 +1570,28 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
if req.ChannelID <= 0 {
return CommandResult{}, fmt.Errorf("channel_id is required")
}
if s == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin photos dependency is not configured")
}
if s.channels == nil {
return CommandResult{}, fmt.Errorf("admin channel dependency is not configured")
if s == nil || s.channels == nil || s.photos == nil {
return CommandResult{}, fmt.Errorf("admin channel avatar dependencies are not configured")
}
if len(req.Data) == 0 || len(req.Data) > MaxAccountAvatarBytes || !s.photos.ValidateAvatarUpload(req.Data) {
return CommandResult{}, domain.ErrPhotoInvalid
}
target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}
return s.runCommand(ctx, req.CommandMeta, ActionSetChannelAvatar, 0, target, req, func() (CommandResult, error) {
details := map[string]any{"file_name": req.FileName, "bytes": len(req.Data)}
channel, err := s.channels.GetChannelByID(ctx, req.ChannelID)
if err != nil {
return CommandResult{}, err
}
if channel.Deleted || channel.Monoforum || (!channel.Broadcast && !channel.Megagroup) {
return CommandResult{}, domain.ErrChannelInvalid
}
details := map[string]any{
"file_name": req.FileName,
"bytes": len(req.Data),
"previous_photo_id": strconv.FormatInt(channel.PhotoID, 10),
}
if req.DryRun {
return CommandResult{Message: "avatar validated", Details: details}, nil
return CommandResult{Message: "channel avatar update validated", Details: details}, nil
}
photo, err := s.photos.CreateAvatarFromBytes(ctx, req.Data, 0)
if err != nil {
@ -1537,7 +1601,7 @@ func (s *Service) SetChannelAvatar(ctx context.Context, req SetChannelAvatarRequ
if err != nil {
return CommandResult{Details: details}, err
}
details["photo_id"] = photo.ID
details["photo_id"] = strconv.FormatInt(photo.ID, 10)
if err := s.notifyChannelChanged(ctx, updated); err != nil {
details["notify_error"] = err.Error()
}
@ -1562,38 +1626,10 @@ func (s *Service) ChannelAvatar(ctx context.Context, channelID int64) ([]byte, s
return nil, "", false, nil
}
photo, found, err := s.photos.GetPhoto(ctx, channel.PhotoID)
if err != nil {
return nil, "", false, err
if err != nil || !found {
return nil, "", found, err
}
if !found {
return nil, "", false, nil
}
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
if !ok {
return nil, "", false, nil
}
data := inline
if len(data) == 0 {
chunk, found, err := s.photos.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
}
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
}
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
if !safeAccountImageType(detected) {
return nil, "", false, nil
}
return data, detected, true, nil
return s.avatarBytes(ctx, photo)
}
// SetUserColor force-sets or clears a user's name/profile color.
@ -1970,6 +2006,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
if !domain.ValidCollectibleUsername(req.Username) {
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
}
if req.ExpectedOwnerUserID < 0 {
return CommandResult{}, fmt.Errorf("expected_owner_user_id must not be negative")
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeCollectibleUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"username": req.Username, "burn": req.Burn}
asset, err := s.usernames.Collectible(ctx, req.Username)
@ -1986,6 +2025,9 @@ func (s *Service) RevokeCollectibleUsername(ctx context.Context, req RevokeColle
if !req.Burn && !asset.Owned() {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.ExpectedOwnerUserID > 0 && asset.Owner != (domain.Peer{Type: domain.PeerTypeUser, ID: req.ExpectedOwnerUserID}) {
return CommandResult{Details: details}, codedError(CodeCollectibleNotOwned, domain.ErrCollectibleUsernameNotOwned)
}
if req.DryRun {
return CommandResult{Message: "collectible username revoke validated", Details: details}, nil
}
@ -2362,7 +2404,17 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
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)) {
modeCount := 0
if req.Hash != 0 {
modeCount++
}
if req.KeepHash != 0 {
modeCount++
}
if req.RevokeAll {
modeCount++
}
if modeCount != 1 {
return CommandResult{}, fmt.Errorf("choose one revoke mode")
}
return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) {
@ -2377,7 +2429,7 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
details := map[string]any{
"target_hashes": authorizationHashes(targets),
"target_count": len(targets),
"keep_hash": keep.Hash,
"keep_hash": authorizationHashString(keep.Hash),
}
if req.DryRun {
return CommandResult{Message: "dry-run completed", Details: details}, nil
@ -2388,9 +2440,10 @@ func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest)
if err != nil {
return CommandResult{}, err
}
if found {
revoked = append(revoked, deleted)
if !found {
return CommandResult{}, fmt.Errorf("authorization hash not found")
}
revoked = append(revoked, deleted)
} else {
deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID)
if err != nil {
@ -2544,12 +2597,13 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
return nil, "", false, nil
}
photo, found, err := s.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, domain.ProfilePhotoKindProfile)
if err != nil {
return nil, "", false, err
}
if !found {
return nil, "", false, nil
if err != nil || !found {
return nil, "", found, err
}
return s.avatarBytes(ctx, photo)
}
func (s *Service) avatarBytes(ctx context.Context, photo domain.Photo) ([]byte, string, bool, error) {
size, inline, ok := bestAccountPhotoSize(photo.Sizes)
if !ok {
return nil, "", false, nil
@ -2560,10 +2614,10 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
LocationKey: fmt.Sprintf("photo:%d:%s", photo.ID, size.Type),
Limit: MaxAccountAvatarBytes + 1,
})
if err != nil {
return nil, "", false, err
if err != nil || !found {
return nil, "", found, err
}
if !found || chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
if chunk.Total <= 0 || chunk.Total > MaxAccountAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total {
return nil, "", false, nil
}
data = chunk.Bytes
@ -2571,11 +2625,11 @@ func (s *Service) AccountAvatar(ctx context.Context, userID int64) ([]byte, stri
if len(data) == 0 || len(data) > MaxAccountAvatarBytes {
return nil, "", false, nil
}
detected := http.DetectContentType(data)
if !safeAccountImageType(detected) {
mimeType := http.DetectContentType(data)
if !safeAccountImageType(mimeType) {
return nil, "", false, nil
}
return data, detected, true, nil
return data, mimeType, true, nil
}
func bestAccountPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) {
@ -2659,7 +2713,7 @@ func (s *Service) SetStickerSetArchived(ctx context.Context, req SetStickerSetAr
func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetSortOrderRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetStickerSetSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "sort_order": req.SortOrder}
@ -2674,7 +2728,7 @@ func (s *Service) SetStickerSetSortOrder(ctx context.Context, req SetStickerSetS
func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || strings.TrimSpace(req.Title) == "" {
return CommandResult{}, fmt.Errorf("valid sticker set, title and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionRenameStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "title": req.Title}
@ -2692,7 +2746,7 @@ func (s *Service) RenameStickerSet(ctx context.Context, req RenameStickerSetRequ
func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteStickerSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10)}
@ -2710,7 +2764,7 @@ func (s *Service) DeleteStickerSet(ctx context.Context, req DeleteStickerSetRequ
func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil {
return CommandResult{}, fmt.Errorf("sticker sets service is not configured")
return CommandResult{}, domain.ErrStickerSetInvalid
}
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.ShortName) == "" || strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetFileInvalid
@ -2728,6 +2782,9 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
"title": req.Title, "short_name": req.ShortName, "kind": string(kind),
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
if err := s.stickerSets.ValidateAdminCreateStickerSet(ctx, req.Title, req.ShortName, req.Emoji, kind); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "sticker pack validated", Details: details}, nil
}
@ -2757,7 +2814,7 @@ func (s *Service) CreateStickerSet(ctx context.Context, req CreateStickerSetRequ
func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
if strings.TrimSpace(req.Emoji) == "" {
return CommandResult{}, domain.ErrStickerSetEmojiInvalid
@ -2771,6 +2828,12 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
"set_id": strconv.FormatInt(req.SetID, 10), "emoji": req.Emoji,
"file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
// Validate the target and item before materializing a loose
// document/blob. Keeping this inside runCommand preserves replay of a
// previously completed command even if the pack has since changed.
if err := s.stickerSets.ValidateAdminAddStickerToSet(ctx, req.SetID, req.Emoji); err != nil {
return CommandResult{Details: details}, err
}
if req.DryRun {
return CommandResult{Message: "sticker upload validated", Details: details}, nil
}
@ -2795,7 +2858,7 @@ func (s *Service) AddStickerToSet(ctx context.Context, req AddStickerToSetReques
func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFromSetRequest) (CommandResult, error) {
if s == nil || s.stickerSets == nil || req.SetID <= 0 || req.DocumentID <= 0 {
return CommandResult{}, fmt.Errorf("valid sticker set, document and service are required")
return CommandResult{}, domain.ErrStickerSetInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionRemoveStickerFromSet, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"set_id": strconv.FormatInt(req.SetID, 10), "document_id": strconv.FormatInt(req.DocumentID, 10)}
@ -2811,9 +2874,18 @@ func (s *Service) RemoveStickerFromSet(ctx context.Context, req RemoveStickerFro
})
}
// GifCatalog returns every GIF catalog entry for the admin console's
// management view.
func (s *Service) GifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error) {
if s == nil || s.gifCatalog == nil {
return nil, domain.ErrGifCatalogUnavailable
}
return s.gifCatalog.AdminListGifCatalog(ctx)
}
func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil {
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
return CommandResult{}, domain.ErrGifCatalogUnavailable
}
if strings.TrimSpace(req.Title) == "" {
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
@ -2822,10 +2894,19 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
if !ok {
return CommandResult{}, domain.ErrGifCatalogFileInvalid
}
digest := sha256.Sum256(req.Data)
req.ContentSHA256 = hex.EncodeToString(digest[:])
return s.runCommand(ctx, req.CommandMeta, ActionCreateGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{
"title": req.Title, "file_name": req.FileName, "mime_type": mimeType, "bytes": len(req.Data),
}
entries, err := s.gifCatalog.AdminListGifCatalog(ctx)
if err != nil {
return CommandResult{Details: details}, err
}
if len(entries) >= domain.MaxGifCatalogEntries {
return CommandResult{Details: details}, domain.ErrGifCatalogFull
}
if req.DryRun {
return CommandResult{Message: "gif catalog entry validated", Details: details}, nil
}
@ -2845,7 +2926,7 @@ func (s *Service) CreateGifCatalogEntry(ctx context.Context, req CreateGifCatalo
func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEnabledRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogEnabled, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "enabled": req.Enabled}
@ -2860,7 +2941,7 @@ func (s *Service) SetGifCatalogEnabled(ctx context.Context, req SetGifCatalogEna
func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogSortOrderRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 || req.SortOrder < math.MinInt32 || req.SortOrder > math.MaxInt32 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogSortOrder, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "sort_order": req.SortOrder}
@ -2938,7 +3019,7 @@ func (s *Service) DeleteUncategorizedGifs(ctx context.Context, req DeleteUncateg
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
}
return s.runCommand(ctx, req.CommandMeta, ActionDeleteGifCatalogEntry, 0, domain.Peer{}, req, func() (CommandResult, error) {
details := map[string]any{"id": strconv.FormatInt(req.ID, 10)}
@ -3201,7 +3282,7 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
return []domain.Authorization{a}, domain.Authorization{}, nil
}
}
return nil, domain.Authorization{}, nil
return nil, domain.Authorization{}, fmt.Errorf("authorization hash not found")
}
var keep domain.Authorization
if req.KeepHash != 0 {
@ -3227,15 +3308,26 @@ func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]d
return targets, keep, nil
}
func authorizationHashes(items []domain.Authorization) []int64 {
out := make([]int64, 0, len(items))
func authorizationHashes(items []domain.Authorization) []string {
hashes := make([]int64, 0, len(items))
for _, a := range items {
out = append(out, a.Hash)
hashes = append(hashes, a.Hash)
}
sort.Slice(hashes, func(i, j int) bool { return hashes[i] < hashes[j] })
out := make([]string, 0, len(hashes))
for _, hash := range hashes {
out = append(out, authorizationHashString(hash))
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func authorizationHashString(hash int64) string {
if hash == 0 {
return ""
}
return strconv.FormatInt(hash, 10)
}
func normalizeIDs(ids []int) ([]int, error) {
if len(ids) == 0 {
return nil, domain.ErrMessageIDInvalid