This commit is contained in:
madipo2611 2025-05-01 12:17:42 +03:00
parent a5a90bed7e
commit 0d4b8b203e
43 changed files with 1295 additions and 9796 deletions

13
.env Normal file
View File

@ -0,0 +1,13 @@
SERVER_HOST=localhost
SERVER_PORT=3006
DB_DSN=postgres://user:password@localhost:5432/blog?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.ваш_токен

View File

@ -13,6 +13,7 @@ import (
"tailly_back_v2/internal/ws" "tailly_back_v2/internal/ws"
"tailly_back_v2/pkg/auth" "tailly_back_v2/pkg/auth"
"tailly_back_v2/pkg/database" "tailly_back_v2/pkg/database"
"tailly_back_v2/pkg/encryption"
"time" "time"
) )
@ -38,31 +39,52 @@ func main() {
cfg.Auth.RefreshTokenExpiry, cfg.Auth.RefreshTokenExpiry,
) )
// Инициализация чата
chatHub := ws.NewChatHub()
go chatHub.Run()
// Репозитории // Репозитории
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
postRepo := repository.NewPostRepository(db) postRepo := repository.NewPostRepository(db)
commentRepo := repository.NewCommentRepository(db) commentRepo := repository.NewCommentRepository(db)
likeRepo := repository.NewLikeRepository(db) likeRepo := repository.NewLikeRepository(db)
chatRepo := repository.NewChatRepository(db) chatRepo := repository.NewChatRepository(db)
// Инициализация MailService
mailService, err := service.NewMailService(
cfg.SMTP.From,
cfg.SMTP.Host,
cfg.SMTP.Port,
cfg.SMTP.Username,
cfg.SMTP.Password,
)
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( services := service.NewServices(
service.NewAuthService(userRepo, *tokenAuth), service.NewAuthService(userRepo, tokenAuth, mailService),
service.NewUserService(userRepo), service.NewUserService(userRepo),
service.NewPostService(postRepo), service.NewPostService(postRepo),
service.NewCommentService(commentRepo), service.NewCommentService(commentRepo),
service.NewLikeService(likeRepo), service.NewLikeService(likeRepo),
service.NewChatService(chatRepo), service.NewChatService(chatRepo),
service.NewEncryptionService(vaultService, userRepo),
) )
// Инициализация чата
chatHub := ws.NewChatHub()
go chatHub.Run()
chatService := service.NewChatService(
chatRepo,
userRepo,
chatHub,
encryptionService,
)
// HTTP сервер // HTTP сервер
server := http.NewServer(cfg, services, tokenAuth) server := http.NewServer(cfg, services, tokenAuth)

30
go.mod
View File

@ -1,30 +0,0 @@
module tailly_back_v2
go 1.24
require (
github.com/99designs/gqlgen v0.17.72
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.0
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.22.0
github.com/vektah/gqlparser/v2 v2.5.25
golang.org/x/crypto v0.37.0
)
require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sosodev/duration v1.3.1 // indirect
golang.org/x/sys v0.32.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

70
go.sum
View File

@ -1,70 +0,0 @@
github.com/99designs/gqlgen v0.17.72 h1:2JDAuutIYtAN26BAtigfLZFnTN53fpYbIENL8bVgAKY=
github.com/99designs/gqlgen v0.17.72/go.mod h1:BoL4C3j9W2f95JeWMrSArdDNGWmZB9MOS2EMHJDZmUc=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vektah/gqlparser/v2 v2.5.25 h1:FmWtFEa+invTIzWlWK6Vk7BVEZU/97QBzeI8Z1JjGt8=
github.com/vektah/gqlparser/v2 v2.5.25/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -10,32 +10,49 @@ model:
package: graph package: graph
resolver: resolver:
filename: internal/http/graph dir: internal/http/graph/resolvers
layout: follow-schema layout: follow-schema
package: graph package: graph
skip_generation:
- "*_resolvers.go"
- "resolvers.go"
models: models:
User: User:
model: tailly_back_v2/internal/domain.User model: tailly_back_v2/internal/domain.User
Post: Post:
model: tailly_back_v2/internal/domain.Post model: tailly_back_v2/internal/domain.Post
RegisterInput:
model: tailly_back_v2/internal/domain.RegisterInput
LoginInput:
model: tailly_back_v2/internal/domain.LoginInput
Comment: Comment:
model: tailly_back_v2/internal/domain.Comment model: tailly_back_v2/internal/domain.Comment
Like: Like:
model: tailly_back_v2/internal/domain.like model: tailly_back_v2/internal/domain.Like
Tokens: Device:
model: tailly_back_v2/internal/domain.Tokens model: tailly_back_v2/internal/domain.Device
Session:
model: tailly_back_v2/internal/domain.Session
RecoveryRequest:
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: Chat:
model: tailly_back_v2/internal/domain.Chat model: tailly_back_v2/internal/domain.Chat
Message: Message:
model: tailly_back_v2/internal/domain.Message model: tailly_back_v2/internal/domain.Message
WSMessage: WSMessage:
model: tailly_back_v2/internal/domain.WSMessage model: tailly_back_v2/internal/domain.WSMessage
AuditLog:
model: tailly_back_v2/internal/domain.AuditLog
RegisterInput:
model: tailly_back_v2/internal/domain.RegisterInput
LoginInput:
model: tailly_back_v2/internal/domain.LoginInput
Tokens:
model: tailly_back_v2/internal/domain.Tokens
autobind: autobind:
- "tailly_back_v2/internal/domain" - "tailly_back_v2/internal/domain"

View File

