first commit
This commit is contained in:
823
app/accounts/controllers/user.go
Normal file
823
app/accounts/controllers/user.go
Normal file
@@ -0,0 +1,823 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
githuboauth "golang.org/x/oauth2/github"
|
||||
"golang.org/x/oauth2/google"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goaresv3/app/accounts/models"
|
||||
"goaresv3/config"
|
||||
jwtHelper "goaresv3/pkg/jwt"
|
||||
"goaresv3/pkg/mailer"
|
||||
)
|
||||
|
||||
const googleOAuthStateCookieName = "google_oauth_state"
|
||||
const githubOAuthStateCookieName = "github_oauth_state"
|
||||
|
||||
type socialUserProfile struct {
|
||||
ProviderID string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
EmailVerified bool
|
||||
}
|
||||
|
||||
type googleUserInfo struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
}
|
||||
|
||||
var exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return cfg.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
var fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("userinfo status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info googleUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
type githubUserInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type githubEmailInfo struct {
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
var exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return cfg.Exchange(ctx, code)
|
||||
}
|
||||
|
||||
var fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) {
|
||||
client := cfg.Client(ctx, token)
|
||||
|
||||
userResp, err := client.Get("https://api.github.com/user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer userResp.Body.Close()
|
||||
if userResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("github userinfo status: %d", userResp.StatusCode)
|
||||
}
|
||||
|
||||
var user githubUserInfo
|
||||
if err := json.NewDecoder(userResp.Body).Decode(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(user.Email)
|
||||
verifiedFromProvider := email != ""
|
||||
|
||||
emailsResp, err := client.Get("https://api.github.com/user/emails")
|
||||
if err == nil {
|
||||
defer emailsResp.Body.Close()
|
||||
if emailsResp.StatusCode == http.StatusOK {
|
||||
var emails []githubEmailInfo
|
||||
if err := json.NewDecoder(emailsResp.Body).Decode(&emails); err == nil {
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified && strings.TrimSpace(e.Email) != "" {
|
||||
email = strings.TrimSpace(e.Email)
|
||||
verifiedFromProvider = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if email == "" {
|
||||
for _, e := range emails {
|
||||
if e.Verified && strings.TrimSpace(e.Email) != "" {
|
||||
email = strings.TrimSpace(e.Email)
|
||||
verifiedFromProvider = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.ID == 0 || email == "" {
|
||||
return nil, fmt.Errorf("github profile is missing required fields")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(user.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(user.Login)
|
||||
}
|
||||
if name == "" {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) > 0 {
|
||||
name = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return &socialUserProfile{
|
||||
ProviderID: fmt.Sprintf("%d", user.ID),
|
||||
Email: email,
|
||||
Name: name,
|
||||
AvatarURL: strings.TrimSpace(user.AvatarURL),
|
||||
EmailVerified: verifiedFromProvider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// ── Request DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
type RegisterRequest struct {
|
||||
UserName string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
func generateEmailVerifyToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func generateOAuthStateToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func envValue(key string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
return strings.Trim(v, "'\"")
|
||||
}
|
||||
|
||||
func fallbackRedirectURL(path string) string {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
return baseURL + path
|
||||
}
|
||||
|
||||
func getGoogleOAuthConfig() (*oauth2.Config, error) {
|
||||
clientID := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
|
||||
clientSecret := envValue("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
|
||||
redirectURL := envValue("SOCIAL_AUTH_GOOGLE_REDIRECT_URL")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fallbackRedirectURL("/api/v1/auth/google/callback")
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" || redirectURL == "" {
|
||||
return nil, fmt.Errorf("google oauth configuration is incomplete")
|
||||
}
|
||||
|
||||
scopes := []string{"openid", "email", "profile"}
|
||||
if rawScopes := envValue("SOCIAL_AUTH_GOOGLE_SCOPES"); rawScopes != "" {
|
||||
scopes = scopes[:0]
|
||||
for _, scope := range strings.Split(rawScopes, ",") {
|
||||
s := strings.TrimSpace(scope)
|
||||
if s != "" {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "email", "profile"}
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: google.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getGitHubOAuthConfig() (*oauth2.Config, error) {
|
||||
clientID := envValue("SOCIAL_AUTH_GITHUB_KEY")
|
||||
clientSecret := envValue("SOCIAL_AUTH_GITHUB_SECRET")
|
||||
redirectURL := envValue("SOCIAL_AUTH_GITHUB_REDIRECT_URL")
|
||||
if redirectURL == "" {
|
||||
redirectURL = fallbackRedirectURL("/api/v1/auth/github/callback")
|
||||
}
|
||||
|
||||
if clientID == "" || clientSecret == "" || redirectURL == "" {
|
||||
return nil, fmt.Errorf("github oauth configuration is incomplete")
|
||||
}
|
||||
|
||||
scopes := []string{"read:user", "user:email"}
|
||||
if rawScopes := envValue("SOCIAL_AUTH_GITHUB_SCOPES"); rawScopes != "" {
|
||||
scopes = scopes[:0]
|
||||
for _, scope := range strings.Split(rawScopes, ",") {
|
||||
s := strings.TrimSpace(scope)
|
||||
if s != "" {
|
||||
scopes = append(scopes, s)
|
||||
}
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"read:user", "user:email"}
|
||||
}
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: scopes,
|
||||
Endpoint: githuboauth.Endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveUserNameFromGoogleProfile(profile *googleUserInfo) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if profile.Name != "" {
|
||||
return profile.Name
|
||||
}
|
||||
if profile.GivenName != "" {
|
||||
return profile.GivenName
|
||||
}
|
||||
if profile.Email != "" {
|
||||
parts := strings.Split(profile.Email, "@")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
return "google-user"
|
||||
}
|
||||
|
||||
func completeSocialLogin(c *gin.Context, provider string, profile *socialUserProfile) {
|
||||
if profile == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing"})
|
||||
return
|
||||
}
|
||||
if profile.ProviderID == "" || profile.Email == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider profile is missing required fields"})
|
||||
return
|
||||
}
|
||||
if !profile.EmailVerified {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "provider email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err := config.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var social models.SocialAccount
|
||||
err := tx.Where("provider = ? AND provider_id = ?", provider, profile.ProviderID).First(&social).Error
|
||||
if err == nil {
|
||||
if err := tx.First(&user, social.UserID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err == gorm.ErrRecordNotFound {
|
||||
err = tx.Where("email = ?", profile.Email).First(&user).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
verified := true
|
||||
user = models.User{
|
||||
UserName: profile.Name,
|
||||
Email: profile.Email,
|
||||
EmailVerified: &verified,
|
||||
EmailVerifiedAt: ptrTime(time.Now()),
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
social = models.SocialAccount{
|
||||
UserID: uint64(user.ID),
|
||||
Provider: provider,
|
||||
ProviderID: profile.ProviderID,
|
||||
Email: profile.Email,
|
||||
Name: profile.Name,
|
||||
AvatarURL: profile.AvatarURL,
|
||||
}
|
||||
if err := tx.Create(&social).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.IsEmailVerified() {
|
||||
verified := true
|
||||
now := time.Now()
|
||||
if err := tx.Model(&user).Updates(map[string]any{
|
||||
"email_verified": &verified,
|
||||
"email_verified_at": &now,
|
||||
"email_verify_token": "",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.EmailVerified = &verified
|
||||
user.EmailVerifiedAt = &now
|
||||
user.EmailVerifyToken = ""
|
||||
}
|
||||
|
||||
return tx.Model(&models.SocialAccount{}).
|
||||
Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, provider, profile.ProviderID).
|
||||
Updates(map[string]any{
|
||||
"email": profile.Email,
|
||||
"name": profile.Name,
|
||||
"avatar_url": profile.AvatarURL,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process social login"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
"provider": provider,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Creates a user and sends an email verification link.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "register payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Password != req.ConfirmPassword {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "password and confirm_password do not match"})
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not process password"})
|
||||
return
|
||||
}
|
||||
|
||||
verifyToken, err := generateEmailVerifyToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate verification token"})
|
||||
return
|
||||
}
|
||||
|
||||
verified := false
|
||||
|
||||
user := models.User{
|
||||
UserName: req.UserName,
|
||||
Email: req.Email,
|
||||
Password: string(hashed),
|
||||
EmailVerified: &verified,
|
||||
EmailVerifyToken: verifyToken,
|
||||
}
|
||||
|
||||
if result := config.DB.Create(&user); result.Error != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already in use"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(os.Getenv("APP_BASE_URL"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify-email?token=%s", baseURL, verifyToken)
|
||||
|
||||
body := fmt.Sprintf("Hello %s,\n\nPlease verify your email by clicking the link below:\n%s\n\nIf you did not create this account, you can ignore this email.", user.UserName, verifyURL)
|
||||
if err := mailer.Send(user.Email, "Verify your email", body); err != nil {
|
||||
_ = config.DB.Delete(&user).Error
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send verification email"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "user created. please verify your email",
|
||||
"user_id": user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Activates account using email verification token.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param token query string true "email verification token"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/auth/verify-email [get]
|
||||
func VerifyEmail(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
verified := true
|
||||
|
||||
result := config.DB.Model(&models.User{}).
|
||||
Where("email_verify_token = ?", token).
|
||||
Updates(map[string]interface{}{
|
||||
"email_verified": &verified,
|
||||
"email_verify_token": "",
|
||||
"email_verified_at": &now,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not verify email"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired verification token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "email verified successfully"})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login with email/password
|
||||
// @Description Returns access and refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "login payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := config.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
|
||||
// Return generic message to avoid user enumeration
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsEmailVerified() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
// GoogleLogin godoc
|
||||
// @Summary Start Google OAuth login
|
||||
// @Description Returns Google authorization URL and sets state cookie for CSRF protection.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/google/login [get]
|
||||
func GoogleLogin(c *gin.Context) {
|
||||
cfg, err := getGoogleOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateOAuthStateToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize google login"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(googleOAuthStateCookieName, state, 600, "/", "", false, true)
|
||||
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
|
||||
}
|
||||
|
||||
// GoogleCallback godoc
|
||||
// @Summary Google OAuth callback
|
||||
// @Description Exchanges Google code and returns local access/refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param state query string true "oauth state"
|
||||
// @Param code query string true "authorization code"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/google/callback [get]
|
||||
func GoogleCallback(c *gin.Context) {
|
||||
cfg, err := getGoogleOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "google oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "google authorization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
if state == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
|
||||
return
|
||||
}
|
||||
|
||||
storedState, err := c.Cookie(googleOAuthStateCookieName)
|
||||
if err != nil || storedState == "" || storedState != state {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie(googleOAuthStateCookieName, "", -1, "/", "", false, true)
|
||||
|
||||
token, err := exchangeGoogleCode(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange google code"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := fetchGoogleUserInfo(c.Request.Context(), cfg, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch google profile"})
|
||||
return
|
||||
}
|
||||
|
||||
if profile.Sub == "" || profile.Email == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "google profile is missing required fields"})
|
||||
return
|
||||
}
|
||||
|
||||
completeSocialLogin(c, "google", &socialUserProfile{
|
||||
ProviderID: profile.Sub,
|
||||
Email: profile.Email,
|
||||
Name: resolveUserNameFromGoogleProfile(profile),
|
||||
AvatarURL: profile.Picture,
|
||||
EmailVerified: profile.EmailVerified,
|
||||
})
|
||||
}
|
||||
|
||||
// GitHubLogin godoc
|
||||
// @Summary Start GitHub OAuth login
|
||||
// @Description Returns GitHub authorization URL and sets state cookie for CSRF protection.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/github/login [get]
|
||||
func GitHubLogin(c *gin.Context) {
|
||||
cfg, err := getGitHubOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateOAuthStateToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not initialize github login"})
|
||||
return
|
||||
}
|
||||
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(githubOAuthStateCookieName, state, 600, "/", "", false, true)
|
||||
|
||||
authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
c.JSON(http.StatusOK, gin.H{"auth_url": authURL})
|
||||
}
|
||||
|
||||
// GitHubCallback godoc
|
||||
// @Summary GitHub OAuth callback
|
||||
// @Description Exchanges GitHub code and returns local access/refresh tokens.
|
||||
// @Tags Auth
|
||||
// @Produce json
|
||||
// @Param state query string true "oauth state"
|
||||
// @Param code query string true "authorization code"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 503 {object} map[string]string
|
||||
// @Router /api/v1/auth/github/callback [get]
|
||||
func GitHubCallback(c *gin.Context) {
|
||||
cfg, err := getGitHubOAuthConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "github oauth is not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
if oauthErr := strings.TrimSpace(c.Query("error")); oauthErr != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "github authorization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
state := strings.TrimSpace(c.Query("state"))
|
||||
code := strings.TrimSpace(c.Query("code"))
|
||||
if state == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "state and code are required"})
|
||||
return
|
||||
}
|
||||
|
||||
storedState, err := c.Cookie(githubOAuthStateCookieName)
|
||||
if err != nil || storedState == "" || storedState != state {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid oauth state"})
|
||||
return
|
||||
}
|
||||
c.SetCookie(githubOAuthStateCookieName, "", -1, "/", "", false, true)
|
||||
|
||||
token, err := exchangeGitHubCode(c.Request.Context(), cfg, code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to exchange github code"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := fetchGitHubUserInfo(c.Request.Context(), cfg, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "failed to fetch github profile"})
|
||||
return
|
||||
}
|
||||
|
||||
completeSocialLogin(c, "github", profile)
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
// RefreshToken godoc
|
||||
// @Summary Refresh access token
|
||||
// @Description Exchanges a valid refresh token for a new access token.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefreshRequest true "refresh payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func RefreshToken(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := jwtHelper.ValidateToken(req.RefreshToken, os.Getenv("JWT_REFRESH_SECRET"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if result := config.DB.Select("email", "user_name", "email_verified").First(&user, claims.UserID); result.Error != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user"})
|
||||
return
|
||||
}
|
||||
if !user.IsEmailVerified() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "email is not verified"})
|
||||
return
|
||||
}
|
||||
|
||||
userName := claims.UserName
|
||||
if userName == "" {
|
||||
if result := config.DB.Select("user_name").First(&user, claims.UserID); result.Error == nil {
|
||||
userName = user.UserName
|
||||
}
|
||||
}
|
||||
if userName == "" {
|
||||
userName = user.UserName
|
||||
}
|
||||
|
||||
accessToken, err := jwtHelper.GenerateAccessToken(claims.UserID, user.Email, userName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get current user info
|
||||
// @Description Returns user_id, email and username from the authenticated user.
|
||||
// @Tags User
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/v1/me [get]
|
||||
func Me(c *gin.Context) {
|
||||
userName := c.GetString("username")
|
||||
if userName == "" {
|
||||
var user models.User
|
||||
if result := config.DB.Select("user_name").First(&user, c.GetUint("user_id")); result.Error == nil {
|
||||
userName = user.UserName
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": c.GetUint("user_id"),
|
||||
"email": c.GetString("email"),
|
||||
"username": userName,
|
||||
})
|
||||
}
|
||||
652
app/accounts/controllers/user_test.go
Normal file
652
app/accounts/controllers/user_test.go
Normal file
@@ -0,0 +1,652 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goaresv3/app/accounts/models"
|
||||
"goaresv3/config"
|
||||
jwtHelper "goaresv3/pkg/jwt"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate user model: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SocialAccount{}); err != nil {
|
||||
t.Fatalf("failed to migrate social account model: %v", err)
|
||||
}
|
||||
config.DB = db
|
||||
return db
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestVerifyEmailSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
user := models.User{UserName: "u1", Email: "u1@example.com", EmailVerified: boolPtr(false), EmailVerifyToken: "tok-123"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/verify-email", VerifyEmail)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=tok-123", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var updated models.User
|
||||
if err := db.First(&updated, user.ID).Error; err != nil {
|
||||
t.Fatalf("failed to fetch updated user: %v", err)
|
||||
}
|
||||
if !updated.IsEmailVerified() {
|
||||
t.Fatal("expected email_verified=true")
|
||||
}
|
||||
if updated.EmailVerifyToken != "" {
|
||||
t.Fatalf("expected email_verify_token cleared, got %q", updated.EmailVerifyToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailMissingToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/verify-email", VerifyEmail)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailInvalidToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/verify-email", VerifyEmail)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/verify-email?token=missing", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsUnverifiedUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||
user := models.User{UserName: "u2", Email: "u2@example.com", Password: string(hashed), EmailVerified: boolPtr(false)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(LoginRequest{Email: "u2@example.com", Password: "password123"})
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", Login)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBadRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", Login)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"email":"bad"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginInvalidCredentials(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||
user := models.User{UserName: "u2x", Email: "u2x@example.com", Password: string(hashed), EmailVerified: boolPtr(true)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(LoginRequest{Email: "u2x@example.com", Password: "wrong-pass"})
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", Login)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginVerifiedUserSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||
user := models.User{UserName: "u3", Email: "u3@example.com", Password: string(hashed), EmailVerified: boolPtr(true)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(LoginRequest{Email: "u3@example.com", Password: "password123"})
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", Login)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRejectsUnverifiedUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
user := models.User{UserName: "u4", Email: "u4@example.com", EmailVerified: boolPtr(false)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
rt, err := jwtHelper.GenerateRefreshToken(user.ID, user.Email, user.UserName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(RefreshRequest{RefreshToken: rt})
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", RefreshToken)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshBadRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", RefreshToken)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewBufferString(`{"x":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshInvalidToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", RefreshToken)
|
||||
|
||||
body := []byte(`{"refresh_token":"not-a-jwt"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshInvalidUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
rt, err := jwtHelper.GenerateRefreshToken(9999, "ghost@example.com", "ghost")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(RefreshRequest{RefreshToken: rt})
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", RefreshToken)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPasswordMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/register", Register)
|
||||
|
||||
body := []byte(`{"username":"u","email":"u@example.com","password":"password123","confirm_password":"different123"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
verified := true
|
||||
hashed, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
||||
seed := models.User{UserName: "seed", Email: "dup@example.com", Password: string(hashed), EmailVerified: &verified}
|
||||
if err := db.Create(&seed).Error; err != nil {
|
||||
t.Fatalf("failed to seed user: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/register", Register)
|
||||
|
||||
body := []byte(`{"username":"newuser","email":"dup@example.com","password":"password123","confirm_password":"password123"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMailFailureRollsBackUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
t.Setenv("EMAIL_HOST", "")
|
||||
t.Setenv("EMAIL_PORT", "")
|
||||
t.Setenv("EMAIL_FROM", "")
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/register", Register)
|
||||
|
||||
body := []byte(`{"username":"rollback","email":"rollback@example.com","password":"password123","confirm_password":"password123"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", w.Code)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.User{}).Where("email = ?", "rollback@example.com").Count(&count).Error; err != nil {
|
||||
t.Fatalf("failed to count users: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("expected rollback delete, user count=%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeIncludesUsernameFallbackFromDB(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
|
||||
user := models.User{UserName: "fallback-user", Email: "u5@example.com", EmailVerified: boolPtr(true)}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("email", user.Email)
|
||||
|
||||
Me(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["username"] != "fallback-user" {
|
||||
t.Fatalf("expected username=fallback-user, got %v", resp["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeUsesContextUsernameWhenProvided(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
c.Set("user_id", uint(99))
|
||||
c.Set("email", "ctx@example.com")
|
||||
c.Set("username", "ctx-user")
|
||||
|
||||
Me(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["username"] != "ctx-user" {
|
||||
t.Fatalf("expected username=ctx-user, got %v", resp["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleLoginMissingConfig(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "")
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/google/login", GoogleLogin)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCallbackInvalidState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback")
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/google/callback", GoogleCallback)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state1&code=code1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleCallbackSuccessCreatesUserAndTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "client-id")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "client-secret")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback")
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
originalExchange := exchangeGoogleCode
|
||||
originalFetch := fetchGoogleUserInfo
|
||||
t.Cleanup(func() {
|
||||
exchangeGoogleCode = originalExchange
|
||||
fetchGoogleUserInfo = originalFetch
|
||||
})
|
||||
|
||||
exchangeGoogleCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "google-access", TokenType: "Bearer"}, nil
|
||||
}
|
||||
fetchGoogleUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*googleUserInfo, error) {
|
||||
return &googleUserInfo{
|
||||
Sub: "google-sub-123",
|
||||
Email: "google.user@example.com",
|
||||
EmailVerified: true,
|
||||
Name: "Google User",
|
||||
Picture: "https://cdn.example.com/avatar.png",
|
||||
GivenName: "Google",
|
||||
FamilyName: "User",
|
||||
}, nil
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/google/callback", GoogleCallback)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/google/callback?state=state123&code=code123", nil)
|
||||
req.AddCookie(&http.Cookie{Name: googleOAuthStateCookieName, Value: "state123"})
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["access_token"] == "" || resp["refresh_token"] == "" {
|
||||
t.Fatal("expected access_token and refresh_token in response")
|
||||
}
|
||||
if resp["provider"] != "google" {
|
||||
t.Fatalf("expected provider=google, got %v", resp["provider"])
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", "google.user@example.com").First(&user).Error; err != nil {
|
||||
t.Fatalf("expected user to be created, err=%v", err)
|
||||
}
|
||||
if !user.IsEmailVerified() {
|
||||
t.Fatal("expected created google user to be verified")
|
||||
}
|
||||
|
||||
var social models.SocialAccount
|
||||
if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "google", "google-sub-123").First(&social).Error; err != nil {
|
||||
t.Fatalf("expected social account to be linked, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubLoginMissingConfig(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupTestDB(t)
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "")
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/github/login", GitHubLogin)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubCallbackSuccessCreatesVerifiedUserAndTokens(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "gh-client-id")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "gh-client-secret")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback")
|
||||
t.Setenv("JWT_SECRET", "test-secret-1234567890")
|
||||
t.Setenv("JWT_REFRESH_SECRET", "test-refresh-secret-1234567890")
|
||||
|
||||
originalExchange := exchangeGitHubCode
|
||||
originalFetch := fetchGitHubUserInfo
|
||||
t.Cleanup(func() {
|
||||
exchangeGitHubCode = originalExchange
|
||||
fetchGitHubUserInfo = originalFetch
|
||||
})
|
||||
|
||||
exchangeGitHubCode = func(ctx context.Context, cfg *oauth2.Config, code string) (*oauth2.Token, error) {
|
||||
return &oauth2.Token{AccessToken: "github-access", TokenType: "Bearer"}, nil
|
||||
}
|
||||
fetchGitHubUserInfo = func(ctx context.Context, cfg *oauth2.Config, token *oauth2.Token) (*socialUserProfile, error) {
|
||||
return &socialUserProfile{
|
||||
ProviderID: "4242",
|
||||
Email: "github.user@example.com",
|
||||
Name: "GitHub User",
|
||||
AvatarURL: "https://cdn.example.com/gh-avatar.png",
|
||||
EmailVerified: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/auth/github/callback", GitHubCallback)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/github/callback?state=state-gh-123&code=code-gh-123", nil)
|
||||
req.AddCookie(&http.Cookie{Name: githubOAuthStateCookieName, Value: "state-gh-123"})
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp["access_token"] == "" || resp["refresh_token"] == "" {
|
||||
t.Fatal("expected access_token and refresh_token in response")
|
||||
}
|
||||
if resp["provider"] != "github" {
|
||||
t.Fatalf("expected provider=github, got %v", resp["provider"])
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", "github.user@example.com").First(&user).Error; err != nil {
|
||||
t.Fatalf("expected user to be created, err=%v", err)
|
||||
}
|
||||
if !user.IsEmailVerified() {
|
||||
t.Fatal("expected created github user to be verified")
|
||||
}
|
||||
|
||||
var social models.SocialAccount
|
||||
if err := db.Where("user_id = ? AND provider = ? AND provider_id = ?", user.ID, "github", "4242").First(&social).Error; err != nil {
|
||||
t.Fatalf("expected github social account to be linked, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleOAuthConfigSupportsSocialAuthEnv(t *testing.T) {
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", "'google-client-id-from-social'")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "'google-client-secret-from-social'")
|
||||
t.Setenv("SOCIAL_AUTH_GOOGLE_REDIRECT_URL", "")
|
||||
t.Setenv("APP_BASE_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := getGoogleOAuthConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("expected config, got error: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "google-client-id-from-social" {
|
||||
t.Fatalf("unexpected client id: %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.ClientSecret != "google-client-secret-from-social" {
|
||||
t.Fatalf("unexpected client secret: %q", cfg.ClientSecret)
|
||||
}
|
||||
if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/google/callback" {
|
||||
t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubOAuthConfigSupportsSocialAuthEnv(t *testing.T) {
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_KEY", "'github-client-id-from-social'")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_SECRET", "'github-client-secret-from-social'")
|
||||
t.Setenv("SOCIAL_AUTH_GITHUB_REDIRECT_URL", "")
|
||||
t.Setenv("APP_BASE_URL", "http://localhost:8080")
|
||||
|
||||
cfg, err := getGitHubOAuthConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("expected config, got error: %v", err)
|
||||
}
|
||||
if cfg.ClientID != "github-client-id-from-social" {
|
||||
t.Fatalf("unexpected client id: %q", cfg.ClientID)
|
||||
}
|
||||
if cfg.ClientSecret != "github-client-secret-from-social" {
|
||||
t.Fatalf("unexpected client secret: %q", cfg.ClientSecret)
|
||||
}
|
||||
if cfg.RedirectURL != "http://localhost:8080/api/v1/auth/github/callback" {
|
||||
t.Fatalf("unexpected redirect url: %q", cfg.RedirectURL)
|
||||
}
|
||||
}
|
||||
48
app/accounts/models/accounts.go
Normal file
48
app/accounts/models/accounts.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
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:"type:varchar(32);not null;uniqueIndex:idx_social_provider_identity" json:"provider"` // google, github
|
||||
ProviderID string `gorm:"type:varchar(191);not null;uniqueIndex:idx_social_provider_identity" 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
|
||||
|
||||
}
|
||||
27
app/accounts/models/token.go
Normal file
27
app/accounts/models/token.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RefreshToken represents a server-side record of issued refresh tokens
|
||||
// to support rotation, revocation and reuse detection.
|
||||
type RefreshToken struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
TokenID string `gorm:"type:varchar(128);not null;uniqueIndex" json:"token_id"`
|
||||
// TokenHash is SHA-256 hex of the refresh token string (64 chars).
|
||||
// Stored instead of the raw token for security, while still allowing debug/lookup.
|
||||
TokenHash string `gorm:"type:char(64);index" json:"token_hash"`
|
||||
// TokenFingerprint is a masked representation (e.g. first6...last4) to help operators
|
||||
// visually correlate DB rows with logs without storing full token.
|
||||
TokenFingerprint string `gorm:"type:varchar(32);index" json:"token_fingerprint"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
Revoked bool `gorm:"index" json:"revoked"`
|
||||
ReplacedByTokenID string `gorm:"type:varchar(128)" json:"replaced_by_token_id"`
|
||||
UserAgent string `gorm:"type:varchar(255)" json:"user_agent"`
|
||||
IP string `gorm:"type:varchar(64)" json:"ip"`
|
||||
}
|
||||
|
||||
425
app/blog/controllers/blog.go
Normal file
425
app/blog/controllers/blog.go
Normal file
@@ -0,0 +1,425 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
blogModels "goaresv3/app/blog/models"
|
||||
"goaresv3/config"
|
||||
)
|
||||
|
||||
type UpsertCategoryRequest struct {
|
||||
Title string `json:"title" binding:"required,max=254"`
|
||||
Slug string `json:"slug" binding:"required,max=254"`
|
||||
Description string `json:"description"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
}
|
||||
|
||||
type UpsertTagRequest struct {
|
||||
Name string `json:"name" binding:"required,max=254"`
|
||||
}
|
||||
|
||||
type UpsertPostRequest struct {
|
||||
Title string `json:"title" binding:"required,max=254"`
|
||||
Images string `json:"images" binding:"required"`
|
||||
ImagesMid string `json:"images_mid" binding:"required"`
|
||||
ImagesMin string `json:"images_min" binding:"required"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format" binding:"omitempty,max=10"`
|
||||
Content string `json:"content"`
|
||||
Slug string `json:"slug" binding:"required,max=254"`
|
||||
CategoryIDs []uint `json:"category_ids"`
|
||||
TagIDs []uint `json:"tag_ids"`
|
||||
}
|
||||
|
||||
func parseBlogID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// ListCategories godoc
|
||||
// @Summary List blog categories
|
||||
// @Tags Blog
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/blog/categories [get]
|
||||
func ListCategories(c *gin.Context) {
|
||||
var items []blogModels.Category
|
||||
if err := config.DB.Preload("Children").Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch categories"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateCategory godoc
|
||||
// @Summary Create blog category
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertCategoryRequest true "category payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/blog/categories [post]
|
||||
func CreateCategory(c *gin.Context) {
|
||||
var req UpsertCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := blogModels.Category{
|
||||
Title: req.Title,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
ParentID: req.ParentID,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create category"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateCategory godoc
|
||||
// @Summary Update blog category
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "category id"
|
||||
// @Param request body UpsertCategoryRequest true "category payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/blog/categories/{id} [put]
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item blogModels.Category
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "category not found"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"title": req.Title,
|
||||
"slug": req.Slug,
|
||||
"description": req.Description,
|
||||
"parent_id": req.ParentID,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update category"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, id).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteCategory godoc
|
||||
// @Summary Delete blog category
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "category id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/blog/categories/{id} [delete]
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&blogModels.Category{}, id)
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "category not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "category deleted"})
|
||||
}
|
||||
|
||||
// ListTags godoc
|
||||
// @Summary List blog tags
|
||||
// @Tags Blog
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /api/v1/blog/tags [get]
|
||||
func ListTags(c *gin.Context) {
|
||||
var items []blogModels.Tag
|
||||
if err := config.DB.Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateTag godoc
|
||||
// @Summary Create blog tag
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertTagRequest true "tag payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Router /api/v1/blog/tags [post]
|
||||
func CreateTag(c *gin.Context) {
|
||||
var req UpsertTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := blogModels.Tag{Name: req.Name}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create tag"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateTag godoc
|
||||
// @Summary Update blog tag
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "tag id"
|
||||
// @Param request body UpsertTagRequest true "tag payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/blog/tags/{id} [put]
|
||||
func UpdateTag(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item blogModels.Tag
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Update("name", req.Name).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update tag"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, id).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteTag godoc
|
||||
// @Summary Delete blog tag
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "tag id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/v1/blog/tags/{id} [delete]
|
||||
func DeleteTag(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&blogModels.Tag{}, id)
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "tag deleted"})
|
||||
}
|
||||
|
||||
// ListPosts godoc
|
||||
// @Summary List blog posts
|
||||
// @Tags Blog
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Router /api/v1/blog/posts [get]
|
||||
func ListPosts(c *gin.Context) {
|
||||
var items []blogModels.Post
|
||||
if err := config.DB.Preload("Categories").Preload("Tags").Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch posts"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GetPost godoc
|
||||
// @Summary Get blog post
|
||||
// @Tags Blog
|
||||
// @Produce json
|
||||
// @Param id path int true "post id"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/blog/posts/{id} [get]
|
||||
func GetPost(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item blogModels.Post
|
||||
if err := config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// CreatePost godoc
|
||||
// @Summary Create blog post
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertPostRequest true "post payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Router /api/v1/blog/posts [post]
|
||||
func CreatePost(c *gin.Context) {
|
||||
var req UpsertPostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := blogModels.Post{
|
||||
Title: req.Title,
|
||||
Images: req.Images,
|
||||
ImagesMid: req.ImagesMid,
|
||||
ImagesMin: req.ImagesMin,
|
||||
Width: req.Width,
|
||||
Height: req.Height,
|
||||
Quality: req.Quality,
|
||||
Format: req.Format,
|
||||
Content: req.Content,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create post"})
|
||||
return
|
||||
}
|
||||
if err := assignPostRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign relations"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdatePost godoc
|
||||
// @Summary Update blog post
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "post id"
|
||||
// @Param request body UpsertPostRequest true "post payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/blog/posts/{id} [put]
|
||||
func UpdatePost(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertPostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item blogModels.Post
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"title": req.Title,
|
||||
"images": req.Images,
|
||||
"images_mid": req.ImagesMid,
|
||||
"images_min": req.ImagesMin,
|
||||
"width": req.Width,
|
||||
"height": req.Height,
|
||||
"quality": req.Quality,
|
||||
"format": req.Format,
|
||||
"content": req.Content,
|
||||
"slug": req.Slug,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update post"})
|
||||
return
|
||||
}
|
||||
if err := assignPostRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign relations"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeletePost godoc
|
||||
// @Summary Delete blog post
|
||||
// @Tags Blog
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "post id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/v1/blog/posts/{id} [delete]
|
||||
func DeletePost(c *gin.Context) {
|
||||
id, ok := parseBlogID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&blogModels.Post{}, id)
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "post deleted"})
|
||||
}
|
||||
|
||||
func assignPostRelations(postID uint, categoryIDs, tagIDs []uint) error {
|
||||
var p blogModels.Post
|
||||
if err := config.DB.First(&p, postID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if categoryIDs != nil {
|
||||
var categories []blogModels.Category
|
||||
if len(categoryIDs) > 0 {
|
||||
if err := config.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := config.DB.Model(&p).Association("Categories").Replace(categories); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tagIDs != nil {
|
||||
var tags []blogModels.Tag
|
||||
if len(tagIDs) > 0 {
|
||||
if err := config.DB.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := config.DB.Model(&p).Association("Tags").Replace(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
53
app/blog/models/blog.go
Normal file
53
app/blog/models/blog.go
Normal file
@@ -0,0 +1,53 @@
|
||||
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" form:"title"`
|
||||
Images string `gorm:"type:text;not null" json:"images" form:"images"`
|
||||
ImagesMid string `gorm:"type:text;not null" json:"images_mid" form:"images_mid"`
|
||||
ImagesMin string `gorm:"type:text;not null" json:"images_min" form:"images_min"`
|
||||
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" default:"avif"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty" form:"content"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"`
|
||||
Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty" form:"categories"`
|
||||
Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty" form:"tags"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
747
app/settings/controllers/settings.go
Normal file
747
app/settings/controllers/settings.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goaresv3/app/settings/models"
|
||||
"goaresv3/config"
|
||||
)
|
||||
|
||||
type UpsertSettingRequest struct {
|
||||
Title string `json:"title" binding:"required,max=254"`
|
||||
MetaTitle string `json:"meta_title" binding:"required,max=254"`
|
||||
MetaDescription string `json:"meta_description" binding:"required,max=254"`
|
||||
Phone string `json:"phone" binding:"required,max=254"`
|
||||
URL string `json:"url" binding:"required,max=254"`
|
||||
Email string `json:"email" binding:"required,email,max=254"`
|
||||
Facebook string `json:"facebook" binding:"omitempty,max=254"`
|
||||
X string `json:"x" binding:"omitempty,max=254"`
|
||||
Instagram string `json:"instagram" binding:"omitempty,max=254"`
|
||||
Whatsapp string `json:"whatsapp" binding:"omitempty,max=254"`
|
||||
Pinterest string `json:"pinterest" binding:"omitempty,max=254"`
|
||||
Linkedin string `json:"linkedin" binding:"omitempty,max=254"`
|
||||
Slogan string `json:"slogan" binding:"omitempty,max=254"`
|
||||
Address string `json:"address"`
|
||||
Copyright string `json:"copyright" binding:"omitempty,max=254"`
|
||||
MapEmbed string `json:"map_embed"`
|
||||
WLogo string `json:"w_logo"`
|
||||
BLogo string `json:"b_logo"`
|
||||
IsActive bool `json:"is_active"`
|
||||
WWidth int `json:"w_width"`
|
||||
WHeight int `json:"w_height"`
|
||||
WQuality int `json:"w_quality"`
|
||||
WFormat string `json:"w_format" binding:"omitempty,max=10"`
|
||||
BWidth int `json:"b_width"`
|
||||
BHeight int `json:"b_height"`
|
||||
BQuality int `json:"b_quality"`
|
||||
BFormat string `json:"b_format" binding:"omitempty,max=10"`
|
||||
}
|
||||
|
||||
type UpsertHeroRequest struct {
|
||||
Color string `json:"color" binding:"required,max=32"`
|
||||
Title string `json:"title" binding:"omitempty,max=254"`
|
||||
Text1 string `json:"text1" binding:"omitempty,max=254"`
|
||||
Text2 string `json:"text2" binding:"omitempty,max=254"`
|
||||
Text4 string `json:"text4" binding:"omitempty,max=254"`
|
||||
Text5 string `json:"text5" binding:"omitempty,max=254"`
|
||||
Image string `json:"image" binding:"omitempty,max=254"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format" binding:"omitempty,max=10"`
|
||||
}
|
||||
|
||||
type UpsertCorsWhitelistRequest struct {
|
||||
Origin string `json:"origin" binding:"required,max=255"`
|
||||
Description string `json:"description" binding:"omitempty,max=255"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy string `json:"created_by" binding:"omitempty,max=255"`
|
||||
}
|
||||
|
||||
type UpsertCorsBlacklistRequest struct {
|
||||
Origin string `json:"origin" binding:"required,max=255"`
|
||||
Reason string `json:"reason" binding:"omitempty,max=255"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy string `json:"created_by" binding:"omitempty,max=255"`
|
||||
}
|
||||
|
||||
type UpsertRateLimitRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Description string `json:"description" binding:"omitempty,max=255"`
|
||||
MaxRequests int64 `json:"max_requests" binding:"required,min=1"`
|
||||
WindowSeconds int `json:"window_seconds" binding:"required,min=1"`
|
||||
IsActive bool `json:"is_active"`
|
||||
UpdatedBy string `json:"updated_by" binding:"omitempty,max=255"`
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// GetSetting godoc
|
||||
// @Summary Get global setting
|
||||
// @Description Returns the latest settings record.
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings [get]
|
||||
func GetSetting(c *gin.Context) {
|
||||
var setting models.Setting
|
||||
err := config.DB.Order("id DESC").First(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch setting"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
|
||||
// UpsertSetting godoc
|
||||
// @Summary Create or update global setting
|
||||
// @Description Creates the first setting record if none exists, otherwise updates the latest one.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertSettingRequest true "setting payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings [put]
|
||||
func UpsertSetting(c *gin.Context) {
|
||||
var req UpsertSettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var setting models.Setting
|
||||
err := config.DB.Order("id DESC").First(&setting).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
setting = models.Setting{
|
||||
Title: req.Title,
|
||||
MetaTitle: req.MetaTitle,
|
||||
MetaDescription: req.MetaDescription,
|
||||
Phone: req.Phone,
|
||||
URL: req.URL,
|
||||
Email: req.Email,
|
||||
Facebook: req.Facebook,
|
||||
X: req.X,
|
||||
Instagram: req.Instagram,
|
||||
Whatsapp: req.Whatsapp,
|
||||
Pinterest: req.Pinterest,
|
||||
Linkedin: req.Linkedin,
|
||||
Slogan: req.Slogan,
|
||||
Address: req.Address,
|
||||
Copyright: req.Copyright,
|
||||
MapEmbed: req.MapEmbed,
|
||||
WLogo: req.WLogo,
|
||||
BLogo: req.BLogo,
|
||||
IsActive: req.IsActive,
|
||||
WWidth: req.WWidth,
|
||||
WHeight: req.WHeight,
|
||||
WQuality: req.WQuality,
|
||||
WFormat: req.WFormat,
|
||||
BWidth: req.BWidth,
|
||||
BHeight: req.BHeight,
|
||||
BQuality: req.BQuality,
|
||||
BFormat: req.BFormat,
|
||||
}
|
||||
if createErr := config.DB.Create(&setting).Error; createErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create setting"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, setting)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch setting"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"title": req.Title,
|
||||
"meta_title": req.MetaTitle,
|
||||
"meta_description": req.MetaDescription,
|
||||
"phone": req.Phone,
|
||||
"url": req.URL,
|
||||
"email": req.Email,
|
||||
"facebook": req.Facebook,
|
||||
"x": req.X,
|
||||
"instagram": req.Instagram,
|
||||
"whatsapp": req.Whatsapp,
|
||||
"pinterest": req.Pinterest,
|
||||
"linkedin": req.Linkedin,
|
||||
"slogan": req.Slogan,
|
||||
"address": req.Address,
|
||||
"copyright": req.Copyright,
|
||||
"map_embed": req.MapEmbed,
|
||||
"w_logo": req.WLogo,
|
||||
"b_logo": req.BLogo,
|
||||
"is_active": req.IsActive,
|
||||
"w_width": req.WWidth,
|
||||
"w_height": req.WHeight,
|
||||
"w_quality": req.WQuality,
|
||||
"w_format": req.WFormat,
|
||||
"b_width": req.BWidth,
|
||||
"b_height": req.BHeight,
|
||||
"b_quality": req.BQuality,
|
||||
"b_format": req.BFormat,
|
||||
}
|
||||
if err := config.DB.Model(&setting).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.DB.First(&setting, setting.ID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated setting"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
|
||||
// ListHeroes godoc
|
||||
// @Summary List heroes
|
||||
// @Description Returns all hero records ordered by id desc.
|
||||
// @Tags Settings
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/heroes [get]
|
||||
func ListHeroes(c *gin.Context) {
|
||||
var heroes []models.Hero
|
||||
if err := config.DB.Order("id DESC").Find(&heroes).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch heroes"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, heroes)
|
||||
}
|
||||
|
||||
// CreateHero godoc
|
||||
// @Summary Create hero
|
||||
// @Description Creates a new hero record.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertHeroRequest true "hero payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/heroes [post]
|
||||
func CreateHero(c *gin.Context) {
|
||||
var req UpsertHeroRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hero := models.Hero{
|
||||
Color: req.Color,
|
||||
Title: req.Title,
|
||||
Text1: req.Text1,
|
||||
Text2: req.Text2,
|
||||
Text4: req.Text4,
|
||||
Text5: req.Text5,
|
||||
Image: req.Image,
|
||||
IsActive: req.IsActive,
|
||||
Width: req.Width,
|
||||
Height: req.Height,
|
||||
Quality: req.Quality,
|
||||
Format: req.Format,
|
||||
}
|
||||
if err := config.DB.Create(&hero).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create hero"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, hero)
|
||||
}
|
||||
|
||||
// UpdateHero godoc
|
||||
// @Summary Update hero
|
||||
// @Description Updates a hero by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "hero id"
|
||||
// @Param request body UpsertHeroRequest true "hero payload"
|
||||
// @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/settings/heroes/{id} [put]
|
||||
func UpdateHero(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpsertHeroRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var hero models.Hero
|
||||
if err := config.DB.First(&hero, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch hero"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.DB.Model(&hero).Updates(map[string]any{
|
||||
"color": req.Color,
|
||||
"title": req.Title,
|
||||
"text1": req.Text1,
|
||||
"text2": req.Text2,
|
||||
"text4": req.Text4,
|
||||
"text5": req.Text5,
|
||||
"image": req.Image,
|
||||
"is_active": req.IsActive,
|
||||
"width": req.Width,
|
||||
"height": req.Height,
|
||||
"quality": req.Quality,
|
||||
"format": req.Format,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update hero"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.DB.First(&hero, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated hero"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hero)
|
||||
}
|
||||
|
||||
// DeleteHero godoc
|
||||
// @Summary Delete hero
|
||||
// @Description Deletes a hero by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "hero id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/heroes/{id} [delete]
|
||||
func DeleteHero(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&models.Hero{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete hero"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "hero deleted"})
|
||||
}
|
||||
|
||||
// ListCorsWhitelists godoc
|
||||
// @Summary List CORS whitelist items
|
||||
// @Description Returns all CORS whitelist records.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/whitelist [get]
|
||||
func ListCorsWhitelists(c *gin.Context) {
|
||||
var items []models.CorsWhitelist
|
||||
if err := config.DB.Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors whitelists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateCorsWhitelist godoc
|
||||
// @Summary Create CORS whitelist item
|
||||
// @Description Creates a new whitelist origin.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertCorsWhitelistRequest true "cors whitelist payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/whitelist [post]
|
||||
func CreateCorsWhitelist(c *gin.Context) {
|
||||
var req UpsertCorsWhitelistRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := models.CorsWhitelist{
|
||||
Origin: req.Origin,
|
||||
Description: req.Description,
|
||||
IsActive: req.IsActive,
|
||||
CreatedBy: req.CreatedBy,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create cors whitelist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateCorsWhitelist godoc
|
||||
// @Summary Update CORS whitelist item
|
||||
// @Description Updates a whitelist origin by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "whitelist id"
|
||||
// @Param request body UpsertCorsWhitelistRequest true "cors whitelist payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/whitelist/{id} [put]
|
||||
func UpdateCorsWhitelist(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertCorsWhitelistRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item models.CorsWhitelist
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cors whitelist not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors whitelist"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"origin": req.Origin,
|
||||
"description": req.Description,
|
||||
"is_active": req.IsActive,
|
||||
"created_by": req.CreatedBy,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update cors whitelist"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated cors whitelist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteCorsWhitelist godoc
|
||||
// @Summary Delete CORS whitelist item
|
||||
// @Description Deletes a whitelist origin by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "whitelist id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/whitelist/{id} [delete]
|
||||
func DeleteCorsWhitelist(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&models.CorsWhitelist{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cors whitelist"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cors whitelist not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "cors whitelist deleted"})
|
||||
}
|
||||
|
||||
// ListCorsBlacklists godoc
|
||||
// @Summary List CORS blacklist items
|
||||
// @Description Returns all CORS blacklist records.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/blacklist [get]
|
||||
func ListCorsBlacklists(c *gin.Context) {
|
||||
var items []models.CorsBlacklist
|
||||
if err := config.DB.Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors blacklists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateCorsBlacklist godoc
|
||||
// @Summary Create CORS blacklist item
|
||||
// @Description Creates a new blacklist origin.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertCorsBlacklistRequest true "cors blacklist payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/blacklist [post]
|
||||
func CreateCorsBlacklist(c *gin.Context) {
|
||||
var req UpsertCorsBlacklistRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := models.CorsBlacklist{
|
||||
Origin: req.Origin,
|
||||
Reason: req.Reason,
|
||||
IsActive: req.IsActive,
|
||||
CreatedBy: req.CreatedBy,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create cors blacklist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateCorsBlacklist godoc
|
||||
// @Summary Update CORS blacklist item
|
||||
// @Description Updates a blacklist origin by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "blacklist id"
|
||||
// @Param request body UpsertCorsBlacklistRequest true "cors blacklist payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/blacklist/{id} [put]
|
||||
func UpdateCorsBlacklist(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertCorsBlacklistRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item models.CorsBlacklist
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cors blacklist not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cors blacklist"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"origin": req.Origin,
|
||||
"reason": req.Reason,
|
||||
"is_active": req.IsActive,
|
||||
"created_by": req.CreatedBy,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update cors blacklist"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated cors blacklist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteCorsBlacklist godoc
|
||||
// @Summary Delete CORS blacklist item
|
||||
// @Description Deletes a blacklist origin by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "blacklist id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/cors/blacklist/{id} [delete]
|
||||
func DeleteCorsBlacklist(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&models.CorsBlacklist{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cors blacklist"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cors blacklist not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "cors blacklist deleted"})
|
||||
}
|
||||
|
||||
// ListRateLimits godoc
|
||||
// @Summary List rate limits
|
||||
// @Description Returns all rate-limit settings.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/rate-limits [get]
|
||||
func ListRateLimits(c *gin.Context) {
|
||||
var items []models.RateLimitSetting
|
||||
if err := config.DB.Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch rate limits"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateRateLimit godoc
|
||||
// @Summary Create rate limit
|
||||
// @Description Creates a new rate-limit setting.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertRateLimitRequest true "rate limit payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/rate-limits [post]
|
||||
func CreateRateLimit(c *gin.Context) {
|
||||
var req UpsertRateLimitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := models.RateLimitSetting{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
MaxRequests: req.MaxRequests,
|
||||
WindowSeconds: req.WindowSeconds,
|
||||
IsActive: req.IsActive,
|
||||
UpdatedBy: req.UpdatedBy,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create rate limit"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateRateLimit godoc
|
||||
// @Summary Update rate limit
|
||||
// @Description Updates a rate-limit setting by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "rate limit id"
|
||||
// @Param request body UpsertRateLimitRequest true "rate limit payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/settings/rate-limits/{id} [put]
|
||||
func UpdateRateLimit(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertRateLimitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item models.RateLimitSetting
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "rate limit not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch rate limit"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"max_requests": req.MaxRequests,
|
||||
"window_seconds": req.WindowSeconds,
|
||||
"is_active": req.IsActive,
|
||||
"updated_by": req.UpdatedBy,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update rate limit"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated rate limit"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteRateLimit godoc
|
||||
// @Summary Delete rate limit
|
||||
// @Description Deletes a rate-limit setting by id.
|
||||
// @Tags Settings
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "rate limit id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/settings/rate-limits/{id} [delete]
|
||||
func DeleteRateLimit(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&models.RateLimitSetting{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete rate limit"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "rate limit not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "rate limit deleted"})
|
||||
}
|
||||
34
app/settings/models/cors.go
Normal file
34
app/settings/models/cors.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CorsWhitelist - CORS için izin verilen origin'ler
|
||||
type CorsWhitelist struct {
|
||||
gorm.Model
|
||||
Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"`
|
||||
Description string `gorm:"type:varchar(255)" json:"description"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// CorsBlacklist - CORS için yasaklanan origin'ler
|
||||
type CorsBlacklist struct {
|
||||
gorm.Model
|
||||
Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"`
|
||||
Reason string `gorm:"type:varchar(255)" json:"reason"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
// RateLimitSetting - Rate limit ayarları
|
||||
type RateLimitSetting struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"type:varchar(100);uniqueIndex;not null" json:"name"` // e.g., "login", "register", "api"
|
||||
Description string `gorm:"type:varchar(255)" json:"description"`
|
||||
MaxRequests int64 `gorm:"not null" json:"max_requests"` // Max istek sayısı
|
||||
WindowSeconds int `gorm:"not null" json:"window_seconds"` // Zaman penceresi (saniye)
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by,omitempty"`
|
||||
}
|
||||
23
app/settings/models/hero.go
Normal file
23
app/settings/models/hero.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Banner model structure
|
||||
// Represents a banner item with optional thumbnail.
|
||||
type Hero struct {
|
||||
gorm.Model
|
||||
Color string `gorm:"type:varchar(32);not null" json:"color" form:"color"`
|
||||
Title string `gorm:"type:varchar(254)" json:"title,omitempty" form:"title"`
|
||||
Text1 string `gorm:"type:varchar(254)" json:"text1,omitempty" form:"text1"`
|
||||
Text2 string `gorm:"type:varchar(254)" json:"text2,omitempty" form:"text2"`
|
||||
Text4 string `gorm:"type:varchar(254)" json:"text4,omitempty" form:"text4"`
|
||||
Text5 string `gorm:"type:varchar(254)" json:"text5,omitempty" form:"text5"`
|
||||
Image string `gorm:"type:varchar(254)" json:"image" form:"image"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active" form:"is_active"`
|
||||
Width int `gorm:"default:0" json:"width" form:"width"`
|
||||
Height int `gorm:"default:0" json:"height" form:"height"`
|
||||
Quality int `gorm:"default:0" json:"quality" form:"quality"`
|
||||
Format string `gorm:"type:varchar(10)" json:"format" form:"format"`
|
||||
}
|
||||
43
app/settings/models/setting.go
Normal file
43
app/settings/models/setting.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Setting model structure
|
||||
// Stores site-wide metadata and contact information.
|
||||
type Setting struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"`
|
||||
MetaTitle string `gorm:"type:varchar(254);not null" json:"meta_title" form:"meta_title"`
|
||||
MetaDescription string `gorm:"type:varchar(254);not null" json:"meta_description" form:"meta_description"`
|
||||
Phone string `gorm:"type:varchar(254);not null" json:"phone" form:"phone"`
|
||||
URL string `gorm:"type:varchar(254);not null" json:"url" form:"url"`
|
||||
Email string `gorm:"type:varchar(254);not null" json:"email" form:"email"`
|
||||
Facebook string `gorm:"type:varchar(254)" json:"facebook,omitempty" form:"facebook"`
|
||||
X string `gorm:"type:varchar(254)" json:"x,omitempty" form:"x"`
|
||||
Instagram string `gorm:"type:varchar(254)" json:"instagram,omitempty" form:"instagram"`
|
||||
Whatsapp string `gorm:"type:varchar(254)" json:"whatsapp,omitempty" form:"whatsapp"`
|
||||
Pinterest string `gorm:"type:varchar(254)" json:"pinterest,omitempty" form:"pinterest"`
|
||||
Linkedin string `gorm:"type:varchar(254)" json:"linkedin,omitempty" form:"linkedin"`
|
||||
Slogan string `gorm:"type:varchar(254)" json:"slogan,omitempty" form:"slogan"`
|
||||
Address string `gorm:"type:text" json:"address,omitempty" form:"address"`
|
||||
Copyright string `gorm:"type:varchar(254)" json:"copyright,omitempty" form:"copyright"`
|
||||
MapEmbed string `gorm:"type:text" json:"map_embed,omitempty" form:"map_embed"`
|
||||
WLogo string `gorm:"type:text" json:"w_logo,omitempty" form:"w_logo"`
|
||||
BLogo string `gorm:"type:text" json:"b_logo,omitempty" form:"b_logo"`
|
||||
IsActive bool `gorm:"default:false" json:"is_active" form:"is_active"`
|
||||
WWidth int `gorm:"default:0" json:"w_width" form:"w_width"`
|
||||
WHeight int `gorm:"default:0" json:"w_height" form:"w_height"`
|
||||
WQuality int `gorm:"default:0" json:"w_quality" form:"w_quality"`
|
||||
WFormat string `gorm:"type:varchar(10)" json:"w_format" form:"w_format"`
|
||||
BWidth int `gorm:"default:0" json:"b_width" form:"b_width"`
|
||||
BHeight int `gorm:"default:0" json:"b_height" form:"b_height"`
|
||||
BQuality int `gorm:"default:0" json:"b_quality" form:"b_quality"`
|
||||
BFormat string `gorm:"type:varchar(10)" json:"b_format" form:"b_format"`
|
||||
}
|
||||
|
||||
// TableName overrides the table name used by Setting to `settings`
|
||||
func (Setting) TableName() string {
|
||||
return "settings"
|
||||
}
|
||||
665
app/shop/controllers/shop.go
Normal file
665
app/shop/controllers/shop.go
Normal file
@@ -0,0 +1,665 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
shopModels "goaresv3/app/shop/models"
|
||||
"goaresv3/config"
|
||||
)
|
||||
|
||||
type UpsertProductCategoryRequest struct {
|
||||
Title string `json:"title" binding:"required,max=254"`
|
||||
Slug string `json:"slug" binding:"required,max=254"`
|
||||
Description string `json:"description"`
|
||||
Keywords string `json:"keywords"`
|
||||
ParentID *uint `json:"parent_id"`
|
||||
}
|
||||
|
||||
type UpsertProductTagRequest struct {
|
||||
Name string `json:"name" binding:"required,max=254"`
|
||||
}
|
||||
|
||||
type UpsertProductRequest struct {
|
||||
Title string `json:"title" binding:"required,max=254"`
|
||||
Images string `json:"images" binding:"required"`
|
||||
Price float64 `json:"price"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format" binding:"omitempty,max=10"`
|
||||
Content string `json:"content"`
|
||||
Slug string `json:"slug" binding:"required,max=254"`
|
||||
CategoryIDs []uint `json:"category_ids"`
|
||||
TagIDs []uint `json:"tag_ids"`
|
||||
}
|
||||
|
||||
type UpsertCartItemRequest struct {
|
||||
ProductID uint `json:"product_id" binding:"required"`
|
||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
func parseShopID(c *gin.Context, key string) (uint, bool) {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(c.Param(key)), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// ListProductCategories godoc
|
||||
// @Summary List product categories
|
||||
// @Description Returns all categories with children.
|
||||
// @Tags Shop
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/categories [get]
|
||||
func ListProductCategories(c *gin.Context) {
|
||||
var items []shopModels.ProductCategory
|
||||
if err := config.DB.Preload("Children").Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch categories"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateProductCategory godoc
|
||||
// @Summary Create product category
|
||||
// @Description Creates a new shop category.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertProductCategoryRequest true "category payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/shop/categories [post]
|
||||
func CreateProductCategory(c *gin.Context) {
|
||||
var req UpsertProductCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := shopModels.ProductCategory{
|
||||
Title: req.Title,
|
||||
Slug: req.Slug,
|
||||
Description: req.Description,
|
||||
Keywords: req.Keywords,
|
||||
ParentID: req.ParentID,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create category"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateProductCategory godoc
|
||||
// @Summary Update product category
|
||||
// @Description Updates a category by id.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "category id"
|
||||
// @Param request body UpsertProductCategoryRequest true "category payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/shop/categories/{id} [put]
|
||||
func UpdateProductCategory(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertProductCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item shopModels.ProductCategory
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "category not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch category"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"title": req.Title,
|
||||
"slug": req.Slug,
|
||||
"description": req.Description,
|
||||
"keywords": req.Keywords,
|
||||
"parent_id": req.ParentID,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update category"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, id).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteProductCategory godoc
|
||||
// @Summary Delete product category
|
||||
// @Description Deletes a category by id.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "category id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/categories/{id} [delete]
|
||||
func DeleteProductCategory(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&shopModels.ProductCategory{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete category"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "category not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "category deleted"})
|
||||
}
|
||||
|
||||
// ListProductTags godoc
|
||||
// @Summary List product tags
|
||||
// @Description Returns all product tags.
|
||||
// @Tags Shop
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/tags [get]
|
||||
func ListProductTags(c *gin.Context) {
|
||||
var items []shopModels.ProductTag
|
||||
if err := config.DB.Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tags"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// CreateProductTag godoc
|
||||
// @Summary Create product tag
|
||||
// @Description Creates a new product tag.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertProductTagRequest true "tag payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/shop/tags [post]
|
||||
func CreateProductTag(c *gin.Context) {
|
||||
var req UpsertProductTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := shopModels.ProductTag{Name: req.Name}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create tag"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateProductTag godoc
|
||||
// @Summary Update product tag
|
||||
// @Description Updates a tag by id.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "tag id"
|
||||
// @Param request body UpsertProductTagRequest true "tag payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/v1/shop/tags/{id} [put]
|
||||
func UpdateProductTag(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertProductTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item shopModels.ProductTag
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch tag"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Update("name", req.Name).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update tag"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, id).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteProductTag godoc
|
||||
// @Summary Delete product tag
|
||||
// @Description Deletes a tag by id.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "tag id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/tags/{id} [delete]
|
||||
func DeleteProductTag(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&shopModels.ProductTag{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete tag"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "tag deleted"})
|
||||
}
|
||||
|
||||
// ListProducts godoc
|
||||
// @Summary List products
|
||||
// @Description Returns all products with categories and tags.
|
||||
// @Tags Shop
|
||||
// @Produce json
|
||||
// @Success 200 {array} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/products [get]
|
||||
func ListProducts(c *gin.Context) {
|
||||
var items []shopModels.Product
|
||||
if err := config.DB.Preload("Categories").Preload("Tags").Order("id DESC").Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch products"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// GetProduct godoc
|
||||
// @Summary Get product
|
||||
// @Description Returns product details by id.
|
||||
// @Tags Shop
|
||||
// @Produce json
|
||||
// @Param id path int true "product 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/shop/products/{id} [get]
|
||||
func GetProduct(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var item shopModels.Product
|
||||
if err := config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch product"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// CreateProduct godoc
|
||||
// @Summary Create product
|
||||
// @Description Creates a new product and assigns category/tag relations.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertProductRequest true "product payload"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/products [post]
|
||||
func CreateProduct(c *gin.Context) {
|
||||
var req UpsertProductRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item := shopModels.Product{
|
||||
Title: req.Title,
|
||||
Images: req.Images,
|
||||
Price: req.Price,
|
||||
Width: req.Width,
|
||||
Height: req.Height,
|
||||
Quality: req.Quality,
|
||||
Format: req.Format,
|
||||
Content: req.Content,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to create product"})
|
||||
return
|
||||
}
|
||||
if err := assignProductRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign product relations"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.Preload("Categories").Preload("Tags").First(&item, item.ID).Error
|
||||
c.JSON(http.StatusCreated, item)
|
||||
}
|
||||
|
||||
// UpdateProduct godoc
|
||||
// @Summary Update product
|
||||
// @Description Updates a product and reassigns category/tag relations.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "product id"
|
||||
// @Param request body UpsertProductRequest true "product payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/products/{id} [put]
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req UpsertProductRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item shopModels.Product
|
||||
if err := config.DB.First(&item, id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch product"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"title": req.Title,
|
||||
"images": req.Images,
|
||||
"price": req.Price,
|
||||
"width": req.Width,
|
||||
"height": req.Height,
|
||||
"quality": req.Quality,
|
||||
"format": req.Format,
|
||||
"content": req.Content,
|
||||
"slug": req.Slug,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "failed to update product"})
|
||||
return
|
||||
}
|
||||
if err := assignProductRelations(item.ID, req.CategoryIDs, req.TagIDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to assign product relations"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.Preload("Categories").Preload("Tags").First(&item, id).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteProduct godoc
|
||||
// @Summary Delete product
|
||||
// @Description Deletes a product by id.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param id path int true "product id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/products/{id} [delete]
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
id, ok := parseShopID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res := config.DB.Delete(&shopModels.Product{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete product"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "product not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "product deleted"})
|
||||
}
|
||||
|
||||
// GetMyCart godoc
|
||||
// @Summary Get my cart
|
||||
// @Description Returns the authenticated user's cart with items.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/cart [get]
|
||||
func GetMyCart(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var cart shopModels.Cart
|
||||
err := config.DB.Preload("Items.Product").Where("user_id = ?", userID).First(&cart).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []any{}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cart)
|
||||
}
|
||||
|
||||
// AddCartItem godoc
|
||||
// @Summary Add item to my cart
|
||||
// @Description Creates or increments a cart item for authenticated user.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body UpsertCartItemRequest true "cart item payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/cart/items [post]
|
||||
func AddCartItem(c *gin.Context) {
|
||||
userID := c.GetUint("user_id")
|
||||
var req UpsertCartItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := ensureCart(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get cart"})
|
||||
return
|
||||
}
|
||||
|
||||
var item shopModels.CartItem
|
||||
err = config.DB.Where("cart_id = ? AND product_id = ?", cart.ID, req.ProductID).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
item = shopModels.CartItem{CartID: cart.ID, ProductID: req.ProductID, Quantity: req.Quantity}
|
||||
if err := config.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add cart item"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, item)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Update("quantity", item.Quantity+req.Quantity).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cart item"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, item.ID).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// UpdateCartItem godoc
|
||||
// @Summary Update my cart item
|
||||
// @Description Updates a cart item owned by authenticated user.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param itemId path int true "cart item id"
|
||||
// @Param request body UpsertCartItemRequest true "cart item payload"
|
||||
// @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/shop/cart/items/{itemId} [put]
|
||||
func UpdateCartItem(c *gin.Context) {
|
||||
itemID, ok := parseShopID(c, "itemId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := c.GetUint("user_id")
|
||||
var req UpsertCartItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var item shopModels.CartItem
|
||||
err := config.DB.Joins("JOIN carts ON carts.id = cart_items.cart_id").
|
||||
Where("cart_items.id = ? AND carts.user_id = ?", itemID, userID).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cart item not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Model(&item).Updates(map[string]any{
|
||||
"product_id": req.ProductID,
|
||||
"quantity": req.Quantity,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update cart item"})
|
||||
return
|
||||
}
|
||||
_ = config.DB.First(&item, itemID).Error
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteCartItem godoc
|
||||
// @Summary Delete item from my cart
|
||||
// @Description Deletes a cart item owned by authenticated user.
|
||||
// @Tags Shop
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Param itemId path int true "cart item id"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/shop/cart/items/{itemId} [delete]
|
||||
func DeleteCartItem(c *gin.Context) {
|
||||
itemID, ok := parseShopID(c, "itemId")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := c.GetUint("user_id")
|
||||
var item shopModels.CartItem
|
||||
err := config.DB.Joins("JOIN carts ON carts.id = cart_items.cart_id").
|
||||
Where("cart_items.id = ? AND carts.user_id = ?", itemID, userID).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cart item not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch cart item"})
|
||||
return
|
||||
}
|
||||
if err := config.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete cart item"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "cart item deleted"})
|
||||
}
|
||||
|
||||
func ensureCart(userID uint) (*shopModels.Cart, error) {
|
||||
var cart shopModels.Cart
|
||||
err := config.DB.Where("user_id = ?", userID).First(&cart).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
cart = shopModels.Cart{UserID: userID}
|
||||
if createErr := config.DB.Create(&cart).Error; createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return &cart, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func assignProductRelations(productID uint, categoryIDs, tagIDs []uint) error {
|
||||
var p shopModels.Product
|
||||
if err := config.DB.First(&p, productID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if categoryIDs != nil {
|
||||
var categories []shopModels.ProductCategory
|
||||
if len(categoryIDs) > 0 {
|
||||
if err := config.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := config.DB.Model(&p).Association("Categories").Replace(categories); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tagIDs != nil {
|
||||
var tags []shopModels.ProductTag
|
||||
if len(tagIDs) > 0 {
|
||||
if err := config.DB.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := config.DB.Model(&p).Association("Tags").Replace(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
app/shop/models/cart.go
Normal file
20
app/shop/models/cart.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Cart struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Items []CartItem `gorm:"foreignKey:CartID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
gorm.Model
|
||||
CartID uint `gorm:"not null;index" json:"cart_id"`
|
||||
Cart *Cart `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:CartID" json:"cart,omitempty"`
|
||||
ProductID uint `gorm:"not null;index" json:"product_id"`
|
||||
Product *Product `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:ProductID" json:"product,omitempty"`
|
||||
Quantity int `gorm:"default:1" json:"quantity"`
|
||||
}
|
||||
53
app/shop/models/product.go
Normal file
53
app/shop/models/product.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Minimal, temiz GORM modelleri
|
||||
|
||||
type ProductCategory 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"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Parent *ProductCategory `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []ProductCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Products []Product `gorm:"many2many:product_product_categories;" json:"products,omitempty"`
|
||||
}
|
||||
|
||||
type ProductTag struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"type:varchar(254);not null" json:"name"`
|
||||
Products []Product `gorm:"many2many:product_product_tags;" json:"products,omitempty"`
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
gorm.Model
|
||||
Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"`
|
||||
Images string `gorm:"type:text;not null" json:"images" form:"images"`
|
||||
Price float64 `gorm:"type:decimal(10,2);default:0.0" json:"price" form:"price"`
|
||||
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);default:avif" json:"format" form:"format"`
|
||||
Content string `gorm:"type:text" json:"content,omitempty" form:"content"`
|
||||
Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug" form:"slug"`
|
||||
Categories []ProductCategory `gorm:"many2many:product_product_categories;" json:"categories,omitempty" form:"product_category"`
|
||||
Tags []ProductTag `gorm:"many2many:product_product_tags;" json:"tags,omitempty" form:"tags"`
|
||||
}
|
||||
|
||||
type ProductCategoryView struct {
|
||||
gorm.Model
|
||||
CategoryID uint `json:"category_id"`
|
||||
IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"`
|
||||
}
|
||||
|
||||
type ProductComment struct {
|
||||
gorm.Model
|
||||
UserID uint `json:"user_id"`
|
||||
ProductID uint `json:"product_id"`
|
||||
Body string `gorm:"type:text" json:"body,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user