first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:46:42 +03:00
commit 2a5b661443
202 changed files with 49770 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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 proxylere 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 / CIDRları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ıı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
View 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)
}