@ -1,13 +1,14 @@
package config package config
import ( import (
"github.com/caarlos0/env/v8"
"time" "time"
) )
type Config struct { type Config struct {
Server struct { Server struct {
Host string `env:"SERVER_HOST" env-default:"localhost"` Host string `env:"SERVER_HOST" env-default:"localhost"`
Port string `env:"SERVER_PORT" env-default:"3006"` Port string `env:"SERVER_PORT" env-default:"8080"`
} }
Database struct { Database struct {
DSN string `env:"DB_DSN,required"` DSN string `env:"DB_DSN,required"`
@ -18,4 +19,26 @@ type Config struct {
AccessTokenExpiry time.Duration `env:"ACCESS_TOKEN_EXPIRY" env-default:"15m"` AccessTokenExpiry time.Duration `env:"ACCESS_TOKEN_EXPIRY" env-default:"15m"`
RefreshTokenExpiry time.Duration `env:"REFRESH_TOKEN_EXPIRY" env-default:"168h"` // 7 дней RefreshTokenExpiry time.Duration `env:"REFRESH_TOKEN_EXPIRY" env-default:"168h"` // 7 дней
} }
SMTP struct {
Host string `env:"SMTP_HOST,required"`
Port int `env:"SMTP_PORT,required"`
Username string `env:"SMTP_USERNAME,required"`
Password string `env:"SMTP_PASSWORD,required"`
From string `env:"SMTP_FROM,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) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
} }

28
internal/domain/audit.go Normal file
View File

@ -0,0 +1,28 @@
package domain
import (
"time"
)
type AuditLog struct {
ID int `json:"id"`
UserID *int `json:"userId,omitempty"` // nil для неаутентифицированных действий
Action string `json:"action"` // "login", "password_change", "recovery_initiated"
EntityType string `json:"entityType"` // "user", "device", "session"
EntityID *int `json:"entityId,omitempty"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
Metadata string `json:"metadata"` // JSON с дополнительными данными
Status string `json:"status"` // "success", "failed"
CreatedAt time.Time `json:"createdAt"`
}
type AuditFilter struct {
UserID *int
Action string
EntityType string
DateFrom time.Time
DateTo time.Time
Limit int
Offset int
}

View File

@ -0,0 +1,19 @@
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

@ -1,7 +1,6 @@
package domain package domain
import ( import (
"context"
"time" "time"
) )
@ -10,6 +9,8 @@ type User struct {
ID int `json:"id"` ID int `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
EmailConfirmationToken string `json:"-"`
EmailConfirmedAt *time.Time `json:"emailConfirmedAt,omitempty"`
Password string `json:"-"` // Пароль не должен возвращаться в ответах Password string `json:"-"` // Пароль не должен возвращаться в ответах
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@ -49,7 +50,6 @@ type Tokens struct {
RefreshToken string `json:"refreshToken"` RefreshToken string `json:"refreshToken"`
} }
// Типы для аутентификации
type RegisterInput struct { type RegisterInput struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
@ -60,13 +60,3 @@ type LoginInput struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
} }
type MutationResolver interface {
CreatePost(ctx context.Context, title string, content string) (*Post, error)
AddComment(ctx context.Context, postID int, content string) (*Comment, error)
}
type QueryResolver interface {
Posts(ctx context.Context) ([]*Post, error)
Post(ctx context.Context, id int) (*Post, error)
}

View File

@ -0,0 +1,22 @@
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

@ -0,0 +1,22 @@
package domain
import "time"
type RecoveryRequest struct {
ID int `json:"id"`
UserID int `json:"userId"`
Token string `json:"-"`
NewDevice *Device `json:"newDevice,omitempty"`
Status string `json:"status"` // "pending", "completed", "expired"
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type RecoveryMethod struct {
ID int `json:"id"`
UserID int `json:"userId"`
MethodType string `json:"methodType"` // "email", "phone", "totp"
Value string `json:"value"` // email/phone number
IsPrimary bool `json:"isPrimary"`
VerifiedAt time.Time `json:"verifiedAt"`
}

View File

@ -0,0 +1,22 @@
package domain
import "time"
type Device struct {
ID int `json:"id"`
UserID int `json:"userId"`
Name string `json:"name"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
ConfirmationToken string `json:"-"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Session struct {
ID int `json:"id"`
UserID int `json:"userId"`
DeviceID int `json:"deviceId"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt,omitempty"`
}

View File

@ -1,38 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type chatSessionResolver struct{ *Resolver }
// User is the resolver for the user field.
func (r *chatSessionResolver) User(ctx context.Context, obj *domain.ChatSession) (*domain.User, error) {
panic("not implemented")
}
// LastMessage is the resolver for the lastMessage field.
func (r *chatSessionResolver) LastMessage(ctx context.Context, obj *domain.ChatSession) (*domain.Message, error) {
panic("not implemented")
}
// UnreadCount is the resolver for the unreadCount field.
func (r *chatSessionResolver) UnreadCount(ctx context.Context, obj *domain.ChatSession) (int, error) {
panic("not implemented")
}
// Sender is the resolver for the sender field.
func (r *messageResolver) Sender(ctx context.Context, obj *domain.Message) (*domain.User, error) {
panic("not implemented")
}
// Receiver is the resolver for the receiver field.
func (r *messageResolver) Receiver(ctx context.Context, obj *domain.Message) (*domain.User, error) {
panic("not implemented")
}
// CreatedAt is the resolver for the createdAt field.
func (r *messageResolver) CreatedAt(ctx context.Context, obj *domain.Message) (string, error) {
panic("not implemented")
}

View File

@ -1,29 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// Comment is the resolver for the comment field.
type commentResolver struct{ *Resolver }
// Author is the resolver for the author field.
func (r *commentResolver) Author(ctx context.Context, obj *domain.Comment) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.AuthorID)
}
// Post is the resolver for the post field.
func (r *commentResolver) Post(ctx context.Context, obj *domain.Comment) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, obj.PostID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *commentResolver) CreatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *commentResolver) UpdatedAt(ctx context.Context, obj *domain.Comment) (string, error) {
panic("not implemented")
}

View File

