chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,506 @@
package account
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"telesrv/internal/domain"
)
const (
maxBusinessLocationAddress = 96
maxBusinessIntroTitle = 64
maxBusinessIntroDesc = 160
)
func (s *Service) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessProfile{UserID: userID}, false, nil
}
return s.business.GetBusinessProfile(ctx, userID)
}
func (s *Service) UpdateBusinessWorkHours(ctx context.Context, userID int64, hours *domain.BusinessWorkHours) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessWorkHours(hours)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.WorkHours = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessLocation(ctx context.Context, userID int64, location *domain.BusinessLocation) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessLocation(location)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Location = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessIntro(ctx context.Context, userID int64, intro *domain.BusinessIntro) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessIntro(intro)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Intro = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessGreetingMessage(ctx context.Context, userID int64, greeting *domain.BusinessGreetingMessage) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessGreeting(greeting)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Greeting = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) UpdateBusinessAwayMessage(ctx context.Context, userID int64, away *domain.BusinessAwayMessage) (domain.BusinessProfile, error) {
profile, err := s.businessProfileForUpdate(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
normalized, err := normalizeBusinessAway(away)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.Away = normalized
return s.saveBusinessProfile(ctx, profile)
}
func (s *Service) ListBusinessChatLinks(ctx context.Context, userID int64) ([]domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return nil, nil
}
return s.business.ListBusinessChatLinks(ctx, userID)
}
func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessChatLink{}, domain.ErrPremiumRequired
}
normalized, err := domain.NormalizeBusinessChatLinkInput(input)
if err != nil {
return domain.BusinessChatLink{}, err
}
now := time.Now().Unix()
for i := 0; i < 8; i++ {
slug, err := randomBusinessChatLinkSlug()
if err != nil {
return domain.BusinessChatLink{}, err
}
link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{
OwnerUserID: userID,
Slug: slug,
Link: businessChatLinkURL(slug),
Message: normalized.Message,
Entities: normalized.Entities,
Title: normalized.Title,
CreatedAt: now,
UpdatedAt: now,
})
if err == nil {
return link, nil
}
if !errors.Is(err, domain.ErrBusinessChatLinkInvalid) {
return domain.BusinessChatLink{}, err
}
}
return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid
}
func (s *Service) EditBusinessChatLink(ctx context.Context, userID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessChatLink{}, domain.ErrPremiumRequired
}
normalized, err := domain.NormalizeBusinessChatLinkInput(input)
if err != nil {
return domain.BusinessChatLink{}, err
}
return s.business.UpdateBusinessChatLink(ctx, userID, strings.TrimSpace(slug), normalized)
}
func (s *Service) DeleteBusinessChatLink(ctx context.Context, userID int64, slug string) (bool, error) {
if s == nil || s.business == nil || userID == 0 {
return false, domain.ErrBusinessChatLinkNotFound
}
return s.business.DeleteBusinessChatLink(ctx, userID, strings.TrimSpace(slug))
}
func (s *Service) ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) {
if s == nil || s.business == nil {
return domain.BusinessChatLink{}, false, nil
}
return s.business.ResolveBusinessChatLink(ctx, strings.TrimSpace(slug), bumpViews)
}
func (s *Service) GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 {
return domain.ConnectedBusinessBot{}, false, nil
}
return s.business.GetConnectedBusinessBot(ctx, ownerUserID)
}
func (s *Service) SaveConnectedBusinessBot(ctx context.Context, ownerUserID int64, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) {
if s == nil || s.business == nil || ownerUserID == 0 || bot.BotUserID == 0 || bot.BotUserID == ownerUserID {
return domain.ConnectedBusinessBot{}, domain.ErrBotBusinessMissing
}
recipients, err := normalizeBusinessBotRecipients(bot.Recipients)
if err != nil {
return domain.ConnectedBusinessBot{}, err
}
now := time.Now().Unix()
bot.OwnerUserID = ownerUserID
bot.Recipients = recipients
if bot.CreatedAtUnix == 0 {
bot.CreatedAtUnix = now
}
bot.UpdatedAtUnix = now
return s.business.SaveConnectedBusinessBot(ctx, bot)
}
func (s *Service) DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 || botUserID == 0 {
return false, domain.ErrBotBusinessMissing
}
return s.business.DeleteConnectedBusinessBot(ctx, ownerUserID, botUserID)
}
func (s *Service) SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
} else if !ok {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
state, err := s.business.SetConnectedBusinessBotPaused(ctx, ownerUserID, peerUserID, paused)
if err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
}
if state.UpdatedAtUnix == 0 {
state.UpdatedAtUnix = time.Now().Unix()
}
return state, nil
}
func (s *Service) DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
} else if !ok {
return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing
}
state, err := s.business.DisableConnectedBusinessBotForPeer(ctx, ownerUserID, peerUserID)
if err != nil {
return domain.ConnectedBusinessBotPeerState{}, err
}
if state.UpdatedAtUnix == 0 {
state.UpdatedAtUnix = time.Now().Unix()
}
return state, nil
}
func (s *Service) GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) {
if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 {
return domain.ConnectedBusinessBotPeerState{}, false, nil
}
return s.business.GetConnectedBusinessBotPeerState(ctx, ownerUserID, peerUserID)
}
func (s *Service) ListQuickReplies(ctx context.Context, userID int64) (domain.QuickReplyList, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyList{OwnerUserID: userID}, nil
}
return s.business.ListQuickReplies(ctx, userID, true)
}
func (s *Service) CheckQuickReplyShortcut(ctx context.Context, userID int64, shortcut string) (bool, error) {
if s == nil || s.business == nil || userID == 0 {
if _, err := domain.NormalizeQuickReplyShortcut(shortcut); err != nil {
return false, err
}
return true, nil
}
return s.business.CheckQuickReplyShortcut(ctx, userID, shortcut)
}
func (s *Service) SaveQuickReplyText(ctx context.Context, userID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.SaveQuickReplyText(ctx, userID, shortcut, msg)
}
func (s *Service) GetQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid
}
return s.business.GetQuickReplyMessages(ctx, userID, shortcutID, ids)
}
func (s *Service) RenameQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
return s.business.RenameQuickReplyShortcut(ctx, userID, shortcutID, shortcut)
}
func (s *Service) ReorderQuickReplies(ctx context.Context, userID int64, order []int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrPremiumRequired
}
return s.business.ReorderQuickReplies(ctx, userID, append([]int(nil), order...))
}
func (s *Service) DeleteQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.DeleteQuickReplyShortcut(ctx, userID, shortcutID)
}
func (s *Service) DeleteQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid
}
return s.business.DeleteQuickReplyMessages(ctx, userID, shortcutID, append([]int(nil), ids...))
}
func (s *Service) businessProfileForUpdate(ctx context.Context, userID int64) (domain.BusinessProfile, error) {
if s == nil || s.business == nil || userID == 0 {
return domain.BusinessProfile{}, domain.ErrPremiumRequired
}
profile, _, err := s.business.GetBusinessProfile(ctx, userID)
if err != nil {
return domain.BusinessProfile{}, err
}
profile.UserID = userID
return profile, nil
}
func (s *Service) saveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) (domain.BusinessProfile, error) {
profile.UpdatedAtUnix = time.Now().Unix()
if err := s.business.SaveBusinessProfile(ctx, profile); err != nil {
return domain.BusinessProfile{}, err
}
return profile, nil
}
func normalizeBusinessWorkHours(in *domain.BusinessWorkHours) (*domain.BusinessWorkHours, error) {
if in == nil {
return nil, nil
}
out := *in
out.TimezoneID = strings.TrimSpace(out.TimezoneID)
out.OpenNow = false
if out.TimezoneID == "" || len(out.WeeklyOpen) == 0 || len(out.WeeklyOpen) > domain.MaxBusinessWorkHourIntervals {
return nil, domain.ErrBusinessProfileInvalid
}
out.WeeklyOpen = append([]domain.BusinessWeeklyOpen(nil), out.WeeklyOpen...)
for _, item := range out.WeeklyOpen {
if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 {
return nil, domain.ErrBusinessProfileInvalid
}
}
return &out, nil
}
func normalizeBusinessLocation(in *domain.BusinessLocation) (*domain.BusinessLocation, error) {
if in == nil {
return nil, nil
}
out := *in
out.Address = strings.TrimSpace(out.Address)
if out.Address == "" || utf8.RuneCountInString(out.Address) > maxBusinessLocationAddress {
return nil, domain.ErrBusinessProfileInvalid
}
if out.Geo != nil {
geo := *out.Geo
if geo.Lat < -90 || geo.Lat > 90 || geo.Long < -180 || geo.Long > 180 {
return nil, domain.ErrBusinessProfileInvalid
}
out.Geo = &geo
}
return &out, nil
}
func normalizeBusinessIntro(in *domain.BusinessIntro) (*domain.BusinessIntro, error) {
if in == nil {
return nil, nil
}
out := *in
out.Title = strings.TrimSpace(out.Title)
out.Description = strings.TrimSpace(out.Description)
if out.Title == "" && out.Description == "" && out.StickerDocumentID == 0 {
return nil, nil
}
if utf8.RuneCountInString(out.Title) > maxBusinessIntroTitle || utf8.RuneCountInString(out.Description) > maxBusinessIntroDesc {
return nil, domain.ErrBusinessProfileInvalid
}
return &out, nil
}
func normalizeBusinessGreeting(in *domain.BusinessGreetingMessage) (*domain.BusinessGreetingMessage, error) {
if in == nil {
return nil, nil
}
out := *in
if out.ShortcutID <= 0 || !validGreetingNoActivityDays(out.NoActivityDays) {
return nil, domain.ErrBusinessProfileInvalid
}
recipients, err := normalizeBusinessRecipients(out.Recipients)
if err != nil {
return nil, err
}
out.Recipients = recipients
return &out, nil
}
func normalizeBusinessAway(in *domain.BusinessAwayMessage) (*domain.BusinessAwayMessage, error) {
if in == nil {
return nil, nil
}
out := *in
if out.ShortcutID <= 0 {
return nil, domain.ErrBusinessProfileInvalid
}
switch out.Schedule.Kind {
case domain.BusinessAwayScheduleAlways, domain.BusinessAwayScheduleOutsideWorkHours:
out.Schedule.StartDate = 0
out.Schedule.EndDate = 0
case domain.BusinessAwayScheduleCustom:
if out.Schedule.StartDate <= 0 || out.Schedule.EndDate <= out.Schedule.StartDate {
return nil, domain.ErrBusinessProfileInvalid
}
default:
return nil, domain.ErrBusinessProfileInvalid
}
recipients, err := normalizeBusinessRecipients(out.Recipients)
if err != nil {
return nil, err
}
out.Recipients = recipients
return &out, nil
}
func normalizeBusinessRecipients(in domain.BusinessRecipients) (domain.BusinessRecipients, error) {
out := in
out.Users = append([]int64(nil), in.Users...)
if len(out.Users) > domain.MaxBusinessRecipientUsers {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
seen := make(map[int64]struct{}, len(out.Users))
users := out.Users[:0]
for _, id := range out.Users {
if id <= 0 {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
users = append(users, id)
}
out.Users = users
if !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 {
return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid
}
return out, nil
}
func normalizeBusinessBotRecipients(in domain.BusinessBotRecipients) (domain.BusinessBotRecipients, error) {
out := in
users, err := dedupeBusinessUserIDs(in.Users)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
excluded, err := dedupeBusinessUserIDs(in.ExcludeUsers)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
if len(users)+len(excluded) > domain.MaxBusinessRecipientUsers {
return domain.BusinessBotRecipients{}, domain.ErrBusinessProfileInvalid
}
if out.ExcludeSelected {
merged := append(users, excluded...)
users, err = dedupeBusinessUserIDs(merged)
if err != nil {
return domain.BusinessBotRecipients{}, err
}
excluded = nil
}
out.Users = users
out.ExcludeUsers = excluded
if !out.ExcludeSelected && !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 {
return domain.BusinessBotRecipients{}, domain.ErrBusinessRecipientsEmpty
}
return out, nil
}
func dedupeBusinessUserIDs(in []int64) ([]int64, error) {
if len(in) == 0 {
return nil, nil
}
seen := make(map[int64]struct{}, len(in))
out := make([]int64, 0, len(in))
for _, id := range in {
if id <= 0 {
return nil, domain.ErrBusinessProfileInvalid
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func validGreetingNoActivityDays(days int) bool {
switch days {
case 7, 14, 21, 28:
return true
default:
return false
}
}
func randomBusinessChatLinkSlug() (string, error) {
value, err := randomInt64()
if err != nil {
return "", err
}
return fmt.Sprintf("%x", value), nil
}
func businessChatLinkURL(slug string) string {
return "https://telesrv.net/m/" + slug
}

View file

@ -0,0 +1,158 @@
package account
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestBusinessProfileAndChatLinks(t *testing.T) {
ctx := context.Background()
const userID int64 = 1001
store := memory.NewPasswordStore()
svc := NewService(store, WithBusinessAutomation(store))
profile, err := svc.UpdateBusinessWorkHours(ctx, userID, &domain.BusinessWorkHours{
TimezoneID: "Asia/Shanghai",
WeeklyOpen: []domain.BusinessWeeklyOpen{{
StartMinute: 6*24*60 + 21*60,
EndMinute: 7*24*60 + 4*60,
}},
OpenNow: true,
})
if err != nil {
t.Fatalf("UpdateBusinessWorkHours: %v", err)
}
if profile.WorkHours == nil || profile.WorkHours.OpenNow {
t.Fatalf("WorkHours = %+v, want persisted hours with OpenNow cleared", profile.WorkHours)
}
if _, err := svc.UpdateBusinessLocation(ctx, userID, &domain.BusinessLocation{
Address: "No. 1 Test Road",
Geo: &domain.GeoPoint{Lat: 31.2, Long: 121.5},
}); err != nil {
t.Fatalf("UpdateBusinessLocation: %v", err)
}
if _, err := svc.UpdateBusinessIntro(ctx, userID, &domain.BusinessIntro{
Title: "Support",
Description: "Fast replies",
}); err != nil {
t.Fatalf("UpdateBusinessIntro: %v", err)
}
got, found, err := svc.GetBusinessProfile(ctx, userID)
if err != nil || !found {
t.Fatalf("GetBusinessProfile found=%v err=%v", found, err)
}
if got.Location == nil || got.Intro == nil {
t.Fatalf("profile = %+v, want location and intro", got)
}
link, err := svc.CreateBusinessChatLink(ctx, userID, domain.BusinessChatLinkInput{
Message: "Hello from link",
Title: "Support link",
Entities: []domain.MessageEntity{{
Type: domain.MessageEntityBold,
Offset: 0,
Length: 5,
}},
})
if err != nil {
t.Fatalf("CreateBusinessChatLink: %v", err)
}
if link.Slug == "" || link.Link == "" {
t.Fatalf("created link = %+v, want slug/link", link)
}
links, err := svc.ListBusinessChatLinks(ctx, userID)
if err != nil || len(links) != 1 {
t.Fatalf("ListBusinessChatLinks len=%d err=%v", len(links), err)
}
resolved, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, true)
if err != nil || !found || resolved.Views != 1 {
t.Fatalf("ResolveBusinessChatLink found=%v link=%+v err=%v", found, resolved, err)
}
edited, err := svc.EditBusinessChatLink(ctx, userID, link.Slug, domain.BusinessChatLinkInput{
Message: "Edited",
Title: "New title",
})
if err != nil {
t.Fatalf("EditBusinessChatLink: %v", err)
}
if edited.Message != "Edited" || edited.Title != "New title" {
t.Fatalf("edited link = %+v", edited)
}
deleted, err := svc.DeleteBusinessChatLink(ctx, userID, link.Slug)
if err != nil || !deleted {
t.Fatalf("DeleteBusinessChatLink deleted=%v err=%v", deleted, err)
}
if _, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, false); err != nil || found {
t.Fatalf("Resolve deleted found=%v err=%v", found, err)
}
}
func TestQuickRepliesLifecycle(t *testing.T) {
ctx := context.Background()
const userID int64 = 1002
store := memory.NewPasswordStore()
svc := NewService(store, WithBusinessAutomation(store))
available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello")
if err != nil || !available {
t.Fatalf("CheckQuickReplyShortcut available=%v err=%v", available, err)
}
mutation, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{
RandomID: 11,
Date: 123,
Message: "First template",
Entities: []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}},
})
if err != nil {
t.Fatalf("SaveQuickReplyText first: %v", err)
}
if mutation.Kind != domain.QuickReplyMutationNew || mutation.ShortcutID == 0 || mutation.Message.ID == 0 {
t.Fatalf("first mutation = %+v", mutation)
}
shortcutID := mutation.ShortcutID
second, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{
RandomID: 12,
Date: 124,
Message: "Second template",
})
if err != nil {
t.Fatalf("SaveQuickReplyText second: %v", err)
}
if second.Kind != domain.QuickReplyMutationMessage || second.ShortcutID != shortcutID {
t.Fatalf("second mutation = %+v", second)
}
if available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello"); err != nil || available {
t.Fatalf("CheckQuickReplyShortcut duplicate available=%v err=%v", available, err)
}
list, err := svc.ListQuickReplies(ctx, userID)
if err != nil || len(list.QuickReplies) != 1 || list.QuickReplies[0].Count != 2 || list.Hash == 0 {
t.Fatalf("ListQuickReplies = %+v err=%v", list, err)
}
msgs, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil)
if err != nil || msgs.Count != 2 || len(msgs.Messages) != 2 || msgs.Hash == 0 {
t.Fatalf("GetQuickReplyMessages = %+v err=%v", msgs, err)
}
if _, err := svc.RenameQuickReplyShortcut(ctx, userID, shortcutID, "renamed"); err != nil {
t.Fatalf("RenameQuickReplyShortcut: %v", err)
}
if _, err := svc.ReorderQuickReplies(ctx, userID, []int{shortcutID}); err != nil {
t.Fatalf("ReorderQuickReplies: %v", err)
}
deleteMutation, err := svc.DeleteQuickReplyMessages(ctx, userID, shortcutID, []int{msgs.Messages[0].ID})
if err != nil {
t.Fatalf("DeleteQuickReplyMessages: %v", err)
}
if deleteMutation.Kind != domain.QuickReplyMutationIDs || len(deleteMutation.MessageIDs) != 1 {
t.Fatalf("delete mutation = %+v", deleteMutation)
}
if _, err := svc.DeleteQuickReplyShortcut(ctx, userID, shortcutID); err != nil {
t.Fatalf("DeleteQuickReplyShortcut: %v", err)
}
if _, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil); !errors.Is(err, domain.ErrShortcutInvalid) {
t.Fatalf("GetQuickReplyMessages deleted err = %v, want ErrShortcutInvalid", err)
}
}

