This commit is contained in:
madipo2611 2025-05-03 02:37:08 +03:00
parent 0d4b8b203e
commit 6f5298d420
53 changed files with 2244 additions and 652 deletions

16
.env
View File

@ -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"

View File

@ -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() {

View File

@ -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:

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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 {

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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"`
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@ -1,6 +0,0 @@
package graph
import (
_ "tailly_back_v2/internal/domain"
_ "tailly_back_v2/internal/service"
)

View File

@ -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!

View 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
}

View 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
}

View 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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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)
}
}()
}

View 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
}

View File

@ -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
}

View File

@ -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}
}

View 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
}

View File

@ -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
}

View File

@ -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}
}

View 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
}

View 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
}

View File

@ -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,

View File

@ -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
}

View File

@ -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 {

View File

@ -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,

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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>&copy; {{.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">&copy; {{.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>

View File

@ -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>&copy; {{.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>&copy; {{.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>

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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()
}
}
}

View 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;

View 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);

View File

@ -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
}

View File

@ -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
}

View File

@ -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']