@ -0,0 +1,15 @@
package graph
import (
"context"
"errors"
)
// getUserIDFromContext извлекает userID из контекста
func getUserIDFromContext(ctx context.Context) (int, error) {
userID, ok := ctx.Value("userID").(int)
if !ok {
return 0, errors.New("unauthorized")
}
return userID, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// LikeResolver реализует методы для работы с лайками в GraphQL
type likeResolver struct{ *Resolver }
// User возвращает пользователя, который поставил лайк
func (r *likeResolver) User(ctx context.Context, obj *domain.Like) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.UserID)
}
// Post возвращает пост, который был лайкнут
func (r *likeResolver) Post(ctx context.Context, obj *domain.Like) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, obj.PostID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *likeResolver) CreatedAt(ctx context.Context, obj *domain.Like) (string, error) {
panic("not implemented")
}

View File

@ -1,12 +0,0 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package graph
type Mutation struct {
}
type Query struct {
}
type Subscription struct {
}

View File

@ -1,48 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type postResolver struct{ *Resolver }
// Author is the resolver for the author field.
func (r *postResolver) Author(ctx context.Context, obj *domain.Post) (*domain.User, error) {
return r.services.User.GetByID(ctx, obj.AuthorID)
}
// Comments is the resolver for the comments field.
func (r *postResolver) Comments(ctx context.Context, obj *domain.Post) ([]*domain.Comment, error) {
return r.services.Comment.GetByPostID(ctx, obj.ID)
}
// Likes is the resolver for the likes field.
func (r *postResolver) Likes(ctx context.Context, obj *domain.Post) ([]*domain.Like, error) {
return r.services.Like.GetByPostID(ctx, obj.ID)
}
// Post.likesCount - получение количества лайков для поста
func (r *postResolver) LikesCount(ctx context.Context, obj *domain.Post) (int, error) {
return r.services.Like.GetCountForPost(ctx, obj.ID)
}
// Post.isLiked - проверка, поставил ли текущий пользователь лайк
func (r *postResolver) IsLiked(ctx context.Context, obj *domain.Post) (bool, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return false, nil // Не авторизован - считаем что не лайкал
}
return r.services.Like.CheckIfLiked(ctx, userID, obj.ID)
}
// CreatedAt is the resolver for the createdAt field.
func (r *postResolver) CreatedAt(ctx context.Context, obj *domain.Post) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *postResolver) UpdatedAt(ctx context.Context, obj *domain.Post) (string, error) {
panic("not implemented")
}

View File

@ -1,221 +1,6 @@
package graph package graph
import ( import (
"context" _ "tailly_back_v2/internal/domain"
"errors" _ "tailly_back_v2/internal/service"
"github.com/vektah/gqlparser/v2/gqlerror"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/service"
) )
// Реализуем корневые резолверы
type Resolver struct {
services *service.Services
}
// Ensure все интерфейсы реализованы
var _ domain.QueryResolver = (*Resolver)(nil)
var _ domain.MutationResolver = (*Resolver)(nil)
func NewResolver(services *service.Services) *Resolver {
return &Resolver{services: services}
}
// ChatSession returns ChatSessionResolver implementation.
func (r *Resolver) ChatSession() ChatSessionResolver { return &chatSessionResolver{r} }
// Comment returns CommentResolver implementation.
func (r *Resolver) Comment() CommentResolver { return &commentResolver{r} }
// Like returns LikeResolver implementation.
func (r *Resolver) Like() LikeResolver { return &likeResolver{r} }
// Message returns MessageResolver implementation.
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Post returns PostResolver implementation.
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
// Subscription returns SubscriptionResolver implementation.
func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} }
// User returns UserResolver implementation.
func (r *Resolver) User() UserResolver { return &userResolver{r} }
// Query resolvers
func (r *Resolver) Me(ctx context.Context) (*domain.User, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.User.GetByID(ctx, userID)
}
func (r *Resolver) Posts(ctx context.Context) ([]*domain.Post, error) {
return r.services.Post.GetAll(ctx)
}
// Mutation resolvers
func (r *Resolver) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error) {
return r.services.Auth.Register(ctx, service.RegisterInput{
Username: input.Username,
Email: input.Email,
Password: input.Password,
})
}
func (r *Resolver) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) {
return r.services.Auth.Login(ctx, input.Email, input.Password)
}
// mutationResolver реализует MutationResolver интерфейс
type mutationResolver struct{ *Resolver }
// Register is the resolver for the register field.
func (r *mutationResolver) Register(ctx context.Context, input domain.RegisterInput) (*domain.User, error) {
return r.services.Auth.Register(ctx, service.RegisterInput(input))
}
// Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, input domain.LoginInput) (*domain.Tokens, error) {
return r.services.Auth.Login(ctx, input.Email, input.Password)
}
// RefreshTokens is the resolver for the refreshTokens field.
func (r *mutationResolver) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) {
return r.services.Auth.RefreshTokens(ctx, refreshToken)
}
// 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, err
}
return r.services.Post.Create(ctx, userID, title, content)
}
// CreateComment is the resolver for the createComment field.
func (r *mutationResolver) CreateComment(ctx context.Context, postID string, content string) (*domain.Comment, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.Comment.Create(ctx, postID, userID, content)
}
// Mutation: likePost - добавление лайка к посту
func (r *mutationResolver) LikePost(ctx context.Context, postID string) (*domain.Like, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
// Проверяем, не поставил ли пользователь уже лайк
liked, err := r.services.Like.CheckIfLiked(ctx, userID, postID)
if err != nil {
return nil, err
}
if liked {
return nil, gqlerror.Errorf("you have already liked this post")
}
return r.services.Like.LikePost(ctx, userID, postID)
}
// Mutation: unlikePost - удаление лайка с поста
func (r *mutationResolver) UnlikePost(ctx context.Context, postID string) (bool, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return false, err
}
// Проверяем, ставил ли пользователь лайк
liked, err := r.services.Like.CheckIfLiked(ctx, userID, postID)
if err != nil {
return false, err
}
if !liked {
return false, gqlerror.Errorf("you haven't liked this post yet")
}
if err := r.services.Like.UnlikePost(ctx, userID, postID); err != nil {
return false, err
}
return true, nil
}
// Mutation.changePassword - смена пароля
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, err
}
return true, nil
}
// SendMessage is the resolver for the sendMessage field.
func (r *mutationResolver) SendMessage(ctx context.Context, receiverID string, content string) (*domain.Message, error) {
panic("not implemented")
}
// MarkAsRead is the resolver for the markAsRead field.
func (r *mutationResolver) MarkAsRead(ctx context.Context, messageID string) (bool, error) {
panic("not implemented")
}
// queryResolver реализует QueryResolver интерфейс
type queryResolver struct{ *Resolver }
// 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
}
return r.services.User.GetByID(ctx, userID)
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id string) (*domain.Post, error) {
return r.services.Post.GetByID(ctx, id)
}
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context) ([]*domain.Post, error) {
return r.services.Post.GetAll(ctx)
}
// GetChatHistory is the resolver for the getChatHistory field.
func (r *queryResolver) GetChatHistory(ctx context.Context, userID string) ([]*domain.Message, error) {
panic("not implemented")
}
// GetUserChats is the resolver for the getUserChats field.
func (r *queryResolver) GetUserChats(ctx context.Context) ([]*domain.ChatSession, error) {
panic("not implemented")
}
// getUserIDFromContext извлекает userID из контекста
func getUserIDFromContext(ctx context.Context) (int, error) {
userID, ok := ctx.Value("userID").(int)
if !ok {
return 0, errors.New("unauthorized")
}
return userID, nil
}

