v0.0.3
This commit is contained in:
parent
0d4b8b203e
commit
6f5298d420
16
.env
16
.env
@ -1,13 +1,11 @@
|
||||
SERVER_HOST=localhost
|
||||
SERVER_PORT=3006
|
||||
DB_DSN=postgres://user:password@localhost:5432/blog?sslmode=disable
|
||||
DB_DSN=postgres://tailly_v2:U%26bB0y%25GYn9r%2681%23@79.174.89.104:15452/tailly_v2?sslmode=disable
|
||||
ACCESS_TOKEN_SECRET="e5159eb2a712a47fb578284c36d507664f922f968ee99fa0b68260208d85688b"
|
||||
REFRESH_TOKEN_SECRET="75afdd4abdc49f647c57bfa700f0cc01fb931b5f6b2176386f93f64692179946"
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=user@example.com
|
||||
SMTP_PASSWORD=yourpassword
|
||||
SMTP_FROM=noreply@example.com
|
||||
APP_URL=https://your-app.com
|
||||
VAULT_ADDR=http://localhost:8200
|
||||
VAULT_TOKEN=s.ваш_токен
|
||||
SMTP_HOST=mail.altomta.ru
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=info@tailly.ru
|
||||
SMTP_PASSWORD="PWntAfh3KgBxSsFmqv86Jk"
|
||||
SMTP_FROM=info@tailly.ru
|
||||
AppURL="https://tailly.ru"
|
||||
@ -13,7 +13,6 @@ import (
|
||||
"tailly_back_v2/internal/ws"
|
||||
"tailly_back_v2/pkg/auth"
|
||||
"tailly_back_v2/pkg/database"
|
||||
"tailly_back_v2/pkg/encryption"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -49,6 +48,11 @@ func main() {
|
||||
commentRepo := repository.NewCommentRepository(db)
|
||||
likeRepo := repository.NewLikeRepository(db)
|
||||
chatRepo := repository.NewChatRepository(db)
|
||||
auditRepo := repository.NewAuditRepository(db)
|
||||
recoveryRepo := repository.NewRecoveryRepository(db)
|
||||
deviceRepo := repository.NewDeviceRepository(db)
|
||||
sessionRepo := repository.NewSessionRepository(db)
|
||||
|
||||
// Инициализация MailService
|
||||
mailService, err := service.NewMailService(
|
||||
cfg.SMTP.From,
|
||||
@ -56,37 +60,39 @@ func main() {
|
||||
cfg.SMTP.Port,
|
||||
cfg.SMTP.Username,
|
||||
cfg.SMTP.Password,
|
||||
cfg.SMTP.URL,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create mail service: %v", err)
|
||||
}
|
||||
vaultService := encryption.NewVaultService(
|
||||
cfg.Vault.Address,
|
||||
cfg.Vault.Token,
|
||||
)
|
||||
auditService := service.NewAuditService(repository.NewAuditRepository(db))
|
||||
recoveryService := service.NewRecoveryService(
|
||||
repository.NewRecoveryRepository(db),
|
||||
repository.NewUserRepository(db),
|
||||
repository.NewSessionRepository(db),
|
||||
repository.NewDeviceRepository(db),
|
||||
mailService,
|
||||
encryptionService,
|
||||
)
|
||||
|
||||
// Сервисы
|
||||
services := service.NewServices(
|
||||
service.NewAuthService(userRepo, tokenAuth, mailService),
|
||||
service.NewUserService(userRepo),
|
||||
service.NewPostService(postRepo),
|
||||
service.NewCommentService(commentRepo),
|
||||
service.NewLikeService(likeRepo),
|
||||
service.NewChatService(chatRepo),
|
||||
service.NewEncryptionService(vaultService, userRepo),
|
||||
)
|
||||
authService := service.NewAuthService(userRepo, tokenAuth, mailService)
|
||||
userService := service.NewUserService(userRepo)
|
||||
postService := service.NewPostService(postRepo)
|
||||
commentService := service.NewCommentService(commentRepo, postRepo)
|
||||
likeService := service.NewLikeService(likeRepo, postRepo)
|
||||
chatService := service.NewChatService(chatRepo, userRepo, chatHub)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
recoveryService := service.NewRecoveryService(recoveryRepo, userRepo, sessionRepo, deviceRepo, mailService)
|
||||
sessionService := service.NewSessionService(sessionRepo, deviceRepo, userRepo, mailService)
|
||||
|
||||
// HTTP сервер
|
||||
server := http.NewServer(cfg, services, tokenAuth)
|
||||
// Создаем структуру Services
|
||||
services := &service.Services{
|
||||
Auth: authService,
|
||||
User: userService,
|
||||
Post: postService,
|
||||
Comment: commentService,
|
||||
Like: likeService,
|
||||
Chat: chatService,
|
||||
Audit: auditService,
|
||||
Recovery: recoveryService,
|
||||
Session: sessionService,
|
||||
Mail: mailService,
|
||||
}
|
||||
|
||||
// HTTP сервер - передаем db как дополнительный параметр
|
||||
server := http.NewServer(cfg, services, tokenAuth, db)
|
||||
|
||||
// Запуск сервера в отдельной горутине
|
||||
go func() {
|
||||
|
||||
12
gqlgen.yml
12
gqlgen.yml
@ -10,7 +10,7 @@ model:
|
||||
package: graph
|
||||
|
||||
resolver:
|
||||
dir: internal/http/graph/resolvers
|
||||
dir: internal/http/graph
|
||||
layout: follow-schema
|
||||
package: graph
|
||||
|
||||
@ -31,20 +31,10 @@ models:
|
||||
model: tailly_back_v2/internal/domain.RecoveryRequest
|
||||
RecoveryMethod:
|
||||
model: tailly_back_v2/internal/domain.RecoveryMethod
|
||||
Notification:
|
||||
model: tailly_back_v2/internal/domain.Notification
|
||||
NotificationPreferences:
|
||||
model: tailly_back_v2/internal/domain.NotificationPreferences
|
||||
EncryptionKey:
|
||||
model: tailly_back_v2/internal/domain.EncryptionKey
|
||||
KeyExchange:
|
||||
model: tailly_back_v2/internal/domain.KeyExchange
|
||||
Chat:
|
||||
model: tailly_back_v2/internal/domain.Chat
|
||||
Message:
|
||||
model: tailly_back_v2/internal/domain.Message
|
||||
WSMessage:
|
||||
model: tailly_back_v2/internal/domain.WSMessage
|
||||
AuditLog:
|
||||
model: tailly_back_v2/internal/domain.AuditLog
|
||||
RegisterInput:
|
||||
|
||||
@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"github.com/caarlos0/env/v8"
|
||||
"github.com/joho/godotenv"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -25,20 +27,22 @@ type Config struct {
|
||||
Username string `env:"SMTP_USERNAME,required"`
|
||||
Password string `env:"SMTP_PASSWORD,required"`
|
||||
From string `env:"SMTP_FROM,required"`
|
||||
URL string `env:"AppURL,required"`
|
||||
}
|
||||
App struct {
|
||||
URL string `env:"APP_URL,required"`
|
||||
}
|
||||
Vault struct {
|
||||
Address string `env:"VAULT_ADDR,required"`
|
||||
Token string `env:"VAULT_TOKEN,required"`
|
||||
} `envPrefix:"VAULT_"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
// Пытаемся загрузить .env файл (если он есть)
|
||||
// Игнорируем ошибку, если файла нет
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{}
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Для отладки (можно убрать в продакшене)
|
||||
log.Printf("Config loaded: %+v", cfg)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@ -10,19 +10,11 @@ type Chat struct {
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int `json:"id"`
|
||||
ChatID int `json:"chatId"`
|
||||
SenderID int `json:"senderId"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"` // "sent", "delivered", "read"
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
MessageID int `json:"messageId"`
|
||||
ChatID int `json:"chatId"`
|
||||
SenderID int `json:"senderId"`
|
||||
Content string `json:"content"`
|
||||
Recipient int `json:"recipient"`
|
||||
ID int `json:"id"`
|
||||
ChatID int `json:"chatId"`
|
||||
SenderID int `json:"senderId"`
|
||||
ReceiverID int `json:"receiverId"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"` // "sent", "delivered", "read"
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type EncryptionKey struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"userId"`
|
||||
PublicKey string `json:"publicKey"` // В формате PEM
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type KeyExchange struct {
|
||||
ID int `json:"id"`
|
||||
InitiatorID int `json:"initiatorId"`
|
||||
ReceiverID int `json:"receiverId"`
|
||||
SessionKey string `json:"sessionKey"` // Зашифрованный ключ
|
||||
Status string `json:"status"` // "pending", "confirmed"
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@ -46,8 +46,10 @@ type Like struct {
|
||||
|
||||
// Токены для аутентификации
|
||||
type Tokens struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
AccessTokenExpires time.Time `json:"accessTokenExpires"`
|
||||
RefreshTokenExpires time.Time `json:"refreshTokenExpires"`
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Notification struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"userId"`
|
||||
Type string `json:"type"` // "new_device", "login_attempt"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Data string `json:"data"` // JSON
|
||||
IsRead bool `json:"isRead"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type NotificationPreferences struct {
|
||||
UserID int `json:"userId"`
|
||||
EmailNewDevice bool `json:"emailNewDevice"`
|
||||
PushNewDevice bool `json:"pushNewDevice"`
|
||||
EmailLoginAttempt bool `json:"emailLoginAttempt"`
|
||||
PushLoginAttempt bool `json:"pushLoginAttempt"`
|
||||
}
|
||||
@ -19,4 +19,5 @@ type RecoveryMethod struct {
|
||||
Value string `json:"value"` // email/phone number
|
||||
IsPrimary bool `json:"isPrimary"`
|
||||
VerifiedAt time.Time `json:"verifiedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ type Session struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"userId"`
|
||||
DeviceID int `json:"deviceId"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt,omitempty"`
|
||||
IsCurrent bool `json:"isCurrent,omitempty"`
|
||||
}
|
||||
|
||||
105
internal/http/graph/comment_resolvers.go
Normal file
105
internal/http/graph/comment_resolvers.go
Normal file
@ -0,0 +1,105 @@
|
||||
// comment_resolvers.go
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type commentResolver struct{ *Resolver }
|
||||
|
||||
// Comment returns CommentResolver implementation.
|
||||
func (r *Resolver) Comment() CommentResolver { return &commentResolver{r} }
|
||||
|
||||
// Post is the resolver for the post field.
|
||||
func (r *commentResolver) Post(ctx context.Context, obj *domain.Comment) (*domain.Post, error) {
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("comment is nil")
|
||||
}
|
||||
return r.Services.Post.GetByID(ctx, obj.PostID)
|
||||
}
|
||||
|
||||
// Author is the resolver for the author field.
|
||||
func (r *commentResolver) Author(ctx context.Context, obj *domain.Comment) (*domain.User, error) {
|
||||
if obj == nil {
|
||||
return nil, fmt.Errorf("comment is nil")
|
||||
}
|
||||
return r.Services.User.GetByID(ctx, obj.AuthorID)
|
||||
}
|
||||
|
||||
// CreatedAt is the resolver for the createdAt field.
|
||||
func (r *commentResolver) CreatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
|
||||
if obj == nil {
|
||||
return "", fmt.Errorf("comment is nil")
|
||||
}
|
||||
return obj.CreatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// UpdatedAt is the resolver for the updatedAt field.
|
||||
func (r *commentResolver) UpdatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
|
||||
if obj == nil {
|
||||
return "", fmt.Errorf("comment is nil")
|
||||
}
|
||||
return obj.UpdatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// CreateComment is the resolver for the createComment field.
|
||||
func (r *mutationResolver) CreateComment(ctx context.Context, postID int, content string) (*domain.Comment, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.Services.Comment.Create(ctx, postID, userID, content)
|
||||
}
|
||||
|
||||
// Comments is the resolver for the comments field.
|
||||
func (r *queryResolver) Comments(ctx context.Context, postID int) ([]*domain.Comment, error) {
|
||||
return r.Services.Comment.GetByPostID(ctx, postID)
|
||||
}
|
||||
|
||||
// UpdateComment is the resolver for the updateComment field.
|
||||
func (r *mutationResolver) UpdateComment(ctx context.Context, id int, content string) (*domain.Comment, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что комментарий принадлежит пользователю
|
||||
comment, err := r.Services.Comment.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("comment not found")
|
||||
}
|
||||
|
||||
if comment.AuthorID != userID {
|
||||
return nil, fmt.Errorf("unauthorized to update this comment")
|
||||
}
|
||||
|
||||
return r.Services.Comment.Update(ctx, id, content)
|
||||
}
|
||||
|
||||
// DeleteComment is the resolver for the deleteComment field.
|
||||
func (r *mutationResolver) DeleteComment(ctx context.Context, id int) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Проверяем, что комментарий принадлежит пользователю
|
||||
comment, err := r.Services.Comment.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("comment not found")
|
||||
}
|
||||
|
||||
if comment.AuthorID != userID {
|
||||
return false, fmt.Errorf("unauthorized to delete this comment")
|
||||
}
|
||||
|
||||
if err := r.Services.Comment.Delete(ctx, id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
69
internal/http/graph/device_resolvers.go
Normal file
69
internal/http/graph/device_resolvers.go
Normal file
@ -0,0 +1,69 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/repository"
|
||||
"time"
|
||||
)
|
||||
|
||||
type deviceResolver struct{ *Resolver }
|
||||
|
||||
// Device returns DeviceResolver implementation.
|
||||
func (r *Resolver) Device() DeviceResolver { return &deviceResolver{r} }
|
||||
|
||||
// LastActiveAt is the resolver for the lastActiveAt field.
|
||||
func (r *deviceResolver) LastActiveAt(ctx context.Context, obj *domain.Device) (string, error) {
|
||||
if obj == nil {
|
||||
return "", fmt.Errorf("device is nil")
|
||||
}
|
||||
// Используем ExpiresAt как время последней активности
|
||||
return obj.ExpiresAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// RenameDevice is the resolver for the renameDevice field.
|
||||
func (r *mutationResolver) RenameDevice(ctx context.Context, deviceID int, name string) (*domain.Device, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем устройство через репозиторий
|
||||
device, err := r.DeviceRepo.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrDeviceNotFound) {
|
||||
return nil, fmt.Errorf("device not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get device: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем владельца устройства
|
||||
if device.UserID != userID {
|
||||
return nil, fmt.Errorf("unauthorized to rename this device")
|
||||
}
|
||||
|
||||
// Обновляем имя
|
||||
device.Name = name
|
||||
if err := r.DeviceRepo.Update(ctx, device); err != nil {
|
||||
return nil, fmt.Errorf("failed to update device: %v", err)
|
||||
}
|
||||
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// Devices is the resolver for the devices field.
|
||||
func (r *queryResolver) Devices(ctx context.Context) ([]*domain.Device, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, err := r.DeviceRepo.GetByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user devices: %v", err)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
34
internal/http/graph/like_resolvers.go
Normal file
34
internal/http/graph/like_resolvers.go
Normal file
@ -0,0 +1,34 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type likeResolver struct{ *Resolver }
|
||||
|
||||
// Like returns LikeResolver implementation.
|
||||
func (r *Resolver) Like() LikeResolver { return &likeResolver{r} }
|
||||
|
||||
// Post is the resolver for the post field.
|
||||
func (r *likeResolver) Post(ctx context.Context, obj *domain.Like) (*domain.Post, error) {
|
||||
post, err := r.Services.Post.GetByID(ctx, obj.PostID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// User is the resolver for the user field.
|
||||
func (r *likeResolver) User(ctx context.Context, obj *domain.Like) (*domain.User, error) {
|
||||
// This would typically use a UserService to fetch the user
|
||||
// For now, we'll return nil as the user service isn't shown in the provided code
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// CreatedAt is the resolver for the createdAt field.
|
||||
func (r *likeResolver) CreatedAt(ctx context.Context, obj *domain.Like) (string, error) {
|
||||
return obj.CreatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
206
internal/http/graph/message_resolvers.go
Normal file
206
internal/http/graph/message_resolvers.go
Normal file
@ -0,0 +1,206 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/ws"
|
||||
"time"
|
||||
)
|
||||
|
||||
type messageResolver struct{ *Resolver }
|
||||
|
||||
// GetChatHistory - возвращает историю сообщений
|
||||
func (r *queryResolver) GetChatHistory(ctx context.Context, userID int) ([]*domain.Message, error) {
|
||||
currentUserID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.New("не авторизован")
|
||||
}
|
||||
|
||||
chat, err := r.Services.Chat.GetOrCreateChat(ctx, currentUserID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения чата: %v", err)
|
||||
}
|
||||
|
||||
messages, err := r.Services.Chat.GetChatMessages(ctx, chat.ID, currentUserID, 50, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения сообщений: %v", err)
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// GetUserChats - возвращает чаты пользователя
|
||||
func (r *queryResolver) GetUserChats(ctx context.Context) ([]*ChatSession, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.New("не авторизован")
|
||||
}
|
||||
|
||||
// Получаем чаты пользователя
|
||||
chats, err := r.Services.Chat.GetUserChats(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения чатов: %v", err)
|
||||
}
|
||||
|
||||
var sessions []*ChatSession
|
||||
for _, chat := range chats {
|
||||
// Определяем другого участника чата
|
||||
otherUserID := chat.User1ID
|
||||
if userID == chat.User1ID {
|
||||
otherUserID = chat.User2ID
|
||||
}
|
||||
|
||||
// Получаем данные другого пользователя
|
||||
otherUser, err := r.Services.User.GetByID(ctx, otherUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения пользователя %d: %v", otherUserID, err)
|
||||
}
|
||||
|
||||
// Получаем последнее сообщение
|
||||
messages, err := r.Services.Chat.GetChatMessages(ctx, chat.ID, userID, 1, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения сообщений: %v", err)
|
||||
}
|
||||
|
||||
var lastMessage *domain.Message
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
} else {
|
||||
// Если нет сообщений, возвращаем ошибку, так как в схеме lastMessage обязательное поле
|
||||
continue
|
||||
}
|
||||
|
||||
// Получаем количество непрочитанных сообщений
|
||||
unreadCount, err := r.chatRepo.GetUnreadCount(ctx, chat.ID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения количества непрочитанных: %v", err)
|
||||
}
|
||||
|
||||
sessions = append(sessions, &ChatSession{
|
||||
User: otherUser,
|
||||
LastMessage: lastMessage,
|
||||
UnreadCount: unreadCount,
|
||||
})
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// Sender - возвращает отправителя сообщения
|
||||
func (r *messageResolver) Sender(ctx context.Context, obj *domain.Message) (*domain.User, error) {
|
||||
user, err := r.Services.User.GetByID(ctx, obj.SenderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения отправителя: %v", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Receiver - возвращает получателя сообщения
|
||||
func (r *messageResolver) Receiver(ctx context.Context, obj *domain.Message) (*domain.User, error) {
|
||||
chat, err := r.chatRepo.GetChatByID(ctx, obj.ChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения чата: %v", err)
|
||||
}
|
||||
|
||||
receiverID := chat.User1ID
|
||||
if obj.SenderID == chat.User1ID {
|
||||
receiverID = chat.User2ID
|
||||
}
|
||||
|
||||
user, err := r.Services.User.GetByID(ctx, receiverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка получения получателя: %v", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreatedAt - форматирует время сообщения
|
||||
func (r *messageResolver) CreatedAt(ctx context.Context, obj *domain.Message) (string, error) {
|
||||
return obj.CreatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// SendMessage - отправляет сообщение
|
||||
func (r *mutationResolver) SendMessage(ctx context.Context, receiverID int, content string) (*domain.Message, error) {
|
||||
senderID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.New("не авторизован")
|
||||
}
|
||||
|
||||
chat, err := r.Services.Chat.GetOrCreateChat(ctx, senderID, receiverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка создания чата: %v", err)
|
||||
}
|
||||
|
||||
message, err := r.Services.Chat.SendMessage(ctx, senderID, chat.ID, content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка отправки сообщения: %v", err)
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// MarkAsRead - помечает сообщение как прочитанное
|
||||
func (r *mutationResolver) MarkAsRead(ctx context.Context, messageID int) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, errors.New("не авторизован")
|
||||
}
|
||||
|
||||
// Получаем сообщение напрямую из репозитория
|
||||
message, err := r.chatRepo.GetMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ошибка получения сообщения: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем доступ к сообщению
|
||||
chat, err := r.chatRepo.GetChatByID(ctx, message.ChatID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ошибка получения чата: %v", err)
|
||||
}
|
||||
|
||||
if userID != chat.User1ID && userID != chat.User2ID {
|
||||
return false, errors.New("нет доступа к сообщению")
|
||||
}
|
||||
|
||||
err = r.Services.Chat.MarkAsRead(ctx, messageID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("ошибка обновления статуса: %v", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type subscriptionResolver struct{ *Resolver }
|
||||
|
||||
// Subscription returns SubscriptionResolver implementation.
|
||||
func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} }
|
||||
|
||||
// MessageReceived - подписка на новые сообщения
|
||||
func (r *subscriptionResolver) MessageReceived(ctx context.Context) (<-chan *domain.Message, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.New("не авторизован")
|
||||
}
|
||||
|
||||
messageChan := make(chan *domain.Message, 1)
|
||||
|
||||
// Создаем клиента для хаба
|
||||
client := &ws.Client{
|
||||
UserID: userID,
|
||||
Send: messageChan,
|
||||
}
|
||||
|
||||
// Регистрируем клиента в хабе
|
||||
r.Services.ChatHub.Register(client)
|
||||
|
||||
// Горутина для обработки отключения
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
r.Services.ChatHub.Unregister(client)
|
||||
close(messageChan)
|
||||
}()
|
||||
|
||||
return messageChan, nil
|
||||
}
|
||||
129
internal/http/graph/post_resolvers.go
Normal file
129
internal/http/graph/post_resolvers.go
Normal file
@ -0,0 +1,129 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type postResolver struct{ *Resolver }
|
||||
|
||||
// Post is the resolver for the post field.
|
||||
func (r *queryResolver) Post(ctx context.Context, id int) (*domain.Post, error) {
|
||||
post, err := r.Services.Post.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// Posts is the resolver for the posts field.
|
||||
func (r *queryResolver) Posts(ctx context.Context) ([]*domain.Post, error) {
|
||||
posts, err := r.Services.Post.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get posts: %w", err)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// Post returns PostResolver implementation.
|
||||
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
|
||||
|
||||
// Author is the resolver for the author field.
|
||||
func (r *postResolver) Author(ctx context.Context, obj *domain.Post) (*domain.User, error) {
|
||||
// This would typically use a UserService to fetch the author
|
||||
// For now, we'll return nil as the user service isn't shown in the provided code
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Comments is the resolver for the comments field.
|
||||
func (r *postResolver) Comments(ctx context.Context, obj *domain.Post) ([]*domain.Comment, error) {
|
||||
// This would use a CommentService to fetch comments for the post
|
||||
// For now, return empty slice as comment service isn't shown
|
||||
return []*domain.Comment{}, nil
|
||||
}
|
||||
|
||||
// Likes is the resolver for the likes field.
|
||||
func (r *postResolver) Likes(ctx context.Context, obj *domain.Post) ([]*domain.Like, error) {
|
||||
likes, err := r.Services.Like.GetByPostID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get likes: %w", err)
|
||||
}
|
||||
return likes, nil
|
||||
}
|
||||
|
||||
// LikesCount is the resolver for the likesCount field.
|
||||
func (r *postResolver) LikesCount(ctx context.Context, obj *domain.Post) (int, error) {
|
||||
count, err := r.Services.Like.GetCountForPost(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get likes count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// IsLiked is the resolver for the isLiked field.
|
||||
func (r *postResolver) IsLiked(ctx context.Context, obj *domain.Post) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, nil // Return false if user is not authenticated
|
||||
}
|
||||
|
||||
liked, err := r.Services.Like.CheckIfLiked(ctx, userID, obj.ID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check like status: %w", err)
|
||||
}
|
||||
return liked, nil
|
||||
}
|
||||
|
||||
// CreatedAt is the resolver for the createdAt field.
|
||||
func (r *postResolver) CreatedAt(ctx context.Context, obj *domain.Post) (string, error) {
|
||||
return obj.CreatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// UpdatedAt is the resolver for the updatedAt field.
|
||||
func (r *postResolver) UpdatedAt(ctx context.Context, obj *domain.Post) (string, error) {
|
||||
return obj.UpdatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// LikePost is the resolver for the likePost field.
|
||||
func (r *mutationResolver) LikePost(ctx context.Context, postID int) (*domain.Like, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unauthorized: %w", err)
|
||||
}
|
||||
|
||||
like, err := r.Services.Like.LikePost(ctx, userID, postID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to like post: %w", err)
|
||||
}
|
||||
return like, nil
|
||||
}
|
||||
|
||||
// UnlikePost is the resolver for the unlikePost field.
|
||||
func (r *mutationResolver) UnlikePost(ctx context.Context, postID int) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unauthorized: %w", err)
|
||||
}
|
||||
|
||||
err = r.Services.Like.UnlikePost(ctx, userID, postID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to unlike post: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CreatePost is the resolver for the createPost field.
|
||||
func (r *mutationResolver) CreatePost(ctx context.Context, title string, content string) (*domain.Post, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unauthorized: %w", err)
|
||||
}
|
||||
|
||||
post, err := r.Services.Post.Create(ctx, userID, title, content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
_ "tailly_back_v2/internal/domain"
|
||||
_ "tailly_back_v2/internal/service"
|
||||
)
|
||||
@ -1,6 +1,6 @@
|
||||
# Тип пользователя
|
||||
type User {
|
||||
id: ID! # Уникальный идентификатор
|
||||
id: Int! # Уникальный идентификатор
|
||||
username: String! # Имя пользователя
|
||||
email: String! # Email (уникальный)
|
||||
emailConfirmedAt: String # Дата подтверждения email (может быть null)
|
||||
@ -10,7 +10,7 @@ type User {
|
||||
|
||||
# Пост в блоге
|
||||
type Post {
|
||||
id: ID! # Уникальный идентификатор
|
||||
id: Int! # Уникальный идентификатор
|
||||
title: String! # Заголовок поста
|
||||
content: String! # Содержание поста
|
||||
author: User! # Автор поста
|
||||
@ -24,7 +24,7 @@ type Post {
|
||||
|
||||
# Комментарий к посту
|
||||
type Comment {
|
||||
id: ID! # Уникальный идентификатор
|
||||
id: Int! # Уникальный идентификатор
|
||||
content: String! # Текст комментария
|
||||
post: Post! # Пост, к которому относится
|
||||
author: User! # Автор комментария
|
||||
@ -34,17 +34,18 @@ type Comment {
|
||||
|
||||
# Лайк к посту
|
||||
type Like {
|
||||
id: ID! # Уникальный идентификатор
|
||||
id: Int! # Уникальный идентификатор
|
||||
post: Post! # Пост, который лайкнули
|
||||
user: User! # Пользователь, который поставил лайк
|
||||
createdAt: String! # Дата создания
|
||||
}
|
||||
|
||||
# Токены для аутентификации
|
||||
type Tokens {
|
||||
accessToken: String! # Access токен (короткоживущий)
|
||||
refreshToken: String! # Refresh токен (долгоживущий)
|
||||
emailConfirmed: Boolean! # Флаг подтверждения email
|
||||
accessToken: String!
|
||||
refreshToken: String!
|
||||
accessTokenExpires: String! # или DateTime, если такой scalar есть
|
||||
refreshTokenExpires: String!
|
||||
emailConfirmed: Boolean!
|
||||
}
|
||||
|
||||
# Входные данные для регистрации
|
||||
@ -61,7 +62,7 @@ input LoginInput {
|
||||
}
|
||||
|
||||
type Message {
|
||||
id: ID!
|
||||
id: Int!
|
||||
sender: User!
|
||||
receiver: User!
|
||||
content: String!
|
||||
@ -76,7 +77,7 @@ type ChatSession {
|
||||
}
|
||||
|
||||
type Session {
|
||||
id: ID!
|
||||
id: Int!
|
||||
device: Device!
|
||||
startedAt: String!
|
||||
lastActiveAt: String!
|
||||
@ -84,24 +85,22 @@ type Session {
|
||||
}
|
||||
|
||||
type Device {
|
||||
id: ID!
|
||||
id: Int!
|
||||
name: String!
|
||||
type: String!
|
||||
ipAddress: String!
|
||||
location: String!
|
||||
userAgent: String!
|
||||
lastActiveAt: String!
|
||||
}
|
||||
|
||||
# Запросы (получение данных)
|
||||
type Query {
|
||||
me: User! # Получить текущего пользователя
|
||||
post(id: ID!): Post! # Получить пост по ID
|
||||
post(id: Int!): Post! # Получить пост по ID
|
||||
posts: [Post!]! # Получить все посты
|
||||
user(id: ID!): User! # Получить пользователя по ID
|
||||
getChatHistory(userId: ID!): [Message!]!
|
||||
user(id: Int!): User! # Получить пользователя по ID
|
||||
getChatHistory(userId: Int!): [Message!]!
|
||||
getUserChats: [ChatSession!]!
|
||||
mySessions: [Session!]!
|
||||
activeSessions: [Session!]!
|
||||
}
|
||||
|
||||
# Мутации (изменение данных)
|
||||
@ -119,19 +118,19 @@ type Mutation {
|
||||
createPost(title: String!, content: String!): Post!
|
||||
|
||||
# Создание комментария
|
||||
createComment(postId: ID!, content: String!): Comment!
|
||||
createComment(postId: Int!, content: String!): Comment!
|
||||
|
||||
# Лайк поста
|
||||
likePost(postId: ID!): Like!
|
||||
likePost(postId: Int!): Like!
|
||||
|
||||
# Удаление лайка
|
||||
unlikePost(postId: ID!): Boolean!
|
||||
unlikePost(postId: Int!): Boolean!
|
||||
updateProfile(username: String!, email: String!): User!
|
||||
changePassword(oldPassword: String!, newPassword: String!): Boolean!
|
||||
sendMessage(receiverId: ID!, content: String!): Message!
|
||||
markAsRead(messageId: ID!): Boolean!
|
||||
terminateSession(sessionId: ID!): Boolean!
|
||||
renameDevice(deviceId: ID!, name: String!): Device!
|
||||
sendMessage(receiverId: Int!, content: String!): Message!
|
||||
markAsRead(messageId: Int!): Boolean!
|
||||
terminateSession(sessionId: Int!): Boolean!
|
||||
renameDevice(deviceId: Int!, name: String!): Device!
|
||||
# Запрос на подтверждение email
|
||||
requestEmailConfirmation: Boolean!
|
||||
|
||||
|
||||
54
internal/http/graph/session_resolvers.go
Normal file
54
internal/http/graph/session_resolvers.go
Normal file
@ -0,0 +1,54 @@
|
||||
// session_resolvers.go
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sessionResolver struct{ *Resolver }
|
||||
|
||||
// MySessions is the resolver for the mySessions field.
|
||||
func (r *queryResolver) MySessions(ctx context.Context) ([]*domain.Session, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessions, err := r.Services.Session.GetActiveSessions(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get sessions: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// Session returns SessionResolver implementation.
|
||||
func (r *Resolver) Session() SessionResolver { return &sessionResolver{r} }
|
||||
|
||||
// StartedAt is the resolver for the startedAt field.
|
||||
func (r *sessionResolver) StartedAt(ctx context.Context, obj *domain.Session) (string, error) {
|
||||
return obj.StartedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// LastActiveAt is the resolver for the lastActiveAt field.
|
||||
func (r *sessionResolver) LastActiveAt(ctx context.Context, obj *domain.Session) (string, error) {
|
||||
if !obj.EndedAt.IsZero() {
|
||||
return obj.EndedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
return time.Now().Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// TerminateSession is the resolver for the terminateSession field.
|
||||
func (r *mutationResolver) TerminateSession(ctx context.Context, sessionID int) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := r.Services.Session.Terminate(ctx, userID, sessionID); err != nil {
|
||||
return false, fmt.Errorf("failed to terminate session: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
37
internal/http/graph/tokens_resolvers.go
Normal file
37
internal/http/graph/tokens_resolvers.go
Normal file
@ -0,0 +1,37 @@
|
||||
// tokens_resolvers.go
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
)
|
||||
|
||||
type tokensResolver struct{ *Resolver }
|
||||
|
||||
// Tokens returns TokensResolver implementation.
|
||||
func (r *Resolver) Tokens() TokensResolver { return &tokensResolver{r} }
|
||||
|
||||
// EmailConfirmed is the resolver for the emailConfirmed field.
|
||||
func (r *tokensResolver) EmailConfirmed(ctx context.Context, obj *domain.Tokens) (bool, error) {
|
||||
// Пытаемся извлечь userID из контекста
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
user, err := r.Services.User.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
return user.EmailConfirmedAt != nil, nil
|
||||
}
|
||||
|
||||
// RefreshTokens is the resolver for the refreshTokens field.
|
||||
func (r *mutationResolver) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) {
|
||||
tokens, err := r.Services.Auth.RefreshTokens(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh tokens: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
106
internal/http/graph/user_resolvers.go
Normal file
106
internal/http/graph/user_resolvers.go
Normal file
@ -0,0 +1,106 @@
|
||||
// user_resolvers.go
|
||||
package graph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/service"
|
||||
"time"
|
||||
)
|
||||
|
||||
type userResolver struct{ *Resolver }
|
||||
|
||||
// User is the resolver for the user field.
|
||||
func (r *queryResolver) User(ctx context.Context, id int) (*domain.User, error) {
|
||||
user, err := r.Services.User.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Me is the resolver for the me field.
|
||||
func (r *queryResolver) Me(ctx context.Context) (*domain.User, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := r.Services.User.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// User returns UserResolver implementation.
|
||||
func (r *Resolver) User() UserResolver { return &userResolver{r} }
|
||||
|
||||
// EmailConfirmedAt is the resolver for the emailConfirmedAt field.
|
||||
func (r *userResolver) EmailConfirmedAt(ctx context.Context, obj *domain.User) (*string, error) {
|
||||
if obj.EmailConfirmedAt == nil {
|
||||
return nil, nil
|
||||
}
|
||||
formatted := obj.EmailConfirmedAt.Format(time.RFC3339)
|
||||
return &formatted, nil
|
||||
}
|
||||
|
||||
// CreatedAt is the resolver for the createdAt field.
|
||||
func (r *userResolver) CreatedAt(ctx context.Context, obj *domain.User) (string, error) {
|
||||
return obj.CreatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// UpdatedAt is the resolver for the updatedAt field.
|
||||
func (r *userResolver) UpdatedAt(ctx context.Context, obj *domain.User) (string, error) {
|
||||
return obj.UpdatedAt.Format(time.RFC3339), nil
|
||||
}
|
||||
|
||||
// UpdateProfile is the resolver for the updateProfile field.
|
||||
func (r *mutationResolver) UpdateProfile(ctx context.Context, username string, email string) (*domain.User, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := r.Services.User.UpdateProfile(ctx, userID, username, email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update profile: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ChangePassword is the resolver for the changePassword field.
|
||||
func (r *mutationResolver) ChangePassword(ctx context.Context, oldPassword string, newPassword string) (bool, error) {
|
||||
userID, err := getUserIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := r.Services.User.ChangePassword(ctx, userID, oldPassword, newPassword); err != nil {
|
||||
return false, fmt.Errorf("failed to change password: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Register is the resolver for the register field.
|
||||
func (r *mutationResolver) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error) {
|
||||
user, err := r.Services.Auth.Register(ctx, service.RegisterInput{
|
||||
Username: input.Username,
|
||||
Email: input.Email,
|
||||
Password: input.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login is the resolver for the login field.
|
||||
func (r *mutationResolver) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) {
|
||||
tokens, err := r.Services.Auth.Login(ctx, input.Email, input.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to login: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"tailly_back_v2/internal/domain"
|
||||
@ -56,11 +56,29 @@ func (h *ChatHandler) readPump(conn *websocket.Conn, client *ws.Client) {
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
var msg struct {
|
||||
ChatID int `json:"chatId"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
// Обработка входящих сообщений (если нужно)
|
||||
|
||||
// Отправляем сообщение через сервис
|
||||
message, err := h.chatService.SendMessage(
|
||||
context.Background(),
|
||||
client.UserID,
|
||||
msg.ChatID,
|
||||
msg.Content,
|
||||
)
|
||||
if err != nil {
|
||||
// Обработка ошибки (можно отправить ошибку обратно клиенту)
|
||||
continue
|
||||
}
|
||||
|
||||
// Отправляем сообщение получателю через хаб
|
||||
h.hub.Broadcast(message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/ws"
|
||||
)
|
||||
|
||||
var notificationUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Настроить правильно для production
|
||||
},
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
userID, err := strconv.Atoi(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := notificationUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Could not open websocket connection", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
client := &ws.NotificationClient{
|
||||
UserID: userID,
|
||||
Send: make(chan *domain.Notification, 256),
|
||||
}
|
||||
|
||||
h.hub.Register(client)
|
||||
|
||||
go h.writePump(conn, client)
|
||||
go h.readPump(conn, client)
|
||||
}
|
||||
@ -16,7 +16,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers",
|
||||
"Accept, Content-Type, Content-Length, Accept-Encoding, Authorization")
|
||||
"Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, bypass-auth")
|
||||
w.Header().Set("Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS")
|
||||
}
|
||||
|
||||
@ -2,17 +2,16 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"tailly_back_v2/internal/config"
|
||||
"tailly_back_v2/internal/http/graph"
|
||||
"tailly_back_v2/internal/http/handlers"
|
||||
"tailly_back_v2/internal/http/middleware"
|
||||
"tailly_back_v2/internal/service"
|
||||
"tailly_back_v2/pkg/auth"
|
||||
@ -23,46 +22,44 @@ type Server struct {
|
||||
cfg *config.Config
|
||||
services *service.Services
|
||||
tokenAuth *auth.TokenAuth
|
||||
db *sql.DB // Добавляем подключение к БД
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, services *service.Services, tokenAuth *auth.TokenAuth) *Server {
|
||||
func NewServer(
|
||||
cfg *config.Config,
|
||||
services *service.Services,
|
||||
tokenAuth *auth.TokenAuth,
|
||||
db *sql.DB, // Добавляем параметр БД
|
||||
) *Server {
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
cfg: cfg,
|
||||
services: services,
|
||||
tokenAuth: tokenAuth,
|
||||
db: db,
|
||||
}
|
||||
|
||||
s.configureRouter()
|
||||
s.configureMetrics()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) configureRouter() {
|
||||
allowedOrigins := []string{
|
||||
"http://localhost:3000", // React dev server
|
||||
"https://your-production.app", // Продакшен домен
|
||||
"http://localhost:3000", // React dev server
|
||||
"https://tailly.ru", // Продакшен домен
|
||||
}
|
||||
|
||||
// Логирование
|
||||
logger := log.New(os.Stdout, "HTTP: ", log.LstdFlags)
|
||||
s.router.Use(middleware.LoggingMiddleware(logger))
|
||||
|
||||
// Регистрация middleware
|
||||
s.router.Use(middleware.AuditMiddleware(auditService, "http_request"))
|
||||
|
||||
// Регистрация обработчиков
|
||||
handler.RegisterRecoveryHandlers(router, recoveryService)
|
||||
handler.RegisterAuditHandlers(router, auditService, authMiddleware)
|
||||
|
||||
// Метрики
|
||||
s.router.Use(middleware.MetricsMiddleware)
|
||||
|
||||
s.router.Use(middleware.CORS(allowedOrigins))
|
||||
s.router.Use(middleware.AuthMiddleware(s.tokenAuth))
|
||||
|
||||
// GraphQL handler
|
||||
resolver := graph.NewResolver(s.services)
|
||||
resolver := graph.NewResolver(s.services, s.db) // Теперь передаем оба аргумента
|
||||
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
|
||||
Resolvers: resolver,
|
||||
}))
|
||||
@ -72,6 +69,17 @@ func (s *Server) configureRouter() {
|
||||
s.router.Handle("/query", srv)
|
||||
}
|
||||
|
||||
func (s *Server) configureMetrics() {
|
||||
metricsRouter := chi.NewRouter()
|
||||
metricsRouter.Get("/metrics", promhttp.Handler().ServeHTTP)
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(":9100", metricsRouter); err != nil {
|
||||
log.Printf("Metrics server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
return http.ListenAndServe(s.cfg.Server.Host+":"+s.cfg.Server.Port, s.router)
|
||||
}
|
||||
@ -80,20 +88,3 @@ func (s *Server) Shutdown(ctx context.Context) error {
|
||||
// Здесь можно добавить логику graceful shutdown
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) configureMetrics() {
|
||||
prometheus.MustRegister(
|
||||
middleware.httpRequestsTotal,
|
||||
middleware.httpRequestDuration,
|
||||
middleware.httpResponseSize,
|
||||
)
|
||||
|
||||
metricsRouter := chi.NewRouter()
|
||||
metricsRouter.Get("/metrics", promhttp.Handler().ServeHTTP)
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(":9100", metricsRouter); err != nil {
|
||||
log.Printf("Metrics server error: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
170
internal/repository/audit_repository.go
Normal file
170
internal/repository/audit_repository.go
Normal file
@ -0,0 +1,170 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
)
|
||||
|
||||
type AuditRepository interface {
|
||||
Save(ctx context.Context, log *domain.AuditLog) error
|
||||
Get(ctx context.Context, filter domain.AuditFilter) ([]*domain.AuditLog, error)
|
||||
}
|
||||
|
||||
type auditRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuditRepository(db *sql.DB) AuditRepository {
|
||||
return &auditRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *auditRepository) Save(ctx context.Context, log *domain.AuditLog) error {
|
||||
query := `
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
metadata,
|
||||
status,
|
||||
created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var userID, entityID interface{}
|
||||
if log.UserID != nil {
|
||||
userID = *log.UserID
|
||||
} else {
|
||||
userID = nil
|
||||
}
|
||||
if log.EntityID != nil {
|
||||
entityID = *log.EntityID
|
||||
} else {
|
||||
entityID = nil
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
userID,
|
||||
log.Action,
|
||||
log.EntityType,
|
||||
entityID,
|
||||
log.IPAddress,
|
||||
log.UserAgent,
|
||||
log.Metadata,
|
||||
log.Status,
|
||||
log.CreatedAt,
|
||||
).Scan(&log.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *auditRepository) Get(ctx context.Context, filter domain.AuditFilter) ([]*domain.AuditLog, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
metadata,
|
||||
status,
|
||||
created_at
|
||||
FROM audit_logs
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
args := []interface{}{}
|
||||
argPos := 1
|
||||
|
||||
if filter.UserID != nil {
|
||||
query += fmt.Sprintf(" AND user_id = $%d", argPos)
|
||||
args = append(args, *filter.UserID)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if filter.Action != "" {
|
||||
query += fmt.Sprintf(" AND action = $%d", argPos)
|
||||
args = append(args, filter.Action)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if filter.EntityType != "" {
|
||||
query += fmt.Sprintf(" AND entity_type = $%d", argPos)
|
||||
args = append(args, filter.EntityType)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if !filter.DateFrom.IsZero() {
|
||||
query += fmt.Sprintf(" AND created_at >= $%d", argPos)
|
||||
args = append(args, filter.DateFrom)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if !filter.DateTo.IsZero() {
|
||||
query += fmt.Sprintf(" AND created_at <= $%d", argPos)
|
||||
args = append(args, filter.DateTo)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
if filter.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argPos)
|
||||
args = append(args, filter.Limit)
|
||||
argPos++
|
||||
}
|
||||
|
||||
if filter.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argPos)
|
||||
args = append(args, filter.Offset)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []*domain.AuditLog
|
||||
for rows.Next() {
|
||||
var log domain.AuditLog
|
||||
var userID, entityID sql.NullInt64
|
||||
|
||||
err := rows.Scan(
|
||||
&log.ID,
|
||||
&userID,
|
||||
&log.Action,
|
||||
&log.EntityType,
|
||||
&entityID,
|
||||
&log.IPAddress,
|
||||
&log.UserAgent,
|
||||
&log.Metadata,
|
||||
&log.Status,
|
||||
&log.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if userID.Valid {
|
||||
uid := int(userID.Int64)
|
||||
log.UserID = &uid
|
||||
}
|
||||
if entityID.Valid {
|
||||
eid := int(entityID.Int64)
|
||||
log.EntityID = &eid
|
||||
}
|
||||
|
||||
logs = append(logs, &log)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
@ -26,6 +26,7 @@ type ChatRepository interface {
|
||||
GetChatByID(ctx context.Context, id int) (*domain.Chat, error)
|
||||
GetUserChats(ctx context.Context, userID int) ([]*domain.Chat, error)
|
||||
GetChatByParticipants(ctx context.Context, user1ID, user2ID int) (*domain.Chat, error)
|
||||
GetUnreadCount(ctx context.Context, chatID, userID int) (int, error)
|
||||
}
|
||||
|
||||
type chatRepository struct {
|
||||
@ -204,3 +205,14 @@ func (r *chatRepository) GetChatByParticipants(ctx context.Context, user1ID, use
|
||||
}
|
||||
return chat, err
|
||||
}
|
||||
func (r *chatRepository) GetUnreadCount(ctx context.Context, chatID, userID int) (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM messages
|
||||
WHERE chat_id = $1
|
||||
AND sender_id != $2
|
||||
AND status = 'sent'
|
||||
`, chatID, userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
@ -23,7 +23,22 @@ type commentRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *sql.DB) CommentRepository {
|
||||
func (r *commentRepository) GetByID(ctx context.Context, id int) (*domain.Comment, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (r *commentRepository) Update(ctx context.Context, comment *domain.Comment) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (r *commentRepository) Delete(ctx context.Context, id int) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *sql.DB) *commentRepository {
|
||||
return &commentRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
200
internal/repository/device_repository.go
Normal file
200
internal/repository/device_repository.go
Normal file
@ -0,0 +1,200 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"tailly_back_v2/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDeviceNotFound = errors.New("device not found")
|
||||
ErrInvalidToken = errors.New("invalid confirmation token")
|
||||
)
|
||||
|
||||
type DeviceRepository interface {
|
||||
Save(ctx context.Context, device *domain.Device) error
|
||||
GetByID(ctx context.Context, id int) (*domain.Device, error)
|
||||
GetByToken(ctx context.Context, token string) (*domain.Device, error)
|
||||
GetByUser(ctx context.Context, userID int) ([]*domain.Device, error)
|
||||
Update(ctx context.Context, device *domain.Device) error
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
type deviceRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewDeviceRepository(db *sql.DB) DeviceRepository {
|
||||
return &deviceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *deviceRepository) Save(ctx context.Context, device *domain.Device) error {
|
||||
query := `
|
||||
INSERT INTO devices (
|
||||
user_id,
|
||||
name,
|
||||
ip_address,
|
||||
user_agent,
|
||||
confirmation_token,
|
||||
expires_at,
|
||||
created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
device.UserID,
|
||||
device.Name,
|
||||
device.IPAddress,
|
||||
device.UserAgent,
|
||||
device.ConfirmationToken,
|
||||
device.ExpiresAt,
|
||||
device.CreatedAt,
|
||||
).Scan(&device.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *deviceRepository) GetByID(ctx context.Context, id int) (*domain.Device, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
ip_address,
|
||||
user_agent,
|
||||
confirmation_token,
|
||||
expires_at,
|
||||
created_at
|
||||
FROM devices
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
device := &domain.Device{}
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&device.ID,
|
||||
&device.UserID,
|
||||
&device.Name,
|
||||
&device.IPAddress,
|
||||
&device.UserAgent,
|
||||
&device.ConfirmationToken,
|
||||
&device.ExpiresAt,
|
||||
&device.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrDeviceNotFound
|
||||
}
|
||||
|
||||
return device, err
|
||||
}
|
||||
|
||||
func (r *deviceRepository) GetByToken(ctx context.Context, token string) (*domain.Device, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
ip_address,
|
||||
user_agent,
|
||||
confirmation_token,
|
||||
expires_at,
|
||||
created_at
|
||||
FROM devices
|
||||
WHERE confirmation_token = $1
|
||||
`
|
||||
|
||||
device := &domain.Device{}
|
||||
err := r.db.QueryRowContext(ctx, query, token).Scan(
|
||||
&device.ID,
|
||||
&device.UserID,
|
||||
&device.Name,
|
||||
&device.IPAddress,
|
||||
&device.UserAgent,
|
||||
&device.ConfirmationToken,
|
||||
&device.ExpiresAt,
|
||||
&device.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return device, err
|
||||
}
|
||||
|
||||
func (r *deviceRepository) GetByUser(ctx context.Context, userID int) ([]*domain.Device, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
ip_address,
|
||||
user_agent,
|
||||
confirmation_token,
|
||||
expires_at,
|
||||
created_at
|
||||
FROM devices
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []*domain.Device
|
||||
for rows.Next() {
|
||||
var device domain.Device
|
||||
err := rows.Scan(
|
||||
&device.ID,
|
||||
&device.UserID,
|
||||
&device.Name,
|
||||
&device.IPAddress,
|
||||
&device.UserAgent,
|
||||
&device.ConfirmationToken,
|
||||
&device.ExpiresAt,
|
||||
&device.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, &device)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (r *deviceRepository) Update(ctx context.Context, device *domain.Device) error {
|
||||
query := `
|
||||
UPDATE devices
|
||||
SET
|
||||
name = $1,
|
||||
ip_address = $2,
|
||||
user_agent = $3,
|
||||
confirmation_token = $4,
|
||||
expires_at = $5
|
||||
WHERE id = $6
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
device.Name,
|
||||
device.IPAddress,
|
||||
device.UserAgent,
|
||||
device.ConfirmationToken,
|
||||
device.ExpiresAt,
|
||||
device.ID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *deviceRepository) Delete(ctx context.Context, id int) error {
|
||||
query := `DELETE FROM devices WHERE id = $1`
|
||||
_, err := r.db.ExecContext(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
@ -25,7 +25,17 @@ type likeRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *sql.DB) LikeRepository {
|
||||
func (r *likeRepository) GetByID(ctx context.Context, id int) (*domain.Like, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (r *likeRepository) Delete(ctx context.Context, id int) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *sql.DB) *likeRepository {
|
||||
return &likeRepository{db: db}
|
||||
}
|
||||
|
||||
@ -94,3 +104,28 @@ func (r *likeRepository) DeleteByUserAndPost(ctx context.Context, userID, postID
|
||||
_, err := r.db.ExecContext(ctx, query, userID, postID)
|
||||
return err
|
||||
}
|
||||
func (r *likeRepository) GetByUserAndPost(ctx context.Context, userID, postID int) (*domain.Like, error) {
|
||||
query := `
|
||||
SELECT id, post_id, user_id, created_at
|
||||
FROM likes
|
||||
WHERE user_id = $1 AND post_id = $2
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
like := &domain.Like{}
|
||||
err := r.db.QueryRowContext(ctx, query, userID, postID).Scan(
|
||||
&like.ID,
|
||||
&like.PostID,
|
||||
&like.UserID,
|
||||
&like.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrLikeNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return like, nil
|
||||
}
|
||||
|
||||
@ -24,7 +24,22 @@ type postRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPostRepository(db *sql.DB) PostRepository {
|
||||
func (r *postRepository) GetByAuthorID(ctx context.Context, authorID int) ([]*domain.Post, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (r *postRepository) Update(ctx context.Context, post *domain.Post) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (r *postRepository) Delete(ctx context.Context, id int) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func NewPostRepository(db *sql.DB) *postRepository {
|
||||
return &postRepository{db: db}
|
||||
}
|
||||
|
||||
|
||||
227
internal/repository/recovery_repository.go
Normal file
227
internal/repository/recovery_repository.go
Normal file
@ -0,0 +1,227 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"tailly_back_v2/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRecoveryRequestNotFound = errors.New("recovery request not found")
|
||||
ErrRecoveryMethodNotFound = errors.New("recovery method not found")
|
||||
)
|
||||
|
||||
type RecoveryRepository interface {
|
||||
SaveRequest(ctx context.Context, req *domain.RecoveryRequest) error
|
||||
GetRequestByToken(ctx context.Context, token string) (*domain.RecoveryRequest, error)
|
||||
UpdateRequest(ctx context.Context, req *domain.RecoveryRequest) error
|
||||
GetMethods(ctx context.Context, userID int) ([]*domain.RecoveryMethod, error)
|
||||
SaveMethod(ctx context.Context, method *domain.RecoveryMethod) error
|
||||
}
|
||||
|
||||
type recoveryRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRecoveryRepository(db *sql.DB) RecoveryRepository {
|
||||
return &recoveryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *recoveryRepository) SaveRequest(ctx context.Context, req *domain.RecoveryRequest) error {
|
||||
query := `
|
||||
INSERT INTO recovery_requests (
|
||||
user_id,
|
||||
token,
|
||||
new_device_id,
|
||||
status,
|
||||
created_at,
|
||||
expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var deviceID interface{}
|
||||
if req.NewDevice != nil {
|
||||
deviceID = req.NewDevice.ID
|
||||
} else {
|
||||
deviceID = nil
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
req.UserID,
|
||||
req.Token,
|
||||
deviceID,
|
||||
req.Status,
|
||||
req.CreatedAt,
|
||||
req.ExpiresAt,
|
||||
).Scan(&req.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recoveryRepository) GetRequestByToken(ctx context.Context, token string) (*domain.RecoveryRequest, error) {
|
||||
query := `
|
||||
SELECT
|
||||
r.id,
|
||||
r.user_id,
|
||||
r.token,
|
||||
r.status,
|
||||
r.created_at,
|
||||
r.expires_at,
|
||||
d.id,
|
||||
d.user_id,
|
||||
d.name,
|
||||
d.ip_address,
|
||||
d.user_agent,
|
||||
d.created_at
|
||||
FROM recovery_requests r
|
||||
LEFT JOIN devices d ON r.new_device_id = d.id
|
||||
WHERE r.token = $1
|
||||
`
|
||||
|
||||
req := &domain.RecoveryRequest{}
|
||||
var device domain.Device
|
||||
var deviceID sql.NullInt64
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, token).Scan(
|
||||
&req.ID,
|
||||
&req.UserID,
|
||||
&req.Token,
|
||||
&req.Status,
|
||||
&req.CreatedAt,
|
||||
&req.ExpiresAt,
|
||||
&deviceID,
|
||||
&device.UserID,
|
||||
&device.Name,
|
||||
&device.IPAddress,
|
||||
&device.UserAgent,
|
||||
&device.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrRecoveryRequestNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if deviceID.Valid {
|
||||
device.ID = int(deviceID.Int64)
|
||||
req.NewDevice = &device
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRepository) UpdateRequest(ctx context.Context, req *domain.RecoveryRequest) error {
|
||||
query := `
|
||||
UPDATE recovery_requests
|
||||
SET
|
||||
status = $1,
|
||||
new_device_id = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
var deviceID interface{}
|
||||
if req.NewDevice != nil {
|
||||
deviceID = req.NewDevice.ID
|
||||
} else {
|
||||
deviceID = nil
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
req.Status,
|
||||
deviceID,
|
||||
req.ID,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *recoveryRepository) GetMethods(ctx context.Context, userID int) ([]*domain.RecoveryMethod, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
method_type,
|
||||
value,
|
||||
is_primary,
|
||||
verified_at,
|
||||
created_at
|
||||
FROM recovery_methods
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_primary DESC, created_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var methods []*domain.RecoveryMethod
|
||||
for rows.Next() {
|
||||
var method domain.RecoveryMethod
|
||||
var verifiedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&method.ID,
|
||||
&method.UserID,
|
||||
&method.MethodType,
|
||||
&method.Value,
|
||||
&method.IsPrimary,
|
||||
&verifiedAt,
|
||||
&method.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if verifiedAt.Valid {
|
||||
method.VerifiedAt = verifiedAt.Time
|
||||
}
|
||||
|
||||
methods = append(methods, &method)
|
||||
}
|
||||
|
||||
if len(methods) == 0 {
|
||||
return nil, ErrRecoveryMethodNotFound
|
||||
}
|
||||
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRepository) SaveMethod(ctx context.Context, method *domain.RecoveryMethod) error {
|
||||
query := `
|
||||
INSERT INTO recovery_methods (
|
||||
user_id,
|
||||
method_type,
|
||||
value,
|
||||
is_primary,
|
||||
verified_at,
|
||||
created_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
var verifiedAt interface{}
|
||||
if !method.VerifiedAt.IsZero() {
|
||||
verifiedAt = method.VerifiedAt
|
||||
} else {
|
||||
verifiedAt = nil
|
||||
}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
method.UserID,
|
||||
method.MethodType,
|
||||
method.Value,
|
||||
method.IsPrimary,
|
||||
verifiedAt,
|
||||
method.CreatedAt,
|
||||
).Scan(&method.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
159
internal/repository/session_repository.go
Normal file
159
internal/repository/session_repository.go
Normal file
@ -0,0 +1,159 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
)
|
||||
|
||||
type SessionRepository interface {
|
||||
Save(ctx context.Context, session *domain.Session) error
|
||||
GetByID(ctx context.Context, id int) (*domain.Session, error)
|
||||
GetActiveByUser(ctx context.Context, userID int) ([]*domain.Session, error)
|
||||
Terminate(ctx context.Context, id int) error
|
||||
TerminateAllForUser(ctx context.Context, userID int) error
|
||||
}
|
||||
|
||||
type sessionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSessionRepository(db *sql.DB) SessionRepository {
|
||||
return &sessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Save(ctx context.Context, session *domain.Session) error {
|
||||
query := `
|
||||
INSERT INTO sessions (
|
||||
user_id,
|
||||
device_id,
|
||||
started_at
|
||||
)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
session.UserID,
|
||||
session.DeviceID,
|
||||
session.StartedAt,
|
||||
).Scan(&session.ID)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sessionRepository) GetByID(ctx context.Context, id int) (*domain.Session, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
started_at,
|
||||
ended_at
|
||||
FROM sessions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
session := &domain.Session{}
|
||||
var endedAt sql.NullTime
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.DeviceID,
|
||||
&session.StartedAt,
|
||||
&endedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
if endedAt.Valid {
|
||||
session.EndedAt = endedAt.Time
|
||||
}
|
||||
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (r *sessionRepository) GetActiveByUser(ctx context.Context, userID int) ([]*domain.Session, error) {
|
||||
query := `
|
||||
SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.device_id,
|
||||
s.started_at,
|
||||
s.ended_at,
|
||||
d.name as device_name,
|
||||
d.ip_address,
|
||||
d.user_agent
|
||||
FROM sessions s
|
||||
JOIN devices d ON s.device_id = d.id
|
||||
WHERE s.user_id = $1 AND s.ended_at IS NULL
|
||||
ORDER BY s.started_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []*domain.Session
|
||||
for rows.Next() {
|
||||
var session domain.Session
|
||||
var endedAt sql.NullTime
|
||||
var device domain.Device
|
||||
|
||||
err := rows.Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.DeviceID,
|
||||
&session.StartedAt,
|
||||
&endedAt,
|
||||
&device.Name,
|
||||
&device.IPAddress,
|
||||
&device.UserAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if endedAt.Valid {
|
||||
session.EndedAt = endedAt.Time
|
||||
}
|
||||
|
||||
session.Device = &device
|
||||
sessions = append(sessions, &session)
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (r *sessionRepository) Terminate(ctx context.Context, id int) error {
|
||||
query := `
|
||||
UPDATE sessions
|
||||
SET ended_at = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sessionRepository) TerminateAllForUser(ctx context.Context, userID int) error {
|
||||
query := `
|
||||
UPDATE sessions
|
||||
SET ended_at = $1
|
||||
WHERE user_id = $2 AND ended_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), userID)
|
||||
return err
|
||||
}
|
||||
@ -27,6 +27,12 @@ type auditService struct {
|
||||
auditRepo repository.AuditRepository
|
||||
}
|
||||
|
||||
func NewAuditService(auditRepo repository.AuditRepository) AuditService {
|
||||
return &auditService{
|
||||
auditRepo: auditRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *auditService) LogEvent(
|
||||
ctx context.Context,
|
||||
action string,
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/repository"
|
||||
"tailly_back_v2/pkg/auth"
|
||||
@ -16,7 +17,7 @@ type AuthService interface {
|
||||
Register(ctx context.Context, input RegisterInput) (*domain.User, error)
|
||||
Login(ctx context.Context, email, password string) (*domain.Tokens, error)
|
||||
RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error)
|
||||
ConfirmEmail(ctx context.Context, token string) error
|
||||
ConfirmEmail(ctx context.Context, token string) (bool, error)
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
@ -25,14 +26,10 @@ type authService struct {
|
||||
mailer MailService
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
tokenAuth auth.TokenAuth,
|
||||
mailer MailService,
|
||||
) AuthService {
|
||||
func NewAuthService(userRepo repository.UserRepository, tokenAuth *auth.TokenAuth, mailer MailService) AuthService {
|
||||
return &authService{
|
||||
userRepo: userRepo,
|
||||
tokenAuth: tokenAuth,
|
||||
tokenAuth: *tokenAuth,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
@ -76,10 +73,11 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*domai
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.mailer.SendConfirmationEmail(user.Email, confirmationToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to send confirmation email: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := s.mailer.SendConfirmationEmail(user.Email, confirmationToken); err != nil {
|
||||
log.Printf("Async email send failed: %v", err)
|
||||
}
|
||||
}()
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@ -128,15 +126,18 @@ func (s *authService) RefreshTokens(ctx context.Context, refreshToken string) (*
|
||||
return s.tokenAuth.GenerateTokens(userID)
|
||||
}
|
||||
|
||||
func (s *authService) ConfirmEmail(ctx context.Context, token string) error {
|
||||
func (s *authService) ConfirmEmail(ctx context.Context, token string) (bool, error) {
|
||||
user, err := s.userRepo.GetByConfirmationToken(ctx, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid or expired confirmation token")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
user.EmailConfirmedAt = &now
|
||||
user.EmailConfirmationToken = ""
|
||||
|
||||
return s.userRepo.Update(ctx, user)
|
||||
err = s.userRepo.Update(ctx, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@ -19,23 +19,20 @@ type ChatService interface {
|
||||
}
|
||||
|
||||
type chatService struct {
|
||||
chatRepo repository.ChatRepository
|
||||
userRepo repository.UserRepository
|
||||
hub *ws.ChatHub
|
||||
encryptor EncryptionService
|
||||
chatRepo repository.ChatRepository
|
||||
userRepo repository.UserRepository
|
||||
hub *ws.ChatHub
|
||||
}
|
||||
|
||||
func NewChatService(
|
||||
chatRepo repository.ChatRepository,
|
||||
userRepo repository.UserRepository,
|
||||
hub *ws.ChatHub,
|
||||
encryptor EncryptionService,
|
||||
) ChatService {
|
||||
return &chatService{
|
||||
chatRepo: chatRepo,
|
||||
userRepo: userRepo,
|
||||
hub: hub,
|
||||
encryptor: encryptor,
|
||||
chatRepo: chatRepo,
|
||||
userRepo: userRepo,
|
||||
hub: hub,
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,16 +48,10 @@ func (s *chatService) SendMessage(ctx context.Context, senderID, chatID int, con
|
||||
return nil, errors.New("user is not a participant of this chat")
|
||||
}
|
||||
|
||||
// Шифруем сообщение (если используется E2EE)
|
||||
encryptedContent, err := s.encryptor.EncryptMessage(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
message := &domain.Message{
|
||||
ChatID: chatID,
|
||||
SenderID: senderID,
|
||||
Content: encryptedContent,
|
||||
Content: content,
|
||||
Status: "sent",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
@ -75,13 +66,14 @@ func (s *chatService) SendMessage(ctx context.Context, senderID, chatID int, con
|
||||
recipientID = chat.User2ID
|
||||
}
|
||||
|
||||
s.hub.Broadcast(&domain.WSMessage{
|
||||
Type: "new_message",
|
||||
MessageID: message.ID,
|
||||
ChatID: chatID,
|
||||
SenderID: senderID,
|
||||
Content: encryptedContent,
|
||||
Recipient: recipientID,
|
||||
s.hub.Broadcast(&domain.Message{
|
||||
ID: message.ID,
|
||||
ChatID: chatID,
|
||||
SenderID: senderID,
|
||||
ReceiverID: recipientID,
|
||||
Content: content,
|
||||
Status: "sent",
|
||||
CreatedAt: message.CreatedAt,
|
||||
})
|
||||
|
||||
return message, nil
|
||||
@ -98,21 +90,7 @@ func (s *chatService) GetChatMessages(ctx context.Context, chatID, userID int, l
|
||||
return nil, errors.New("access denied")
|
||||
}
|
||||
|
||||
messages, err := s.chatRepo.GetMessagesByChat(ctx, chatID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Расшифровываем сообщения (если используется E2EE)
|
||||
for i := range messages {
|
||||
decrypted, err := s.encryptor.DecryptMessage(messages[i].Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages[i].Content = decrypted
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
return s.chatRepo.GetMessagesByChat(ctx, chatID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *chatService) MarkAsRead(ctx context.Context, messageID int) error {
|
||||
|
||||
@ -24,10 +24,7 @@ type commentService struct {
|
||||
}
|
||||
|
||||
// Конструктор сервиса
|
||||
func NewCommentService(
|
||||
commentRepo repository.CommentRepository,
|
||||
postRepo repository.PostRepository,
|
||||
) CommentService {
|
||||
func NewCommentService(commentRepo repository.CommentRepository, postRepo repository.PostRepository) CommentService {
|
||||
return &commentService{
|
||||
commentRepo: commentRepo,
|
||||
postRepo: postRepo,
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/repository"
|
||||
|
||||
"tailly_back_v2/pkg/encryption"
|
||||
)
|
||||
|
||||
type EncryptionService interface {
|
||||
EncryptMessage(ctx context.Context, senderID int, receiverID int, message string) (string, error)
|
||||
DecryptMessage(ctx context.Context, receiverID int, encryptedMsg string) (string, error)
|
||||
}
|
||||
|
||||
type encryptionService struct {
|
||||
vault *encryption.VaultService
|
||||
userRepo repository.UserRepository
|
||||
}
|
||||
|
||||
func NewEncryptionService(vault *encryption.VaultService, userRepo repository.UserRepository) EncryptionService {
|
||||
return &encryptionService{
|
||||
vault: vault,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *encryptionService) EncryptMessage(ctx context.Context, senderID, receiverID int, message string) (string, error) {
|
||||
// Проверяем существование получателя
|
||||
_, err := s.userRepo.GetByID(ctx, receiverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get receiver: %w", err)
|
||||
}
|
||||
|
||||
// Шифруем через Vault (используем userID получателя как ключ)
|
||||
encrypted, err := s.vault.Encrypt(fmt.Sprintf("user-%d", receiverID), []byte(message))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt message: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
func (s *encryptionService) DecryptMessage(ctx context.Context, receiverID int, encryptedMsg string) (string, error) {
|
||||
// Проверяем существование получателя
|
||||
_, err := s.userRepo.GetByID(ctx, receiverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to verify receiver: %w", err)
|
||||
}
|
||||
|
||||
// Декодируем из base64
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||
}
|
||||
|
||||
// Расшифровываем через Vault
|
||||
decrypted, err := s.vault.Decrypt(fmt.Sprintf("user-%d", receiverID), ciphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt message: %w", err)
|
||||
}
|
||||
|
||||
return string(decrypted), nil
|
||||
}
|
||||
@ -24,10 +24,7 @@ type likeService struct {
|
||||
}
|
||||
|
||||
// Конструктор сервиса
|
||||
func NewLikeService(
|
||||
likeRepo repository.LikeRepository,
|
||||
postRepo repository.PostRepository,
|
||||
) LikeService {
|
||||
func NewLikeService(likeRepo repository.LikeRepository, postRepo repository.PostRepository) LikeService {
|
||||
return &likeService{
|
||||
likeRepo: likeRepo,
|
||||
postRepo: postRepo,
|
||||
|
||||
@ -3,8 +3,11 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
@ -15,6 +18,8 @@ type MailService interface {
|
||||
SendTemplateEmail(to, subject string, templateName string, data interface{}) error
|
||||
SendConfirmationEmail(to, token string) error
|
||||
SendPasswordResetEmail(to, token string) error
|
||||
SendRecoveryEmail(value string, str string, address string, agent string) interface{}
|
||||
SendSessionConfirmation(email string, token string, address string, agent string) error
|
||||
}
|
||||
|
||||
type mailService struct {
|
||||
@ -24,12 +29,23 @@ type mailService struct {
|
||||
smtpUser string
|
||||
smtpPass string
|
||||
templates *template.Template
|
||||
appURL string
|
||||
}
|
||||
|
||||
func (s *mailService) SendSessionConfirmation(email string, token string, address string, agent string) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (s *mailService) SendRecoveryEmail(value string, str string, address string, agent string) interface{} {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
//go:embed templates/*
|
||||
var templateFS embed.FS
|
||||
|
||||
func NewMailService(from, smtpHost string, smtpPort int, smtpUser, smtpPass string) (MailService, error) {
|
||||
func NewMailService(from, smtpHost string, smtpPort int, smtpUser, smtpPass, appURL string) (MailService, error) {
|
||||
// Загружаем шаблоны писем
|
||||
templates, err := template.ParseFS(templateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
@ -43,6 +59,7 @@ func NewMailService(from, smtpHost string, smtpPort int, smtpUser, smtpPass stri
|
||||
smtpUser: smtpUser,
|
||||
smtpPass: smtpPass,
|
||||
templates: templates,
|
||||
appURL: appURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -50,33 +67,46 @@ func (s *mailService) SendEmail(to, subject, body string) error {
|
||||
mail := gomail.NewMessage()
|
||||
mail.SetHeader("From", s.from)
|
||||
mail.SetHeader("To", to)
|
||||
mail.SetHeader("Subject", subject)
|
||||
mail.SetBody("text/html", body)
|
||||
mail.SetHeader("Subject", encodeSubject(subject))
|
||||
mail.SetHeader("Message-ID", fmt.Sprintf(generateMessageID()))
|
||||
mail.SetBody("text/html; charset=UTF-8", body)
|
||||
mail.SetHeader("Contens-Transfer-Encoding", "base64")
|
||||
|
||||
dialer := gomail.NewDialer(s.smtpHost, s.smtpPort, s.smtpUser, s.smtpPass)
|
||||
log.Printf("SendEmail dialer: %v", dialer)
|
||||
return dialer.DialAndSend(mail)
|
||||
}
|
||||
func encodeSubject(subject string) string {
|
||||
return fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
||||
}
|
||||
func generateMessageID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("<%x.%x@tailly.ru>", time.Now().UnixNano(), b)
|
||||
}
|
||||
|
||||
func (s *mailService) SendTemplateEmail(to, subject string, templateName string, data interface{}) error {
|
||||
var body bytes.Buffer
|
||||
if err := s.templates.ExecuteTemplate(&body, templateName+".html", data); err != nil {
|
||||
return fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("SendTemplateEmail : %v", to, subject, body.String())
|
||||
return s.SendEmail(to, subject, body.String())
|
||||
}
|
||||
|
||||
func (s *mailService) SendConfirmationEmail(to, token string) error {
|
||||
data := struct {
|
||||
Email string
|
||||
Token string
|
||||
Year int
|
||||
Email string
|
||||
Token string
|
||||
Year int
|
||||
AppURL string
|
||||
}{
|
||||
Email: to,
|
||||
Token: token,
|
||||
Year: time.Now().Year(),
|
||||
Email: to,
|
||||
Token: token,
|
||||
Year: time.Now().Year(),
|
||||
AppURL: s.appURL,
|
||||
}
|
||||
|
||||
log.Printf("SendConfirmationEmail data: %v", data)
|
||||
return s.SendTemplateEmail(to, "Подтверждение email", "confirmation", data)
|
||||
}
|
||||
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/repository"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NotificationService interface {
|
||||
NotifyNewDevice(ctx context.Context, userID int, device *domain.Device) error
|
||||
GetNotifications(ctx context.Context, userID int) ([]*domain.Notification, error)
|
||||
MarkAsRead(ctx context.Context, notificationID int) error
|
||||
UpdatePreferences(ctx context.Context, prefs *domain.NotificationPreferences) error
|
||||
}
|
||||
|
||||
type notificationService struct {
|
||||
repo repository.NotificationRepository
|
||||
userRepo repository.UserRepository
|
||||
deviceRepo repository.DeviceRepository
|
||||
mailer MailService
|
||||
pusher PushService
|
||||
}
|
||||
|
||||
func (s *notificationService) NotifyNewDevice(ctx context.Context, userID int, device *domain.Device) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prefs, err := s.repo.GetPreferences(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Создаем уведомление в БД
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"device_id": device.ID,
|
||||
"ip_address": device.IPAddress,
|
||||
"user_agent": device.UserAgent,
|
||||
"created_at": device.CreatedAt,
|
||||
})
|
||||
|
||||
notification := &domain.Notification{
|
||||
UserID: userID,
|
||||
Type: "new_device",
|
||||
Title: "New device connected",
|
||||
Message: fmt.Sprintf("New login from %s (%s)", device.IPAddress, device.UserAgent),
|
||||
Data: string(data),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.repo.Save(ctx, notification); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем email уведомление
|
||||
if prefs.EmailNewDevice {
|
||||
s.mailer.SendEmail(
|
||||
user.Email,
|
||||
"New Device Alert",
|
||||
fmt.Sprintf(`
|
||||
<h1>New Device Alert</h1>
|
||||
<p>Your account was accessed from:</p>
|
||||
<ul>
|
||||
<li>Device: %s</li>
|
||||
<li>IP: %s</li>
|
||||
<li>Location: %s</li>
|
||||
<li>Time: %s</li>
|
||||
</ul>
|
||||
<p>If this wasn't you, please terminate the session immediately.</p>
|
||||
`, device.UserAgent, device.IPAddress, device.Location, device.CreatedAt),
|
||||
)
|
||||
}
|
||||
|
||||
// Отправляем push уведомление
|
||||
if prefs.PushNewDevice {
|
||||
s.pusher.SendPush(
|
||||
userID,
|
||||
"New Device Alert",
|
||||
fmt.Sprintf("New login from %s", device.IPAddress),
|
||||
map[string]interface{}{
|
||||
"type": "new_device",
|
||||
"device_id": device.ID,
|
||||
"session_id": ctx.Value("sessionID"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -23,7 +23,26 @@ type recoveryService struct {
|
||||
sessionRepo repository.SessionRepository
|
||||
deviceRepo repository.DeviceRepository
|
||||
mailer MailService
|
||||
encryptor EncryptionService
|
||||
}
|
||||
|
||||
func (s *recoveryService) AddRecoveryMethod(ctx context.Context, userID int, method *domain.RecoveryMethod) error {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (s *recoveryService) GetRecoveryMethods(ctx context.Context, userID int) ([]*domain.RecoveryMethod, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func NewRecoveryService(recoveryRepo repository.RecoveryRepository, userRepo repository.UserRepository, sessionRepo repository.SessionRepository, deviceRepo repository.DeviceRepository, mailer MailService) RecoveryService {
|
||||
return &recoveryService{
|
||||
recoveryRepo: recoveryRepo,
|
||||
userRepo: userRepo,
|
||||
sessionRepo: sessionRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *recoveryService) InitiateRecovery(ctx context.Context, email string, newDevice *domain.Device) (string, error) {
|
||||
@ -69,7 +88,7 @@ func (s *recoveryService) InitiateRecovery(ctx context.Context, email string, ne
|
||||
newDevice.IPAddress,
|
||||
newDevice.UserAgent,
|
||||
); err != nil {
|
||||
return "", err
|
||||
return "", nil
|
||||
}
|
||||
// case "phone": отправка SMS
|
||||
}
|
||||
@ -104,11 +123,6 @@ func (s *recoveryService) VerifyRecovery(ctx context.Context, token string) (*do
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Генерируем новые ключи шифрования
|
||||
if _, err := s.encryptor.GenerateKeyPair(ctx, req.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Помечаем запрос как выполненный
|
||||
req.Status = "completed"
|
||||
if err := s.recoveryRepo.UpdateRequest(ctx, req); err != nil {
|
||||
|
||||
@ -1,27 +1,36 @@
|
||||
package service
|
||||
|
||||
import _ "tailly_back_v2/internal/repository"
|
||||
import (
|
||||
_ "tailly_back_v2/internal/repository"
|
||||
"tailly_back_v2/internal/ws"
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Auth AuthService
|
||||
User UserService
|
||||
Post PostService
|
||||
Comment CommentService
|
||||
Like LikeService
|
||||
Auth AuthService
|
||||
User UserService
|
||||
Post PostService
|
||||
Comment CommentService
|
||||
Like LikeService
|
||||
Session SessionService
|
||||
Mail MailService
|
||||
Recovery RecoveryService
|
||||
Audit AuditService
|
||||
Chat ChatService
|
||||
ChatHub *ws.ChatHub
|
||||
}
|
||||
|
||||
func NewServices(
|
||||
authService AuthService,
|
||||
userService UserService,
|
||||
postService PostService,
|
||||
commentService CommentService,
|
||||
likeService LikeService,
|
||||
) *Services {
|
||||
func NewServices(authService AuthService, userService UserService, postService PostService, commentService CommentService, likeService LikeService, mailService MailService, auditService AuditService, recoveryService RecoveryService, sessionService SessionService, chatService ChatService, chatHub *ws.ChatHub) *Services {
|
||||
return &Services{
|
||||
Auth: authService,
|
||||
User: userService,
|
||||
Post: postService,
|
||||
Comment: commentService,
|
||||
Like: likeService,
|
||||
Auth: authService,
|
||||
User: userService,
|
||||
Post: postService,
|
||||
Comment: commentService,
|
||||
Like: likeService,
|
||||
Session: sessionService,
|
||||
Mail: mailService,
|
||||
Recovery: recoveryService,
|
||||
Audit: auditService,
|
||||
Chat: chatService,
|
||||
ChatHub: chatHub,
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_back_v2/internal/domain"
|
||||
"tailly_back_v2/internal/repository"
|
||||
"time"
|
||||
@ -14,15 +15,46 @@ type SessionService interface {
|
||||
InitiateSession(ctx context.Context, device *domain.Device) error
|
||||
ConfirmSession(ctx context.Context, token string) (*domain.Session, error)
|
||||
GetActiveSessions(ctx context.Context, userID int) ([]*domain.Session, error)
|
||||
TerminateSession(ctx context.Context, sessionID int) error
|
||||
Terminate(ctx context.Context, userID, sessionID int) error // Изменили сигнатуру
|
||||
GetUserSessions(ctx context.Context, userID int) ([]*domain.Session, error) // Добавили этот метод
|
||||
}
|
||||
|
||||
type sessionService struct {
|
||||
sessionRepo repository.SessionRepository
|
||||
deviceRepo repository.DeviceRepository
|
||||
userRepo repository.UserRepository // Добавлено
|
||||
mailer MailService
|
||||
}
|
||||
|
||||
func (s *sessionService) GetActiveSessions(ctx context.Context, userID int) ([]*domain.Session, error) {
|
||||
// Используем метод репозитория GetActiveByUser, который уже фильтрует активные сессии
|
||||
sessions, err := s.sessionRepo.GetActiveByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active sessions: %w", err)
|
||||
}
|
||||
|
||||
// Дополнительная обработка, если нужна
|
||||
for _, session := range sessions {
|
||||
// Устанавливаем флаг IsCurrent, если нужно
|
||||
if session.Device != nil {
|
||||
// Можно добавить дополнительную логику проверки текущего устройства
|
||||
// Например, сравнение с текущим устройством пользователя
|
||||
session.IsCurrent = false // Замените на реальную логику
|
||||
}
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func NewSessionService(sessionRepo repository.SessionRepository, deviceRepo repository.DeviceRepository, userRepo repository.UserRepository, mailer MailService) SessionService {
|
||||
return &sessionService{
|
||||
sessionRepo: sessionRepo,
|
||||
deviceRepo: deviceRepo,
|
||||
userRepo: userRepo,
|
||||
mailer: mailer,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionService) InitiateSession(ctx context.Context, device *domain.Device) error {
|
||||
// Генерация токена подтверждения
|
||||
token := make([]byte, 32)
|
||||
|
||||
@ -1,14 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Подтверждение email</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Подтвердите ваш email</title>
|
||||
<style type="text/css">
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #FFCC00;
|
||||
color: #000000 !important;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.small {
|
||||
font-size: 12px;
|
||||
color: #999999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Подтвердите ваш email</h1>
|
||||
<p>Для завершения регистрации перейдите по ссылке:</p>
|
||||
<a href="{{.AppURL}}/confirm-email?token={{.Token}}">Подтвердить email</a>
|
||||
<p>С уважением,<br>Команда сервиса</p>
|
||||
<p>© {{.Year}}</p>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h1 class="text-center">Подтвердите ваш email</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Благодарим вас за регистрацию. Для завершения процесса, пожалуйста, подтвердите ваш email, нажав на кнопку ниже:</p>
|
||||
|
||||
<p class="text-center">
|
||||
<a href="{{.AppURL}}/confirm-email?token={{.Token}}" class="button">Подтвердить email</a>
|
||||
</p>
|
||||
|
||||
<p>Если кнопка не работает, скопируйте и вставьте следующую ссылку в адресную строку браузера:</p>
|
||||
<p class="small">{{.AppURL}}/confirm-email?token={{.Token}}</p>
|
||||
|
||||
<p>Если вы не регистрировались на нашем сервисе, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>С уважением,<br />Команда Tailly</p>
|
||||
<p class="small">Это письмо отправлено автоматически. Пожалуйста, не отвечайте на него.</p>
|
||||
<p class="small">© {{.Year}} Tailly. Все права защищены.</p>
|
||||
<p class="small">
|
||||
<a href="{{.AppURL}}/privacy" style="color: #999999;">Политика конфиденциальности</a> |
|
||||
<a href="{{.AppURL}}/terms" style="color: #999999;">Условия использования</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,14 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Сброс пароля</title>
|
||||
<style type="text/css">
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
padding: 25px 30px;
|
||||
background-color: #FFCC00;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
}
|
||||
.footer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background-color: #f9f9f9;
|
||||
font-size: 13px;
|
||||
color: #777777;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 14px 28px;
|
||||
background-color: #FF3333;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
margin: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.urgent {
|
||||
color: #FF3333;
|
||||
font-weight: bold;
|
||||
}
|
||||
.link {
|
||||
word-break: break-all;
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
background-color: #f0f0f0;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #eeeeee;
|
||||
margin: 25px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Сброс пароля</h1>
|
||||
<p>Для сброса пароля перейдите по ссылке:</p>
|
||||
<a href="{{.AppURL}}/reset-password?token={{.Token}}">Сбросить пароль</a>
|
||||
<p>Ссылка действительна 24 часа.</p>
|
||||
<p>© {{.Year}}</p>
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h2 class="text-center">Сброс пароля</h2>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Мы получили запрос на сброс пароля для вашей учетной записи. Чтобы установить новый пароль, нажмите на кнопку ниже:</p>
|
||||
|
||||
<p class="text-center">
|
||||
<a href="{{.AppURL}}/reset-password?token={{.Token}}" class="button">Сбросить пароль</a>
|
||||
</p>
|
||||
|
||||
<p class="urgent text-center">Ссылка действительна <u>только 24 часа</u>.</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p>Если вы не можете нажать на кнопку, скопируйте и вставьте следующую ссылку в адресную строку браузера:</p>
|
||||
<p class="link">{{.AppURL}}/reset-password?token={{.Token}}</p>
|
||||
|
||||
<p><strong>Важно:</strong> Если вы не запрашивали сброс пароля, пожалуйста, проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>С уважением,<br>Команда <strong>Tailly</strong></p>
|
||||
<p>Это письмо отправлено автоматически. Пожалуйста, не отвечайте на него.</p>
|
||||
<p>© {{.Year}} Tailly. Все права защищены.</p>
|
||||
<p>
|
||||
<a href="{{.AppURL}}/privacy" style="color: #777777; text-decoration: none;">Политика конфиденциальности</a> |
|
||||
<a href="{{.AppURL}}/terms" style="color: #777777; text-decoration: none;">Условия использования</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -21,10 +21,20 @@ type userService struct {
|
||||
tokenAuth *auth.TokenAuth
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepository, tokenAuth *auth.TokenAuth) UserService {
|
||||
func (s *userService) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
user, err := s.userRepo.GetByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepository) *userService {
|
||||
return &userService{
|
||||
userRepo: userRepo,
|
||||
tokenAuth: tokenAuth,
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,21 @@ func NewChatHub() *ChatHub {
|
||||
}
|
||||
}
|
||||
|
||||
// Register добавляет нового клиента в хаб
|
||||
func (h *ChatHub) Register(client *Client) {
|
||||
h.register <- client
|
||||
}
|
||||
|
||||
// Unregister удаляет клиента из хаба
|
||||
func (h *ChatHub) Unregister(client *Client) {
|
||||
h.unregister <- client
|
||||
}
|
||||
|
||||
// Broadcast отправляет сообщение всем клиентам
|
||||
func (h *ChatHub) Broadcast(message *domain.Message) {
|
||||
h.broadcast <- message
|
||||
}
|
||||
|
||||
func (h *ChatHub) Run() {
|
||||
for {
|
||||
select {
|
||||
@ -56,7 +71,3 @@ func (h *ChatHub) Run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ChatHub) Broadcast(message *domain.Message) {
|
||||
h.broadcast <- message
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"tailly_back_v2/internal/domain"
|
||||
)
|
||||
|
||||
type NotificationClient struct {
|
||||
UserID int
|
||||
Send chan *domain.Notification
|
||||
}
|
||||
|
||||
type NotificationHub struct {
|
||||
clients map[int]*NotificationClient
|
||||
register chan *NotificationClient
|
||||
unregister chan *NotificationClient
|
||||
broadcast chan *domain.Notification
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewNotificationHub() *NotificationHub {
|
||||
return &NotificationHub{
|
||||
clients: make(map[int]*NotificationClient),
|
||||
register: make(chan *NotificationClient),
|
||||
unregister: make(chan *NotificationClient),
|
||||
broadcast: make(chan *domain.Notification),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NotificationHub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client.UserID] = client
|
||||
h.mu.Unlock()
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if c, ok := h.clients[client.UserID]; ok {
|
||||
close(c.Send)
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
case notification := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
if client, ok := h.clients[notification.UserID]; ok {
|
||||
select {
|
||||
case client.Send <- notification:
|
||||
default:
|
||||
close(client.Send)
|
||||
delete(h.clients, notification.UserID)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
11
migrations/0001_initial_schema.down.sql
Normal file
11
migrations/0001_initial_schema.down.sql
Normal file
@ -0,0 +1,11 @@
|
||||
DROP TABLE IF EXISTS audit_logs CASCADE;
|
||||
DROP TABLE IF EXISTS recovery_requests CASCADE;
|
||||
DROP TABLE IF EXISTS recovery_methods CASCADE;
|
||||
DROP TABLE IF EXISTS messages CASCADE;
|
||||
DROP TABLE IF EXISTS chats CASCADE;
|
||||
DROP TABLE IF EXISTS likes CASCADE;
|
||||
DROP TABLE IF EXISTS comments CASCADE;
|
||||
DROP TABLE IF EXISTS posts CASCADE;
|
||||
DROP TABLE IF EXISTS sessions CASCADE;
|
||||
DROP TABLE IF EXISTS devices CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
134
migrations/0001_initial_schema.up.sql
Normal file
134
migrations/0001_initial_schema.up.sql
Normal file
@ -0,0 +1,134 @@
|
||||
-- Пользователи
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
email_confirmation_token VARCHAR(255),
|
||||
email_confirmed_at TIMESTAMP WITH TIME ZONE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Устройства
|
||||
CREATE TABLE devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
confirmation_token VARCHAR(255),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Сессии
|
||||
CREATE TABLE sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMP WITH TIME ZONE,
|
||||
is_current BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Посты
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Комментарии
|
||||
CREATE TABLE comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Лайки
|
||||
CREATE TABLE likes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(post_id, user_id) -- Один лайк на пост от пользователя
|
||||
);
|
||||
|
||||
-- Чаты
|
||||
CREATE TABLE chats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user1_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user2_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user1_id, user2_id), -- Уникальный чат между двумя пользователями
|
||||
CHECK (user1_id < user2_id) -- Предотвращение дублирования чатов (1-2 и 2-1)
|
||||
);
|
||||
-- Сообщения
|
||||
CREATE TABLE messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
chat_id INTEGER NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
receiver_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'sent' CHECK (status IN ('sent', 'delivered', 'read')),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Методы восстановления
|
||||
CREATE TABLE recovery_methods (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
method_type VARCHAR(20) NOT NULL CHECK (method_type IN ('email', 'phone', 'totp')),
|
||||
value VARCHAR(255) NOT NULL, -- email/phone number
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
verified_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Запросы восстановления
|
||||
CREATE TABLE recovery_requests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) NOT NULL,
|
||||
new_device_id INTEGER REFERENCES devices(id) ON DELETE SET NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'expired')),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
-- Аудит
|
||||
CREATE TABLE audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
user_agent TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failed')),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Индексы для улучшения производительности
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_device_id ON sessions(device_id);
|
||||
CREATE INDEX idx_posts_author_id ON posts(author_id);
|
||||
CREATE INDEX idx_comments_post_id ON comments(post_id);
|
||||
CREATE INDEX idx_comments_author_id ON comments(author_id);
|
||||
CREATE INDEX idx_likes_post_id ON likes(post_id);
|
||||
CREATE INDEX idx_likes_user_id ON likes(user_id);
|
||||
CREATE INDEX idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX idx_messages_sender_id ON messages(sender_id);
|
||||
CREATE INDEX idx_messages_receiver_id ON messages(receiver_id);
|
||||
CREATE INDEX idx_recovery_requests_user_id ON recovery_requests(user_id);
|
||||
CREATE INDEX idx_recovery_requests_token ON recovery_requests(token);
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
@ -32,6 +32,9 @@ func NewTokenAuth(accessSecret, refreshSecret string, accessExpiry, refreshExpir
|
||||
|
||||
// GenerateTokens создает пару access и refresh токенов
|
||||
func (a *TokenAuth) GenerateTokens(userID int) (*domain.Tokens, error) {
|
||||
accessExpires := time.Now().Add(a.accessTokenExpiry)
|
||||
refreshExpires := time.Now().Add(a.refreshTokenExpiry)
|
||||
|
||||
accessToken, err := a.generateAccessToken(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -43,8 +46,10 @@ func (a *TokenAuth) GenerateTokens(userID int) (*domain.Tokens, error) {
|
||||
}
|
||||
|
||||
return &domain.Tokens{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
AccessTokenExpires: accessExpires,
|
||||
RefreshTokenExpires: refreshExpires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type VaultService struct {
|
||||
addr string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewVaultService(addr, token string) *VaultService {
|
||||
return &VaultService{
|
||||
addr: addr,
|
||||
token: token,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VaultService) Encrypt(keyID string, plaintext []byte) ([]byte, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"plaintext": base64.StdEncoding.EncodeToString(plaintext),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(
|
||||
"POST",
|
||||
fmt.Sprintf("%s/v1/transit/encrypt/%s", v.addr, keyID),
|
||||
bytes.NewBuffer(reqBody),
|
||||
)
|
||||
req.Header.Set("X-Vault-Token", v.token)
|
||||
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(
|
||||
result["data"].(map[string]interface{})["ciphertext"].(string),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func (v *VaultService) Decrypt(keyID string, ciphertext []byte) ([]byte, error) {
|
||||
reqBody, _ := json.Marshal(map[string]interface{}{
|
||||
"ciphertext": string(ciphertext),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest(
|
||||
"POST",
|
||||
fmt.Sprintf("%s/v1/transit/decrypt/%s", v.addr, keyID),
|
||||
bytes.NewBuffer(reqBody),
|
||||
)
|
||||
req.Header.Set("X-Vault-Token", v.token)
|
||||
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plaintext, err := base64.StdEncoding.DecodeString(
|
||||
result["data"].(map[string]interface{})["plaintext"].(string),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
scrape_configs:
|
||||
- job_name: 'blog_api'
|
||||
- job_name: 'tailly_back_v2'
|
||||
scrape_interval: 15s
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:9100']
|
||||
Loading…
x
Reference in New Issue
Block a user