This commit is contained in:
2026-04-18 10:21:51 +03:00
commit 90d027025b
37 changed files with 6493 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
package auth
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"social-raiting.nekiiinkognito.ru/internal/models"
)
func RequireAdmin(db *gorm.DB) gin.HandlerFunc {
return func(ctx *gin.Context) {
userID, exists := ctx.Get("userID")
if !exists {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
var user models.User
if err := db.First(&user, "id = ?", userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if !user.IsAdmin {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
ctx.Next()
}
}

View File

@@ -0,0 +1,123 @@
package auth
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"social-raiting.nekiiinkognito.ru/internal/models"
)
type Handler struct {
DB *gorm.DB
JWTSecret string
}
func NewHandler(db *gorm.DB, jwtSecret string) Handler {
return Handler{
DB: db,
JWTSecret: jwtSecret,
}
}
func (h Handler) Register(ctx *gin.Context) {
var req RegisterRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
user := models.User{
Email: email,
PasswordHash: string(passwordHash),
IsAdmin: req.IsAdmin,
}
if err := h.DB.Create(&user).Error; err != nil {
if isDuplicateEmailError(err) {
ctx.JSON(http.StatusConflict, gin.H{"error": "email is already registered"})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
ctx.JSON(http.StatusCreated, UserResponse{User: user})
}
func (h Handler) Login(ctx *gin.Context) {
var req LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := h.DB.Where("email = ?", strings.ToLower(strings.TrimSpace(req.Email))).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, err := GenerateToken(user.ID, h.JWTSecret, TokenOptions{})
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
ctx.JSON(http.StatusOK, AuthResponse{
Token: token,
User: user,
})
}
func (h Handler) Me(ctx *gin.Context) {
userID, exists := ctx.Get("userID")
if !exists {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
var user models.User
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
return
}
ctx.JSON(http.StatusOK, UserResponse{User: user})
}
func isDuplicateEmailError(err error) bool {
if err == nil {
return false
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "duplicate") || strings.Contains(errText, "1062")
}

View File

@@ -0,0 +1,28 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
func GenerateToken(userID uint, jwtSecret string, options TokenOptions) (string, error) {
ttl := options.TTL
if ttl <= 0 {
ttl = 24 * time.Hour
}
now := time.Now()
claims := JWTClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(jwtSecret))
}

View File

@@ -0,0 +1,42 @@
package auth
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
func Middleware(jwtSecret string) gin.HandlerFunc {
return func(ctx *gin.Context) {
header := strings.TrimSpace(ctx.GetHeader("Authorization"))
if header == "" {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(parts[1], claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
ctx.Set("userID", claims.UserID)
ctx.Next()
}
}

View File

@@ -0,0 +1,38 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
"social-raiting.nekiiinkognito.ru/internal/models"
)
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
IsAdmin bool `json:"isAdmin"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.User `json:"user"`
}
type JWTClaims struct {
UserID uint `json:"userId"`
jwt.RegisteredClaims
}
type UserResponse struct {
User models.User `json:"user"`
}
type TokenOptions struct {
TTL time.Duration
}

View File

@@ -0,0 +1,52 @@
package config
import (
"log"
"os"
"strings"
)
type Config struct {
ServerPort string
JWTSecret string
DBUser string
DBPassword string
DBHost string
DBPort string
DBName string
DefaultAdminEmail string
DefaultAdminPassword string
}
func Load() Config {
cfg := Config{
ServerPort: getEnv("SERVER_PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBHost: getEnv("DB_HOST", "127.0.0.1"),
DBPort: getEnv("DB_PORT", "3306"),
DBName: getEnv("DB_NAME", "social_raiting"),
DefaultAdminEmail: getEnv("DEFAULT_ADMIN_EMAIL", "admin@example.com"),
DefaultAdminPassword: getEnv("DEFAULT_ADMIN_PASSWORD", "change-admin-password"),
}
if cfg.JWTSecret == "change-me-in-production" {
log.Println("warning: JWT_SECRET is using the default development value")
}
if cfg.DefaultAdminPassword == "change-admin-password" {
log.Println("warning: DEFAULT_ADMIN_PASSWORD is using the default development value")
}
return cfg
}
func getEnv(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}

View File

@@ -0,0 +1,94 @@
package database
import (
"fmt"
"log"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"social-raiting.nekiiinkognito.ru/internal/config"
"social-raiting.nekiiinkognito.ru/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func Connect(cfg config.Config) *gorm.DB {
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
cfg.DBUser,
cfg.DBPassword,
cfg.DBHost,
cfg.DBPort,
cfg.DBName,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect to mysql: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("failed to create sql database handle: %v", err)
}
sqlDB.SetConnMaxLifetime(5 * time.Minute)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(25)
if err := sqlDB.Ping(); err != nil {
log.Fatalf("failed to ping mysql: %v", err)
}
if err := db.AutoMigrate(&models.User{}); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
if err := db.AutoMigrate(&models.UserSocialRating{}, &models.SocialRatingOperation{}); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
ensureDefaultAdmin(db, cfg)
return db
}
func ensureDefaultAdmin(db *gorm.DB, cfg config.Config) {
email := strings.ToLower(strings.TrimSpace(cfg.DefaultAdminEmail))
password := strings.TrimSpace(cfg.DefaultAdminPassword)
if email == "" || password == "" {
log.Println("skipping default admin bootstrap because admin credentials are empty")
return
}
var user models.User
err := db.Where("email = ?", email).First(&user).Error
if err == nil {
if !user.IsAdmin {
if updateErr := db.Model(&user).Update("is_admin", true).Error; updateErr != nil {
log.Fatalf("failed to promote default admin user: %v", updateErr)
}
}
return
}
if err != gorm.ErrRecordNotFound {
log.Fatalf("failed to check default admin user: %v", err)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Fatalf("failed to hash default admin password: %v", err)
}
user = models.User{
Email: email,
PasswordHash: string(passwordHash),
IsAdmin: true,
}
if err := db.Create(&user).Error; err != nil {
log.Fatalf("failed to create default admin user: %v", err)
}
}

View File

@@ -0,0 +1,26 @@
package models
import "time"
type SocialRatingOperation struct {
ID uint `json:"id" gorm:"primaryKey"`
TargetUserID uint `json:"targetUserId" gorm:"not null;index"`
TargetUser User `json:"-" gorm:"foreignKey:TargetUserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ActorUserID *uint `json:"actorUserId,omitempty" gorm:"index"`
ActorUser *User `json:"-" gorm:"foreignKey:ActorUserID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
Delta int `json:"delta" gorm:"not null"`
OperationType string `json:"operationType" gorm:"size:32;not null;index"`
Reason string `json:"reason,omitempty" gorm:"size:255"`
Source string `json:"source,omitempty" gorm:"size:64;index"`
BalanceAfter int `json:"balanceAfter" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
}
type UserSocialRating struct {
UserID uint `json:"userId" gorm:"primaryKey"`
User User `json:"-" gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Score int `json:"score" gorm:"not null;default:0;index"`
LastOperationID *uint `json:"lastOperationId,omitempty" gorm:"index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,23 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"size:255;uniqueIndex;not null"`
PasswordHash string `json:"-" gorm:"size:255;not null"`
IsAdmin bool `json:"isAdmin" gorm:"not null;default:false;index"`
SocialRating *UserSocialRating `json:"socialRating,omitempty" gorm:"foreignKey:UserID"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (u *User) AfterCreate(tx *gorm.DB) error {
return tx.FirstOrCreate(&UserSocialRating{}, UserSocialRating{
UserID: u.ID,
}).Error
}

View File

@@ -0,0 +1,49 @@
package server
import (
"net/http"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"social-raiting.nekiiinkognito.ru/internal/auth"
"social-raiting.nekiiinkognito.ru/internal/config"
"social-raiting.nekiiinkognito.ru/internal/socialrating"
)
func NewRouter(db *gorm.DB, cfg config.Config) *gin.Engine {
router := gin.Default()
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:8081", "http://127.0.0.1:8081", "https://social-rating.nekiiinkognito.ru/"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
router.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"message": "ok"})
})
router.StaticFile("/swagger.yaml", "./docs/swagger.yaml")
authHandler := auth.NewHandler(db, cfg.JWTSecret)
socialRatingHandler := socialrating.NewHandler(socialrating.NewService(db))
api := router.Group("/api")
api.POST("/auth/login", authHandler.Login)
protected := api.Group("/")
protected.Use(auth.Middleware(cfg.JWTSecret))
protected.GET("/auth/me", authHandler.Me)
protected.POST("/social-rating/increase", socialRatingHandler.Increase)
protected.POST("/social-rating/decrease", socialRatingHandler.Decrease)
admin := protected.Group("/")
admin.Use(auth.RequireAdmin(db))
admin.POST("/auth/register", authHandler.Register)
return router
}

View File

@@ -0,0 +1,99 @@
package socialrating
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"social-raiting.nekiiinkognito.ru/internal/models"
)
type Handler struct {
Service Service
}
type ChangeRequest struct {
TargetUserID uint `json:"targetUserId" binding:"required"`
Amount int `json:"amount"`
Reason string `json:"reason"`
Source string `json:"source"`
}
type ChangeResponse struct {
Operation models.SocialRatingOperation `json:"operation"`
CurrentRating models.UserSocialRating `json:"currentRating"`
}
func NewHandler(service Service) Handler {
return Handler{Service: service}
}
func (h Handler) Increase(ctx *gin.Context) {
h.applySignedChange(ctx, "increase", 1)
}
func (h Handler) Decrease(ctx *gin.Context) {
h.applySignedChange(ctx, "decrease", -1)
}
func (h Handler) applySignedChange(ctx *gin.Context, operationType string, direction int) {
var req ChangeRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actorUserID, ok := getActorUserID(ctx)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "missing user context"})
return
}
amount := req.Amount
if amount == 0 {
amount = 1
}
if amount < 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "amount must be greater than zero"})
return
}
operation, currentRating, err := h.Service.ApplyChange(ChangeInput{
TargetUserID: req.TargetUserID,
ActorUserID: &actorUserID,
Delta: amount * direction,
OperationType: operationType,
Reason: req.Reason,
Source: req.Source,
})
if err != nil {
handleApplyChangeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, ChangeResponse{
Operation: operation,
CurrentRating: currentRating,
})
}
func getActorUserID(ctx *gin.Context) (uint, bool) {
value, exists := ctx.Get("userID")
if !exists {
return 0, false
}
userID, ok := value.(uint)
return userID, ok
}
func handleApplyChangeError(ctx *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}

View File

@@ -0,0 +1,97 @@
package socialrating
import (
"errors"
"strings"
"gorm.io/gorm"
"social-raiting.nekiiinkognito.ru/internal/models"
)
type Service struct {
DB *gorm.DB
}
type ChangeInput struct {
TargetUserID uint
ActorUserID *uint
Delta int
OperationType string
Reason string
Source string
}
func NewService(db *gorm.DB) Service {
return Service{DB: db}
}
func (s Service) ApplyChange(input ChangeInput) (models.SocialRatingOperation, models.UserSocialRating, error) {
var operation models.SocialRatingOperation
var currentRating models.UserSocialRating
if err := validateChangeInput(input); err != nil {
return operation, currentRating, err
}
err := s.DB.Transaction(func(tx *gorm.DB) error {
if err := ensureUserExists(tx, input.TargetUserID); err != nil {
return err
}
if input.ActorUserID != nil {
if err := ensureUserExists(tx, *input.ActorUserID); err != nil {
return err
}
}
currentRating = models.UserSocialRating{UserID: input.TargetUserID}
if err := tx.FirstOrCreate(&currentRating, models.UserSocialRating{
UserID: input.TargetUserID,
}).Error; err != nil {
return err
}
// Social rating is allowed to go below zero, so no lower bound is applied here.
currentRating.Score += input.Delta
operation = models.SocialRatingOperation{
TargetUserID: input.TargetUserID,
ActorUserID: input.ActorUserID,
Delta: input.Delta,
OperationType: strings.TrimSpace(input.OperationType),
Reason: strings.TrimSpace(input.Reason),
Source: strings.TrimSpace(input.Source),
BalanceAfter: currentRating.Score,
}
if err := tx.Create(&operation).Error; err != nil {
return err
}
currentRating.LastOperationID = &operation.ID
return tx.Save(&currentRating).Error
})
return operation, currentRating, err
}
func validateChangeInput(input ChangeInput) error {
if input.TargetUserID == 0 {
return errors.New("target user id is required")
}
if strings.TrimSpace(input.OperationType) == "" {
return errors.New("operation type is required")
}
if input.Delta == 0 {
return errors.New("delta must not be zero")
}
return nil
}
func ensureUserExists(tx *gorm.DB, userID uint) error {
var user models.User
return tx.First(&user, "id = ?", userID).Error
}