View File

@ -3,6 +3,7 @@ type User {
id: ID! # Уникальный идентификатор id: ID! # Уникальный идентификатор
username: String! # Имя пользователя username: String! # Имя пользователя
email: String! # Email (уникальный) email: String! # Email (уникальный)
emailConfirmedAt: String # Дата подтверждения email (может быть null)
createdAt: String! # Дата создания createdAt: String! # Дата создания
updatedAt: String! # Дата обновления updatedAt: String! # Дата обновления
} }
@ -41,11 +42,23 @@ type Like {
# Токены для аутентификации # Токены для аутентификации
type Tokens { type Tokens {
accessToken: String! # Access токен (короткоживущий) accessToken: String! # Access токен (короткоживущий)
refreshToken: String! # Refresh токен (долгоживущий) refreshToken: String! # Refresh токен (долгоживущий)
emailConfirmed: Boolean! # Флаг подтверждения email
} }
# Входные данные для регистрации
input RegisterInput {
username: String! # Имя пользователя
email: String! # Email
password: String! # Пароль
}
# Входные данные для входа
input LoginInput {
email: String! # Email
password: String! # Пароль
}
type Message { type Message {
id: ID! id: ID!
@ -62,19 +75,21 @@ type ChatSession {
unreadCount: Int! unreadCount: Int!
} }
type Subscription { type Session {
messageReceived: Message! id: ID!
device: Device!
startedAt: String!
lastActiveAt: String!
isCurrent: Boolean!
} }
input RegisterInput { type Device {
username: String! id: ID!
email: String! name: String!
password: String! type: String!
} ipAddress: String!
location: String!
input LoginInput { lastActiveAt: String!
email: String!
password: String!
} }
# Запросы (получение данных) # Запросы (получение данных)
@ -85,6 +100,8 @@ type Query {
user(id: ID!): User! # Получить пользователя по ID user(id: ID!): User! # Получить пользователя по ID
getChatHistory(userId: ID!): [Message!]! getChatHistory(userId: ID!): [Message!]!
getUserChats: [ChatSession!]! getUserChats: [ChatSession!]!
mySessions: [Session!]!
activeSessions: [Session!]!
} }
# Мутации (изменение данных) # Мутации (изменение данных)
@ -109,9 +126,22 @@ type Mutation {
# Удаление лайка # Удаление лайка
unlikePost(postId: ID!): Boolean! unlikePost(postId: ID!): Boolean!
updateProfile(username: String!, email: String!): User! updateProfile(username: String!, email: String!): User!
changePassword(oldPassword: String!, newPassword: String!): Boolean! changePassword(oldPassword: String!, newPassword: String!): Boolean!
sendMessage(receiverId: ID!, content: String!): Message! sendMessage(receiverId: ID!, content: String!): Message!
markAsRead(messageId: ID!): Boolean! markAsRead(messageId: ID!): Boolean!
terminateSession(sessionId: ID!): Boolean!
renameDevice(deviceId: ID!, name: String!): Device!
# Запрос на подтверждение email
requestEmailConfirmation: Boolean!
# Подтверждение email по токену
confirmEmail(token: String!): Boolean!
# Повторная отправка подтверждения email
resendEmailConfirmation: Boolean!
}
type Subscription {
messageReceived: Message!
} }

View File

@ -1,3 +0,0 @@
package graph
// THIS CODE WILL BE UPDATED WITH SCHEMA CHANGES. PREVIOUS IMPLEMENTATION FOR SCHEMA CHANGES WILL BE KEPT IN THE COMMENT SECTION. IMPLEMENTATION FOR UNCHANGED SCHEMA WILL BE KEPT.

View File

@ -1,13 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
type subscriptionResolver struct{ *Resolver }
// MessageReceived is the resolver for the messageReceived field.
func (r *subscriptionResolver) MessageReceived(ctx context.Context) (<-chan *domain.Message, error) {
panic("not implemented")
}

View File