View file

@ -0,0 +1,116 @@
package account
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newLoginEmailService(t *testing.T) (*Service, *memory.UserStore) {
t.Helper()
users := memory.NewUserStore()
svc := NewService(memory.NewPasswordStore(), WithUsers(users))
return svc, users
}
func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User {
t.Helper()
u, err := users.Create(context.Background(), domain.User{Phone: phone, FirstName: "Test"})
if err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始
// 地址只在 LoginEmail 读路径可见。
func TestSetLoginEmailPersistsAndMasks(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010001")
if err := svc.SetLoginEmail(ctx, u.ID, "alice@example.com"); err != nil {
t.Fatalf("SetLoginEmail: %v", err)
}
settings, err := svc.GetPassword(ctx, u.ID)
if err != nil {
t.Fatalf("GetPassword: %v", err)
}
if got, want := settings.LoginEmailPattern, "a***e@example.com"; got != want {
t.Fatalf("LoginEmailPattern = %q, want %q", got, want)
}
if settings.LoginEmail != "alice@example.com" {
t.Fatalf("LoginEmail = %q, want raw address", settings.LoginEmail)
}
email, found, err := svc.LoginEmail(ctx, u.ID)
if err != nil || !found || email != "alice@example.com" {
t.Fatalf("LoginEmail = %q found=%v err=%v", email, found, err)
}
}
// TestLoginEmailByPhoneAndClear 验证按手机号读取/清除登录邮箱(sendCode 检测 + reset 用)。
func TestLoginEmailByPhoneAndClear(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
createUser(t, users, "15550010002")
if err := svc.SetLoginEmailByPhone(ctx, "+1 555 001 0002", "bob@mail.com"); err != nil {
t.Fatalf("SetLoginEmailByPhone: %v", err)
}
email, found, err := svc.LoginEmailByPhone(ctx, "15550010002")
if err != nil || !found || email != "bob@mail.com" {
t.Fatalf("LoginEmailByPhone = %q found=%v err=%v", email, found, err)
}
if err := svc.ClearLoginEmailByPhone(ctx, "15550010002"); err != nil {
t.Fatalf("ClearLoginEmailByPhone: %v", err)
}
if _, found, _ := svc.LoginEmailByPhone(ctx, "15550010002"); found {
t.Fatal("login email still present after clear")
}
}
// TestSetLoginEmailRejectsInvalid 空/无 @ 的邮箱被拒。
func TestSetLoginEmailRejectsInvalid(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010003")
for _, bad := range []string{"", " ", "not-an-email"} {
if err := svc.SetLoginEmail(ctx, u.ID, bad); !errors.Is(err, domain.ErrEmailInvalid) {
t.Fatalf("SetLoginEmail(%q) err = %v, want ErrEmailInvalid", bad, err)
}
}
}
// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱
// 不得把恢复邮箱掩码写进 login_email_pattern(历史 bug)。
func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) {
ctx := context.Background()
svc, users := newLoginEmailService(t)
u := createUser(t, users, "15550010004")
// 设置 2FA 恢复邮箱(email-only 路径即可触发历史 bug 的写入点)。
if err := svc.UpdatePasswordSettings(ctx, u.ID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{
Email: "recovery@secret.com",
HasEmail: true,
}); err != nil {
t.Fatalf("UpdatePasswordSettings: %v", err)
}
settings, err := svc.GetPassword(ctx, u.ID)
if err != nil {
t.Fatalf("GetPassword: %v", err)
}
if settings.LoginEmailPattern != "" {
t.Fatalf("LoginEmailPattern = %q, want empty (recovery email must not leak into login email)", settings.LoginEmailPattern)
}
if !settings.HasRecovery {
t.Fatal("HasRecovery = false, want true after setting recovery email")
}
}

