first commit
This commit is contained in:
615
app/controllers/AuthControllers.go
Normal file
615
app/controllers/AuthControllers.go
Normal file
@@ -0,0 +1,615 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
"goGin/app/middlewares"
|
||||
"goGin/app/services"
|
||||
configs "goGin/config"
|
||||
utils "goGin/pkg/utis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/github"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthResponse
|
||||
type AuthResponse struct {
|
||||
User UserResponse `json:"user"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// RegisterPayload
|
||||
type RegisterPayload struct {
|
||||
UserName string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginPayload
|
||||
type LoginPayload struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshPayload
|
||||
type RefreshPayload struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// Helper to generate secure token for email verification
|
||||
func generateSecureToken() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Register a new user. Sends verification email. Does NOT return tokens.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param register body RegisterPayload true "Register payload"
|
||||
// @Success 201 {object} controllers.AuthResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload RegisterPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check existing email
|
||||
var existing models.User
|
||||
if err := database.DB.Where("email = ?", payload.Email).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPwd, err := utils.HashPassword(payload.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Email Verification Token
|
||||
verificationToken := generateSecureToken()
|
||||
emailVerified := false
|
||||
|
||||
user := models.User{
|
||||
UserName: payload.UserName,
|
||||
Email: payload.Email,
|
||||
Password: hashedPwd,
|
||||
EmailVerified: &emailVerified,
|
||||
EmailVerifyToken: verificationToken,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Verification Email
|
||||
go func() {
|
||||
if err := utils.SendVerificationEmail(user.Email, verificationToken); err != nil {
|
||||
fmt.Printf("Failed to send verification email to %s: %v\n", user.Email, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Response
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Registration successful. Please check your email to verify your account.",
|
||||
"user": toUserResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Verify email using token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param token query string true "Verification Token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/auth/verify-email [get]
|
||||
func VerifyEmail(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.Where("email_verify_token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
verified := true
|
||||
user.EmailVerified = &verified
|
||||
user.EmailVerifiedAt = &now
|
||||
user.EmailVerifyToken = "" // Clear token
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify email"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login user
|
||||
// @Description Login with email and password, returns tokens
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param login body LoginPayload true "Login payload"
|
||||
// @Success 200 {object} controllers.AuthResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload LoginPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.Where("email = ?", payload.Email).First(&user).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPasswordHash(payload.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if user.EmailVerified != nil && !*user.EmailVerified {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "email not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil && *user.IsAdmin {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
jwtService := services.NewJWTService()
|
||||
accessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
|
||||
return
|
||||
}
|
||||
refreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": toUserResponse(user),
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary Refresh access token
|
||||
// @Description usage: send refresh token to get new access token and refresh token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param refresh body RefreshPayload true "Refresh token payload"
|
||||
// @Success 200 {object} map[string]string "Returns both access_token and refresh_token"
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func Refresh(c *gin.Context) {
|
||||
var payload RefreshPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
jwtService := services.NewJWTService()
|
||||
claims, err := jwtService.ValidateToken(payload.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
if claims.TokenType != "refresh" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not a refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get User
|
||||
var userID uint
|
||||
switch v := claims.UserID.(type) {
|
||||
case float64:
|
||||
userID = uint(v)
|
||||
case uint:
|
||||
userID = v
|
||||
default:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil && *user.IsAdmin {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
newAccessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
newRefreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": newAccessToken,
|
||||
"refresh_token": newRefreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current user (me)
|
||||
// @Description Get current authenticated user information
|
||||
// @Tags auth
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/me [get]
|
||||
func Me(c *gin.Context) {
|
||||
claims, ok := middlewares.GetAuthClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var userID uint
|
||||
switch v := claims.UserID.(type) {
|
||||
case float64:
|
||||
userID = uint(v)
|
||||
case uint:
|
||||
userID = v
|
||||
default:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil && *user.IsAdmin {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
isVerified := false
|
||||
if user.EmailVerified != nil && *user.EmailVerified {
|
||||
isVerified = true
|
||||
}
|
||||
|
||||
// Frontend'in beklediği formata göre response döndür
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.UserName,
|
||||
"email": user.Email,
|
||||
"email_verified": isVerified,
|
||||
"is_admin": isAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuth Helpers
|
||||
var (
|
||||
googleOauthConfig = &oauth2.Config{
|
||||
RedirectURL: "", // Will be set in init or handler
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
githubOauthConfig = &oauth2.Config{
|
||||
RedirectURL: "",
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
Scopes: []string{"user:email"},
|
||||
Endpoint: github.Endpoint,
|
||||
}
|
||||
)
|
||||
|
||||
func getGoogleConfig() *oauth2.Config {
|
||||
googleOauthConfig.ClientID = configs.AppConfig.GoogleClientID
|
||||
googleOauthConfig.ClientSecret = configs.AppConfig.GoogleClientSecret
|
||||
googleOauthConfig.RedirectURL = configs.AppConfig.GoogleRedirectURL
|
||||
return googleOauthConfig
|
||||
}
|
||||
|
||||
func getGithubConfig() *oauth2.Config {
|
||||
githubOauthConfig.ClientID = configs.AppConfig.GithubClientID
|
||||
githubOauthConfig.ClientSecret = configs.AppConfig.GithubClientSecret
|
||||
githubOauthConfig.RedirectURL = configs.AppConfig.GithubRedirectURL
|
||||
return githubOauthConfig
|
||||
}
|
||||
|
||||
// GoogleLogin godoc
|
||||
// @Summary Google OAuth2 Login
|
||||
// @Description Redirects to Google for authentication
|
||||
// @Tags auth
|
||||
// @Success 302
|
||||
// @Router /api/v1/auth/google [get]
|
||||
func GoogleLogin(c *gin.Context) {
|
||||
url := getGoogleConfig().AuthCodeURL("state_google", oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// GoogleCallback godoc
|
||||
// @Summary Google OAuth2 Callback
|
||||
// @Description Handles Google OAuth2 callback
|
||||
// @Tags auth
|
||||
// @Success 200 {object} controllers.AuthResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/google/callback [get]
|
||||
func GoogleCallback(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
token, err := getGoogleConfig().Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client := getGoogleConfig().Client(context.Background(), token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
userData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"})
|
||||
return
|
||||
}
|
||||
|
||||
var googleUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
VerifiedEmail bool `json:"verified_email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
if err := json.Unmarshal(userData, &googleUser); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"})
|
||||
return
|
||||
}
|
||||
|
||||
handleSocialLogin(c, "google", googleUser.ID, googleUser.Email, googleUser.Name, googleUser.Picture)
|
||||
}
|
||||
|
||||
// GithubLogin godoc
|
||||
// @Summary GitHub OAuth2 Login
|
||||
// @Description Redirects to GitHub for authentication
|
||||
// @Tags auth
|
||||
// @Success 302
|
||||
// @Router /api/v1/auth/github [get]
|
||||
func GithubLogin(c *gin.Context) {
|
||||
url := getGithubConfig().AuthCodeURL("state_github", oauth2.AccessTypeOffline)
|
||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
|
||||
// GithubCallback godoc
|
||||
// @Summary GitHub OAuth2 Callback
|
||||
// @Description Handles GitHub OAuth2 callback
|
||||
// @Tags auth
|
||||
// @Success 200 {object} controllers.AuthResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/github/callback [get]
|
||||
func GithubCallback(c *gin.Context) {
|
||||
code := c.Query("code")
|
||||
token, err := getGithubConfig().Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client := getGithubConfig().Client(context.Background(), token)
|
||||
resp, err := client.Get("https://api.github.com/user")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
userData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"})
|
||||
return
|
||||
}
|
||||
|
||||
var githubUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
if err := json.Unmarshal(userData, &githubUser); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"})
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub email might be private, need to fetch separately if empty
|
||||
email := githubUser.Email
|
||||
if email == "" {
|
||||
// Fetch emails
|
||||
emailResp, err := client.Get("https://api.github.com/user/emails")
|
||||
if err == nil {
|
||||
defer emailResp.Body.Close()
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
if body, err := io.ReadAll(emailResp.Body); err == nil {
|
||||
json.Unmarshal(body, &emails)
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified {
|
||||
email = e.Email
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Could not retrieve email from GitHub"})
|
||||
return
|
||||
}
|
||||
|
||||
handleSocialLogin(c, "github", fmt.Sprintf("%d", githubUser.ID), email, githubUser.Name, githubUser.AvatarURL)
|
||||
}
|
||||
|
||||
func handleSocialLogin(c *gin.Context, provider, providerID, email, name, avatarURL string) {
|
||||
var user models.User
|
||||
var socialAccount models.SocialAccount
|
||||
|
||||
// Check if social account exists
|
||||
err := database.DB.Where("provider = ? AND provider_id = ?", provider, providerID).First(&socialAccount).Error
|
||||
|
||||
if err == nil {
|
||||
// Found social account, find user
|
||||
if err := database.DB.First(&user, socialAccount.UserID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "User record missing for social account"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Social account not found. Check if email exists
|
||||
if err := database.DB.Where("email = ?", email).First(&user).Error; err == nil {
|
||||
// User exists, add social account
|
||||
newSocial := models.SocialAccount{
|
||||
UserID: uint64(user.ID),
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
AvatarURL: avatarURL,
|
||||
}
|
||||
database.DB.Create(&newSocial)
|
||||
} else {
|
||||
// Create new user
|
||||
verified := true
|
||||
now := time.Now()
|
||||
// Generate random password
|
||||
randomPass := generateSecureToken()
|
||||
hashedPwd, _ := utils.HashPassword(randomPass)
|
||||
|
||||
user = models.User{
|
||||
UserName: name, // Handle duplicate usernames?
|
||||
Email: email,
|
||||
Password: hashedPwd,
|
||||
EmailVerified: &verified,
|
||||
EmailVerifiedAt: &now,
|
||||
}
|
||||
// Fallback username if empty
|
||||
if user.UserName == "" {
|
||||
user.UserName = strings.Split(email, "@")[0]
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newSocial := models.SocialAccount{
|
||||
UserID: uint64(user.ID),
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
AvatarURL: avatarURL,
|
||||
}
|
||||
database.DB.Create(&newSocial)
|
||||
}
|
||||
}
|
||||
|
||||
// Login logic
|
||||
isAdmin := false
|
||||
if user.IsAdmin != nil && *user.IsAdmin {
|
||||
isAdmin = true
|
||||
}
|
||||
|
||||
jwtService := services.NewJWTService()
|
||||
accessToken, err := jwtService.GenerateToken(user.ID, isAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
|
||||
return
|
||||
}
|
||||
refreshToken, err := jwtService.GenerateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": toUserResponse(user),
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
2289
app/controllers/BlogContraller.go
Normal file
2289
app/controllers/BlogContraller.go
Normal file
File diff suppressed because it is too large
Load Diff
538
app/controllers/HeroController.go
Normal file
538
app/controllers/HeroController.go
Normal file
@@ -0,0 +1,538 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Hero payload
|
||||
type HeroPayload struct {
|
||||
Color string `json:"color" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
Text1 string `json:"text1"`
|
||||
Text2 string `json:"text2"`
|
||||
Text4 string `json:"text4"`
|
||||
Text5 string `json:"text5"`
|
||||
Image string `json:"image"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// AdminListHeroes godoc
|
||||
// @Summary Admin: List heroes
|
||||
// @Description Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with to include deleted.
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number"
|
||||
// @Param per_page query int false "Items per page"
|
||||
// @Param soft query string false "Soft delete filter: only|with"
|
||||
// @Success 200 {object} controllers.HeroListResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes [get]
|
||||
func AdminListHeroes(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "20")
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
soft := c.Query("soft")
|
||||
var query *gorm.DB
|
||||
if soft == "only" {
|
||||
query = database.DB.Unscoped().Model(&models.Hero{}).Where("deleted_at IS NOT NULL")
|
||||
} else if soft == "with" {
|
||||
query = database.DB.Unscoped().Model(&models.Hero{})
|
||||
} else {
|
||||
query = database.DB.Model(&models.Hero{})
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Hero
|
||||
if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
// AdminGetHero godoc
|
||||
// @Summary Admin: Get a hero by id
|
||||
// @Description Return a single hero by id
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Hero ID"
|
||||
// @Success 200 {object} controllers.HeroResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes/{id} [get]
|
||||
func AdminGetHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var h models.Hero
|
||||
if err := database.DB.Unscoped().First(&h, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": h})
|
||||
}
|
||||
|
||||
// CreateHero godoc
|
||||
// @Summary Admin: Create a hero
|
||||
// @Description Create a new hero item (multipart/form-data)
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param color formData string true "Color"
|
||||
// @Param title formData string false "Title"
|
||||
// @Param text1 formData string false "Text1"
|
||||
// @Param text2 formData string false "Text2"
|
||||
// @Param text4 formData string false "Text4"
|
||||
// @Param text5 formData string false "Text5"
|
||||
// @Param is_active formData boolean false "Is Active"
|
||||
// @Param width formData int false "Image width (frontend-provided)"
|
||||
// @Param height formData int false "Image height (frontend-provided)"
|
||||
// @Param quality formData int false "Image quality (frontend-provided)"
|
||||
// @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)"
|
||||
// @Param image formData file false "Image file"
|
||||
// @Success 201 {object} controllers.HeroResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes [post]
|
||||
func CreateHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
// Parse form fields
|
||||
color := c.PostForm("color")
|
||||
if color == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "color is required"})
|
||||
return
|
||||
}
|
||||
title := c.PostForm("title")
|
||||
text1 := c.PostForm("text1")
|
||||
text2 := c.PostForm("text2")
|
||||
text4 := c.PostForm("text4")
|
||||
text5 := c.PostForm("text5")
|
||||
isActive := true
|
||||
if v := c.PostForm("is_active"); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
isActive = b
|
||||
}
|
||||
}
|
||||
// optional frontend-provided image metadata
|
||||
var width, height, quality int
|
||||
if w := c.PostForm("width"); w != "" {
|
||||
if wi, err := strconv.Atoi(w); err == nil {
|
||||
width = wi
|
||||
}
|
||||
}
|
||||
if h := c.PostForm("height"); h != "" {
|
||||
if hi, err := strconv.Atoi(h); err == nil {
|
||||
height = hi
|
||||
}
|
||||
}
|
||||
if q := c.PostForm("quality"); q != "" {
|
||||
if qi, err := strconv.Atoi(q); err == nil {
|
||||
quality = qi
|
||||
}
|
||||
}
|
||||
format := c.PostForm("format")
|
||||
|
||||
hero := models.Hero{
|
||||
Color: color,
|
||||
Title: title,
|
||||
Text1: text1,
|
||||
Text2: text2,
|
||||
Text4: text4,
|
||||
Text5: text5,
|
||||
IsActive: isActive,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Quality: quality,
|
||||
Format: format,
|
||||
}
|
||||
|
||||
// handle file upload (no server-side image processing)
|
||||
file, err := c.FormFile("image")
|
||||
if err == nil {
|
||||
// ensure uploads/heroes exists
|
||||
uploadDir := filepath.Join("uploads", "heroes")
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"})
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext)
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
hero.Image = "/uploads/heroes/" + newName
|
||||
// do not attempt to decode/process image here; frontend provides metadata
|
||||
// if format not provided, fallback to extension without dot
|
||||
if heroFormat := format; heroFormat == "" {
|
||||
if ext != "" {
|
||||
hero.Format = ext[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&hero).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": hero})
|
||||
}
|
||||
|
||||
// UpdateHero godoc
|
||||
// @Summary Admin: Update a hero
|
||||
// @Description Update an existing hero (multipart/form-data)
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param id path int true "Hero ID"
|
||||
// @Param color formData string false "Color"
|
||||
// @Param title formData string false "Title"
|
||||
// @Param text1 formData string false "Text1"
|
||||
// @Param text2 formData string false "Text2"
|
||||
// @Param text4 formData string false "Text4"
|
||||
// @Param text5 formData string false "Text5"
|
||||
// @Param is_active formData boolean false "Is Active"
|
||||
// @Param width formData int false "Image width (frontend-provided)"
|
||||
// @Param height formData int false "Image height (frontend-provided)"
|
||||
// @Param quality formData int false "Image quality (frontend-provided)"
|
||||
// @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)"
|
||||
// @Param image formData file false "Image file"
|
||||
// @Success 200 {object} controllers.HeroResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes/{id} [put]
|
||||
func UpdateHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var h models.Hero
|
||||
if err := database.DB.First(&h, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// read form fields (if present)
|
||||
if color := c.PostForm("color"); color != "" {
|
||||
h.Color = color
|
||||
}
|
||||
if title := c.PostForm("title"); title != "" {
|
||||
h.Title = title
|
||||
}
|
||||
if t := c.PostForm("text1"); t != "" {
|
||||
h.Text1 = t
|
||||
}
|
||||
if t := c.PostForm("text2"); t != "" {
|
||||
h.Text2 = t
|
||||
}
|
||||
if t := c.PostForm("text4"); t != "" {
|
||||
h.Text4 = t
|
||||
}
|
||||
if t := c.PostForm("text5"); t != "" {
|
||||
h.Text5 = t
|
||||
}
|
||||
if v := c.PostForm("is_active"); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
h.IsActive = b
|
||||
}
|
||||
}
|
||||
// optional frontend-provided image metadata
|
||||
if w := c.PostForm("width"); w != "" {
|
||||
if wi, err := strconv.Atoi(w); err == nil {
|
||||
h.Width = wi
|
||||
}
|
||||
}
|
||||
if hgt := c.PostForm("height"); hgt != "" {
|
||||
if hi, err := strconv.Atoi(hgt); err == nil {
|
||||
h.Height = hi
|
||||
}
|
||||
}
|
||||
if q := c.PostForm("quality"); q != "" {
|
||||
if qi, err := strconv.Atoi(q); err == nil {
|
||||
h.Quality = qi
|
||||
}
|
||||
}
|
||||
if fmtStr := c.PostForm("format"); fmtStr != "" {
|
||||
h.Format = fmtStr
|
||||
}
|
||||
|
||||
// handle optional file upload (no server-side processing)
|
||||
file, err := c.FormFile("image")
|
||||
if err == nil {
|
||||
// Save new file first
|
||||
uploadDir := filepath.Join("uploads", "heroes")
|
||||
if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"})
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext)
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If there was a previous image, attempt to remove it safely
|
||||
prev := h.Image
|
||||
if prev != "" {
|
||||
// normalize and ensure it's inside uploads/
|
||||
prevPath := strings.TrimPrefix(prev, "/")
|
||||
clean := filepath.Clean(prevPath)
|
||||
// only remove files under uploads/ to avoid accidental deletions
|
||||
if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) {
|
||||
_ = os.Remove(clean) // ignore error
|
||||
}
|
||||
}
|
||||
|
||||
h.Image = "/uploads/heroes/" + newName
|
||||
// if format not provided by frontend, fallback to extension
|
||||
if h.Format == "" && ext != "" {
|
||||
h.Format = ext[1:]
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&h).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": h})
|
||||
}
|
||||
|
||||
// DeleteHero godoc
|
||||
// @Summary Admin: Delete a hero
|
||||
// @Description Soft-delete a hero by ID
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Hero ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes/{id} [delete]
|
||||
func DeleteHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var h models.Hero
|
||||
if err := database.DB.First(&h, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := database.DB.Delete(&h).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// attempt to remove image file if present
|
||||
if h.Image != "" {
|
||||
imgPath := strings.TrimPrefix(h.Image, "/")
|
||||
clean := filepath.Clean(imgPath)
|
||||
if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) {
|
||||
_ = os.Remove(clean)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "hero deleted successfully", "id": h.ID})
|
||||
}
|
||||
|
||||
// RestoreHero godoc
|
||||
// @Summary Admin: Restore a soft-deleted hero
|
||||
// @Description Restore a soft-deleted hero by ID
|
||||
// @Tags heroes
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Hero ID"
|
||||
// @Success 200 {object} controllers.HeroResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/heroes/{id}/restore [post]
|
||||
func RestoreHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var h models.Hero
|
||||
// Find soft-deleted record with Unscoped
|
||||
if err := database.DB.Unscoped().Where("id = ?", id).First(&h).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Clear deleted_at (restore)
|
||||
if err := database.DB.Unscoped().Model(&models.Hero{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Reload the record in normal scope to ensure DeletedAt is nil in struct
|
||||
if err := database.DB.First(&h, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": h})
|
||||
}
|
||||
|
||||
// ListHeroes godoc
|
||||
// @Summary Public: List heroes
|
||||
// @Description Return active heroes with pagination
|
||||
// @Tags heroes
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number"
|
||||
// @Param per_page query int false "Items per page"
|
||||
// @Success 200 {object} controllers.HeroListResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/heroes [get]
|
||||
func ListHeroes(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "20")
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
query := database.DB.Model(&models.Hero{}).Where("is_active = ?", true)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Hero
|
||||
if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
// GetHero godoc
|
||||
// @Summary Public: Get a hero by id
|
||||
// @Description Return a single hero by id
|
||||
// @Tags heroes
|
||||
// @Produce json
|
||||
// @Param id path int true "Hero ID"
|
||||
// @Success 200 {object} controllers.HeroResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/heroes/{id} [get]
|
||||
func GetHero(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var h models.Hero
|
||||
if err := database.DB.First(&h, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": h})
|
||||
}
|
||||
758
app/controllers/SettingController.go
Normal file
758
app/controllers/SettingController.go
Normal file
@@ -0,0 +1,758 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Payload for creating/updating settings
|
||||
type SettingPayload struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
MetaTitle string `json:"meta_title" binding:"required"`
|
||||
MetaDescription string `json:"meta_description" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Facebook string `json:"facebook"`
|
||||
X string `json:"x"`
|
||||
Instagram string `json:"instagram"`
|
||||
Whatsapp string `json:"whatsapp"`
|
||||
Pinterest string `json:"pinterest"`
|
||||
Linkedin string `json:"linkedin"`
|
||||
Slogan string `json:"slogan"`
|
||||
Address string `json:"address"`
|
||||
Copyright string `json:"copyright"`
|
||||
MapEmbed string `json:"map_embed"`
|
||||
WLogo string `json:"w_logo"`
|
||||
BLogo string `json:"b_logo"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
// Optional image transformation / dimension settings
|
||||
WWidth *int `json:"w_width"`
|
||||
WHeight *int `json:"w_height"`
|
||||
WQuality *int `json:"w_quality"`
|
||||
WFormat string `json:"w_format"`
|
||||
BWidth *int `json:"b_width"`
|
||||
BHeight *int `json:"b_height"`
|
||||
BQuality *int `json:"b_quality"`
|
||||
BFormat string `json:"b_format"`
|
||||
}
|
||||
|
||||
// AdminListSettings godoc
|
||||
// @Summary Admin: List settings
|
||||
// @Description Admin listing of settings. Use ?soft=only to list deleted, ?soft=with to include deleted.
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number"
|
||||
// @Param per_page query int false "Items per page"
|
||||
// @Param soft query string false "Soft delete filter: only|with"
|
||||
// @Success 200 {object} controllers.SettingListResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings [get]
|
||||
func AdminListSettings(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "20")
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 200 {
|
||||
perPage = 200
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
soft := c.Query("soft")
|
||||
var query *gorm.DB
|
||||
if soft == "only" {
|
||||
query = database.DB.Unscoped().Model(&models.Setting{}).Where("deleted_at IS NOT NULL")
|
||||
} else if soft == "with" {
|
||||
query = database.DB.Unscoped().Model(&models.Setting{})
|
||||
} else {
|
||||
query = database.DB.Model(&models.Setting{})
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.Setting
|
||||
if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
// AdminGetSetting godoc
|
||||
// @Summary Admin: Get a setting by id
|
||||
// @Description Return a single setting by id
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Setting ID"
|
||||
// @Success 200 {object} controllers.SettingResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings/{id} [get]
|
||||
func AdminGetSetting(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var s models.Setting
|
||||
if err := database.DB.Unscoped().First(&s, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||
}
|
||||
|
||||
// AdminCreateSetting godoc
|
||||
// @Summary Admin: Create a setting
|
||||
// @Description Create a new setting
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param title formData string true "Title"
|
||||
// @Param meta_title formData string true "Meta title"
|
||||
// @Param meta_description formData string true "Meta description"
|
||||
// @Param phone formData string true "Phone"
|
||||
// @Param url formData string true "URL"
|
||||
// @Param email formData string true "Email"
|
||||
// @Param facebook formData string false "Facebook"
|
||||
// @Param x formData string false "X"
|
||||
// @Param instagram formData string false "Instagram"
|
||||
// @Param whatsapp formData string false "Whatsapp"
|
||||
// @Param pinterest formData string false "Pinterest"
|
||||
// @Param linkedin formData string false "Linkedin"
|
||||
// @Param slogan formData string false "Slogan"
|
||||
// @Param address formData string false "Address"
|
||||
// @Param copyright formData string false "Copyright"
|
||||
// @Param map_embed formData string false "Map embed"
|
||||
// @Param w_logo formData file false "White logo file upload (or provide w_logo path as string)"
|
||||
// @Param b_logo formData file false "Black logo file upload (or provide b_logo path as string)"
|
||||
// @Param is_active formData boolean false "Is active"
|
||||
// @Param w_width formData int false "W logo width"
|
||||
// @Param w_height formData int false "W logo height"
|
||||
// @Param w_quality formData int false "W logo quality"
|
||||
// @Param w_format formData string false "W logo format"
|
||||
// @Param b_width formData int false "B logo width"
|
||||
// @Param b_height formData int false "B logo height"
|
||||
// @Param b_quality formData int false "B logo quality"
|
||||
// @Param b_format formData string false "B logo format"
|
||||
// @Success 201 {object} controllers.SettingResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings [post]
|
||||
func AdminCreateSetting(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
// Support both JSON and multipart/form-data
|
||||
var payload SettingPayload
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
// read form fields
|
||||
payload.Title = c.PostForm("title")
|
||||
payload.MetaTitle = c.PostForm("meta_title")
|
||||
payload.MetaDescription = c.PostForm("meta_description")
|
||||
payload.Phone = c.PostForm("phone")
|
||||
payload.URL = c.PostForm("url")
|
||||
payload.Email = c.PostForm("email")
|
||||
payload.Facebook = c.PostForm("facebook")
|
||||
payload.X = c.PostForm("x")
|
||||
payload.Instagram = c.PostForm("instagram")
|
||||
payload.Whatsapp = c.PostForm("whatsapp")
|
||||
payload.Pinterest = c.PostForm("pinterest")
|
||||
payload.Linkedin = c.PostForm("linkedin")
|
||||
payload.Slogan = c.PostForm("slogan")
|
||||
payload.Address = c.PostForm("address")
|
||||
payload.Copyright = c.PostForm("copyright")
|
||||
payload.MapEmbed = c.PostForm("map_embed")
|
||||
// keep payload.WLogo/BLogo as string if client sends path
|
||||
payload.WLogo = c.PostForm("w_logo")
|
||||
payload.BLogo = c.PostForm("b_logo")
|
||||
if v := c.PostForm("is_active"); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
payload.IsActive = &b
|
||||
}
|
||||
}
|
||||
// numeric metadata
|
||||
if v := c.PostForm("w_width"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.WWidth = &n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_height"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.WHeight = &n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_quality"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.WQuality = &n
|
||||
}
|
||||
}
|
||||
payload.WFormat = c.PostForm("w_format")
|
||||
if v := c.PostForm("b_width"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.BWidth = &n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("b_height"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.BHeight = &n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("b_quality"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload.BQuality = &n
|
||||
}
|
||||
}
|
||||
payload.BFormat = c.PostForm("b_format")
|
||||
} else {
|
||||
// JSON
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// basic required validation
|
||||
if payload.Title == "" || payload.MetaTitle == "" || payload.MetaDescription == "" || payload.Phone == "" || payload.URL == "" || payload.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"})
|
||||
return
|
||||
}
|
||||
|
||||
isActive := false
|
||||
if payload.IsActive != nil {
|
||||
isActive = *payload.IsActive
|
||||
}
|
||||
setting := models.Setting{
|
||||
Title: payload.Title,
|
||||
MetaTitle: payload.MetaTitle,
|
||||
MetaDescription: payload.MetaDescription,
|
||||
Phone: payload.Phone,
|
||||
URL: payload.URL,
|
||||
Email: payload.Email,
|
||||
Facebook: payload.Facebook,
|
||||
X: payload.X,
|
||||
Instagram: payload.Instagram,
|
||||
Whatsapp: payload.Whatsapp,
|
||||
Pinterest: payload.Pinterest,
|
||||
Linkedin: payload.Linkedin,
|
||||
Slogan: payload.Slogan,
|
||||
Address: payload.Address,
|
||||
Copyright: payload.Copyright,
|
||||
MapEmbed: payload.MapEmbed,
|
||||
WLogo: payload.WLogo,
|
||||
BLogo: payload.BLogo,
|
||||
IsActive: isActive,
|
||||
}
|
||||
// optional image transform params
|
||||
if payload.WWidth != nil {
|
||||
setting.WWidth = *payload.WWidth
|
||||
}
|
||||
if payload.WHeight != nil {
|
||||
setting.WHeight = *payload.WHeight
|
||||
}
|
||||
if payload.WQuality != nil {
|
||||
setting.WQuality = *payload.WQuality
|
||||
}
|
||||
setting.WFormat = payload.WFormat
|
||||
if payload.BWidth != nil {
|
||||
setting.BWidth = *payload.BWidth
|
||||
}
|
||||
if payload.BHeight != nil {
|
||||
setting.BHeight = *payload.BHeight
|
||||
}
|
||||
if payload.BQuality != nil {
|
||||
setting.BQuality = *payload.BQuality
|
||||
}
|
||||
setting.BFormat = payload.BFormat
|
||||
|
||||
// Handle optional logo file uploads when multipart/form-data
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
// Support file upload on field name 'w_logo' (preferred) or fallback to provided path
|
||||
if file, err := c.FormFile("w_logo"); err == nil {
|
||||
uploadDir := filepath.Join("uploads", "logos")
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := "wlogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err == nil {
|
||||
setting.WLogo = "/uploads/logos/" + newName
|
||||
if setting.WFormat == "" && ext != "" {
|
||||
setting.WFormat = ext[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Support file upload on field name 'b_logo'
|
||||
if file, err := c.FormFile("b_logo"); err == nil {
|
||||
uploadDir := filepath.Join("uploads", "logos")
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := "blogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err == nil {
|
||||
setting.BLogo = "/uploads/logos/" + newName
|
||||
if setting.BFormat == "" && ext != "" {
|
||||
setting.BFormat = ext[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce single active setting rule
|
||||
if setting.IsActive {
|
||||
// Deactivate all other settings
|
||||
if err := database.DB.Model(&models.Setting{}).Where("1 = 1").Update("is_active", false).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&setting).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": setting})
|
||||
}
|
||||
|
||||
// AdminUpdateSetting godoc
|
||||
// @Summary Admin: Update a setting
|
||||
// @Description Update an existing setting
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param id path int true "Setting ID"
|
||||
// @Param title formData string false "Title"
|
||||
// @Param meta_title formData string false "Meta title"
|
||||
// @Param meta_description formData string false "Meta description"
|
||||
// @Param phone formData string false "Phone"
|
||||
// @Param url formData string false "URL"
|
||||
// @Param email formData string false "Email"
|
||||
// @Param facebook formData string false "Facebook"
|
||||
// @Param x formData string false "X"
|
||||
// @Param instagram formData string false "Instagram"
|
||||
// @Param whatsapp formData string false "Whatsapp"
|
||||
// @Param pinterest formData string false "Pinterest"
|
||||
// @Param linkedin formData string false "Linkedin"
|
||||
// @Param slogan formData string false "Slogan"
|
||||
// @Param address formData string false "Address"
|
||||
// @Param copyright formData string false "Copyright"
|
||||
// @Param map_embed formData string false "Map embed"
|
||||
// @Param w_logo formData file false "White logo file upload (or provide w_logo path as string)"
|
||||
// @Param b_logo formData file false "Black logo file upload (or provide b_logo path as string)"
|
||||
// @Param is_active formData boolean false "Is active"
|
||||
// @Param w_width formData int false "W logo width"
|
||||
// @Param w_height formData int false "W logo height"
|
||||
// @Param w_quality formData int false "W logo quality"
|
||||
// @Param w_format formData string false "W logo format"
|
||||
// @Param b_width formData int false "B logo width"
|
||||
// @Param b_height formData int false "B logo height"
|
||||
// @Param b_quality formData int false "B logo quality"
|
||||
// @Param b_format formData string false "B logo format"
|
||||
// @Success 200 {object} controllers.SettingResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings/{id} [put]
|
||||
func AdminUpdateSetting(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var s models.Setting
|
||||
if err := database.DB.First(&s, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
// read form fields and update if present
|
||||
if v := c.PostForm("title"); v != "" {
|
||||
s.Title = v
|
||||
}
|
||||
if v := c.PostForm("meta_title"); v != "" {
|
||||
s.MetaTitle = v
|
||||
}
|
||||
if v := c.PostForm("meta_description"); v != "" {
|
||||
s.MetaDescription = v
|
||||
}
|
||||
if v := c.PostForm("phone"); v != "" {
|
||||
s.Phone = v
|
||||
}
|
||||
if v := c.PostForm("url"); v != "" {
|
||||
s.URL = v
|
||||
}
|
||||
if v := c.PostForm("email"); v != "" {
|
||||
s.Email = v
|
||||
}
|
||||
if v := c.PostForm("facebook"); v != "" {
|
||||
s.Facebook = v
|
||||
}
|
||||
if v := c.PostForm("x"); v != "" {
|
||||
s.X = v
|
||||
}
|
||||
if v := c.PostForm("instagram"); v != "" {
|
||||
s.Instagram = v
|
||||
}
|
||||
if v := c.PostForm("whatsapp"); v != "" {
|
||||
s.Whatsapp = v
|
||||
}
|
||||
if v := c.PostForm("pinterest"); v != "" {
|
||||
s.Pinterest = v
|
||||
}
|
||||
if v := c.PostForm("linkedin"); v != "" {
|
||||
s.Linkedin = v
|
||||
}
|
||||
if v := c.PostForm("slogan"); v != "" {
|
||||
s.Slogan = v
|
||||
}
|
||||
if v := c.PostForm("address"); v != "" {
|
||||
s.Address = v
|
||||
}
|
||||
if v := c.PostForm("copyright"); v != "" {
|
||||
s.Copyright = v
|
||||
}
|
||||
if v := c.PostForm("map_embed"); v != "" {
|
||||
s.MapEmbed = v
|
||||
}
|
||||
if v := c.PostForm("w_logo"); v != "" {
|
||||
s.WLogo = v
|
||||
}
|
||||
if v := c.PostForm("b_logo"); v != "" {
|
||||
s.BLogo = v
|
||||
}
|
||||
if v := c.PostForm("is_active"); v != "" {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
s.IsActive = b
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_width"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.WWidth = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_height"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.WHeight = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_quality"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.WQuality = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("w_format"); v != "" {
|
||||
s.WFormat = v
|
||||
}
|
||||
if v := c.PostForm("b_width"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.BWidth = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("b_height"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.BHeight = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("b_quality"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
s.BQuality = n
|
||||
}
|
||||
}
|
||||
if v := c.PostForm("b_format"); v != "" {
|
||||
s.BFormat = v
|
||||
}
|
||||
|
||||
// Handle optional file uploads
|
||||
if file, err := c.FormFile("w_logo"); err == nil {
|
||||
uploadDir := filepath.Join("uploads", "logos")
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := "wlogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err == nil {
|
||||
s.WLogo = "/uploads/logos/" + newName
|
||||
if s.WFormat == "" && ext != "" {
|
||||
s.WFormat = ext[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
if file, err := c.FormFile("b_logo"); err == nil {
|
||||
uploadDir := filepath.Join("uploads", "logos")
|
||||
_ = os.MkdirAll(uploadDir, os.ModePerm)
|
||||
ext := filepath.Ext(file.Filename)
|
||||
newName := "blogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext
|
||||
destination := filepath.Join(uploadDir, newName)
|
||||
if err := c.SaveUploadedFile(file, destination); err == nil {
|
||||
s.BLogo = "/uploads/logos/" + newName
|
||||
if s.BFormat == "" && ext != "" {
|
||||
s.BFormat = ext[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON payload
|
||||
var payload SettingPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// update fields from payload
|
||||
s.Title = payload.Title
|
||||
s.MetaTitle = payload.MetaTitle
|
||||
s.MetaDescription = payload.MetaDescription
|
||||
s.Phone = payload.Phone
|
||||
s.URL = payload.URL
|
||||
s.Email = payload.Email
|
||||
s.Facebook = payload.Facebook
|
||||
s.X = payload.X
|
||||
s.Instagram = payload.Instagram
|
||||
s.Whatsapp = payload.Whatsapp
|
||||
s.Pinterest = payload.Pinterest
|
||||
s.Linkedin = payload.Linkedin
|
||||
s.Slogan = payload.Slogan
|
||||
s.Address = payload.Address
|
||||
s.Copyright = payload.Copyright
|
||||
s.MapEmbed = payload.MapEmbed
|
||||
s.WLogo = payload.WLogo
|
||||
s.BLogo = payload.BLogo
|
||||
if payload.IsActive != nil {
|
||||
s.IsActive = *payload.IsActive
|
||||
}
|
||||
if payload.WWidth != nil {
|
||||
s.WWidth = *payload.WWidth
|
||||
}
|
||||
if payload.WHeight != nil {
|
||||
s.WHeight = *payload.WHeight
|
||||
}
|
||||
if payload.WQuality != nil {
|
||||
s.WQuality = *payload.WQuality
|
||||
}
|
||||
s.WFormat = payload.WFormat
|
||||
if payload.BWidth != nil {
|
||||
s.BWidth = *payload.BWidth
|
||||
}
|
||||
if payload.BHeight != nil {
|
||||
s.BHeight = *payload.BHeight
|
||||
}
|
||||
if payload.BQuality != nil {
|
||||
s.BQuality = *payload.BQuality
|
||||
}
|
||||
s.BFormat = payload.BFormat
|
||||
}
|
||||
|
||||
// Enforce single active setting rule
|
||||
if s.IsActive {
|
||||
// Deactivate all other settings except this one
|
||||
if err := database.DB.Model(&models.Setting{}).Where("id != ?", s.ID).Update("is_active", false).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||
}
|
||||
|
||||
// AdminDeleteSetting godoc
|
||||
// @Summary Admin: Delete a setting
|
||||
// @Description Soft-delete a setting by ID
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Setting ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings/{id} [delete]
|
||||
func AdminDeleteSetting(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var s models.Setting
|
||||
if err := database.DB.First(&s, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := database.DB.Delete(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// attempt to remove logo files if present (safe: only under uploads/)
|
||||
for _, p := range []string{s.WLogo, s.BLogo} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
imgPath := strings.TrimPrefix(p, "/")
|
||||
clean := filepath.Clean(imgPath)
|
||||
if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) {
|
||||
_ = os.Remove(clean)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "setting deleted successfully", "id": s.ID})
|
||||
}
|
||||
|
||||
// AdminRestoreSetting godoc
|
||||
// @Summary Admin: Restore a soft-deleted setting
|
||||
// @Description Restore a soft-deleted setting by ID
|
||||
// @Tags settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "Setting ID"
|
||||
// @Success 200 {object} controllers.SettingResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/settings/{id}/restore [post]
|
||||
func AdminRestoreSetting(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var s models.Setting
|
||||
// Find soft-deleted record using Unscoped
|
||||
if err := database.DB.Unscoped().Where("id = ?", id).First(&s).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// If DeletedAt is zero, record is not soft-deleted
|
||||
if s.DeletedAt.Time.IsZero() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "setting is not deleted"})
|
||||
return
|
||||
}
|
||||
// Clear deleted_at (restore) using Unscoped Model to allow update on soft-deleted rows
|
||||
res := database.DB.Unscoped().Model(&models.Setting{}).Where("id = ?", id).UpdateColumn("deleted_at", nil)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": res.Error.Error()})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "restore failed (no rows affected)"})
|
||||
return
|
||||
}
|
||||
// Reload the record in normal scope to ensure DeletedAt is nil in struct
|
||||
if err := database.DB.First(&s, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce single active setting rule if restored setting is active
|
||||
if s.IsActive {
|
||||
if err := database.DB.Model(&models.Setting{}).Where("id != ?", s.ID).Update("is_active", false).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||
}
|
||||
|
||||
// GetSettings godoc
|
||||
// @Summary Public: Get site settings
|
||||
// @Description Return the active site setting (latest active). If none active, return latest setting.
|
||||
// @Tags settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} controllers.SettingResponse
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings [get]
|
||||
func GetSettings(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
var s models.Setting
|
||||
// Try to find active setting
|
||||
if err := database.DB.Where("is_active = ?", true).Order("updated_at desc").First(&s).Error; err != nil {
|
||||
// if not found, fallback to latest
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if err2 := database.DB.Order("updated_at desc").First(&s).Error; err2 != nil {
|
||||
if err2 == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"data": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err2.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||
}
|
||||
374
app/controllers/UserControllers.go
Normal file
374
app/controllers/UserControllers.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
"goGin/app/middlewares"
|
||||
utils "goGin/pkg/utis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserResponse, kullanıcı verilerini güvenli bir şekilde döndürmek için
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserName string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
// AdminUserListItem, admin listesinde deleted_at ile ayırt etmek için
|
||||
type AdminUserListItem struct {
|
||||
UserResponse
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// UserPayload, kullanıcı güncelleme payload'u
|
||||
type UserPayload struct {
|
||||
UserName string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"` // Opsiyonel şifre güncellemesi
|
||||
}
|
||||
|
||||
// AdminUserUpdatePayload, admin tarafından kullanıcı güncelleme
|
||||
type AdminUserUpdatePayload struct {
|
||||
UserName string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin *bool `json:"is_admin"` // Pointer allows checking if field is present
|
||||
}
|
||||
|
||||
// Helper to convert model to response
|
||||
func toUserResponse(u models.User) UserResponse {
|
||||
isAdmin := false
|
||||
if u.IsAdmin != nil {
|
||||
isAdmin = *u.IsAdmin
|
||||
}
|
||||
isVerified := false
|
||||
if u.EmailVerified != nil {
|
||||
isVerified = *u.EmailVerified
|
||||
}
|
||||
|
||||
return UserResponse{
|
||||
ID: u.ID,
|
||||
UserName: u.UserName,
|
||||
Email: u.Email,
|
||||
EmailVerified: isVerified,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
// toAdminUserListItem, admin listesinde deleted_at döndürmek için
|
||||
func toAdminUserListItem(u models.User) AdminUserListItem {
|
||||
item := AdminUserListItem{UserResponse: toUserResponse(u)}
|
||||
if u.DeletedAt.Valid {
|
||||
item.DeletedAt = &u.DeletedAt.Time
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// GetProfile godoc
|
||||
// @Summary Get current user profile
|
||||
// @Description Get profile of the logged-in user
|
||||
// @Tags users
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} controllers.UserResponse
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/users/profile [get]
|
||||
func GetProfile(c *gin.Context) {
|
||||
claims, ok := middlewares.GetAuthClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.Preload("SocialAccounts").Preload("Profile").First(&user, claims.UserID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)})
|
||||
}
|
||||
|
||||
// UpdateProfile godoc
|
||||
// @Summary Update current user profile
|
||||
// @Description Update profile of the logged-in user
|
||||
// @Tags users
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param user body UserPayload true "User update payload"
|
||||
// @Success 200 {object} controllers.UserResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/users/profile [put]
|
||||
func UpdateProfile(c *gin.Context) {
|
||||
claims, ok := middlewares.GetAuthClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload UserPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, claims.UserID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.UserName != "" {
|
||||
user.UserName = payload.UserName
|
||||
}
|
||||
if payload.Email != "" {
|
||||
user.Email = payload.Email
|
||||
// Email değişirse doğrulama sıfırlanabilir
|
||||
f := false
|
||||
user.EmailVerified = &f
|
||||
}
|
||||
if payload.Password != "" {
|
||||
hashed, err := utils.HashPassword(payload.Password)
|
||||
if err == nil {
|
||||
user.Password = hashed
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)})
|
||||
}
|
||||
|
||||
// AdminListUsers godoc
|
||||
// @Summary Admin: List users
|
||||
// @Description Admin listing of users with pagination and search
|
||||
// @Tags users_admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number"
|
||||
// @Param per_page query int false "Items per page"
|
||||
// @Param q query string false "Search query (username or email)"
|
||||
// @Param soft query string false "Soft delete filter: only|with"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/users [get]
|
||||
func AdminListUsers(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"})
|
||||
return
|
||||
}
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
perPageStr := c.DefaultQuery("per_page", "20")
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
perPage, _ := strconv.Atoi(perPageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
soft := c.Query("soft")
|
||||
var query *gorm.DB
|
||||
if soft == "only" {
|
||||
query = database.DB.Unscoped().Model(&models.User{}).Where("deleted_at IS NOT NULL")
|
||||
} else if soft == "with" {
|
||||
query = database.DB.Unscoped().Model(&models.User{})
|
||||
} else {
|
||||
query = database.DB.Model(&models.User{})
|
||||
}
|
||||
|
||||
if q := c.Query("q"); q != "" {
|
||||
like := "%" + q + "%"
|
||||
query = query.Where("user_name LIKE ? OR email LIKE ?", like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var data []AdminUserListItem
|
||||
for _, u := range users {
|
||||
data = append(data, toAdminUserListItem(u))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": data, "total": total, "page": page, "per_page": perPage})
|
||||
}
|
||||
|
||||
// AdminGetUser godoc
|
||||
// @Summary Admin: Get user
|
||||
// @Description Get user details by ID
|
||||
// @Tags users_admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} controllers.UserResponse
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/users/{id} [get]
|
||||
func AdminGetUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
// Admin deleted kullanıcıyı da görebilmeli mi? Genelde evet, soft=with ile listede görüyorsa detayda da görmeli.
|
||||
// Varsayılan olarak normal get soft-deleted getirmez. Unscoped kullanalım veya id ile direk bakalım.
|
||||
if err := database.DB.Unscoped().First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)})
|
||||
}
|
||||
|
||||
// AdminUpdateUser godoc
|
||||
// @Summary Admin: Update user
|
||||
// @Description Update user details (admin)
|
||||
// @Tags users_admin
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Param user body AdminUserUpdatePayload true "User update payload"
|
||||
// @Success 200 {object} controllers.UserResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/users/{id} [put]
|
||||
func AdminUpdateUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload AdminUserUpdatePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.Unscoped().First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if payload.UserName != "" {
|
||||
user.UserName = payload.UserName
|
||||
}
|
||||
if payload.Email != "" {
|
||||
user.Email = payload.Email
|
||||
}
|
||||
if payload.IsAdmin != nil {
|
||||
user.IsAdmin = payload.IsAdmin
|
||||
}
|
||||
|
||||
if err := database.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)})
|
||||
}
|
||||
|
||||
// AdminDeleteUser godoc
|
||||
// @Summary Admin: Delete user
|
||||
// @Description Soft delete user
|
||||
// @Tags users_admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/users/{id} [delete]
|
||||
func AdminDeleteUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Delete(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "user deleted successfully",
|
||||
"id": user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminRestoreUser godoc
|
||||
// @Summary Admin: Restore user
|
||||
// @Description Restore soft-deleted user
|
||||
// @Tags users_admin
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} controllers.UserResponse
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/admin/users/{id}/restore [post]
|
||||
func AdminRestoreUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := database.DB.Unscoped().First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.DeletedAt.Valid {
|
||||
// Restore
|
||||
if err := database.DB.Unscoped().Model(&user).Update("deleted_at", nil).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)})
|
||||
}
|
||||
137
app/controllers/swagger_models.go
Normal file
137
app/controllers/swagger_models.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package controllers
|
||||
|
||||
import "time"
|
||||
|
||||
// Note: these structs are only used for Swagger documentation generation.
|
||||
// They intentionally avoid embedding gorm.Model to keep swag parser happy.
|
||||
|
||||
type CategorySimple struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Title string `json:"title" example:"News"`
|
||||
Slug string `json:"slug" example:"news"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
// AdminCategoryListItem admin listesinde deleted_at ile ayırt etmek için
|
||||
type AdminCategoryListItem struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Title string `json:"title" example:"News"`
|
||||
Slug string `json:"slug" example:"news"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type TagSimple struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"golang"`
|
||||
}
|
||||
|
||||
// AdminTagListItem admin listesinde deleted_at ile ayırt etmek için
|
||||
type AdminTagListItem struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Name string `json:"name" example:"golang"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
type PostResponse struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Title string `json:"title" example:"My post title"`
|
||||
Slug string `json:"slug" example:"my-post-title"`
|
||||
Images string `json:"images"`
|
||||
Content string `json:"content"`
|
||||
Categories []CategorySimple `json:"categories,omitempty"`
|
||||
Tags []TagSimple `json:"tags,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PostListResponse struct {
|
||||
Items []PostResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
|
||||
// New swagger-only types
|
||||
type CommentSimple struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
UserID uint `json:"user_id" example:"2"`
|
||||
PostID uint `json:"post_id" example:"1"`
|
||||
Body string `json:"body" example:"Nice post"`
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CategoryViewSimple struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
CategoryID uint `json:"category_id" example:"1"`
|
||||
IPAddress string `json:"ip_address" example:"127.0.0.1"`
|
||||
Created time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Setting swagger-only types
|
||||
type SettingResponse struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Title string `json:"title" example:"Site Başlığı"`
|
||||
MetaTitle string `json:"meta_title" example:"Meta Başlık"`
|
||||
MetaDescription string `json:"meta_description" example:"Site açıklaması"`
|
||||
Phone string `json:"phone" example:" +90 555 555 55 55"`
|
||||
URL string `json:"url" example:"https://example.com"`
|
||||
Email string `json:"email" example:"info@example.com"`
|
||||
Facebook string `json:"facebook" example:"https://facebook.com/example"`
|
||||
X string `json:"x" example:"https://x.com/example"`
|
||||
Instagram string `json:"instagram" example:"https://instagram.com/example"`
|
||||
Whatsapp string `json:"whatsapp" example:"https://wa.me/90555"`
|
||||
Pinterest string `json:"pinterest" example:"https://pinterest.com/example"`
|
||||
Linkedin string `json:"linkedin" example:"https://linkedin.com/company/example"`
|
||||
Slogan string `json:"slogan" example:"En iyi içerik"`
|
||||
Address string `json:"address" example:"Adres örneği"`
|
||||
Copyright string `json:"copyright" example:"© 2026 Example"`
|
||||
MapEmbed string `json:"map_embed"`
|
||||
WLogo string `json:"w_logo"`
|
||||
BLogo string `json:"b_logo"`
|
||||
IsActive bool `json:"is_active"`
|
||||
// image transform / metadata fields (match app/database/models/setting.go)
|
||||
WWidth int `json:"w_width"`
|
||||
WHeight int `json:"w_height"`
|
||||
WQuality int `json:"w_quality"`
|
||||
WFormat string `json:"w_format"`
|
||||
BWidth int `json:"b_width"`
|
||||
BHeight int `json:"b_height"`
|
||||
BQuality int `json:"b_quality"`
|
||||
BFormat string `json:"b_format"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SettingListResponse struct {
|
||||
Items []SettingResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
|
||||
// Hero swagger-only types
|
||||
type HeroResponse struct {
|
||||
ID uint `json:"id" example:"1"`
|
||||
Color string `json:"color" example:"#ffffff"`
|
||||
Title string `json:"title" example:"Hero Başlık"`
|
||||
Text1 string `json:"text1" example:"Kısa açıklama"`
|
||||
Text2 string `json:"text2" example:"İkinci metin"`
|
||||
Text4 string `json:"text4" example:"Yardımcı metin"`
|
||||
Text5 string `json:"text5" example:"Ek metin"`
|
||||
Image string `json:"image" example:"/uploads/heroes/img.jpg"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Width int `json:"width" example:"1920"`
|
||||
Height int `json:"height" example:"1080"`
|
||||
Quality int `json:"quality" example:"80"`
|
||||
Format string `json:"format" example:"jpeg"`
|
||||
Created time.Time `json:"created_at"`
|
||||
Updated time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HeroListResponse struct {
|
||||
Items []HeroResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
}
|
||||
39
app/database/config/mysql_db.go
Normal file
39
app/database/config/mysql_db.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
configs "goGin/config"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func ConnectDB() {
|
||||
dsn := configs.AppConfig.DBUrl
|
||||
if dsn == "" {
|
||||
log.Println(".env dosyasında DB_URL ayarlı değil — veritabanı bağlantısı atlanıyor (geliştirme modu)")
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Yapılandırmada DB_URL bulundu, veritabanına bağlanılmaya çalışılıyor...")
|
||||
|
||||
// GORM için MySQL konfigürasyonu
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info), // Info seviyesi (performans etkileyebilir); üretimde Error seviyesine alınabilir
|
||||
PrepareStmt: true, // PrepareStmt performansını artırmak için
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Println("MySQL veritabanı bağlantısı kurulamadı:", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("MySQL bağlantısı Sağlandı.")
|
||||
DB = db
|
||||
}
|
||||
108
app/database/config/redis_db.go
Normal file
108
app/database/config/redis_db.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/redis/go-redis/v9"
|
||||
config "goGin/config"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var RedisClient *redis.Client
|
||||
var RedisOptions *redis.Options
|
||||
var ctx = context.Background()
|
||||
|
||||
func ConnectRedis() {
|
||||
redisURL := config.AppConfig.RedisUrl
|
||||
if redisURL == "" {
|
||||
log.Println("Warning: REDIS_URL is not set, continuing without Redis cache")
|
||||
return
|
||||
}
|
||||
|
||||
opt, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse Redis URL: %v, continuing without Redis cache", err)
|
||||
RedisOptions = nil
|
||||
return
|
||||
}
|
||||
|
||||
RedisOptions = opt
|
||||
RedisClient = redis.NewClient(opt)
|
||||
|
||||
// Test connection
|
||||
_, err = RedisClient.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to connect to Redis: %v, continuing without Redis cache", err)
|
||||
RedisClient = nil
|
||||
RedisOptions = nil
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Redis Bağlatısı Sağlandı")
|
||||
}
|
||||
|
||||
// Set stores a key-value pair in Redis with expiration
|
||||
func Set(key string, value interface{}, expiration time.Duration) error {
|
||||
if RedisClient == nil {
|
||||
return nil // Gracefully handle when Redis is not available
|
||||
}
|
||||
return RedisClient.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a value from Redis
|
||||
func Get(key string) (string, error) {
|
||||
if RedisClient == nil {
|
||||
return "", redis.Nil // Return Nil error when Redis is not available
|
||||
}
|
||||
return RedisClient.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Delete removes a key from Redis
|
||||
func Delete(key string) error {
|
||||
if RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// Exists checks if a key exists in Redis
|
||||
func Exists(key string) (bool, error) {
|
||||
if RedisClient == nil {
|
||||
return false, nil
|
||||
}
|
||||
count, err := RedisClient.Exists(ctx, key).Result()
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// SetWithJSON stores a JSON-serializable value in Redis
|
||||
func SetEx(key string, value interface{}, seconds int) error {
|
||||
if RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return RedisClient.Set(ctx, key, value, time.Duration(seconds)*time.Second).Err()
|
||||
}
|
||||
|
||||
// Increment increments a counter in Redis
|
||||
func Increment(key string) (int64, error) {
|
||||
if RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return RedisClient.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Expire sets expiration time for a key
|
||||
func Expire(key string, expiration time.Duration) error {
|
||||
if RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return RedisClient.Expire(ctx, key, expiration).Err()
|
||||
}
|
||||
|
||||
// FlushAll clears all keys in the current database
|
||||
func FlushAll() error {
|
||||
if RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
log.Println("🧹 Clearing Redis Cache...")
|
||||
return RedisClient.FlushDB(ctx).Err()
|
||||
}
|
||||
122
app/database/migrate/migrate.go
Normal file
122
app/database/migrate/migrate.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package migrasyon
|
||||
|
||||
import (
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
configs "goGin/config"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Only run AutoMigrate if DB is initialized
|
||||
|
||||
func Migrate() {
|
||||
if database.DB != nil {
|
||||
if err := database.DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.SocialAccount{},
|
||||
&models.Profile{},
|
||||
&models.Hero{},
|
||||
&models.Setting{},
|
||||
&models.CorsWhitelist{},
|
||||
&models.CorsBlacklist{},
|
||||
&models.RateLimitSetting{},
|
||||
&models.Category{},
|
||||
&models.Tag{},
|
||||
&models.Post{},
|
||||
&models.CategoryView{},
|
||||
&models.Comment{},
|
||||
); err != nil {
|
||||
log.Printf("AutoMigrate Yapılamadı !!: %v", err)
|
||||
}
|
||||
seedSecurityDefaults()
|
||||
log.Println("AutoMigrate Yapıldı.")
|
||||
} else {
|
||||
log.Println("DB not initialized: skipping AutoMigrate")
|
||||
}
|
||||
}
|
||||
|
||||
func seedSecurityDefaults() {
|
||||
seedRateLimit("register", "Register endpoint default rate limit", 5, 60)
|
||||
seedRateLimit("login", "Login endpoint default rate limit", 10, 60)
|
||||
|
||||
for _, origin := range defaultWhitelistOrigins() {
|
||||
seedCorsWhitelist(origin, "default seeded whitelist")
|
||||
}
|
||||
}
|
||||
|
||||
func seedRateLimit(name, description string, maxRequests int64, windowSeconds int) {
|
||||
var existing models.RateLimitSetting
|
||||
if err := database.DB.Where("name = ?", name).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
item := models.RateLimitSetting{
|
||||
Name: name,
|
||||
Description: description,
|
||||
MaxRequests: maxRequests,
|
||||
WindowSeconds: windowSeconds,
|
||||
IsActive: true,
|
||||
UpdatedBy: "seed",
|
||||
}
|
||||
if err := database.DB.Create(&item).Error; err != nil {
|
||||
log.Printf("RateLimit seed failed (%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
log.Printf("RateLimit seed created: name=%s max=%d window=%ds", name, maxRequests, windowSeconds)
|
||||
}
|
||||
|
||||
func seedCorsWhitelist(origin, description string) {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var existing models.CorsWhitelist
|
||||
if err := database.DB.Where("origin = ?", origin).First(&existing).Error; err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
item := models.CorsWhitelist{
|
||||
Origin: origin,
|
||||
Description: description,
|
||||
IsActive: true,
|
||||
CreatedBy: "seed",
|
||||
}
|
||||
if err := database.DB.Create(&item).Error; err != nil {
|
||||
log.Printf("CorsWhitelist seed failed (%s): %v", origin, err)
|
||||
return
|
||||
}
|
||||
log.Printf("CorsWhitelist seed created: origin=%s", origin)
|
||||
}
|
||||
|
||||
func defaultWhitelistOrigins() []string {
|
||||
origins := []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:8080",
|
||||
}
|
||||
|
||||
appURL := strings.TrimSpace(configs.AppConfig.AppURL)
|
||||
if appURL != "" {
|
||||
if parsed, err := url.Parse(appURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
origins = append(origins, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
uniq := make(map[string]struct{})
|
||||
out := make([]string, 0, len(origins))
|
||||
for _, origin := range origins {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := uniq[origin]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[origin] = struct{}{}
|
||||
out = append(out, origin)
|
||||
}
|
||||
return out
|
||||
}
|
||||
51
app/database/models/blog.go
Normal file
51
app/database/models/blog.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Minimal, temiz GORM modelleri
|
||||
|
||||
type Category struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *Category `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Posts []Post `gorm:"many2many:post_categories;" json:"posts,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"type:varchar(254);not null" json:"name"`
|
||||
Posts []Post `gorm:"many2many:post_tags;" json:"posts,omitempty"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title"`
|
||||
Images string `gorm:"type:text;not null" json:"images"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"`
|
||||
Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10)" json:"format" form:"format"`
|
||||
}
|
||||
|
||||
type CategoryView struct {
|
||||
gorm.Model
|
||||
CategoryID uint `json:"category_id"`
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
gorm.Model
|
||||
UserID uint `json:"user_id"`
|
||||
PostID uint `json:"post_id"`
|
||||
Body string `gorm:"type:text" json:"body,omitempty"`
|
||||
}
|
||||
34
app/database/models/cors.go
Normal file
34
app/database/models/cors.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CorsWhitelist - CORS için izin verilen origin'ler
|
||||
type CorsWhitelist struct {
|
||||
gorm.Model
|
||||
Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"`
|
||||
Description string `gorm:"type:varchar(255)" json:"description"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// CorsBlacklist - CORS için yasaklanan origin'ler
|
||||
type CorsBlacklist struct {
|
||||
gorm.Model
|
||||
Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"`
|
||||
Reason string `gorm:"type:varchar(255)" json:"reason"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// RateLimitSetting - Rate limit ayarları
|
||||
type RateLimitSetting struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"type:varchar(100);uniqueIndex;not null" json:"name"` // e.g., "login", "register", "api"
|
||||
Description string `gorm:"type:varchar(255)" json:"description"`
|
||||
MaxRequests int64 `gorm:"not null" json:"max_requests"` // Max istek sayısı
|
||||
WindowSeconds int `gorm:"not null" json:"window_seconds"` // Zaman penceresi (saniye)
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by,omitempty"`
|
||||
}
|
||||
23
app/database/models/hero.go
Normal file
23
app/database/models/hero.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Banner model structure
|
||||
// Represents a banner item with optional thumbnail.
|
||||
type Hero struct {
|
||||
gorm.Model
|
||||
Color string `gorm:"type:varchar(32);not null" json:"color" form:"color"`
|
||||
Title string `gorm:"type:varchar(254)" json:"title,omitempty" form:"title"`
|
||||
Text1 string `gorm:"type:varchar(254)" json:"text1,omitempty" form:"text1"`
|
||||
Text2 string `gorm:"type:varchar(254)" json:"text2,omitempty" form:"text2"`
|
||||
Text4 string `gorm:"type:varchar(254)" json:"text4,omitempty" form:"text4"`
|
||||
Text5 string `gorm:"type:varchar(254)" json:"text5,omitempty" form:"text5"`
|
||||
Image string `gorm:"type:varchar(254)" json:"image" form:"image"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active" form:"is_active"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10)" json:"format" form:"format"`
|
||||
}
|
||||
43
app/database/models/setting.go
Normal file
43
app/database/models/setting.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Setting model structure
|
||||
// Stores site-wide metadata and contact information.
|
||||
type Setting struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"`
|
||||
MetaTitle string `gorm:"type:varchar(254);not null" json:"meta_title" form:"meta_title"`
|
||||
MetaDescription string `gorm:"type:varchar(254);not null" json:"meta_description" form:"meta_description"`
|
||||
Phone string `gorm:"type:varchar(254);not null" json:"phone" form:"phone"`
|
||||
URL string `gorm:"type:varchar(254);not null" json:"url" form:"url"`
|
||||
Email string `gorm:"type:varchar(254);not null" json:"email" form:"email"`
|
||||
Facebook string `gorm:"type:varchar(254)" json:"facebook,omitempty" form:"facebook"`
|
||||
X string `gorm:"type:varchar(254)" json:"x,omitempty" form:"x"`
|
||||
Instagram string `gorm:"type:varchar(254)" json:"instagram,omitempty" form:"instagram"`
|
||||
Whatsapp string `gorm:"type:varchar(254)" json:"whatsapp,omitempty" form:"whatsapp"`
|
||||
Pinterest string `gorm:"type:varchar(254)" json:"pinterest,omitempty" form:"pinterest"`
|
||||
Linkedin string `gorm:"type:varchar(254)" json:"linkedin,omitempty" form:"linkedin"`
|
||||
Slogan string `gorm:"type:varchar(254)" json:"slogan,omitempty" form:"slogan"`
|
||||
Address string `gorm:"type:text" json:"address,omitempty" form:"address"`
|
||||
Copyright string `gorm:"type:varchar(254)" json:"copyright,omitempty" form:"copyright"`
|
||||
MapEmbed string `gorm:"type:text" json:"map_embed,omitempty" form:"map_embed"`
|
||||
WLogo string `gorm:"type:text" json:"w_logo,omitempty" form:"w_logo"`
|
||||
BLogo string `gorm:"type:text" json:"b_logo,omitempty" form:"b_logo"`
|
||||
IsActive bool `gorm:"default:false" json:"is_active" form:"is_active"`
|
||||
WWidth int `gorm:"default:0" json:"w_width" form:"w_width"`
|
||||
WHeight int `gorm:"default:0" json:"w_height" form:"w_height"`
|
||||
WQuality int `gorm:"default:0" json:"w_quality" form:"w_quality"`
|
||||
WFormat string `gorm:"type:varchar(10)" json:"w_format" form:"w_format"`
|
||||
BWidth int `gorm:"default:0" json:"b_width" form:"b_width"`
|
||||
BHeight int `gorm:"default:0" json:"b_height" form:"b_height"`
|
||||
BQuality int `gorm:"default:0" json:"b_quality" form:"b_quality"`
|
||||
BFormat string `gorm:"type:varchar(10)" json:"b_format" form:"b_format"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name used by Setting to `settings`
|
||||
func (Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
51
app/database/models/user.go
Normal file
51
app/database/models/user.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
UserName string `json:"username" gorm:"type:varchar(255)"`
|
||||
Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"`
|
||||
Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON
|
||||
EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration
|
||||
EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"`
|
||||
IsAdmin *bool `gorm:"default:false" json:"is_admin"`
|
||||
SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"`
|
||||
Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
// Email Veriyf i False Döndürüyor
|
||||
func (u *User) IsEmailVerified() bool {
|
||||
if u.EmailVerified == nil {
|
||||
return false
|
||||
}
|
||||
return *u.EmailVerified
|
||||
}
|
||||
|
||||
// SocialAccount model structure
|
||||
type SocialAccount struct {
|
||||
gorm.Model
|
||||
UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"`
|
||||
Provider string `gorm:"not null" json:"provider"` // google, github
|
||||
ProviderID string `gorm:"not null" json:"provider_id"`
|
||||
Email string `json:"email" gorm:"type:varchar(255)"`
|
||||
Name string `json:"name,omitempty" gorm:"type:varchar(255)"` // Full name from provider
|
||||
AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider
|
||||
|
||||
}
|
||||
type Profile struct {
|
||||
gorm.Model
|
||||
UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"`
|
||||
AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider
|
||||
FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider
|
||||
LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider
|
||||
|
||||
}
|
||||
119
app/database/seed/seed.go
Normal file
119
app/database/seed/seed.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"goGin/app/database/models"
|
||||
"log"
|
||||
|
||||
dbconfig "goGin/app/database/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func SeedDefaultSettings() {
|
||||
// Seed default CORS whitelist
|
||||
var whitelistCount int64
|
||||
dbconfig.DB.Model(&models.CorsWhitelist{}).Count(&whitelistCount)
|
||||
|
||||
if whitelistCount == 0 {
|
||||
defaultWhitelist := []models.CorsWhitelist{
|
||||
{
|
||||
Origin: "http://localhost:3000",
|
||||
Description: "Default local frontend",
|
||||
IsActive: true,
|
||||
CreatedBy: "system",
|
||||
},
|
||||
{
|
||||
Origin: "http://localhost:8080",
|
||||
Description: "Backend self",
|
||||
IsActive: true,
|
||||
CreatedBy: "system",
|
||||
},
|
||||
}
|
||||
|
||||
for _, w := range defaultWhitelist {
|
||||
dbconfig.DB.Create(&w)
|
||||
}
|
||||
log.Println("Default CORS whitelist seeded")
|
||||
}
|
||||
|
||||
// Seed default rate limit settings
|
||||
var rateLimitCount int64
|
||||
dbconfig.DB.Model(&models.RateLimitSetting{}).Count(&rateLimitCount)
|
||||
|
||||
if rateLimitCount == 0 {
|
||||
defaultRateLimits := []models.RateLimitSetting{
|
||||
{
|
||||
Name: "login",
|
||||
Description: "Login endpoint rate limit",
|
||||
MaxRequests: 5,
|
||||
WindowSeconds: 60, // 1 minute
|
||||
IsActive: true,
|
||||
},
|
||||
{
|
||||
Name: "register",
|
||||
Description: "Registration endpoint rate limit",
|
||||
MaxRequests: 3,
|
||||
WindowSeconds: 300, // 5 minutes
|
||||
IsActive: true,
|
||||
},
|
||||
{
|
||||
Name: "api",
|
||||
Description: "General API rate limit",
|
||||
MaxRequests: 100,
|
||||
WindowSeconds: 60, // 1 minute
|
||||
IsActive: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, r := range defaultRateLimits {
|
||||
dbconfig.DB.Create(&r)
|
||||
}
|
||||
log.Println("Default rate limit settings seeded")
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDefaultAdmin creates the default admin user if it doesn't exist
|
||||
func SeedDefaultAdmin() {
|
||||
// Check if admin user already exists (including soft-deleted)
|
||||
var adminUser models.User
|
||||
err := dbconfig.DB.Unscoped().Where("email = ?", "admin@gauth.local").First(&adminUser).Error
|
||||
|
||||
if err != nil {
|
||||
// Admin user doesn't exist, create one
|
||||
// Hash default password: "Admin@123"
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Failed to hash admin password: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
trueBool := true
|
||||
adminUser = models.User{
|
||||
Email: "admin@gauth.local",
|
||||
UserName: "admin",
|
||||
Password: string(hashedPassword),
|
||||
EmailVerified: &trueBool,
|
||||
}
|
||||
|
||||
if err := dbconfig.DB.Create(&adminUser).Error; err != nil {
|
||||
log.Printf("Failed to create admin user: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("✅ Default admin user created:")
|
||||
log.Println(" Email: admin@gauth.local")
|
||||
log.Println(" Password: Admin@123")
|
||||
log.Println(" ⚠️ Please change this password after first login!")
|
||||
} else {
|
||||
// Admin user exists (possibly soft-deleted)
|
||||
if adminUser.DeletedAt.Valid {
|
||||
log.Println("Restoring deleted admin user...")
|
||||
if err := dbconfig.DB.Model(&adminUser).Unscoped().Update("deleted_at", nil).Error; err != nil {
|
||||
log.Printf("Failed to restore admin user: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin rolü eklenmesi kaldırıldı çünkü Role modeli yok
|
||||
}
|
||||
91
app/middlewares/auth_middleware.go
Normal file
91
app/middlewares/auth_middleware.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
configs "goGin/config"
|
||||
"goGin/app/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"log"
|
||||
)
|
||||
|
||||
const authClaimsKey = "auth_claims"
|
||||
|
||||
func RequireAuth(c *gin.Context) {
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if authHeader == "" {
|
||||
authLogf("auth: missing Authorization header path=%s", c.Request.URL.Path)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
|
||||
authLogf("auth: invalid authorization format path=%s header=%s", c.Request.URL.Path, authHeader)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format, expected: Bearer <token>"})
|
||||
return
|
||||
}
|
||||
|
||||
jwtService := services.NewJWTService()
|
||||
claims, err := jwtService.ValidateToken(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
authLogf("auth: invalid token path=%s error=%v", c.Request.URL.Path, err)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
if claims.TokenType != services.TokenTypeAccess {
|
||||
authLogf("auth: non-access token used path=%s token_type=%s", c.Request.URL.Path, claims.TokenType)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "access token required"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(authClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func RequireAdmin(c *gin.Context) {
|
||||
claims, ok := GetAuthClaims(c)
|
||||
if !ok {
|
||||
authLogf("auth: RequireAdmin missing claims path=%s", c.Request.URL.Path)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if !claims.IsAdmin {
|
||||
authLogf("auth: RequireAdmin forbidden path=%s user_id=%v", c.Request.URL.Path, claims.UserID)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin role required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func RequireNormalUser(c *gin.Context) {
|
||||
claims, ok := GetAuthClaims(c)
|
||||
if !ok {
|
||||
authLogf("auth: RequireNormalUser missing claims path=%s", c.Request.URL.Path)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if claims.IsAdmin {
|
||||
authLogf("auth: RequireNormalUser forbidden (admin tried to access) path=%s user_id=%v", c.Request.URL.Path, claims.UserID)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "only normal users can access this endpoint"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func GetAuthClaims(c *gin.Context) (*services.JWTClaim, bool) {
|
||||
raw, exists := c.Get(authClaimsKey)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
claims, ok := raw.(*services.JWTClaim)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
func authLogf(format string, args ...interface{}) {
|
||||
if configs.AppConfig != nil && configs.AppConfig.Debug {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
154
app/middlewares/dynamic_cors.go
Normal file
154
app/middlewares/dynamic_cors.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
configs "goGin/config"
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
corsWhitelistActiveCacheKey = "cors:active:whitelist"
|
||||
corsBlacklistActiveCacheKey = "cors:active:blacklist"
|
||||
corsCacheTTLSeconds = 60
|
||||
)
|
||||
|
||||
var (
|
||||
allowedMethods = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||
allowedHeaders = "Authorization,Content-Type,Accept,Origin,X-Requested-With"
|
||||
)
|
||||
|
||||
// DynamicCORS validates request Origin using DB-backed whitelist/blacklist with Redis caching.
|
||||
func DynamicCORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if database.DB == nil {
|
||||
corsLogf("[cors][skip] database unavailable origin=%s path=%s", origin, c.Request.URL.Path)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
originKey := strings.ToLower(origin)
|
||||
// Keep same-origin requests working even if DB entries are missing.
|
||||
if origin == requestBaseURL(c) {
|
||||
corsLogf("[cors][allow] same-origin origin=%s path=%s", origin, c.Request.URL.Path)
|
||||
setCORSHeaders(c, origin)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
blacklist, err := loadActiveOriginSet(corsBlacklistActiveCacheKey, true)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "cors blacklist lookup failed"})
|
||||
return
|
||||
}
|
||||
if blacklist[originKey] {
|
||||
log.Printf("[cors][blocked] blacklist origin=%s path=%s", origin, c.Request.URL.Path)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin is blocked by CORS policy"})
|
||||
return
|
||||
}
|
||||
|
||||
whitelist, err := loadActiveOriginSet(corsWhitelistActiveCacheKey, false)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "cors whitelist lookup failed"})
|
||||
return
|
||||
}
|
||||
if !whitelist[originKey] {
|
||||
log.Printf("[cors][blocked] not-whitelisted origin=%s path=%s", origin, c.Request.URL.Path)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin is not allowed by CORS policy"})
|
||||
return
|
||||
}
|
||||
|
||||
corsLogf("[cors][allow] origin=%s path=%s", origin, c.Request.URL.Path)
|
||||
setCORSHeaders(c, origin)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func setCORSHeaders(c *gin.Context, origin string) {
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Methods", allowedMethods)
|
||||
c.Header("Access-Control-Allow-Headers", allowedHeaders)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Max-Age", "600")
|
||||
}
|
||||
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
scheme := c.Request.Header.Get("X-Forwarded-Proto")
|
||||
if scheme == "" {
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
} else {
|
||||
scheme = "http"
|
||||
}
|
||||
}
|
||||
return scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
func loadActiveOriginSet(cacheKey string, isBlacklist bool) (map[string]bool, error) {
|
||||
out := make(map[string]bool)
|
||||
|
||||
if cached, err := database.Get(cacheKey); err == nil {
|
||||
corsLogf("[cors][cache-hit] key=%s", cacheKey)
|
||||
var origins []string
|
||||
if jsonErr := json.Unmarshal([]byte(cached), &origins); jsonErr == nil {
|
||||
for _, origin := range origins {
|
||||
out[strings.ToLower(strings.TrimSpace(origin))] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
return nil, err
|
||||
}
|
||||
corsLogf("[cors][cache-miss] key=%s", cacheKey)
|
||||
|
||||
var origins []string
|
||||
var dbErr error
|
||||
if isBlacklist {
|
||||
dbErr = database.DB.Model(&models.CorsBlacklist{}).
|
||||
Where("is_active = ?", true).
|
||||
Pluck("origin", &origins).Error
|
||||
} else {
|
||||
dbErr = database.DB.Model(&models.CorsWhitelist{}).
|
||||
Where("is_active = ?", true).
|
||||
Pluck("origin", &origins).Error
|
||||
}
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
|
||||
for _, origin := range origins {
|
||||
out[strings.ToLower(strings.TrimSpace(origin))] = true
|
||||
}
|
||||
|
||||
cacheBytes, _ := json.Marshal(origins)
|
||||
_ = database.SetEx(cacheKey, string(cacheBytes), corsCacheTTLSeconds)
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func corsLogf(format string, args ...interface{}) {
|
||||
if configs.AppConfig != nil && (configs.AppConfig.Debug || configs.AppConfig.CorsDebug) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
128
app/middlewares/rate_limit.go
Normal file
128
app/middlewares/rate_limit.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
configs "goGin/config"
|
||||
database "goGin/app/database/config"
|
||||
"goGin/app/database/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type rateLimitRuntime struct {
|
||||
Name string `json:"name"`
|
||||
MaxRequests int64 `json:"max_requests"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// RequireRateLimit applies Redis-backed per-IP rate limiting by setting name.
|
||||
func RequireRateLimit(name string, fallbackMax int64, fallbackWindowSeconds int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if database.DB == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
setting, err := loadRateLimitRuntime(name, fallbackMax, fallbackWindowSeconds)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate limit configuration error"})
|
||||
return
|
||||
}
|
||||
if !setting.IsActive {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if database.RedisClient == nil {
|
||||
rateLimitLogf("[rate-limit][warn] redis unavailable, skipping enforcement name=%s", setting.Name)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(c.ClientIP())
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
|
||||
counterKey := fmt.Sprintf("ratelimit:%s:%s", setting.Name, ip)
|
||||
count, err := database.RedisClient.Incr(context.Background(), counterKey).Result()
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate limit check failed"})
|
||||
return
|
||||
}
|
||||
if count == 1 {
|
||||
_ = database.RedisClient.Expire(context.Background(), counterKey, time.Duration(setting.WindowSeconds)*time.Second).Err()
|
||||
}
|
||||
|
||||
if count > setting.MaxRequests {
|
||||
ttl, _ := database.RedisClient.TTL(context.Background(), counterKey).Result()
|
||||
retryAfter := int(ttl.Seconds())
|
||||
if retryAfter < 1 {
|
||||
retryAfter = setting.WindowSeconds
|
||||
}
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
log.Printf("[rate-limit][blocked] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds)
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "too many requests",
|
||||
"retry_after": retryAfter,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitLogf("[rate-limit][allow] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loadRateLimitRuntime(name string, fallbackMax int64, fallbackWindowSeconds int) (*rateLimitRuntime, error) {
|
||||
cacheKey := "ratelimit:setting:" + name
|
||||
if cached, err := database.Get(cacheKey); err == nil {
|
||||
var s rateLimitRuntime
|
||||
if jsonErr := json.Unmarshal([]byte(cached), &s); jsonErr == nil {
|
||||
return &s, nil
|
||||
}
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setting := &rateLimitRuntime{
|
||||
Name: name,
|
||||
MaxRequests: fallbackMax,
|
||||
WindowSeconds: fallbackWindowSeconds,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
var dbSetting models.RateLimitSetting
|
||||
if err := database.DB.Where("name = ?", name).First(&dbSetting).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
rateLimitLogf("[rate-limit][config] setting=%s not found, using fallback max=%d window=%ds", name, fallbackMax, fallbackWindowSeconds)
|
||||
} else {
|
||||
setting.MaxRequests = dbSetting.MaxRequests
|
||||
setting.WindowSeconds = dbSetting.WindowSeconds
|
||||
setting.IsActive = dbSetting.IsActive
|
||||
rateLimitLogf("[rate-limit][config] loaded from db name=%s active=%t max=%d window=%ds", name, setting.IsActive, setting.MaxRequests, setting.WindowSeconds)
|
||||
}
|
||||
|
||||
cacheJSON, _ := json.Marshal(setting)
|
||||
_ = database.SetEx(cacheKey, string(cacheJSON), 60)
|
||||
|
||||
return setting, nil
|
||||
}
|
||||
|
||||
func rateLimitLogf(format string, args ...interface{}) {
|
||||
if configs.AppConfig != nil && (configs.AppConfig.Debug || configs.AppConfig.CorsDebug) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
150
app/routes/router.go
Normal file
150
app/routes/router.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"goGin/app/controllers"
|
||||
"goGin/app/middlewares"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Böylece Gin artık “tüm proxy’lere güveniyorum” modundan çıktı, uyarı gidecek ve IP/scheme güvenliği artmış olacak.
|
||||
// İleride reverse proxy arkası deploy yaparsan, SetTrustedProxies(nil)
|
||||
// satırını kendi proxy IP / CIDR’larınla değiştirebiliriz.
|
||||
func SetupRouter() *gin.Engine {
|
||||
r := gin.Default()
|
||||
// Güvenli varsayılan: hiçbir proxy'ye güvenme (lokal geliştirme ve basit deploy'lar için ideal).
|
||||
// İleride reverse proxy arkasına alırsan, ilgili IP/CIDR bloklarını burada SetTrustedProxies ile tanımlayabilirsin.
|
||||
if err := r.SetTrustedProxies(nil); err != nil {
|
||||
log.Fatalf("failed to set trusted proxies: %v", err)
|
||||
}
|
||||
r.Use(middlewares.DynamicCORS())
|
||||
r.Use(middlewares.RequireRateLimit("global", 100, 60))
|
||||
// Uploads klasörünü statik olarak dışarı açıyoruz
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
admin := r.Group("/api/v1/admin")
|
||||
// Protect admin group with auth + admin requirement
|
||||
admin.Use(middlewares.RequireAuth)
|
||||
admin.Use(middlewares.RequireAdmin)
|
||||
|
||||
auth := r.Group("/api/v1/auth")
|
||||
{
|
||||
auth.POST("/register", controllers.Register, middlewares.RequireRateLimit("register", 10, 60))
|
||||
auth.POST("/login", controllers.Login, middlewares.RequireRateLimit("login", 10, 60))
|
||||
auth.POST("/refresh", controllers.Refresh)
|
||||
auth.GET("/verify-email", controllers.VerifyEmail, middlewares.RequireRateLimit("verify_email", 10, 60))
|
||||
|
||||
// Protected auth endpoints
|
||||
authProtected := auth.Group("")
|
||||
authProtected.Use(middlewares.RequireAuth)
|
||||
{
|
||||
authProtected.GET("/me", controllers.Me)
|
||||
}
|
||||
|
||||
auth.GET("/google", controllers.GoogleLogin)
|
||||
auth.GET("/google/callback", controllers.GoogleCallback)
|
||||
auth.GET("/github", controllers.GithubLogin)
|
||||
auth.GET("/github/callback", controllers.GithubCallback)
|
||||
}
|
||||
|
||||
// Public GET endpoints
|
||||
api.GET("/posts", controllers.ListPosts)
|
||||
// use slug instead of numeric id
|
||||
api.GET("/posts/:slug", controllers.GetPost)
|
||||
api.GET("/categories", controllers.ListCategories)
|
||||
// use slug for category retrieval as well
|
||||
api.GET("/categories/:slug", controllers.GetCategory)
|
||||
api.GET("/tags", controllers.ListTags)
|
||||
api.GET("/tags/:id", controllers.GetTag)
|
||||
api.GET("/comments", controllers.ListComments)
|
||||
api.GET("/comments/:id", controllers.GetComment)
|
||||
api.GET("/categoryviews", controllers.ListCategoryViews)
|
||||
api.GET("/categoryviews/:id", controllers.GetCategoryView)
|
||||
api.GET("/tags/:id/posts", controllers.FilterPostsByTag)
|
||||
|
||||
// Settings public endpoints
|
||||
api.GET("/settings", controllers.GetSettings)
|
||||
// optional public get by id
|
||||
api.GET("/settings/:id", controllers.AdminGetSetting)
|
||||
|
||||
// Hero public endpoints
|
||||
api.GET("/heroes", controllers.ListHeroes)
|
||||
api.GET("/heroes/:id", controllers.GetHero)
|
||||
|
||||
// User routes (Profile)
|
||||
userGroup := api.Group("/users")
|
||||
userGroup.Use(middlewares.RequireAuth)
|
||||
{
|
||||
userGroup.GET("/profile", controllers.GetProfile)
|
||||
userGroup.PUT("/profile", controllers.UpdateProfile)
|
||||
}
|
||||
|
||||
// Admin POST, PUT, DELETE endpoints
|
||||
admin.POST("/posts", controllers.CreatePost)
|
||||
admin.GET("/posts/:id", controllers.AdminGetPost)
|
||||
admin.PUT("/posts/:id", controllers.UpdatePost)
|
||||
admin.DELETE("/posts/:id", controllers.DeletePost)
|
||||
// Admin GET list (with soft-delete filters)
|
||||
admin.GET("/posts", controllers.AdminListPosts)
|
||||
// soft-delete management
|
||||
admin.GET("/posts/deleted", controllers.ListDeletedPosts)
|
||||
admin.POST("/posts/:id/restore", controllers.RestorePost)
|
||||
|
||||
admin.POST("/categories", controllers.CreateCategory)
|
||||
admin.PUT("/categories/:id", controllers.UpdateCategory)
|
||||
admin.DELETE("/categories/:id", controllers.DeleteCategory)
|
||||
// Admin GET list (with soft-delete filters)
|
||||
admin.GET("/categories", controllers.AdminListCategories)
|
||||
// categories soft-delete management
|
||||
admin.GET("/categories/deleted", controllers.ListDeletedCategories)
|
||||
admin.POST("/categories/:id/restore", controllers.RestoreCategory)
|
||||
|
||||
admin.POST("/tags", controllers.CreateTag)
|
||||
admin.PUT("/tags/:id", controllers.UpdateTag)
|
||||
admin.DELETE("/tags/:id", controllers.DeleteTag)
|
||||
// Admin GET list (with soft-delete filters)
|
||||
admin.GET("/tags", controllers.AdminListTags)
|
||||
admin.POST("/tags/:id/restore", controllers.RestoreTag)
|
||||
|
||||
admin.POST("/comments", controllers.CreateComment)
|
||||
admin.PUT("/comments/:id", controllers.UpdateComment)
|
||||
admin.DELETE("/comments/:id", controllers.DeleteComment)
|
||||
// Admin GET list (with soft-delete filters)
|
||||
admin.GET("/comments", controllers.AdminListComments)
|
||||
|
||||
admin.POST("/categoryviews", controllers.CreateCategoryView)
|
||||
// Admin GET list (with soft-delete filters)
|
||||
admin.GET("/categoryviews", controllers.AdminListCategoryViews)
|
||||
|
||||
// Admin Settings endpoints
|
||||
admin.GET("/settings", controllers.AdminListSettings)
|
||||
admin.POST("/settings", controllers.AdminCreateSetting)
|
||||
admin.GET("/settings/:id", controllers.AdminGetSetting)
|
||||
admin.PUT("/settings/:id", controllers.AdminUpdateSetting)
|
||||
admin.DELETE("/settings/:id", controllers.AdminDeleteSetting)
|
||||
admin.POST("/settings/:id/restore", controllers.AdminRestoreSetting)
|
||||
|
||||
// Hero Admin endpoints
|
||||
admin.GET("/heroes", controllers.AdminListHeroes)
|
||||
admin.POST("/heroes", controllers.CreateHero)
|
||||
admin.GET("/heroes/:id", controllers.AdminGetHero)
|
||||
admin.PUT("/heroes/:id", controllers.UpdateHero)
|
||||
admin.DELETE("/heroes/:id", controllers.DeleteHero)
|
||||
admin.POST("/heroes/:id/restore", controllers.RestoreHero)
|
||||
|
||||
// User Management
|
||||
admin.GET("/users", controllers.AdminListUsers)
|
||||
admin.GET("/users/:id", controllers.AdminGetUser)
|
||||
admin.PUT("/users/:id", controllers.AdminUpdateUser)
|
||||
admin.DELETE("/users/:id", controllers.AdminDeleteUser)
|
||||
admin.POST("/users/:id/restore", controllers.AdminRestoreUser)
|
||||
|
||||
// İlişkili işlemler
|
||||
admin.POST("/posts/:id/comments", controllers.AddCommentToPost)
|
||||
admin.POST("/categories/:id/posts", controllers.AddPostToCategory)
|
||||
|
||||
return r
|
||||
}
|
||||
127
app/services/jwt_service.go
Normal file
127
app/services/jwt_service.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
configs "goGin/config"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TokenTypeAccess sabiti, auth middleware'in beklediği token türünü temsil eder.
|
||||
const TokenTypeAccess = "access"
|
||||
|
||||
// JWTClaim, authorization middleware'inin beklediği claim yapısını temsil eder.
|
||||
// İleride ihtiyaç oldukça alanlar genişletilebilir.
|
||||
type JWTClaim struct {
|
||||
TokenType string
|
||||
IsAdmin bool
|
||||
UserID any
|
||||
}
|
||||
|
||||
// JWTService, JWT ile ilgili operasyonları kapsayan servis.
|
||||
type JWTService struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewJWTService yeni bir JWTService örneği döner.
|
||||
// Secret, config içindeki JWT_SECRET üzerinden okunur.
|
||||
func NewJWTService() *JWTService {
|
||||
secret := ""
|
||||
if configs.AppConfig != nil {
|
||||
secret = configs.AppConfig.JWTSecret
|
||||
}
|
||||
return &JWTService{
|
||||
secret: []byte(secret),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken verilen JWT'yi doğrular ve claim'leri döner.
|
||||
// HMAC (HS256 vb.) ile imzalanmış token'lar beklenir.
|
||||
func (s *JWTService) ValidateToken(tokenString string) (*JWTClaim, error) {
|
||||
if len(s.secret) == 0 {
|
||||
return nil, errors.New("jwt secret is not configured")
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
// Sadece HMAC algoritmalarına izin veriyoruz.
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return s.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid token claims")
|
||||
}
|
||||
|
||||
// Token tipi (access / refresh vs.)
|
||||
var tokenType string
|
||||
if v, ok := claims["token_type"].(string); ok {
|
||||
tokenType = v
|
||||
} else if v, ok := claims["tokenType"].(string); ok {
|
||||
tokenType = v
|
||||
}
|
||||
|
||||
// Yönetici flag'i
|
||||
var isAdmin bool
|
||||
if v, ok := claims["is_admin"]; ok {
|
||||
switch vv := v.(type) {
|
||||
case bool:
|
||||
isAdmin = vv
|
||||
case float64:
|
||||
isAdmin = vv != 0
|
||||
}
|
||||
} else if v, ok := claims["isAdmin"]; ok {
|
||||
if vv, ok2 := v.(bool); ok2 {
|
||||
isAdmin = vv
|
||||
}
|
||||
}
|
||||
|
||||
// Kullanıcı ID'si (sub claim'i üzerinden)
|
||||
var userID any
|
||||
if v, ok := claims["sub"]; ok {
|
||||
userID = v
|
||||
} else if v, ok := claims["user_id"]; ok {
|
||||
userID = v
|
||||
}
|
||||
|
||||
return &JWTClaim{
|
||||
TokenType: tokenType,
|
||||
IsAdmin: isAdmin,
|
||||
UserID: userID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateToken creates a short-lived access token
|
||||
func (s *JWTService) GenerateToken(userID uint, isAdmin bool) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"is_admin": isAdmin,
|
||||
"token_type": TokenTypeAccess,
|
||||
"exp": jwt.NewNumericDate(time.Now().Add(time.Duration(configs.AppConfig.AccessTokenExpireMinutes) * time.Minute)),
|
||||
"iat": jwt.NewNumericDate(time.Now()),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.secret)
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a long-lived refresh token
|
||||
func (s *JWTService) GenerateRefreshToken(userID uint) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"token_type": "refresh",
|
||||
"exp": jwt.NewNumericDate(time.Now().Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)),
|
||||
"iat": jwt.NewNumericDate(time.Now()),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.secret)
|
||||
}
|
||||
Reference in New Issue
Block a user