@ -1,34 +0,0 @@
package graph
import (
"context"
"tailly_back_v2/internal/domain"
)
// userResolver реализует методы для работы с пользователями
type userResolver struct{ *Resolver }
// Query.user - получение пользователя по ID
func (r *queryResolver) User(ctx context.Context, id string) (*domain.User, error) {
return r.services.User.GetByID(ctx, id)
}
// CreatedAt is the resolver for the createdAt field.
func (r *userResolver) CreatedAt(ctx context.Context, obj *domain.User) (string, error) {
panic("not implemented")
}
// UpdatedAt is the resolver for the updatedAt field.
func (r *userResolver) UpdatedAt(ctx context.Context, obj *domain.User) (string, error) {
panic("not implemented")
}
// Mutation.updateProfile - обновление профиля
func (r *mutationResolver) UpdateProfile(ctx context.Context, username string, email string) (*domain.User, error) {
userID, err := getUserIDFromContext(ctx)
if err != nil {
return nil, err
}
return r.services.User.UpdateProfile(ctx, userID, username, email)
}

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"tailly_back_v2/internal/domain" "tailly_back_v2/internal/domain"

View File

@ -0,0 +1,40 @@
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

@ -0,0 +1,56 @@
package middleware
import (
"net/http"
"tailly_back_v2/internal/service"
"time"
)
func AuditMiddleware(audit service.AuditService, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseRecorder{w, http.StatusOK, 0}
next.ServeHTTP(rw, r)
duration := time.Since(start)
status := "success"
if rw.status >= 400 {
status = "failed"
}
audit.LogEvent(
r.Context(),
action,
"request",
nil,
r,
status,
map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"status": rw.status,
"duration": duration.String(),
},
)
})
}
}
type responseRecorder struct {
http.ResponseWriter
status int
size int
}
func (r *responseRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *responseRecorder) Write(b []byte) (int, error) {
size, err := r.ResponseWriter.Write(b)
r.size += size
return size, err
}

View File

@ -56,9 +56,3 @@ func AuthMiddleware(tokenAuth *auth.TokenAuth) func(http.Handler) http.Handler {
}) })
} }
} }
// GetUserIDFromContext извлекает userID из контекста
func GetUserIDFromContext(ctx context.Context) (int, bool) {
userID, ok := ctx.Value(userIDKey).(int)
return userID, ok
}

View File