View file

@ -4,7 +4,6 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"fmt"
"strings"
"time"
@ -19,22 +18,17 @@ const (
passwordResetRetry = 24 * time.Hour
)
// EmailUnconfirmedError reports the dev recovery-code length expected by TDesktop.
type EmailUnconfirmedError struct {
Length int
}
func (e EmailUnconfirmedError) Error() string {
if e.Length <= 0 {
return "email unconfirmed"
}
return fmt.Sprintf("email unconfirmed: %d", e.Length)
}
// Service 提供账号安全配置查询。
type Service struct {
passwords store.PasswordStore
reactions store.AccountReactionSettingsStore
passwords store.PasswordStore
reactions store.AccountReactionSettingsStore
settings store.AccountSettingsStore
notify store.NotifySettingsStore
stickers store.StickerCollectionStore
savedMusic store.SavedMusicStore
business store.BusinessAutomationStore
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
users store.UserStore
}
// ServiceOption 调整 account 服务依赖。
@ -47,6 +41,48 @@ func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceO
}
}
// WithAccountSettings 注入账号级单例设置(全局隐私/TTL/敏感内容/注册通知)持久化。
func WithAccountSettings(settings store.AccountSettingsStore) ServiceOption {
return func(s *Service) {
s.settings = settings
}
}
// WithNotifySettings 注入 per-scope 通知设置持久化。
func WithNotifySettings(notify store.NotifySettingsStore) ServiceOption {
return func(s *Service) {
s.notify = notify
}
}
// WithStickerCollections 注入个人贴纸/GIF 集合持久化(faved/recent/gif)。
func WithStickerCollections(stickers store.StickerCollectionStore) ServiceOption {
return func(s *Service) {
s.stickers = stickers
}
}
// WithSavedMusic 注入账号级 profile music 列表持久化。
func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption {
return func(s *Service) {
s.savedMusic = savedMusic
}
}
// WithBusinessAutomation 注入账号级 Business Profile/Quick Replies/Chat Links 持久化。
func WithBusinessAutomation(business store.BusinessAutomationStore) ServiceOption {
return func(s *Service) {
s.business = business
}
}
// WithUsers 注入用户读存储,供登录邮箱的 phone→user 解析使用。
func WithUsers(users store.UserStore) ServiceOption {
return func(s *Service) {
s.users = users
}
}
// NewService 创建 account 服务。
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
s := &Service{passwords: passwords}
@ -56,6 +92,42 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
return s
}
// SaveMusic adds, removes, or reorders a song in the current user's profile music list.
func (s *Service) SaveMusic(ctx context.Context, userID int64, req domain.SaveMusicRequest) (bool, error) {
if userID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() {
return false, domain.ErrDocumentInvalid
}
if s == nil || s.savedMusic == nil {
return true, nil
}
req.UserID = userID
return true, s.savedMusic.SaveMusic(ctx, req)
}
// ListSavedMusicIDs returns the full ordered id list for account.getSavedMusicIds.
func (s *Service) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
if s == nil || s.savedMusic == nil || userID == 0 {
return nil, nil
}
return s.savedMusic.ListSavedMusicIDs(ctx, userID, limit)
}
// ListSavedMusic returns an ordered saved/profile music page.
func (s *Service) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) {
if s == nil || s.savedMusic == nil || userID == 0 {
return domain.SavedMusicList{UserID: userID}, nil
}
return s.savedMusic.ListSavedMusic(ctx, userID, offset, limit)
}
// GetSavedMusicByIDs refreshes file references for songs still present in the user's list.
func (s *Service) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) {
if s == nil || s.savedMusic == nil || userID == 0 || len(ids) == 0 {
return domain.SavedMusicList{UserID: userID}, nil
}
return s.savedMusic.GetSavedMusicByIDs(ctx, userID, ids)
}
// GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。
func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) {
if s == nil || s.passwords == nil || userID == 0 {
@ -110,6 +182,9 @@ func normalizePasswordSettings(settings domain.PasswordSettings) domain.Password
if settings.RecoveryEmail != "" {
settings.HasRecovery = true
}
// login_email_pattern 始终从已确认的登录邮箱派生,与 2FA 恢复邮箱 RecoveryEmail
// 解耦(历史实现曾把恢复邮箱掩码误写进此字段,导致客户端把恢复邮箱当成登录邮箱显示)。
settings.LoginEmailPattern = emailPattern(settings.LoginEmail)
return settings
}
@ -197,7 +272,6 @@ func (s *Service) UpdatePasswordSettings(ctx context.Context, userID int64, chec
}
settings.RecoveryEmail = email
settings.HasRecovery = email != ""
settings.LoginEmailPattern = emailPattern(email)
settings.EmailUnconfirmedPattern = ""
}
settings.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom)
@ -378,15 +452,98 @@ func randomInt64() (int64, error) {
}
func emailPattern(email string) string {
if email == "" {
return ""
return domain.MaskEmail(email)
}
// validLoginEmail 是登录邮箱的最小校验:非空且含 '@'。开发环境不做更严格的 RFC 校验。
func validLoginEmail(email string) bool {
return email != "" && strings.Contains(email, "@")
}
// SetLoginEmail 为已登录用户写入登录邮箱(authed 的 emailVerifyPurposeLoginChange)。
// 账号无 2FA 也可设置:account_passwords 行可在 has_password=false 下仅承载登录邮箱。
func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error {
if s == nil || s.passwords == nil || userID == 0 {
return domain.ErrEmailInvalid
}
at := strings.Index(email, "@")
if at <= 1 {
return email
email = strings.TrimSpace(email)
if !validLoginEmail(email) {
return domain.ErrEmailInvalid
}
name := email[:at]
return name[:1] + "***" + name[len(name)-1:] + email[at:]
settings, err := s.GetPasswordWithoutRefresh(ctx, userID)
if err != nil {
return err
}
settings.LoginEmail = email
settings.LoginEmailPattern = emailPattern(email)
return s.passwords.Save(ctx, userID, settings)
}
// SetLoginEmailByPhone 为某手机号对应的账号写入登录邮箱(登录流程中的
// emailVerifyPurposeLoginSetup,此时尚未鉴权,只能凭 phone 定位用户)。
func (s *Service) SetLoginEmailByPhone(ctx context.Context, phone, email string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return domain.ErrEmailInvalid
}
return s.SetLoginEmail(ctx, userID, email)
}
// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。
func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) {
if s == nil || s.passwords == nil || userID == 0 {
return "", false, nil
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
if err != nil {
return "", false, err
}
if !found || settings.LoginEmail == "" {
return "", false, nil
}
return settings.LoginEmail, true, nil
}
// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、
// login-setup 回显、reset 回显使用)。
func (s *Service) LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error) {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil || !found {
return "", false, err
}
return s.LoginEmail(ctx, userID)
}
// ClearLoginEmailByPhone 清除某手机号账号的登录邮箱(auth.resetLoginEmail)。
func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) error {
userID, found, err := s.userIDByPhone(ctx, phone)
if err != nil {
return err
}
if !found {
return nil
}
settings, found, err := s.passwords.GetByUser(ctx, userID)
if err != nil || !found {
return err
}
settings.LoginEmail = ""
settings.LoginEmailPattern = ""
return s.passwords.Save(ctx, userID, settings)
}
func (s *Service) userIDByPhone(ctx context.Context, phone string) (int64, bool, error) {
if s == nil || s.users == nil {
return 0, false, nil
}
u, found, err := s.users.ByPhone(ctx, domain.NormalizePhone(phone))
if err != nil || !found {
return 0, false, err
}
return u.ID, true, nil
}
// GetReactionSettings returns account-level reaction preferences.
@ -420,7 +577,7 @@ func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction
if err != nil {
return domain.AccountReactionSettings{}, err
}
if reaction.Type == "" || reaction.Emoticon == "" {
if !reaction.Valid() {
reaction = domain.DefaultAccountReactionSettings().DefaultReaction
}
settings.DefaultReaction = reaction
@ -445,10 +602,157 @@ func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settin
return settings, s.reactions.SaveReactionSettings(ctx, userID, settings)
}
// GetAccountSettings 返回账号级单例设置(未持久化时回落默认)。
func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
if s == nil || s.settings == nil || userID == 0 {
return domain.DefaultAccountSettings(), nil
}
settings, found, err := s.settings.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
if !found {
return domain.DefaultAccountSettings(), nil
}
settings.AccountTTLDays = settings.NormalizedTTLDays()
return settings, nil
}
// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。
func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
if privacy.NoncontactPeersPaidStars < 0 {
privacy.NoncontactPeersPaidStars = 0
}
settings.GlobalPrivacy = privacy
return s.saveAccountSettings(ctx, userID, settings)
}
// SetAccountTTL 持久化账号自毁期限(钳制 >0)。
func (s *Service) SetAccountTTL(ctx context.Context, userID int64, days int) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.AccountTTLDays = days
settings.AccountTTLDays = settings.NormalizedTTLDays()
return s.saveAccountSettings(ctx, userID, settings)
}
// SetSensitiveContent 持久化敏感内容查看开关。
func (s *Service) SetSensitiveContent(ctx context.Context, userID int64, enabled bool) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.SensitiveContentEnabled = enabled
return s.saveAccountSettings(ctx, userID, settings)
}
// SetContactSignUpSilent 持久化“联系人注册时是否静音通知”。
func (s *Service) SetContactSignUpSilent(ctx context.Context, userID int64, silent bool) (domain.AccountSettings, error) {
settings, err := s.GetAccountSettings(ctx, userID)
if err != nil {
return domain.AccountSettings{}, err
}
settings.ContactSignUpSilent = silent
return s.saveAccountSettings(ctx, userID, settings)
}
func (s *Service) saveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) (domain.AccountSettings, error) {
settings.AccountTTLDays = settings.NormalizedTTLDays()
if settings.GlobalPrivacy.NoncontactPeersPaidStars < 0 {
settings.GlobalPrivacy.NoncontactPeersPaidStars = 0
}
if s == nil || s.settings == nil || userID == 0 {
return settings, nil
}
return settings, s.settings.SaveAccountSettings(ctx, userID, settings)
}
// GetNotifySettings 返回某作用域的通知设置(未配置返回零值=继承默认)。
func (s *Service) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return domain.PeerNotifySettings{}, nil
}
settings, _, err := s.notify.GetNotifySettings(ctx, ownerUserID, scope)
if err != nil {
return domain.PeerNotifySettings{}, err
}
return settings, nil
}
// SaveNotifySettings 持久化某作用域的通知设置。
func (s *Service) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil
}
return s.notify.SaveNotifySettings(ctx, ownerUserID, scope, settings)
}
// ResetNotifySettings 清空该用户全部作用域的通知设置(恢复默认)。
func (s *Service) ResetNotifySettings(ctx context.Context, ownerUserID int64) error {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil
}
return s.notify.ResetNotifySettings(ctx, ownerUserID)
}
// PeerNotifySettings 批量取一组 peer 的整-peer 通知设置(dialog 列表投影)。
func (s *Service) PeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 || len(peers) == 0 {
return nil, nil
}
return s.notify.GetPeerNotifySettings(ctx, ownerUserID, peers)
}
// AllPeerNotifySettings 一次取该用户全部整-peer 通知设置(per-user notify 缓存的加载源)。
func (s *Service) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil, nil
}
return s.notify.AllPeerNotifySettings(ctx, ownerUserID)
}
// ListNotifyExceptions 列出该用户全部 per-peer 非默认通知设置(getNotifyExceptions)。
func (s *Service) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
if s == nil || s.notify == nil || ownerUserID == 0 {
return nil, nil
}
return s.notify.ListNotifyExceptions(ctx, ownerUserID)
}
// SaveStickerCollectionItem 收藏/最近/GIF 集合的加入或移除(最新置顶、按类别上界截断)。
func (s *Service) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now int) error {
if s == nil || s.stickers == nil || userID == 0 {
return nil
}
return s.stickers.SaveStickerCollectionItem(ctx, userID, kind, documentID, unsave, now, domain.MaxStickerCollectionItems(kind))
}
// ListStickerCollection 取某类个人贴纸集合(最新在前)。
func (s *Service) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) {
if s == nil || s.stickers == nil || userID == 0 {
return nil, nil
}
return s.stickers.ListStickerCollection(ctx, userID, kind, limit)
}
// ClearStickerCollection 清空某类个人贴纸集合。
func (s *Service) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error {
if s == nil || s.stickers == nil || userID == 0 {
return nil
}
return s.stickers.ClearStickerCollection(ctx, userID, kind)
}
func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings {
defaults := domain.DefaultAccountReactionSettings()
settings.Notify = normalizeNotifySettings(settings.Notify)
if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" {
if !settings.DefaultReaction.Valid() {
settings.DefaultReaction = defaults.DefaultReaction
}
settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy)