@ -12,6 +12,7 @@ import (
"os" "os"
"tailly_back_v2/internal/config" "tailly_back_v2/internal/config"
"tailly_back_v2/internal/http/graph" "tailly_back_v2/internal/http/graph"
"tailly_back_v2/internal/http/handlers"
"tailly_back_v2/internal/http/middleware" "tailly_back_v2/internal/http/middleware"
"tailly_back_v2/internal/service" "tailly_back_v2/internal/service"
"tailly_back_v2/pkg/auth" "tailly_back_v2/pkg/auth"
@ -42,13 +43,21 @@ func (s *Server) configureRouter() {
"http://localhost:3000", // React dev server "http://localhost:3000", // React dev server
"https://your-production.app", // Продакшен домен "https://your-production.app", // Продакшен домен
} }
// Логирование // Логирование
logger := log.New(os.Stdout, "HTTP: ", log.LstdFlags) logger := log.New(os.Stdout, "HTTP: ", log.LstdFlags)
s.router.Use(middleware.LoggingMiddleware(logger)) 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.MetricsMiddleware)
// Middleware
s.router.Use(middleware.CORS(allowedOrigins)) s.router.Use(middleware.CORS(allowedOrigins))
s.router.Use(middleware.AuthMiddleware(s.tokenAuth)) s.router.Use(middleware.AuthMiddleware(s.tokenAuth))
@ -72,7 +81,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
return nil return nil
} }
// Отдельный endpoint для метрик Prometheus
func (s *Server) configureMetrics() { func (s *Server) configureMetrics() {
prometheus.MustRegister( prometheus.MustRegister(
middleware.httpRequestsTotal, middleware.httpRequestsTotal,

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"tailly_back_v2/internal/domain" "tailly_back_v2/internal/domain"
"time"
) )
var ( var (
@ -15,6 +16,7 @@ type UserRepository interface {
Create(ctx context.Context, user *domain.User) error Create(ctx context.Context, user *domain.User) error
GetByID(ctx context.Context, id int) (*domain.User, error) GetByID(ctx context.Context, id int) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error) GetByEmail(ctx context.Context, email string) (*domain.User, error)
GetByConfirmationToken(ctx context.Context, token string) (*domain.User, error)
Update(ctx context.Context, user *domain.User) error Update(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id int) error Delete(ctx context.Context, id int) error
} }
@ -29,8 +31,8 @@ func NewUserRepository(db *sql.DB) UserRepository {
func (r *userRepository) Create(ctx context.Context, user *domain.User) error { func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
query := ` query := `
INSERT INTO users (username, email, password, created_at, updated_at) INSERT INTO users (username, email, password, email_confirmation_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id RETURNING id
` `
@ -38,6 +40,7 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
user.Username, user.Username,
user.Email, user.Email,
user.Password, user.Password,
user.EmailConfirmationToken,
user.CreatedAt, user.CreatedAt,
user.UpdatedAt, user.UpdatedAt,
).Scan(&user.ID) ).Scan(&user.ID)
@ -47,17 +50,21 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
func (r *userRepository) GetByID(ctx context.Context, id int) (*domain.User, error) { func (r *userRepository) GetByID(ctx context.Context, id int) (*domain.User, error) {
query := ` query := `
SELECT id, username, email, password, created_at, updated_at SELECT id, username, email, password, email_confirmation_token, email_confirmed_at, created_at, updated_at
FROM users FROM users
WHERE id = $1 WHERE id = $1
` `
user := &domain.User{} user := &domain.User{}
var confirmedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, id).Scan( err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.ID,
&user.Username, &user.Username,
&user.Email, &user.Email,
&user.Password, &user.Password,
&user.EmailConfirmationToken,
&confirmedAt,
&user.CreatedAt, &user.CreatedAt,
&user.UpdatedAt, &user.UpdatedAt,
) )
@ -66,7 +73,111 @@ func (r *userRepository) GetByID(ctx context.Context, id int) (*domain.User, err
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
if confirmedAt.Valid {
user.EmailConfirmedAt = &confirmedAt.Time
}
return user, err return user, err
} }
// Остальные методы аналогично... func (r *userRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, username, email, password, email_confirmation_token, email_confirmed_at, created_at, updated_at
FROM users
WHERE email = $1
`
user := &domain.User{}
var confirmedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.Username,
&user.Email,
&user.Password,
&user.EmailConfirmationToken,
&confirmedAt,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if confirmedAt.Valid {
user.EmailConfirmedAt = &confirmedAt.Time
}
return user, err
}
func (r *userRepository) GetByConfirmationToken(ctx context.Context, token string) (*domain.User, error) {
query := `
SELECT id, username, email, password, email_confirmation_token, email_confirmed_at, created_at, updated_at
FROM users
WHERE email_confirmation_token = $1
`
user := &domain.User{}
var confirmedAt sql.NullTime
err := r.db.QueryRowContext(ctx, query, token).Scan(
&user.ID,
&user.Username,
&user.Email,
&user.Password,
&user.EmailConfirmationToken,
&confirmedAt,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrUserNotFound
}
if confirmedAt.Valid {
user.EmailConfirmedAt = &confirmedAt.Time
}
return user, err
}
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
query := `
UPDATE users
SET username = $1,
email = $2,
password = $3,
email_confirmation_token = $4,
email_confirmed_at = $5,
updated_at = $6
WHERE id = $7
`
var confirmedAt interface{}
if user.EmailConfirmedAt != nil {
confirmedAt = *user.EmailConfirmedAt
} else {
confirmedAt = nil
}
_, err := r.db.ExecContext(ctx, query,
user.Username,
user.Email,
user.Password,
user.EmailConfirmationToken,
confirmedAt,
time.Now(), // Обновляем updated_at
user.ID,
)
return err
}
func (r *userRepository) Delete(ctx context.Context, id int) error {
query := `DELETE FROM users WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}

View File

@ -0,0 +1,83 @@
package service
import (
"context"
"encoding/json"
"net/http"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
type AuditService interface {
LogEvent(
ctx context.Context,
action string,
entityType string,
entityID *int,
r *http.Request,
status string,
metadata interface{},
) error
GetEvents(ctx context.Context, filter domain.AuditFilter) ([]*domain.AuditLog, error)
ExportEvents(ctx context.Context, filter domain.AuditFilter) ([]byte, error)
}
type auditService struct {
auditRepo repository.AuditRepository
}
func (s *auditService) LogEvent(
ctx context.Context,
action string,
entityType string,
entityID *int,
r *http.Request,
status string,
metadata interface{},
) error {
var userID *int
if ctxUserID, ok := ctx.Value("userID").(int); ok {
userID = &ctxUserID
}
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
ip := r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.RemoteAddr
}
log := &domain.AuditLog{
UserID: userID,
Action: action,
EntityType: entityType,
EntityID: entityID,
IPAddress: ip,
UserAgent: r.UserAgent(),
Metadata: string(metadataJSON),
Status: status,
CreatedAt: time.Now(),
}
return s.auditRepo.Save(ctx, log)
}
func (s *auditService) GetEvents(ctx context.Context, filter domain.AuditFilter) ([]*domain.AuditLog, error) {
if filter.Limit == 0 {
filter.Limit = 50
}
return s.auditRepo.Get(ctx, filter)
}
func (s *auditService) ExportEvents(ctx context.Context, filter domain.AuditFilter) ([]byte, error) {
logs, err := s.auditRepo.Get(ctx, filter)
if err != nil {
return nil, err
}
return json.Marshal(logs)
}

View File

@ -2,44 +2,48 @@ package service
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt"
"tailly_back_v2/internal/domain" "tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository" "tailly_back_v2/internal/repository"
"tailly_back_v2/pkg/auth" "tailly_back_v2/pkg/auth"
"time" "time"
) )
// Интерфейс сервиса аутентификации
type AuthService interface { type AuthService interface {
Register(ctx context.Context, input RegisterInput) (*domain.User, error) Register(ctx context.Context, input RegisterInput) (*domain.User, error)
Login(ctx context.Context, email, password string) (*domain.Tokens, error) Login(ctx context.Context, email, password string) (*domain.Tokens, error)
RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error)
ConfirmEmail(ctx context.Context, token string) error
} }
// Реализация сервиса аутентификации
type authService struct { type authService struct {
userRepo repository.UserRepository userRepo repository.UserRepository
tokenAuth auth.TokenAuth tokenAuth auth.TokenAuth
mailer MailService
} }
// Конструктор сервиса func NewAuthService(
func NewAuthService(userRepo repository.UserRepository, tokenAuth auth.TokenAuth) AuthService { userRepo repository.UserRepository,
tokenAuth auth.TokenAuth,
mailer MailService,
) AuthService {
return &authService{ return &authService{
userRepo: userRepo, userRepo: userRepo,
tokenAuth: tokenAuth, tokenAuth: tokenAuth,
mailer: mailer,
} }
} }
// Входные данные для регистрации
type RegisterInput struct { type RegisterInput struct {
Username string Username string
Email string Email string
Password string Password string
} }
// Регистрация нового пользователя
func (s *authService) Register(ctx context.Context, input RegisterInput) (*domain.User, error) { func (s *authService) Register(ctx context.Context, input RegisterInput) (*domain.User, error) {
// Проверка существования пользователя
_, err := s.userRepo.GetByEmail(ctx, input.Email) _, err := s.userRepo.GetByEmail(ctx, input.Email)
if err == nil { if err == nil {
return nil, errors.New("user with this email already exists") return nil, errors.New("user with this email already exists")
@ -47,17 +51,23 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*domai
return nil, err return nil, err
} }
// Хеширование пароля // Генерация токена подтверждения
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return nil, fmt.Errorf("failed to generate confirmation token: %w", err)
}
confirmationToken := base64.URLEncoding.EncodeToString(token)
hashedPassword, err := auth.HashPassword(input.Password) hashedPassword, err := auth.HashPassword(input.Password)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Создание пользователя
user := &domain.User{ user := &domain.User{
Username: input.Username, Username: input.Username,
Email: input.Email, Email: input.Email,
Password: hashedPassword, Password: hashedPassword,
EmailConfirmationToken: confirmationToken,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
@ -66,10 +76,13 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*domai
return nil, err return nil, err
} }
if err := s.mailer.SendConfirmationEmail(user.Email, confirmationToken); err != nil {
return nil, fmt.Errorf("failed to send confirmation email: %w", err)
}
return user, nil return user, nil
} }
// Вход в систему
func (s *authService) Login(ctx context.Context, email, password string) (*domain.Tokens, error) { func (s *authService) Login(ctx context.Context, email, password string) (*domain.Tokens, error) {
user, err := s.userRepo.GetByEmail(ctx, email) user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil { if err != nil {
@ -79,33 +92,51 @@ func (s *authService) Login(ctx context.Context, email, password string) (*domai
return nil, err return nil, err
} }
// Проверка пароля
if !auth.CheckPasswordHash(password, user.Password) { if !auth.CheckPasswordHash(password, user.Password) {
return nil, errors.New("invalid credentials") return nil, errors.New("invalid credentials")
} }
// Генерация токенов // Проверка подтверждения email (добавьте эту проверку)
if user.EmailConfirmedAt == nil {
return nil, errors.New("email not confirmed")
}
tokens, err := s.tokenAuth.GenerateTokens(user.ID) tokens, err := s.tokenAuth.GenerateTokens(user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
//Получаем информацию об устройстве (если используется)
//if deviceInfo, ok := ctx.Value("device_info").(*domain.Device); ok {
// // Здесь должна быть проверка isNewDevice и вызов notificationService
// // if isNewDevice { ... }
//}
return tokens, nil return tokens, nil
} }
// Обновление токенов
func (s *authService) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) { func (s *authService) RefreshTokens(ctx context.Context, refreshToken string) (*domain.Tokens, error) {
userID, err := s.tokenAuth.ValidateRefreshToken(refreshToken) userID, err := s.tokenAuth.ValidateRefreshToken(refreshToken)
if err != nil { if err != nil {
return nil, errors.New("invalid refresh token") return nil, errors.New("invalid refresh token")
} }
// Проверка существования пользователя if _, err = s.userRepo.GetByID(ctx, userID); err != nil {
_, err = s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, errors.New("user not found") return nil, errors.New("user not found")
} }
// Генерация новых токенов
return s.tokenAuth.GenerateTokens(userID) return s.tokenAuth.GenerateTokens(userID)
} }
func (s *authService) ConfirmEmail(ctx context.Context, token string) error {
user, err := s.userRepo.GetByConfirmationToken(ctx, token)
if err != nil {
return fmt.Errorf("invalid or expired confirmation token")
}
now := time.Now()
user.EmailConfirmedAt = &now
user.EmailConfirmationToken = ""
return s.userRepo.Update(ctx, user)
}