View file

@ -3,11 +3,14 @@ package account
import (
"bytes"
"context"
"crypto/sha512"
"errors"
"math/big"
"testing"
"time"
"golang.org/x/crypto/pbkdf2"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -183,3 +186,19 @@ func clientPasswordCheck(t *testing.T, settings domain.PasswordSettings, passwor
)
return domain.PasswordCheck{SRPID: settings.SRPID, A: aForHash, M1: m1}
}
// passwordDigest 与 verifierForPassword 是客户端侧(明文口令 → verifier)的模拟助手,
// 服务端从不执行明文口令路径,仅供这里的客户端 SRP helper 构造测试输入。
func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte {
hash1 := hashBytes(algo.Salt1, password, algo.Salt1)
hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2)
hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New)
return hashBytes(algo.Salt2, hash3, algo.Salt2)
}
func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte {
p := new(big.Int).SetBytes(algo.P)
g := big.NewInt(int64(algo.G))
x := new(big.Int).SetBytes(passwordDigest(algo, password))
return padToHash(new(big.Int).Exp(g, x, p).Bytes())
}

View file

@ -4,12 +4,9 @@ import (
"bytes"
"crypto/rand"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"math/big"
"golang.org/x/crypto/pbkdf2"
"telesrv/internal/domain"
)
@ -160,20 +157,6 @@ func hashBytes(parts ...[]byte) []byte {
return h.Sum(nil)
}
func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte {
hash1 := hashBytes(algo.Salt1, password, algo.Salt1)
hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2)
hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New)
return hashBytes(algo.Salt2, hash3, algo.Salt2)
}
func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte {
p := new(big.Int).SetBytes(algo.P)
g := big.NewInt(int64(algo.G))
x := new(big.Int).SetBytes(passwordDigest(algo, password))
return padToHash(new(big.Int).Exp(g, x, p).Bytes())
}
func padToHash(in []byte) []byte {
if len(in) >= passwordHashSize {
return append([]byte(nil), in[len(in)-passwordHashSize:]...)