View File

@ -0,0 +1,65 @@
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

@ -0,0 +1,95 @@
package service
import (
"bytes"
"embed"
"fmt"
"html/template"
"time"
"gopkg.in/gomail.v2"
)
type MailService interface {
SendEmail(to, subject, body string) error
SendTemplateEmail(to, subject string, templateName string, data interface{}) error
SendConfirmationEmail(to, token string) error
SendPasswordResetEmail(to, token string) error
}
type mailService struct {
from string
smtpHost string
smtpPort int
smtpUser string
smtpPass string
templates *template.Template
}
//go:embed templates/*
var templateFS embed.FS
func NewMailService(from, smtpHost string, smtpPort int, smtpUser, smtpPass string) (MailService, error) {
// Загружаем шаблоны писем
templates, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse email templates: %w", err)
}
return &mailService{
from: from,
smtpHost: smtpHost,
smtpPort: smtpPort,
smtpUser: smtpUser,
smtpPass: smtpPass,
templates: templates,
}, nil
}
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)
dialer := gomail.NewDialer(s.smtpHost, s.smtpPort, s.smtpUser, s.smtpPass)
return dialer.DialAndSend(mail)
}
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)
}
return s.SendEmail(to, subject, body.String())
}
func (s *mailService) SendConfirmationEmail(to, token string) error {
data := struct {
Email string
Token string
Year int
}{
Email: to,
Token: token,
Year: time.Now().Year(),
}
return s.SendTemplateEmail(to, "Подтверждение email", "confirmation", data)
}
func (s *mailService) SendPasswordResetEmail(to, token string) error {
data := struct {
Email string
Token string
Year int
}{
Email: to,
Token: token,
Year: time.Now().Year(),
}
return s.SendTemplateEmail(to, "Сброс пароля", "password_reset", data)
}

View File

@ -0,0 +1,93 @@
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

@ -0,0 +1,119 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
type RecoveryService interface {
InitiateRecovery(ctx context.Context, email string, newDevice *domain.Device) (string, error)
VerifyRecovery(ctx context.Context, token string) (*domain.Session, error)
AddRecoveryMethod(ctx context.Context, userID int, method *domain.RecoveryMethod) error
GetRecoveryMethods(ctx context.Context, userID int) ([]*domain.RecoveryMethod, error)
}
type recoveryService struct {
recoveryRepo repository.RecoveryRepository
userRepo repository.UserRepository
sessionRepo repository.SessionRepository
deviceRepo repository.DeviceRepository
mailer MailService
encryptor EncryptionService
}
func (s *recoveryService) InitiateRecovery(ctx context.Context, email string, newDevice *domain.Device) (string, error) {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
return "", errors.New("user not found")
}
// Проверяем, что у пользователя есть методы восстановления
methods, err := s.recoveryRepo.GetMethods(ctx, user.ID)
if err != nil || len(methods) == 0 {
return "", errors.New("no recovery methods available")
}
// Генерируем токен восстановления
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return "", err
}
tokenStr := base64.URLEncoding.EncodeToString(token)
req := &domain.RecoveryRequest{
UserID: user.ID,
Token: tokenStr,
NewDevice: newDevice,
Status: "pending",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(1 * time.Hour),
}
if err := s.recoveryRepo.SaveRequest(ctx, req); err != nil {
return "", err
}
// Отправляем письмо на все методы восстановления
for _, method := range methods {
switch method.MethodType {
case "email":
if err := s.mailer.SendRecoveryEmail(
method.Value,
tokenStr,
newDevice.IPAddress,
newDevice.UserAgent,
); err != nil {
return "", err
}
// case "phone": отправка SMS
}
}
return tokenStr, nil
}
func (s *recoveryService) VerifyRecovery(ctx context.Context, token string) (*domain.Session, error) {
req, err := s.recoveryRepo.GetRequestByToken(ctx, token)
if err != nil {
return nil, errors.New("invalid recovery token")
}
if time.Now().After(req.ExpiresAt) {
return nil, errors.New("recovery token expired")
}
// Регистрируем новое устройство
if err := s.deviceRepo.Save(ctx, req.NewDevice); err != nil {
return nil, err
}
// Создаем сессию
session := &domain.Session{
UserID: req.UserID,
DeviceID: req.NewDevice.ID,
StartedAt: time.Now(),
}
if err := s.sessionRepo.Save(ctx, session); err != nil {
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 {
return nil, err
}
return session, nil
}

View File

@ -1,13 +1,13 @@
package service package service
// Services объединяет все сервисы приложения import _ "tailly_back_v2/internal/repository"
type Services struct { type Services struct {
Auth AuthService Auth AuthService
User UserService User UserService
Post PostService Post PostService
Comment CommentService Comment CommentService
Like LikeService Like LikeService
Chat ChatService
} }
func NewServices( func NewServices(
@ -16,7 +16,6 @@ func NewServices(
postService PostService, postService PostService,
commentService CommentService, commentService CommentService,
likeService LikeService, likeService LikeService,
chatService ChatService,
) *Services { ) *Services {
return &Services{ return &Services{
Auth: authService, Auth: authService,
@ -24,6 +23,5 @@ func NewServices(
Post: postService, Post: postService,
Comment: commentService, Comment: commentService,
Like: likeService, Like: likeService,
Chat: chatService,
} }
} }

View File

@ -0,0 +1,103 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"tailly_back_v2/internal/domain"
"tailly_back_v2/internal/repository"
"time"
)
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
}
type sessionService struct {
sessionRepo repository.SessionRepository
deviceRepo repository.DeviceRepository
mailer MailService
}
func (s *sessionService) InitiateSession(ctx context.Context, device *domain.Device) error {
// Генерация токена подтверждения
token := make([]byte, 32)
if _, err := rand.Read(token); err != nil {
return err
}
device.ConfirmationToken = base64.URLEncoding.EncodeToString(token)
device.ExpiresAt = time.Now().Add(24 * time.Hour)
if err := s.deviceRepo.Save(ctx, device); err != nil {
return err
}
// Отправка письма
user, err := s.userRepo.GetByID(ctx, device.UserID)
if err != nil {
return err
}
return s.mailer.SendSessionConfirmation(
user.Email,
device.ConfirmationToken,
device.IPAddress,
device.UserAgent,
)
}
func (s *sessionService) ConfirmSession(ctx context.Context, token string) (*domain.Session, error) {
device, err := s.deviceRepo.GetByToken(ctx, token)
if err != nil {
return nil, err
}
if time.Now().After(device.ExpiresAt) {
return nil, errors.New("confirmation token expired")
}
session := &domain.Session{
UserID: device.UserID,
DeviceID: device.ID,
StartedAt: time.Now(),
}
if err := s.sessionRepo.Save(ctx, session); err != nil {
return nil, err
}
return session, nil
}
func (s *sessionService) GetUserSessions(ctx context.Context, userID int) ([]*domain.Session, error) {
sessions, err := s.sessionRepo.GetActiveByUser(ctx, userID)
if err != nil {
return nil, err
}
currentSessionID, _ := ctx.Value("sessionID").(int)
for _, session := range sessions {
session.IsCurrent = (session.ID == currentSessionID)
}
return sessions, nil
}
func (s *sessionService) Terminate(ctx context.Context, userID, sessionID int) error {
session, err := s.sessionRepo.GetByID(ctx, sessionID)
if err != nil {
return err
}
if session.UserID != userID {
return errors.New("unauthorized")
}
return s.sessionRepo.Terminate(ctx, sessionID)
}

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Подтверждение email</title>
</head>
<body>
<h1>Подтвердите ваш email</h1>
<p>Для завершения регистрации перейдите по ссылке:</p>
<a href="{{.AppURL}}/confirm-email?token={{.Token}}">Подтвердить email</a>
<p>С уважением,<br>Команда сервиса</p>
<p>&copy; {{.Year}}</p>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Сброс пароля</title>
</head>
<body>
<h1>Сброс пароля</h1>
<p>Для сброса пароля перейдите по ссылке:</p>
<a href="{{.AppURL}}/reset-password?token={{.Token}}">Сбросить пароль</a>
<p>Ссылка действительна 24 часа.</p>
<p>&copy; {{.Year}}</p>
</body>
</html>

View File

@ -0,0 +1,59 @@
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,89 @@
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
}