first commit
709
app/accounts/handlers/admin_users.go
Normal file
@@ -0,0 +1,709 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ginimageApi/app/accounts/models"
|
||||
"ginimageApi/app/middleware"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adminUserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type adminUserListResponse struct {
|
||||
Items []adminUserResponse `json:"items"`
|
||||
Meta paginationMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type paginationMeta struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type adminCreateUserRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type adminUpdateUserRequest struct {
|
||||
Username string `json:"username" binding:"omitempty,min=3"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type adminUserStatusRequest struct {
|
||||
IsActive bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
|
||||
type adminUpdateProfileRequest struct {
|
||||
FirstName string `form:"first_name" binding:"omitempty,min=2"`
|
||||
LastName string `form:"last_name" binding:"omitempty,min=2"`
|
||||
}
|
||||
|
||||
type adminProfileResponse struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type adminIssueTokenRequest struct {
|
||||
DurationDays int `json:"duration_days" binding:"required,min=1,max=365"`
|
||||
}
|
||||
|
||||
type adminIssueTokenResponse struct {
|
||||
AccessToken string `json:"access"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
type AdminUserResponse = adminUserResponse
|
||||
type AdminUserListResponse = adminUserListResponse
|
||||
type AdminCreateUserRequest = adminCreateUserRequest
|
||||
type AdminUpdateUserRequest = adminUpdateUserRequest
|
||||
type AdminUserStatusRequest = adminUserStatusRequest
|
||||
type AdminUpdateProfileRequest = adminUpdateProfileRequest
|
||||
type AdminProfileResponse = adminProfileResponse
|
||||
type AdminIssueTokenRequest = adminIssueTokenRequest
|
||||
type AdminIssueTokenResponse = adminIssueTokenResponse
|
||||
|
||||
func adminActorID(c *gin.Context) any {
|
||||
actorID, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
return actorID
|
||||
}
|
||||
|
||||
func maskEmail(email string) string {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "invalid-email"
|
||||
}
|
||||
local := parts[0]
|
||||
domain := parts[1]
|
||||
if len(local) <= 2 {
|
||||
return local[:1] + "***@" + domain
|
||||
}
|
||||
return local[:2] + "***@" + domain
|
||||
}
|
||||
|
||||
func getOrCreateProfileByUserID(userID uint64) (models.Profile, error) {
|
||||
var profile models.Profile
|
||||
err := configs.DB.Where("user_id = ?", userID).First(&profile).Error
|
||||
if err == nil {
|
||||
return profile, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.Profile{}, err
|
||||
}
|
||||
|
||||
profile = models.Profile{UserID: userID}
|
||||
if err := configs.DB.Create(&profile).Error; err != nil {
|
||||
return models.Profile{}, err
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// ListAdminUsers godoc
|
||||
// @Summary Admin kullanicilari listeler
|
||||
// @Tags admin-users
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "Sayfa numarasi" default(1)
|
||||
// @Param limit query int false "Sayfa boyutu (max 100)" default(10)
|
||||
// @Param search query string false "Kullanici adi/email arama"
|
||||
// @Param is_admin query bool false "Admin filtresi"
|
||||
// @Param is_active query bool false "Aktiflik filtresi"
|
||||
// @Success 200 {object} AdminUserListResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users [get]
|
||||
func ListAdminUsers(c *gin.Context) {
|
||||
page := parsePositiveIntOrDefault(c.Query("page"), 1)
|
||||
limit := parsePositiveIntOrDefault(c.Query("limit"), 10)
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
isAdminFilter := strings.TrimSpace(c.Query("is_admin"))
|
||||
isActiveFilter := strings.TrimSpace(c.Query("is_active"))
|
||||
|
||||
query := configs.DB.Model(&models.User{})
|
||||
|
||||
if search != "" {
|
||||
like := "%" + strings.ToLower(search) + "%"
|
||||
query = query.Where("LOWER(user_name) LIKE ? OR LOWER(email) LIKE ?", like, like)
|
||||
}
|
||||
|
||||
if v, ok := parseOptionalBool(isAdminFilter); ok {
|
||||
query = query.Where("is_admin = ?", v)
|
||||
}
|
||||
if v, ok := parseOptionalBool(isActiveFilter); ok {
|
||||
query = query.Where("is_active = ?", v)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
var users []models.User
|
||||
if err := query.
|
||||
Order("id DESC").
|
||||
Offset((page - 1) * limit).
|
||||
Limit(limit).
|
||||
Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]adminUserResponse, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, toAdminUserResponse(user))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, adminUserListResponse{
|
||||
Items: items,
|
||||
Meta: paginationMeta{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminUser godoc
|
||||
// @Summary Admin panel icin kullanici detayi getirir
|
||||
// @Tags admin-users
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Success 200 {object} AdminUserResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id} [get]
|
||||
func GetAdminUser(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toAdminUserResponse(user))
|
||||
}
|
||||
|
||||
// CreateAdminUser godoc
|
||||
// @Summary Admin panel icin kullanici olusturur
|
||||
// @Tags admin-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body AdminCreateUserRequest true "Kullanici olusturma verisi"
|
||||
// @Success 201 {object} AdminUserResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users [post]
|
||||
func CreateAdminUser(c *gin.Context) {
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=start actor_id=%v ip=%s ua=%q", adminActorID(c), c.ClientIP(), c.Request.UserAgent())
|
||||
|
||||
var req adminCreateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=bind_failed actor_id=%v ip=%s error=%q", adminActorID(c), c.ClientIP(), err.Error())
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf(
|
||||
"[ADMIN-USER-CREATE] stage=payload_ok actor_id=%v username=%q email=%q is_admin=%v is_active=%v",
|
||||
adminActorID(c),
|
||||
req.Username,
|
||||
maskEmail(req.Email),
|
||||
req.IsAdmin,
|
||||
req.IsActive,
|
||||
)
|
||||
|
||||
var exists models.User
|
||||
err := configs.DB.Where("email = ?", req.Email).First(&exists).Error
|
||||
if err == nil {
|
||||
log.Printf(
|
||||
"[ADMIN-USER-CREATE] stage=conflict actor_id=%v reason=email_exists incoming_email=%q existing_user_id=%d existing_username=%q existing_active=%v existing_admin=%v",
|
||||
adminActorID(c),
|
||||
maskEmail(req.Email),
|
||||
exists.ID,
|
||||
exists.UserName,
|
||||
exists.IsActive != nil && *exists.IsActive,
|
||||
exists.IsAdmin != nil && *exists.IsAdmin,
|
||||
)
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "email zaten kayitli",
|
||||
"code": "EMAIL_ALREADY_EXISTS",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=check_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=hash_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if req.IsAdmin != nil {
|
||||
isAdmin = *req.IsAdmin
|
||||
}
|
||||
isActive := true
|
||||
if req.IsActive != nil {
|
||||
isActive = *req.IsActive
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
UserName: req.Username,
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
EmailVerified: boolPtr(false),
|
||||
IsActive: boolPtr(isActive),
|
||||
IsAdmin: boolPtr(isAdmin),
|
||||
}
|
||||
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=create_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici olusturulamadi"})
|
||||
return
|
||||
}
|
||||
log.Printf("[ADMIN-USER-CREATE] stage=success actor_id=%v created_user_id=%d email=%q", adminActorID(c), user.ID, maskEmail(user.Email))
|
||||
|
||||
c.JSON(http.StatusCreated, toAdminUserResponse(user))
|
||||
}
|
||||
|
||||
// UpdateAdminUser godoc
|
||||
// @Summary Admin panel icin kullaniciyi gunceller
|
||||
// @Tags admin-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Param request body AdminUpdateUserRequest true "Kullanici guncelleme verisi"
|
||||
// @Success 200 {object} AdminUserResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id} [put]
|
||||
func UpdateAdminUser(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req adminUpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email != "" && req.Email != user.Email {
|
||||
var exists models.User
|
||||
err := configs.DB.Where("email = ? AND id <> ?", req.Email, user.ID).First(&exists).Error
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email zaten kayitli"})
|
||||
return
|
||||
}
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"})
|
||||
return
|
||||
}
|
||||
user.Email = req.Email
|
||||
}
|
||||
|
||||
if req.Username != "" {
|
||||
user.UserName = req.Username
|
||||
}
|
||||
if req.IsAdmin != nil {
|
||||
user.IsAdmin = boolPtr(*req.IsAdmin)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
user.IsActive = boolPtr(*req.IsActive)
|
||||
}
|
||||
if req.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"})
|
||||
return
|
||||
}
|
||||
user.Password = string(hashedPassword)
|
||||
}
|
||||
|
||||
if err := configs.DB.Save(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici guncellenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toAdminUserResponse(user))
|
||||
}
|
||||
|
||||
// UpdateAdminUserStatus godoc
|
||||
// @Summary Admin panel icin kullanici aktiflik durumunu gunceller
|
||||
// @Tags admin-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Param request body AdminUserStatusRequest true "Durum verisi"
|
||||
// @Success 200 {object} AdminUserResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id}/status [patch]
|
||||
func UpdateAdminUserStatus(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req adminUserStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := configs.DB.Model(&models.User{}).Where("id = ?", userID).Update("is_active", req.IsActive)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici durumu guncellenemedi"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toAdminUserResponse(user))
|
||||
}
|
||||
|
||||
// DeleteAdminUser godoc
|
||||
// @Summary Admin panel icin kullanici siler
|
||||
// @Tags admin-users
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Success 200 {object} MessageResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id} [delete]
|
||||
func DeleteAdminUser(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result := configs.DB.Delete(&models.User{}, userID)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici silinemedi"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "kullanici silindi"})
|
||||
}
|
||||
|
||||
// IssueAdminScopedToken godoc
|
||||
// @Summary Admin için gün bazlı access token üretir
|
||||
// @Description Sadece admin rolü için, istekle verilen gün kadar geçerli access token üretir. Refresh token üretilmez.
|
||||
// @Tags admin-users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body AdminIssueTokenRequest true "Token süresi (gün)"
|
||||
// @Success 200 {object} AdminIssueTokenResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/tokens/issue [post]
|
||||
func IssueAdminScopedToken(c *gin.Context) {
|
||||
var req adminIssueTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := currentUserID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
if user.IsAdmin == nil || !*user.IsAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenTTL := time.Duration(req.DurationDays) * 24 * time.Hour
|
||||
accessToken, err := middleware.GenerateAccessToken(user.ID, user.Email, user.UserName, tokenTTL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, adminIssueTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
ExpiresAt: time.Now().Add(tokenTTL).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminUserProfile godoc
|
||||
// @Summary Admin panel icin kullanicinin profilini getirir
|
||||
// @Tags admin-users
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Success 200 {object} AdminProfileResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id}/profile [get]
|
||||
func GetAdminUserProfile(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := getOrCreateProfileByUserID(uint64(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, adminProfileResponse{
|
||||
UserID: profile.UserID,
|
||||
FirstName: profile.FirstName,
|
||||
LastName: profile.LastName,
|
||||
AvatarURL: profile.AvatarURL,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAdminUserProfile godoc
|
||||
// @Summary Admin panel icin kullanici profilini gunceller
|
||||
// @Tags admin-users
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kullanici ID"
|
||||
// @Param first_name formData string false "Ad"
|
||||
// @Param last_name formData string false "Soyad"
|
||||
// @Param avatar formData file false "Avatar dosyasi"
|
||||
// @Success 200 {object} AdminProfileResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 403 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /api/v1/admin/users/{id}/profile [put]
|
||||
func UpdateAdminUserProfile(c *gin.Context) {
|
||||
userID, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req adminUpdateProfileRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := getOrCreateProfileByUserID(uint64(userID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirstName != "" {
|
||||
profile.FirstName = req.FirstName
|
||||
}
|
||||
if req.LastName != "" {
|
||||
profile.LastName = req.LastName
|
||||
}
|
||||
oldAvatarURL := profile.AvatarURL
|
||||
avatarURL, hasAvatar, err := saveAvatarFromMultipart(c, "avatar")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "avatar dosyasi okunamadi"})
|
||||
return
|
||||
}
|
||||
if hasAvatar {
|
||||
profile.AvatarURL = avatarURL
|
||||
}
|
||||
|
||||
if err := configs.DB.Save(&profile).Error; err != nil {
|
||||
if hasAvatar {
|
||||
_ = deleteLocalAvatarByURL(avatarURL)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil guncellenemedi"})
|
||||
return
|
||||
}
|
||||
if hasAvatar && oldAvatarURL != "" && oldAvatarURL != profile.AvatarURL {
|
||||
if err := deleteLocalAvatarByURL(oldAvatarURL); err != nil {
|
||||
log.Printf("[ADMIN-PROFILE-UPDATE] user_id=%d result=warn stage=delete_old_avatar error=%v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, adminProfileResponse{
|
||||
UserID: profile.UserID,
|
||||
FirstName: profile.FirstName,
|
||||
LastName: profile.LastName,
|
||||
AvatarURL: profile.AvatarURL,
|
||||
})
|
||||
}
|
||||
|
||||
func parsePositiveIntOrDefault(raw string, fallback int) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseOptionalBool(raw string) (bool, bool) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return false, false
|
||||
}
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func parseUintParam(c *gin.Context, key string) (uint, bool) {
|
||||
raw := strings.TrimSpace(c.Param(key))
|
||||
if raw == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"})
|
||||
return 0, false
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func toAdminUserResponse(user models.User) adminUserResponse {
|
||||
return adminUserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.UserName,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.IsEmailVerified(),
|
||||
IsActive: user.IsActive != nil && *user.IsActive,
|
||||
IsAdmin: user.IsAdmin != nil && *user.IsAdmin,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
155
app/accounts/handlers/admin_users_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ginimageApi/app/accounts/models"
|
||||
"ginimageApi/app/middleware"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAdminUserProfileGetAndUpdate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
adminFlag := true
|
||||
active := true
|
||||
verified := true
|
||||
adminUser := models.User{
|
||||
UserName: "admin",
|
||||
Email: "admin-profile@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &adminFlag,
|
||||
IsActive: &active,
|
||||
EmailVerified: &verified,
|
||||
}
|
||||
if err := configs.DB.Create(&adminUser).Error; err != nil {
|
||||
t.Fatalf("create admin failed: %v", err)
|
||||
}
|
||||
|
||||
targetFlag := false
|
||||
target := models.User{
|
||||
UserName: "target",
|
||||
Email: "target-profile@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &targetFlag,
|
||||
IsActive: &active,
|
||||
EmailVerified: &verified,
|
||||
}
|
||||
if err := configs.DB.Create(&target).Error; err != nil {
|
||||
t.Fatalf("create target failed: %v", err)
|
||||
}
|
||||
oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_admin_target_avatar.png")
|
||||
seedProfile := models.Profile{UserID: uint64(target.ID), AvatarURL: oldAvatarURL}
|
||||
if err := configs.DB.Create(&seedProfile).Error; err != nil {
|
||||
t.Fatalf("seed profile failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := middleware.BuildAccessTokenForUser(adminUser)
|
||||
if err != nil {
|
||||
t.Fatalf("token create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile)
|
||||
r.PUT("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), UpdateAdminUserProfile)
|
||||
|
||||
// Profile kaydi yoksa GET ile otomatik olusmali.
|
||||
wGet := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
})
|
||||
if wGet.Code != http.StatusOK {
|
||||
t.Fatalf("get admin profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String())
|
||||
}
|
||||
|
||||
var getResp map[string]any
|
||||
if err := json.Unmarshal(wGet.Body.Bytes(), &getResp); err != nil {
|
||||
t.Fatalf("parse get response failed: %v", err)
|
||||
}
|
||||
if int(getResp["user_id"].(float64)) != int(target.ID) {
|
||||
t.Fatalf("user_id mismatch in get response")
|
||||
}
|
||||
|
||||
wPut := performMultipart(
|
||||
r,
|
||||
http.MethodPut,
|
||||
"/admin/users/"+toString(target.ID)+"/profile",
|
||||
map[string]string{"first_name": "Admin", "last_name": "Updated"},
|
||||
"avatar",
|
||||
"admin.png",
|
||||
tinyPNGFixture(t),
|
||||
map[string]string{"Authorization": "Bearer " + token},
|
||||
)
|
||||
if wPut.Code != http.StatusOK {
|
||||
t.Fatalf("update admin profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
|
||||
}
|
||||
|
||||
var profile models.Profile
|
||||
if err := configs.DB.Where("user_id = ?", target.ID).First(&profile).Error; err != nil {
|
||||
t.Fatalf("profile should exist after update: %v", err)
|
||||
}
|
||||
if profile.FirstName != "Admin" || profile.LastName != "Updated" {
|
||||
t.Fatalf("profile name mismatch: %+v", profile)
|
||||
}
|
||||
if !strings.HasPrefix(profile.AvatarURL, "/uploads/avatars/") {
|
||||
t.Fatalf("avatar path mismatch: %s", profile.AvatarURL)
|
||||
}
|
||||
if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old avatar should be deleted, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserProfileRequiresAdminRole(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
active := true
|
||||
verified := true
|
||||
nonAdminFlag := false
|
||||
nonAdmin := models.User{
|
||||
UserName: "nonadmin",
|
||||
Email: "nonadmin-profile@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &nonAdminFlag,
|
||||
IsActive: &active,
|
||||
EmailVerified: &verified,
|
||||
}
|
||||
if err := configs.DB.Create(&nonAdmin).Error; err != nil {
|
||||
t.Fatalf("create non-admin failed: %v", err)
|
||||
}
|
||||
|
||||
target := models.User{
|
||||
UserName: "target2",
|
||||
Email: "target2-profile@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &nonAdminFlag,
|
||||
IsActive: &active,
|
||||
EmailVerified: &verified,
|
||||
}
|
||||
if err := configs.DB.Create(&target).Error; err != nil {
|
||||
t.Fatalf("create target failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := middleware.BuildAccessTokenForUser(nonAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("token create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile)
|
||||
|
||||
w := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
1281
app/accounts/handlers/user.go
Normal file
735
app/accounts/handlers/user_test.go
Normal file
@@ -0,0 +1,735 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ginimageApi/app/accounts/models"
|
||||
"ginimageApi/app/middleware"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupHandlersTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("AVATAR_WIDTH", "64")
|
||||
t.Setenv("AVATAR_HEIGHT", "64")
|
||||
t.Setenv("AVATAR_QUALITY", "80")
|
||||
t.Setenv("AVATAR_MAX_SIZE_MB", "5")
|
||||
t.Setenv("AVATAR_FORMATS", "png")
|
||||
|
||||
prev := configs.DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite open failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}, &models.Profile{}, &models.SocialAccount{}, &models.RefreshToken{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
configs.DB = db
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
configs.DB = prev
|
||||
})
|
||||
}
|
||||
|
||||
func tinyPNGFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
// 1x1 PNG
|
||||
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Zr0kAAAAASUVORK5CYII="
|
||||
b, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
t.Fatalf("png fixture decode failed: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func createOldAvatarFixture(t *testing.T, fileName string) (string, string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join("uploads", "avatars")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir avatars failed: %v", err)
|
||||
}
|
||||
fullPath := filepath.Join(dir, fileName)
|
||||
if err := os.WriteFile(fullPath, []byte("old-avatar"), 0o644); err != nil {
|
||||
t.Fatalf("write old avatar failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(fullPath) })
|
||||
|
||||
return "/uploads/avatars/" + fileName, fullPath
|
||||
}
|
||||
|
||||
func performJSON(r *gin.Engine, method, path string, payload any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
body, _ = json.Marshal(payload)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func performMultipart(
|
||||
r *gin.Engine,
|
||||
method, path string,
|
||||
fields map[string]string,
|
||||
fileField, fileName string,
|
||||
fileContent []byte,
|
||||
headers map[string]string,
|
||||
) *httptest.ResponseRecorder {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for k, v := range fields {
|
||||
_ = writer.WriteField(k, v)
|
||||
}
|
||||
if fileField != "" {
|
||||
part, _ := writer.CreateFormFile(fileField, fileName)
|
||||
_, _ = part.Write(fileContent)
|
||||
}
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(method, path, &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func assertJWTFormat(t *testing.T, token string) {
|
||||
t.Helper()
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token JWT formatinda olmali, segment sayisi: %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthFlowRegisterLoginMeRefresh(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/register", Register)
|
||||
r.POST("/login", Login)
|
||||
r.POST("/refresh", Refresh)
|
||||
r.GET("/verify-email", VerifyEmail)
|
||||
r.GET("/me", middleware.AuthRequired(), Me)
|
||||
|
||||
registerPayload := map[string]any{
|
||||
"username": "john",
|
||||
"email": "john@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
}
|
||||
wReg := performJSON(r, http.MethodPost, "/register", registerPayload, nil)
|
||||
if wReg.Code != http.StatusCreated {
|
||||
t.Fatalf("register expected 201, got %d body=%s", wReg.Code, wReg.Body.String())
|
||||
}
|
||||
|
||||
var regResp map[string]any
|
||||
if err := json.Unmarshal(wReg.Body.Bytes(), ®Resp); err != nil {
|
||||
t.Fatalf("register json parse failed: %v", err)
|
||||
}
|
||||
accessToken, _ := regResp["access"].(string)
|
||||
if accessToken != "" {
|
||||
t.Fatalf("register should not return direct access token before email verification")
|
||||
}
|
||||
verificationToken, _ := regResp["verification_token"].(string)
|
||||
if verificationToken == "" {
|
||||
t.Fatalf("verification_token must be returned")
|
||||
}
|
||||
|
||||
wLogin := performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||
"email": "john@example.com",
|
||||
"password": "secret123",
|
||||
}, nil)
|
||||
if wLogin.Code != http.StatusForbidden {
|
||||
t.Fatalf("login expected 403 before verify, got %d body=%s", wLogin.Code, wLogin.Body.String())
|
||||
}
|
||||
|
||||
verifyPath := "/verify-email?token=" + url.QueryEscape(verificationToken)
|
||||
wVerify := performJSON(r, http.MethodGet, verifyPath, nil, nil)
|
||||
if wVerify.Code != http.StatusOK {
|
||||
t.Fatalf("verify expected 200, got %d body=%s", wVerify.Code, wVerify.Body.String())
|
||||
}
|
||||
|
||||
var verifyResp map[string]any
|
||||
if err := json.Unmarshal(wVerify.Body.Bytes(), &verifyResp); err != nil {
|
||||
t.Fatalf("verify json parse failed: %v", err)
|
||||
}
|
||||
accessToken, _ = verifyResp["access"].(string)
|
||||
refreshToken, _ := verifyResp["refresh"].(string)
|
||||
if accessToken == "" || refreshToken == "" {
|
||||
t.Fatalf("verify should return tokens")
|
||||
}
|
||||
assertJWTFormat(t, accessToken)
|
||||
assertJWTFormat(t, refreshToken)
|
||||
|
||||
wLogin = performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||
"email": "john@example.com",
|
||||
"password": "secret123",
|
||||
}, nil)
|
||||
if wLogin.Code != http.StatusOK {
|
||||
t.Fatalf("login expected 200, got %d body=%s", wLogin.Code, wLogin.Body.String())
|
||||
}
|
||||
var loginResp map[string]any
|
||||
if err := json.Unmarshal(wLogin.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("login json parse failed: %v", err)
|
||||
}
|
||||
loginRefreshToken, _ := loginResp["refresh"].(string)
|
||||
loginAccessToken, _ := loginResp["access"].(string)
|
||||
assertJWTFormat(t, loginAccessToken)
|
||||
assertJWTFormat(t, loginRefreshToken)
|
||||
|
||||
wMe := performJSON(r, http.MethodGet, "/me", nil, map[string]string{"Authorization": "Bearer " + accessToken})
|
||||
if wMe.Code != http.StatusOK {
|
||||
t.Fatalf("me expected 200, got %d", wMe.Code)
|
||||
}
|
||||
|
||||
wRefresh := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil)
|
||||
if wRefresh.Code != http.StatusOK {
|
||||
t.Fatalf("refresh expected 200, got %d body=%s", wRefresh.Code, wRefresh.Body.String())
|
||||
}
|
||||
var refreshResp map[string]any
|
||||
if err := json.Unmarshal(wRefresh.Body.Bytes(), &refreshResp); err != nil {
|
||||
t.Fatalf("refresh json parse failed: %v", err)
|
||||
}
|
||||
newRefreshToken, _ := refreshResp["refresh"].(string)
|
||||
newAccessToken, _ := refreshResp["access"].(string)
|
||||
assertJWTFormat(t, newAccessToken)
|
||||
assertJWTFormat(t, newRefreshToken)
|
||||
if newRefreshToken == refreshToken {
|
||||
t.Fatalf("refresh rotation should return a new refresh token")
|
||||
}
|
||||
|
||||
var oldToken models.RefreshToken
|
||||
if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&oldToken).Error; err != nil {
|
||||
t.Fatalf("refresh token record not found: %v", err)
|
||||
}
|
||||
if !oldToken.Revoked {
|
||||
t.Fatalf("old refresh token should be revoked after refresh")
|
||||
}
|
||||
if oldToken.ReplacedByTokenID == "" {
|
||||
t.Fatalf("old token should keep replaced_by_token_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/register", Register)
|
||||
|
||||
payload := map[string]any{
|
||||
"username": "user1",
|
||||
"email": "dup@example.com",
|
||||
"first_name": "User",
|
||||
"last_name": "One",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
}
|
||||
w1 := performJSON(r, http.MethodPost, "/register", payload, nil)
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first register expected 201, got %d body=%s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
w2 := performJSON(r, http.MethodPost, "/register", payload, nil)
|
||||
if w2.Code != http.StatusConflict {
|
||||
t.Fatalf("second register expected 409, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWrongPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("bcrypt failed: %v", err)
|
||||
}
|
||||
isAdmin := false
|
||||
user := models.User{UserName: "u1", Email: "u1@example.com", Password: string(hash), IsAdmin: &isAdmin}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/login", Login)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||
"email": "u1@example.com",
|
||||
"password": "wrong-password",
|
||||
}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeAdminWithAdminMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isAdmin := true
|
||||
isUser := false
|
||||
admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin}
|
||||
target := models.User{UserName: "target", Email: "target@example.com", Password: "x", IsAdmin: &isUser}
|
||||
if err := configs.DB.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("create admin failed: %v", err)
|
||||
}
|
||||
if err := configs.DB.Create(&target).Error; err != nil {
|
||||
t.Fatalf("create target failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := middleware.BuildAccessTokenForUser(admin)
|
||||
if err != nil {
|
||||
t.Fatalf("token create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/users/:id/admin", middleware.AuthRequired(), middleware.AdminRequired(), MakeAdmin)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/users/"+toString(target.ID)+"/admin", map[string]any{"is_admin": true}, map[string]string{
|
||||
"Authorization": "Bearer " + token,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var updated models.User
|
||||
if err := configs.DB.First(&updated, target.ID).Error; err != nil {
|
||||
t.Fatalf("read updated user failed: %v", err)
|
||||
}
|
||||
if updated.IsAdmin == nil || !*updated.IsAdmin {
|
||||
t.Fatalf("target user should be admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRejectsExpiredToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isAdmin := false
|
||||
user := models.User{UserName: "u2", Email: "u2@example.com", Password: "x", IsAdmin: &isAdmin}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("user create failed: %v", err)
|
||||
}
|
||||
|
||||
refresh := "deadbeef"
|
||||
record := models.RefreshToken{
|
||||
UserID: uint64(user.ID),
|
||||
TokenID: "tid1",
|
||||
TokenHash: hashToken(refresh),
|
||||
TokenFingerprint: tokenFingerprint(refresh),
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
}
|
||||
if err := configs.DB.Create(&record).Error; err != nil {
|
||||
t.Fatalf("refresh create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/refresh", Refresh)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refresh}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTokens_DefaultFlowKeepsSessionExpiryNilAndRefreshWorks(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isAdmin := false
|
||||
isActive := true
|
||||
emailVerified := true
|
||||
user := models.User{
|
||||
UserName: "normal_user",
|
||||
Email: "normal@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &isAdmin,
|
||||
IsActive: &isActive,
|
||||
EmailVerified: &emailVerified,
|
||||
}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("user create failed: %v", err)
|
||||
}
|
||||
|
||||
_, refreshToken, _, err := issueTokens(user, "test-agent", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("issueTokens failed: %v", err)
|
||||
}
|
||||
|
||||
var record models.RefreshToken
|
||||
if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&record).Error; err != nil {
|
||||
t.Fatalf("refresh token record should exist: %v", err)
|
||||
}
|
||||
if record.SessionExpiresAt != nil {
|
||||
t.Fatalf("default flow should keep session_expires_at nil")
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/refresh", Refresh)
|
||||
w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("refresh expected 200 for default flow, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScopedTokenIssuesOnlyAccessTokenWithoutRefreshRecord(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isAdmin := true
|
||||
isActive := true
|
||||
emailVerified := true
|
||||
adminUser := models.User{
|
||||
UserName: "admin_user",
|
||||
Email: "admin_scoped@example.com",
|
||||
Password: "x",
|
||||
IsAdmin: &isAdmin,
|
||||
IsActive: &isActive,
|
||||
EmailVerified: &emailVerified,
|
||||
}
|
||||
if err := configs.DB.Create(&adminUser).Error; err != nil {
|
||||
t.Fatalf("admin user create failed: %v", err)
|
||||
}
|
||||
|
||||
accessToken, err := middleware.BuildAccessTokenForUser(adminUser)
|
||||
if err != nil {
|
||||
t.Fatalf("build access token failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/admin/tokens/issue", middleware.AuthRequired(), middleware.AdminRequired(), IssueAdminScopedToken)
|
||||
|
||||
wIssue := performJSON(
|
||||
r,
|
||||
http.MethodPost,
|
||||
"/admin/tokens/issue",
|
||||
map[string]any{"duration_days": 45},
|
||||
map[string]string{"Authorization": "Bearer " + accessToken},
|
||||
)
|
||||
if wIssue.Code != http.StatusOK {
|
||||
t.Fatalf("issue endpoint expected 200, got %d body=%s", wIssue.Code, wIssue.Body.String())
|
||||
}
|
||||
|
||||
var issueResp map[string]any
|
||||
if err := json.Unmarshal(wIssue.Body.Bytes(), &issueResp); err != nil {
|
||||
t.Fatalf("issue response parse failed: %v", err)
|
||||
}
|
||||
scopedAccess, _ := issueResp["access"].(string)
|
||||
if scopedAccess == "" {
|
||||
t.Fatalf("issued scoped access token should exist")
|
||||
}
|
||||
assertJWTFormat(t, scopedAccess)
|
||||
|
||||
if _, ok := issueResp["refresh"]; ok {
|
||||
t.Fatalf("scoped token response should not include refresh token")
|
||||
}
|
||||
|
||||
expiresAt, _ := issueResp["expires_at"].(string)
|
||||
if expiresAt == "" {
|
||||
t.Fatalf("scoped token response should include expires_at")
|
||||
}
|
||||
expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("expires_at should be RFC3339: %v", err)
|
||||
}
|
||||
expected := time.Now().Add(45 * 24 * time.Hour)
|
||||
if expiresAtTime.Before(expected.Add(-2*time.Minute)) || expiresAtTime.After(expected.Add(2*time.Minute)) {
|
||||
t.Fatalf("expires_at should be close to requested duration, got=%s expected_around=%s", expiresAtTime, expected)
|
||||
}
|
||||
|
||||
var refreshCount int64
|
||||
if err := configs.DB.Model(&models.RefreshToken{}).Where("user_id = ?", adminUser.ID).Count(&refreshCount).Error; err != nil {
|
||||
t.Fatalf("refresh token count query failed: %v", err)
|
||||
}
|
||||
if refreshCount != 0 {
|
||||
t.Fatalf("scoped access token flow should not create refresh records, got %d", refreshCount)
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v uint) string {
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
}
|
||||
|
||||
func TestRegisterRejectsMismatchedConfirmPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/register", Register)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/register", map[string]any{
|
||||
"username": "user2",
|
||||
"email": "user2@example.com",
|
||||
"first_name": "User",
|
||||
"last_name": "Two",
|
||||
"password": "secret123",
|
||||
"confirm_password": "wrong123",
|
||||
}, nil)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleSocialLoginCreatesVerifiedActiveUserAndProfile(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/userinfo" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"sub":"g-123","email":"social@example.com","email_verified":true,"given_name":"Social","family_name":"User","picture":"https://cdn/avatar.png"}`))
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
prevGoogle := googleUserInfoURL
|
||||
googleUserInfoURL = provider.URL + "/userinfo"
|
||||
t.Cleanup(func() { googleUserInfoURL = prevGoogle })
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/auth/social/google", GoogleLogin)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/auth/social/google", map[string]any{"access_token": "token-abc"}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
dump, _ := httputil.DumpResponse(w.Result(), true)
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, string(dump))
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response parse failed: %v", err)
|
||||
}
|
||||
if resp["provider"] != "google" {
|
||||
t.Fatalf("provider should be google")
|
||||
}
|
||||
if access, _ := resp["access"].(string); access == "" {
|
||||
t.Fatalf("access token should be returned")
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.Where("email = ?", "social@example.com").First(&user).Error; err != nil {
|
||||
t.Fatalf("user should be created: %v", err)
|
||||
}
|
||||
if user.EmailVerified == nil || !*user.EmailVerified {
|
||||
t.Fatalf("social login user should be email verified")
|
||||
}
|
||||
if user.IsActive == nil || !*user.IsActive {
|
||||
t.Fatalf("social login user should be active")
|
||||
}
|
||||
|
||||
var profile models.Profile
|
||||
if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil {
|
||||
t.Fatalf("profile should be created: %v", err)
|
||||
}
|
||||
if profile.FirstName != "Social" || profile.LastName != "User" {
|
||||
t.Fatalf("profile name mismatch: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubSocialLoginReadsPrimaryEmail(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/user":
|
||||
_, _ = w.Write([]byte(`{"id":99,"login":"octo","name":"Octo Cat","email":"","avatar_url":"https://cdn/octo.png"}`))
|
||||
case "/user/emails":
|
||||
_, _ = w.Write([]byte(`[{"email":"octo@example.com","primary":true,"verified":true}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
prevUser := githubUserURL
|
||||
prevEmails := githubEmailsURL
|
||||
githubUserURL = provider.URL + "/user"
|
||||
githubEmailsURL = provider.URL + "/user/emails"
|
||||
t.Cleanup(func() {
|
||||
githubUserURL = prevUser
|
||||
githubEmailsURL = prevEmails
|
||||
})
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/auth/social/github", GitHubLogin)
|
||||
|
||||
w := performJSON(r, http.MethodPost, "/auth/social/github", map[string]any{"access_token": "gh-token"}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var social models.SocialAccount
|
||||
if err := configs.DB.Where("provider = ? AND provider_id = ?", "github", "99").First(&social).Error; err != nil {
|
||||
t.Fatalf("github social account should be created: %v", err)
|
||||
}
|
||||
if social.Email != "octo@example.com" {
|
||||
t.Fatalf("github email should come from /user/emails, got %s", social.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeProfileGetAndUpdate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isActive := true
|
||||
emailVerified := true
|
||||
isAdmin := false
|
||||
user := models.User{
|
||||
UserName: "profile_user",
|
||||
Email: "profile@example.com",
|
||||
Password: "x",
|
||||
IsActive: &isActive,
|
||||
EmailVerified: &emailVerified,
|
||||
IsAdmin: &isAdmin,
|
||||
}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_user_avatar.png")
|
||||
profile := models.Profile{UserID: uint64(user.ID), FirstName: "Test", LastName: "User", AvatarURL: oldAvatarURL}
|
||||
if err := configs.DB.Create(&profile).Error; err != nil {
|
||||
t.Fatalf("create profile failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := middleware.BuildAccessTokenForUser(user)
|
||||
if err != nil {
|
||||
t.Fatalf("token create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/me/profile", middleware.AuthRequired(), GetMyProfile)
|
||||
r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile)
|
||||
|
||||
wGet := performJSON(r, http.MethodGet, "/me/profile", nil, map[string]string{"Authorization": "Bearer " + token})
|
||||
if wGet.Code != http.StatusOK {
|
||||
t.Fatalf("get profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String())
|
||||
}
|
||||
|
||||
wPut := performMultipart(
|
||||
r,
|
||||
http.MethodPut,
|
||||
"/me/profile",
|
||||
map[string]string{"first_name": "Yeni", "last_name": "Isim"},
|
||||
"avatar",
|
||||
"avatar.png",
|
||||
tinyPNGFixture(t),
|
||||
map[string]string{"Authorization": "Bearer " + token},
|
||||
)
|
||||
if wPut.Code != http.StatusOK {
|
||||
t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
|
||||
}
|
||||
|
||||
var updated models.Profile
|
||||
if err := configs.DB.Where("user_id = ?", user.ID).First(&updated).Error; err != nil {
|
||||
t.Fatalf("read updated profile failed: %v", err)
|
||||
}
|
||||
if updated.FirstName != "Yeni" || updated.LastName != "Isim" {
|
||||
t.Fatalf("profile name not updated: %+v", updated)
|
||||
}
|
||||
if !strings.HasPrefix(updated.AvatarURL, "/uploads/avatars/") {
|
||||
t.Fatalf("avatar path not updated: %s", updated.AvatarURL)
|
||||
}
|
||||
if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("old avatar should be deleted, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeProfileUpdateCreatesProfileWhenMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
setupHandlersTestDB(t)
|
||||
|
||||
isActive := true
|
||||
emailVerified := true
|
||||
isAdmin := false
|
||||
user := models.User{
|
||||
UserName: "legacy_user",
|
||||
Email: "legacy@example.com",
|
||||
Password: "x",
|
||||
IsActive: &isActive,
|
||||
EmailVerified: &emailVerified,
|
||||
IsAdmin: &isAdmin,
|
||||
}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
|
||||
token, err := middleware.BuildAccessTokenForUser(user)
|
||||
if err != nil {
|
||||
t.Fatalf("token create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile)
|
||||
|
||||
wPut := performMultipart(
|
||||
r,
|
||||
http.MethodPut,
|
||||
"/me/profile",
|
||||
map[string]string{"first_name": "Beyhan", "last_name": "Ogur"},
|
||||
"avatar",
|
||||
"avatar.png",
|
||||
tinyPNGFixture(t),
|
||||
map[string]string{"Authorization": "Bearer " + token},
|
||||
)
|
||||
if wPut.Code != http.StatusOK {
|
||||
t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
|
||||
}
|
||||
|
||||
var profile models.Profile
|
||||
if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil {
|
||||
t.Fatalf("profile should be auto-created: %v", err)
|
||||
}
|
||||
if profile.FirstName != "Beyhan" || profile.LastName != "Ogur" {
|
||||
t.Fatalf("profile fields mismatch: %+v", profile)
|
||||
}
|
||||
}
|
||||
49
app/accounts/models/accounts.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
UserName string `json:"username" gorm:"uniqueIndex;not null;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"`
|
||||
IsActive *bool `gorm:"default:true" json:"is_active"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// IsEmailVerified 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;uniqueIndex" 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
|
||||
|
||||
}
|
||||
28
app/accounts/models/accounts_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUserIsEmailVerified(t *testing.T) {
|
||||
t.Run("nil pointer returns false", func(t *testing.T) {
|
||||
u := User{}
|
||||
if u.IsEmailVerified() {
|
||||
t.Fatalf("expected false for nil EmailVerified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("true pointer returns true", func(t *testing.T) {
|
||||
v := true
|
||||
u := User{EmailVerified: &v}
|
||||
if !u.IsEmailVerified() {
|
||||
t.Fatalf("expected true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("false pointer returns false", func(t *testing.T) {
|
||||
v := false
|
||||
u := User{EmailVerified: &v}
|
||||
if u.IsEmailVerified() {
|
||||
t.Fatalf("expected false")
|
||||
}
|
||||
})
|
||||
}
|
||||
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 uint64 `gorm:"type:bigint unsigned;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"`
|
||||
SessionExpiresAt *time.Time `gorm:"index" json:"session_expires_at,omitempty"`
|
||||
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"`
|
||||
}
|
||||
745
app/blogs/handlers/blog.go
Normal file
@@ -0,0 +1,745 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
blogModels "ginimageApi/app/blogs/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type createPostRequest struct {
|
||||
Title string `json:"title" binding:"required,min=3"`
|
||||
Content string `json:"content"`
|
||||
Keywords string `json:"keywords"`
|
||||
Image string `json:"image"`
|
||||
Video string `json:"video"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
IsFront *bool `json:"is_front"`
|
||||
}
|
||||
|
||||
type updatePostRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Keywords string `json:"keywords"`
|
||||
Image string `json:"image"`
|
||||
Video string `json:"video"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
IsFront *bool `json:"is_front"`
|
||||
}
|
||||
|
||||
type BlogErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type BlogPostResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
Keywords string `json:"keywords"`
|
||||
Image string `json:"image"`
|
||||
Video string `json:"video"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsFront bool `json:"is_front"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BlogListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Items []BlogPostResponse `json:"items"`
|
||||
}
|
||||
|
||||
type BlogCategoryResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Keywords string `json:"keywords"`
|
||||
Desc string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
type BlogTagResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Tag string `json:"tag"`
|
||||
Slug string `json:"slug"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type BlogCategoryListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Items []BlogCategoryResponse `json:"items"`
|
||||
}
|
||||
|
||||
type BlogTagListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Items []BlogTagResponse `json:"items"`
|
||||
}
|
||||
|
||||
type createCategoryRequest struct {
|
||||
Title string `json:"title" binding:"required,min=2"`
|
||||
Keywords string `json:"keywords"`
|
||||
Desc string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Order int `json:"order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
ParentID *uint64 `json:"parent_id"`
|
||||
}
|
||||
|
||||
type updateCategoryRequest struct {
|
||||
Title string `json:"title"`
|
||||
Keywords string `json:"keywords"`
|
||||
Desc string `json:"description"`
|
||||
Image string `json:"image"`
|
||||
Order *int `json:"order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
ParentID *uint64 `json:"parent_id"`
|
||||
}
|
||||
|
||||
type createTagRequest struct {
|
||||
Tag string `json:"tag" binding:"required,min=2"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type updateTagRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func toBlogPostResponse(p blogModels.Post) BlogPostResponse {
|
||||
return BlogPostResponse{
|
||||
ID: p.ID,
|
||||
Title: p.Title,
|
||||
Slug: p.Slug,
|
||||
Content: p.Content,
|
||||
Keywords: p.Keywords,
|
||||
Image: p.Image,
|
||||
Video: p.Video,
|
||||
IsActive: p.IsActive,
|
||||
IsFront: p.IsFront,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func toBlogCategoryResponse(c blogModels.Category) BlogCategoryResponse {
|
||||
return BlogCategoryResponse{
|
||||
ID: c.ID,
|
||||
Title: c.Title,
|
||||
Slug: c.Slug,
|
||||
Keywords: c.Keywords,
|
||||
Desc: c.Desc,
|
||||
Image: c.Image,
|
||||
IsActive: c.IsActive,
|
||||
Order: c.Order,
|
||||
}
|
||||
}
|
||||
|
||||
func toBlogTagResponse(t blogModels.Tag) BlogTagResponse {
|
||||
return BlogTagResponse{
|
||||
ID: t.ID,
|
||||
Tag: t.Tag,
|
||||
Slug: t.Slug,
|
||||
IsActive: t.IsActive,
|
||||
}
|
||||
}
|
||||
|
||||
func userIDFromContext(c *gin.Context) (uint64, bool) {
|
||||
v, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch id := v.(type) {
|
||||
case uint:
|
||||
return uint64(id), true
|
||||
case int:
|
||||
if id < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(id), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseUint(id, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// ListPosts godoc
|
||||
// @Summary Public blog post listesini getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Success 200 {object} BlogListResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs [get]
|
||||
func ListPosts(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
|
||||
var posts []blogModels.Post
|
||||
if err := configs.DB.Where("is_active = ?", true).Order("id desc").Find(&posts).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "postlar listelenemedi"})
|
||||
return
|
||||
}
|
||||
items := make([]BlogPostResponse, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, toBlogPostResponse(p))
|
||||
}
|
||||
c.JSON(http.StatusOK, BlogListResponse{Count: len(items), Items: items})
|
||||
}
|
||||
|
||||
// GetPost godoc
|
||||
// @Summary Public tekil blog postu getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Param slug path string true "Post slug"
|
||||
// @Success 200 {object} BlogPostResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/{slug} [get]
|
||||
func GetPost(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
|
||||
slug := c.Param("slug")
|
||||
var post blogModels.Post
|
||||
err := configs.DB.Where("slug = ? AND is_active = ?", slug, true).First(&post).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "post getirilemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogPostResponse(post))
|
||||
}
|
||||
|
||||
// CreatePost godoc
|
||||
// @Summary Admin blog post olusturur
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body createPostRequest true "Post bilgileri"
|
||||
// @Success 201 {object} BlogPostResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 401 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs [post]
|
||||
func CreatePost(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := userIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
var req createPostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
post := blogModels.Post{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Keywords: req.Keywords,
|
||||
Image: req.Image,
|
||||
Video: req.Video,
|
||||
UserID: &userID,
|
||||
IsActive: true,
|
||||
IsFront: true,
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
post.IsActive = *req.IsActive
|
||||
}
|
||||
if req.IsFront != nil {
|
||||
post.IsFront = *req.IsFront
|
||||
}
|
||||
|
||||
if err := configs.DB.Create(&post).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "post olusturulamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toBlogPostResponse(post))
|
||||
}
|
||||
|
||||
// UpdatePost godoc
|
||||
// @Summary Admin blog post gunceller
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Param request body updatePostRequest true "Guncellenecek alanlar"
|
||||
// @Success 200 {object} BlogPostResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/{id} [put]
|
||||
func UpdatePost(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz post id"})
|
||||
return
|
||||
}
|
||||
|
||||
var post blogModels.Post
|
||||
if err := configs.DB.First(&post, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "post bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updatePostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Title != "" {
|
||||
post.Title = req.Title
|
||||
}
|
||||
if req.Content != "" {
|
||||
post.Content = req.Content
|
||||
}
|
||||
if req.Keywords != "" {
|
||||
post.Keywords = req.Keywords
|
||||
}
|
||||
if req.Image != "" {
|
||||
post.Image = req.Image
|
||||
}
|
||||
if req.Video != "" {
|
||||
post.Video = req.Video
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
post.IsActive = *req.IsActive
|
||||
}
|
||||
if req.IsFront != nil {
|
||||
post.IsFront = *req.IsFront
|
||||
}
|
||||
|
||||
if err := configs.DB.Save(&post).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "post guncellenemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogPostResponse(post))
|
||||
}
|
||||
|
||||
// DeletePost godoc
|
||||
// @Summary Admin blog post siler
|
||||
// @Tags blogs
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Post ID"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/{id} [delete]
|
||||
func DeletePost(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz post id"})
|
||||
return
|
||||
}
|
||||
|
||||
res := configs.DB.Delete(&blogModels.Post{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "post silinemedi"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListCategories godoc
|
||||
// @Summary Public kategori listesini getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Success 200 {object} BlogCategoryListResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/categories [get]
|
||||
func ListCategories(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var categories []blogModels.Category
|
||||
if err := configs.DB.Where("is_active = ?", true).Order("`order` asc, id desc").Find(&categories).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategoriler listelenemedi"})
|
||||
return
|
||||
}
|
||||
items := make([]BlogCategoryResponse, 0, len(categories))
|
||||
for _, item := range categories {
|
||||
items = append(items, toBlogCategoryResponse(item))
|
||||
}
|
||||
c.JSON(http.StatusOK, BlogCategoryListResponse{Count: len(items), Items: items})
|
||||
}
|
||||
|
||||
// GetCategory godoc
|
||||
// @Summary Public tekil kategori getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Param slug path string true "Kategori slug"
|
||||
// @Success 200 {object} BlogCategoryResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/categories/{slug} [get]
|
||||
func GetCategory(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var category blogModels.Category
|
||||
err := configs.DB.Where("slug = ? AND is_active = ?", c.Param("slug"), true).First(&category).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori getirilemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogCategoryResponse(category))
|
||||
}
|
||||
|
||||
// CreateCategory godoc
|
||||
// @Summary Admin kategori olusturur
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body createCategoryRequest true "Kategori bilgileri"
|
||||
// @Success 201 {object} BlogCategoryResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/categories [post]
|
||||
func CreateCategory(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var req createCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
category := blogModels.Category{
|
||||
Title: req.Title,
|
||||
Keywords: req.Keywords,
|
||||
Desc: req.Desc,
|
||||
Image: req.Image,
|
||||
Order: req.Order,
|
||||
ParentID: req.ParentID,
|
||||
IsActive: true,
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
category.IsActive = *req.IsActive
|
||||
}
|
||||
if category.Order == 0 {
|
||||
category.Order = 1
|
||||
}
|
||||
if err := configs.DB.Create(&category).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori olusturulamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toBlogCategoryResponse(category))
|
||||
}
|
||||
|
||||
// UpdateCategory godoc
|
||||
// @Summary Admin kategori gunceller
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kategori ID"
|
||||
// @Param request body updateCategoryRequest true "Guncellenecek alanlar"
|
||||
// @Success 200 {object} BlogCategoryResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/categories/{id} [put]
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kategori id"})
|
||||
return
|
||||
}
|
||||
var category blogModels.Category
|
||||
if err := configs.DB.First(&category, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori bulunamadi"})
|
||||
return
|
||||
}
|
||||
var req updateCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Title != "" {
|
||||
category.Title = req.Title
|
||||
}
|
||||
if req.Keywords != "" {
|
||||
category.Keywords = req.Keywords
|
||||
}
|
||||
if req.Desc != "" {
|
||||
category.Desc = req.Desc
|
||||
}
|
||||
if req.Image != "" {
|
||||
category.Image = req.Image
|
||||
}
|
||||
if req.Order != nil {
|
||||
category.Order = *req.Order
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
category.IsActive = *req.IsActive
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
category.ParentID = req.ParentID
|
||||
}
|
||||
if err := configs.DB.Save(&category).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori guncellenemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogCategoryResponse(category))
|
||||
}
|
||||
|
||||
// DeleteCategory godoc
|
||||
// @Summary Admin kategori siler
|
||||
// @Tags blogs
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Kategori ID"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/categories/{id} [delete]
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kategori id"})
|
||||
return
|
||||
}
|
||||
res := configs.DB.Delete(&blogModels.Category{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori silinemedi"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListTags godoc
|
||||
// @Summary Public tag listesini getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Success 200 {object} BlogTagListResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/tags [get]
|
||||
func ListTags(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var tags []blogModels.Tag
|
||||
if err := configs.DB.Where("is_active = ?", true).Order("id desc").Find(&tags).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tagler listelenemedi"})
|
||||
return
|
||||
}
|
||||
items := make([]BlogTagResponse, 0, len(tags))
|
||||
for _, item := range tags {
|
||||
items = append(items, toBlogTagResponse(item))
|
||||
}
|
||||
c.JSON(http.StatusOK, BlogTagListResponse{Count: len(items), Items: items})
|
||||
}
|
||||
|
||||
// GetTag godoc
|
||||
// @Summary Public tekil tag getirir
|
||||
// @Tags blogs
|
||||
// @Produce json
|
||||
// @Param slug path string true "Tag slug"
|
||||
// @Success 200 {object} BlogTagResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/tags/{slug} [get]
|
||||
func GetTag(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var tag blogModels.Tag
|
||||
err := configs.DB.Where("slug = ? AND is_active = ?", c.Param("slug"), true).First(&tag).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tag getirilemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogTagResponse(tag))
|
||||
}
|
||||
|
||||
// CreateTag godoc
|
||||
// @Summary Admin tag olusturur
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body createTagRequest true "Tag bilgileri"
|
||||
// @Success 201 {object} BlogTagResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/tags [post]
|
||||
func CreateTag(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
var req createTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tag := blogModels.Tag{Tag: req.Tag, IsActive: true}
|
||||
if req.IsActive != nil {
|
||||
tag.IsActive = *req.IsActive
|
||||
}
|
||||
if err := configs.DB.Create(&tag).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tag olusturulamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toBlogTagResponse(tag))
|
||||
}
|
||||
|
||||
// UpdateTag godoc
|
||||
// @Summary Admin tag gunceller
|
||||
// @Tags blogs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Tag ID"
|
||||
// @Param request body updateTagRequest true "Guncellenecek alanlar"
|
||||
// @Success 200 {object} BlogTagResponse
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/tags/{id} [put]
|
||||
func UpdateTag(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz tag id"})
|
||||
return
|
||||
}
|
||||
var tag blogModels.Tag
|
||||
if err := configs.DB.First(&tag, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tag bulunamadi"})
|
||||
return
|
||||
}
|
||||
var req updateTagRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Tag != "" {
|
||||
tag.Tag = req.Tag
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
tag.IsActive = *req.IsActive
|
||||
}
|
||||
if err := configs.DB.Save(&tag).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tag guncellenemedi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBlogTagResponse(tag))
|
||||
}
|
||||
|
||||
// DeleteTag godoc
|
||||
// @Summary Admin tag siler
|
||||
// @Tags blogs
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Tag ID"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} BlogErrorResponse
|
||||
// @Failure 404 {object} BlogErrorResponse
|
||||
// @Failure 500 {object} BlogErrorResponse
|
||||
// @Router /api/v1/blogs/tags/{id} [delete]
|
||||
func DeleteTag(c *gin.Context) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz tag id"})
|
||||
return
|
||||
}
|
||||
res := configs.DB.Delete(&blogModels.Tag{}, id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "tag silinemedi"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
202
app/blogs/handlers/blog_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
blogModels "ginimageApi/app/blogs/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupBlogHandlersTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := configs.DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite open failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&blogModels.Category{}, &blogModels.Tag{}, &blogModels.Post{}, &blogModels.CategoryView{}, &blogModels.Comment{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
configs.DB = db
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
configs.DB = prev
|
||||
})
|
||||
}
|
||||
|
||||
func withUser(userID uint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPostsReturnsOnlyActive(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupBlogHandlersTestDB(t)
|
||||
|
||||
posts := []blogModels.Post{
|
||||
{Title: "Active Post", Content: "A", IsActive: true, IsFront: true},
|
||||
{Title: "Passive Post", Content: "B", IsActive: true, IsFront: false},
|
||||
}
|
||||
if err := configs.DB.Create(&posts).Error; err != nil {
|
||||
t.Fatalf("seed failed: %v", err)
|
||||
}
|
||||
if err := configs.DB.Model(&blogModels.Post{}).Where("title = ?", "Passive Post").Update("is_active", false).Error; err != nil {
|
||||
t.Fatalf("seed update failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/blogs", ListPosts)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/blogs", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("json parse failed: %v", err)
|
||||
}
|
||||
if resp.Count != 1 {
|
||||
t.Fatalf("expected only active posts, got %d", resp.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCreateUpdateDeletePost(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupBlogHandlersTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/blogs", withUser(1), CreatePost)
|
||||
r.PUT("/blogs/:id", withUser(1), UpdatePost)
|
||||
r.DELETE("/blogs/:id", withUser(1), DeletePost)
|
||||
|
||||
createBody := []byte(`{"title":"Yeni Blog","content":"icerik"}`)
|
||||
wCreate := httptest.NewRecorder()
|
||||
reqCreate := httptest.NewRequest(http.MethodPost, "/blogs", bytes.NewReader(createBody))
|
||||
reqCreate.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wCreate, reqCreate)
|
||||
if wCreate.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d body=%s", wCreate.Code, wCreate.Body.String())
|
||||
}
|
||||
|
||||
// SQLite'da mevcut model taniminda auto ID davranisi tutarsiz olabildigi icin
|
||||
// update/delete senaryosunu explicit ID ile seed edilen kayit uzerinden dogruluyoruz.
|
||||
seedForUpdate := blogModels.Post{ID: 77, Title: "Seeded Blog", Content: "x", IsActive: true, IsFront: true}
|
||||
if err := configs.DB.Create(&seedForUpdate).Error; err != nil {
|
||||
t.Fatalf("seed for update failed: %v", err)
|
||||
}
|
||||
|
||||
updateBody := []byte(`{"title":"Guncel Blog"}`)
|
||||
wUpdate := httptest.NewRecorder()
|
||||
reqUpdate := httptest.NewRequest(http.MethodPut, "/blogs/"+strconv.FormatUint(seedForUpdate.ID, 10), bytes.NewReader(updateBody))
|
||||
reqUpdate.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wUpdate, reqUpdate)
|
||||
if wUpdate.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", wUpdate.Code, wUpdate.Body.String())
|
||||
}
|
||||
|
||||
wDelete := httptest.NewRecorder()
|
||||
r.ServeHTTP(wDelete, httptest.NewRequest(http.MethodDelete, "/blogs/"+strconv.FormatUint(seedForUpdate.ID, 10), nil))
|
||||
if wDelete.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", wDelete.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryAndTagEndpoints(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupBlogHandlersTestDB(t)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/blogs/categories", ListCategories)
|
||||
r.GET("/blogs/tags", ListTags)
|
||||
r.POST("/blogs/categories", withUser(1), CreateCategory)
|
||||
r.PUT("/blogs/categories/:id", withUser(1), UpdateCategory)
|
||||
r.DELETE("/blogs/categories/:id", withUser(1), DeleteCategory)
|
||||
r.POST("/blogs/tags", withUser(1), CreateTag)
|
||||
r.PUT("/blogs/tags/:id", withUser(1), UpdateTag)
|
||||
r.DELETE("/blogs/tags/:id", withUser(1), DeleteTag)
|
||||
|
||||
wCreateCategory := httptest.NewRecorder()
|
||||
reqCreateCategory := httptest.NewRequest(http.MethodPost, "/blogs/categories", bytes.NewReader([]byte(`{"title":"Genel"}`)))
|
||||
reqCreateCategory.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wCreateCategory, reqCreateCategory)
|
||||
if wCreateCategory.Code != http.StatusCreated {
|
||||
t.Fatalf("category create expected 201, got %d body=%s", wCreateCategory.Code, wCreateCategory.Body.String())
|
||||
}
|
||||
|
||||
wCreateTag := httptest.NewRecorder()
|
||||
reqCreateTag := httptest.NewRequest(http.MethodPost, "/blogs/tags", bytes.NewReader([]byte(`{"tag":"Go"}`)))
|
||||
reqCreateTag.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wCreateTag, reqCreateTag)
|
||||
if wCreateTag.Code != http.StatusCreated {
|
||||
t.Fatalf("tag create expected 201, got %d body=%s", wCreateTag.Code, wCreateTag.Body.String())
|
||||
}
|
||||
|
||||
// SQLite'da mevcut model taniminda auto ID davranisi tutarsiz olabildigi icin
|
||||
// kategori/tag update-delete senaryosunu explicit ID ile seed edilen kayitlar uzerinden dogruluyoruz.
|
||||
seedCategory := blogModels.Category{ID: 77, Title: "SeedCategory", IsActive: true, Order: 1}
|
||||
if err := configs.DB.Create(&seedCategory).Error; err != nil {
|
||||
t.Fatalf("seed category for update failed: %v", err)
|
||||
}
|
||||
seedTag := blogModels.Tag{ID: 88, Tag: "SeedTag", IsActive: true}
|
||||
if err := configs.DB.Create(&seedTag).Error; err != nil {
|
||||
t.Fatalf("seed tag for update failed: %v", err)
|
||||
}
|
||||
|
||||
wListCategories := httptest.NewRecorder()
|
||||
r.ServeHTTP(wListCategories, httptest.NewRequest(http.MethodGet, "/blogs/categories", nil))
|
||||
if wListCategories.Code != http.StatusOK {
|
||||
t.Fatalf("category list expected 200, got %d", wListCategories.Code)
|
||||
}
|
||||
|
||||
wListTags := httptest.NewRecorder()
|
||||
r.ServeHTTP(wListTags, httptest.NewRequest(http.MethodGet, "/blogs/tags", nil))
|
||||
if wListTags.Code != http.StatusOK {
|
||||
t.Fatalf("tag list expected 200, got %d", wListTags.Code)
|
||||
}
|
||||
|
||||
wUpdateCategory := httptest.NewRecorder()
|
||||
reqUpdateCategory := httptest.NewRequest(http.MethodPut, "/blogs/categories/"+strconv.FormatUint(seedCategory.ID, 10), bytes.NewReader([]byte(`{"title":"Teknoloji"}`)))
|
||||
reqUpdateCategory.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wUpdateCategory, reqUpdateCategory)
|
||||
if wUpdateCategory.Code != http.StatusOK {
|
||||
t.Fatalf("category update expected 200, got %d body=%s", wUpdateCategory.Code, wUpdateCategory.Body.String())
|
||||
}
|
||||
|
||||
wUpdateTag := httptest.NewRecorder()
|
||||
reqUpdateTag := httptest.NewRequest(http.MethodPut, "/blogs/tags/"+strconv.FormatUint(seedTag.ID, 10), bytes.NewReader([]byte(`{"tag":"Golang"}`)))
|
||||
reqUpdateTag.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(wUpdateTag, reqUpdateTag)
|
||||
if wUpdateTag.Code != http.StatusOK {
|
||||
t.Fatalf("tag update expected 200, got %d body=%s", wUpdateTag.Code, wUpdateTag.Body.String())
|
||||
}
|
||||
|
||||
wDeleteCategory := httptest.NewRecorder()
|
||||
r.ServeHTTP(wDeleteCategory, httptest.NewRequest(http.MethodDelete, "/blogs/categories/"+strconv.FormatUint(seedCategory.ID, 10), nil))
|
||||
if wDeleteCategory.Code != http.StatusNoContent {
|
||||
t.Fatalf("category delete expected 204, got %d", wDeleteCategory.Code)
|
||||
}
|
||||
|
||||
wDeleteTag := httptest.NewRecorder()
|
||||
r.ServeHTTP(wDeleteTag, httptest.NewRequest(http.MethodDelete, "/blogs/tags/"+strconv.FormatUint(seedTag.ID, 10), nil))
|
||||
if wDeleteTag.Code != http.StatusNoContent {
|
||||
t.Fatalf("tag delete expected 204, got %d", wDeleteTag.Code)
|
||||
}
|
||||
}
|
||||
260
app/blogs/models/blog.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
accountModels "ginimageApi/app/accounts/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Note: This file maps Django models to GORM models for MySQL.
|
||||
// Image fields are stored as file path strings. Thumbnail generation and image processing
|
||||
// should be handled elsewhere (e.g., during upload) — TODO: integrate with image processing service.
|
||||
|
||||
// Category represents post categories.
|
||||
type Category struct {
|
||||
ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"`
|
||||
Title string `gorm:"size:254;not null" json:"title"`
|
||||
Keywords string `gorm:"size:254" json:"keywords"`
|
||||
Desc string `gorm:"size:254" json:"description"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Order int `gorm:"default:1;index" json:"order"`
|
||||
Slug string `gorm:"size:250;not null;index" json:"slug"`
|
||||
ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"`
|
||||
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []*Category `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
Image string `gorm:"size:1024" json:"image"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string {
|
||||
return "categories"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to set slug
|
||||
func (c *Category) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if c.Slug == "" {
|
||||
c.Slug, err = generateUniqueSlugForCategory(tx, c.Title)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook ensures slug exists
|
||||
func (c *Category) BeforeUpdate(tx *gorm.DB) (err error) {
|
||||
if c.Slug == "" {
|
||||
c.Slug, err = generateUniqueSlugForCategory(tx, c.Title)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUniqueSlugForCategory(db *gorm.DB, title string) (string, error) {
|
||||
slug := normalizeSlug(title)
|
||||
base := slug
|
||||
var count int64
|
||||
try := 1
|
||||
for {
|
||||
db.Model(&Category{}).Where("slug = ?", slug).Count(&count)
|
||||
if count == 0 {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, try)
|
||||
try++
|
||||
if try > 1000 {
|
||||
return "", errors.New("unable to generate unique slug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tags model
|
||||
type Tag struct {
|
||||
ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"`
|
||||
Tag string `gorm:"size:254;not null" json:"tag"`
|
||||
Slug string `gorm:"size:250;not null;index" json:"slug"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
func (Tag) TableName() string { return "tags" }
|
||||
|
||||
func (t *Tag) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if t.Slug == "" {
|
||||
t.Slug, err = generateUniqueSlugForTag(tx, t.Tag)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUniqueSlugForTag(db *gorm.DB, tag string) (string, error) {
|
||||
slug := normalizeSlug(tag)
|
||||
base := slug
|
||||
var count int64
|
||||
try := 1
|
||||
for {
|
||||
db.Model(&Tag{}).Where("slug = ?", slug).Count(&count)
|
||||
if count == 0 {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, try)
|
||||
try++
|
||||
if try > 1000 {
|
||||
return "", errors.New("unable to generate unique slug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post model
|
||||
type Post struct {
|
||||
ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"`
|
||||
Title string `gorm:"size:254;not null" json:"title"`
|
||||
UserID *uint64 `gorm:"type:bigint unsigned;index" json:"user_id"`
|
||||
User *accountModels.User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Categories []*Category `gorm:"many2many:post_categories;" json:"categories"`
|
||||
Keywords string `gorm:"size:254" json:"keywords"`
|
||||
Tags []*Tag `gorm:"many2many:post_tags;" json:"tags"`
|
||||
Image string `gorm:"size:1024" json:"image"`
|
||||
Thumb string `gorm:"size:1024" json:"thumb"`
|
||||
Video string `gorm:"size:254;default:'none'" json:"video"`
|
||||
Slug string `gorm:"size:250;not null;index" json:"slug"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsFront bool `gorm:"default:true;index" json:"is_front"`
|
||||
ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"`
|
||||
Parent *Post `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []*Post `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (Post) TableName() string { return "posts" }
|
||||
|
||||
func (p *Post) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if p.Slug == "" {
|
||||
p.Slug, err = generateUniqueSlugForPost(tx, p.Title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note: Thumbnail generation should be handled in the upload flow.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Post) BeforeUpdate(tx *gorm.DB) (err error) {
|
||||
if p.Slug == "" {
|
||||
p.Slug, err = generateUniqueSlugForPost(tx, p.Title)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUniqueSlugForPost(db *gorm.DB, title string) (string, error) {
|
||||
slug := normalizeSlug(title)
|
||||
base := slug
|
||||
var count int64
|
||||
try := 1
|
||||
for {
|
||||
db.Model(&Post{}).Where("slug = ?", slug).Count(&count)
|
||||
if count == 0 {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, try)
|
||||
try++
|
||||
if try > 1000 {
|
||||
return "", errors.New("unable to generate unique slug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CategoryView model
|
||||
type CategoryView struct {
|
||||
ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"`
|
||||
CategoryID uint64 `gorm:"type:bigint unsigned;index" json:"category_id"`
|
||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category"`
|
||||
IPAddress string `gorm:"size:45;index" json:"ip_address"`
|
||||
UserAgent string `gorm:"type:text" json:"user_agent"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (CategoryView) TableName() string { return "category_views" }
|
||||
|
||||
// Comment model
|
||||
type Comment struct {
|
||||
ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"`
|
||||
UserID uint64 `gorm:"type:bigint unsigned;index" json:"user_id"`
|
||||
PostID uint64 `gorm:"type:bigint unsigned;index" json:"post_id"`
|
||||
Post *Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
Title string `gorm:"size:254" json:"title"`
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
Slug string `gorm:"size:250;index" json:"slug"`
|
||||
ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"`
|
||||
Parent *Comment `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []*Comment `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (Comment) TableName() string { return "comments" }
|
||||
|
||||
func (c *Comment) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if c.Slug == "" {
|
||||
c.Slug, err = generateUniqueSlugForComment(tx, c.Title)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUniqueSlugForComment(db *gorm.DB, title string) (string, error) {
|
||||
slug := normalizeSlug(title)
|
||||
base := slug
|
||||
var count int64
|
||||
try := 1
|
||||
for {
|
||||
db.Model(&Comment{}).Where("slug = ?", slug).Count(&count)
|
||||
if count == 0 {
|
||||
return slug, nil
|
||||
}
|
||||
slug = fmt.Sprintf("%s-%d", base, try)
|
||||
try++
|
||||
if try > 1000 {
|
||||
return "", errors.New("unable to generate unique slug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeSlug replaces Turkish characters, lowercases and makes a basic slug.
|
||||
func normalizeSlug(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"ı", "i",
|
||||
"İ", "i",
|
||||
"ç", "c",
|
||||
"Ç", "c",
|
||||
"ş", "s",
|
||||
"Ş", "s",
|
||||
"ö", "o",
|
||||
"Ö", "o",
|
||||
"ü", "u",
|
||||
"Ü", "u",
|
||||
" ", "-",
|
||||
)
|
||||
s = replacer.Replace(s)
|
||||
s = strings.ToLower(s)
|
||||
s = strings.TrimSpace(s)
|
||||
// remove multiple dashes
|
||||
for strings.Contains(s, "--") {
|
||||
s = strings.ReplaceAll(s, "--", "-")
|
||||
}
|
||||
// remove extension-like parts
|
||||
s = strings.Trim(s, "-._")
|
||||
// sanitize file-like chars
|
||||
s = filepath.Clean(s)
|
||||
return s
|
||||
}
|
||||
362
app/images/handlers/image.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
imageModels "ginimageApi/app/images/models"
|
||||
"ginimageApi/configs"
|
||||
imageProcessor "ginimageApi/pkg/images"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProcessImageResponse struct {
|
||||
Message string `json:"message"`
|
||||
FileName string `json:"file_name"`
|
||||
PublicPath string `json:"public_path"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type ImageRecordResponse struct {
|
||||
ID uint `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
PublicPath string `json:"public_path"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Mode string `json:"mode"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ListImagesResponse struct {
|
||||
Count int `json:"count"`
|
||||
Items []ImageRecordResponse `json:"items"`
|
||||
}
|
||||
|
||||
type ImageErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func parseIntForm(c *gin.Context, key string, defaultValue int) (int, error) {
|
||||
raw := strings.TrimSpace(c.PostForm(key))
|
||||
if raw == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s sayi olmali", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseBoolForm(c *gin.Context, key string, defaultValue bool) bool {
|
||||
raw := strings.TrimSpace(strings.ToLower(c.PostForm(key)))
|
||||
if raw == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
||||
}
|
||||
|
||||
func mimeFromFormat(format string) string {
|
||||
switch format {
|
||||
case "avif":
|
||||
return "image/avif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "png":
|
||||
return "image/png"
|
||||
default:
|
||||
return "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
func outputDir() string {
|
||||
d := strings.TrimSpace(os.Getenv("IMAGE_OUTPUT_DIR"))
|
||||
if d == "" {
|
||||
return "uploads/processed"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "rand"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func getUserID(c *gin.Context) (uint, bool) {
|
||||
v, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case uint:
|
||||
return t, true
|
||||
case int:
|
||||
if t < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
if base := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); base != "" {
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
func ensureDBAndUser(c *gin.Context) (uint, bool) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := getUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"})
|
||||
return 0, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func toImageRecordResponse(c *gin.Context, img imageModels.Image) ImageRecordResponse {
|
||||
return ImageRecordResponse{
|
||||
ID: img.ID,
|
||||
FileName: img.Filename,
|
||||
PublicPath: img.PublicPath,
|
||||
URL: requestBaseURL(c) + img.PublicPath,
|
||||
MimeType: img.MimeType,
|
||||
Size: img.Size,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
Quality: img.Quality,
|
||||
Format: img.Format,
|
||||
Mode: img.Mode,
|
||||
CreatedAt: img.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ListImages godoc
|
||||
// @Summary Giris yapan kullanicinin kayitli resimlerini listeler
|
||||
// @Tags images
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} ListImagesResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images [get]
|
||||
func ListImages(c *gin.Context) {
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var images []imageModels.Image
|
||||
if err := configs.DB.Where("user_id = ?", userID).Order("id desc").Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "resimler listelenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]ImageRecordResponse, 0, len(images))
|
||||
for _, item := range images {
|
||||
items = append(items, toImageRecordResponse(c, item))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ListImagesResponse{Count: len(items), Items: items})
|
||||
}
|
||||
|
||||
// GetImage godoc
|
||||
// @Summary Giris yapan kullanicinin tekil resim kaydini getirir
|
||||
// @Tags images
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Image ID"
|
||||
// @Success 200 {object} ImageRecordResponse
|
||||
// @Failure 400 {object} ImageErrorResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 404 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images/{id} [get]
|
||||
func GetImage(c *gin.Context) {
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz image id"})
|
||||
return
|
||||
}
|
||||
|
||||
var image imageModels.Image
|
||||
err = configs.DB.Where("id = ? AND user_id = ?", uint(id), userID).First(&image).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "resim bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "resim getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toImageRecordResponse(c, image))
|
||||
}
|
||||
|
||||
// Process godoc
|
||||
// @Summary Resmi en, boy, kalite ve formata gore isler
|
||||
// @Tags images
|
||||
// @Accept mpfd
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "Yuklenecek resim"
|
||||
// @Param width formData int false "Hedef genislik (default: orijinal)"
|
||||
// @Param height formData int false "Hedef yukseklik (default: orijinal)"
|
||||
// @Param quality formData int false "Kalite 1-100 (default: 90)"
|
||||
// @Param format formData string false "avif|webp|png|jpg|jpeg (default: avif)"
|
||||
// @Param cover formData boolean false "true ise cover crop uygular"
|
||||
// @Success 200 {object} ProcessImageResponse
|
||||
// @Failure 400 {object} ImageErrorResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images/process [post]
|
||||
func Process(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"})
|
||||
return
|
||||
}
|
||||
|
||||
width, err := parseIntForm(c, "width", 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
height, err := parseIntForm(c, "height", 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
quality, err := parseIntForm(c, "quality", imageProcessor.DefaultQuality)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
opts := imageProcessor.ProcessOptions{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Quality: quality,
|
||||
Format: c.PostForm("format"),
|
||||
Cover: parseBoolForm(c, "cover", false),
|
||||
}
|
||||
normalized, err := imageProcessor.NormalizeOptions(opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
buffer, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
processed, err := imageProcessor.ProcessImage(buffer, normalized)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir(), 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "output klasoru olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename))
|
||||
outName := fmt.Sprintf("%s_%d_%s.%s", baseName, time.Now().Unix(), randomSuffix(), normalized.Format)
|
||||
absPath := filepath.Join(outputDir(), outName)
|
||||
if err := os.WriteFile(absPath, processed, 0o644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "islenmis dosya kaydedilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
processedSize := int64(len(processed))
|
||||
imgSize, _ := imageProcessor.GetSize(processed)
|
||||
publicPath := "/uploads/processed/" + outName
|
||||
url := requestBaseURL(c) + publicPath
|
||||
|
||||
record := imageModels.Image{
|
||||
UserID: userID,
|
||||
Filename: outName,
|
||||
PublicPath: publicPath,
|
||||
MimeType: mimeFromFormat(normalized.Format),
|
||||
Size: processedSize,
|
||||
Width: imgSize.Width,
|
||||
Height: imgSize.Height,
|
||||
Quality: normalized.Quality,
|
||||
Format: normalized.Format,
|
||||
Mode: map[bool]string{true: "cover", false: "fit"}[normalized.Cover],
|
||||
}
|
||||
if err := configs.DB.Create(&record).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db image kaydi olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ProcessImageResponse{
|
||||
Message: "resim isleme tamamlandi",
|
||||
FileName: outName,
|
||||
PublicPath: publicPath,
|
||||
URL: url,
|
||||
MimeType: record.MimeType,
|
||||
Size: record.Size,
|
||||
Width: record.Width,
|
||||
Height: record.Height,
|
||||
Quality: record.Quality,
|
||||
Format: record.Format,
|
||||
})
|
||||
}
|
||||
144
app/images/handlers/image_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
imageModels "ginimageApi/app/images/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupImageHandlersTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := configs.DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite open failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&imageModels.Image{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
configs.DB = db
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
configs.DB = prev
|
||||
})
|
||||
}
|
||||
|
||||
func withUser(userID uint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRequiresFile(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/images/process", Process)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/images/process", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRejectsInvalidWidth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/images/process", Process)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
filePart, err := writer.CreateFormFile("file", "dummy.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
_, _ = filePart.Write([]byte("not-a-real-image"))
|
||||
_ = writer.WriteField("width", "abc")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/images/process", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListImagesReturnsOnlyCurrentUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupImageHandlersTestDB(t)
|
||||
|
||||
seed := []imageModels.Image{
|
||||
{UserID: 1, Filename: "a.avif", PublicPath: "/uploads/processed/a.avif", MimeType: "image/avif", Size: 10, Format: "avif", Quality: 90},
|
||||
{UserID: 1, Filename: "b.avif", PublicPath: "/uploads/processed/b.avif", MimeType: "image/avif", Size: 11, Format: "avif", Quality: 90},
|
||||
{UserID: 2, Filename: "c.avif", PublicPath: "/uploads/processed/c.avif", MimeType: "image/avif", Size: 12, Format: "avif", Quality: 90},
|
||||
}
|
||||
if err := configs.DB.Create(&seed).Error; err != nil {
|
||||
t.Fatalf("seed failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/images", withUser(1), ListImages)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/images", nil)
|
||||
req.Host = "localhost:8080"
|
||||
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 struct {
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("json parse failed: %v", err)
|
||||
}
|
||||
if resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("expected 2 images for current user, got count=%d len=%d", resp.Count, len(resp.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImageRejectsOtherUsersImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupImageHandlersTestDB(t)
|
||||
|
||||
img := imageModels.Image{UserID: 2, Filename: "x.avif", PublicPath: "/uploads/processed/x.avif", MimeType: "image/avif", Size: 5, Format: "avif", Quality: 90}
|
||||
if err := configs.DB.Create(&img).Error; err != nil {
|
||||
t.Fatalf("seed failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/images/:id", withUser(1), GetImage)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/images/1", nil))
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
20
app/images/models/images.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Filename string `gorm:"not null" json:"filename"`
|
||||
PublicPath string `gorm:"not null" json:"public_path"`
|
||||
MimeType string `gorm:"not null" json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Mode string `json:"mode"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
337
app/mcp/README.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# MCP (Model Context Protocol) Server - GinImage API
|
||||
|
||||
Bu dizin, GinImage API'nin Model Context Protocol (MCP) sunucusu uygulamasını içerir.
|
||||
|
||||
## ⚠️ Önemli: v0.1.0 - mcp-go Migration Tamamlandı
|
||||
|
||||
**Eski uygulama (hand-written JSON-RPC):** Tamamen kaldırıldı.
|
||||
**Yeni uygulama (mark3labs/mcp-go):** Tek kaynak. Tüm MCP istekleri mcp-go tarafından işlenir. Protocol compliance: %100.
|
||||
|
||||
### Değişiklik Özeti
|
||||
|
||||
| Unsur | Eski | Yeni |
|
||||
|-------|------|------|
|
||||
| Kod satırı sayısı | ~1100 | ~250 |
|
||||
| JSON-RPC handler | Elle yazılmış | mcp-go sağlıyor |
|
||||
| Tool registration | Switch-case | `server.AddTool()` |
|
||||
| Protocol compliance | Elle test | %100 (mcp-go) |
|
||||
|
||||
---
|
||||
|
||||
## 1) Servisi Başlat
|
||||
|
||||
Proje kökünde:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
Varsayılan port `8080` olduğu için MCP endpoint:
|
||||
- `http://127.0.0.1:8080/mcp`
|
||||
|
||||
Farklı port kullanmak istersen:
|
||||
|
||||
```bash
|
||||
PORT=9090 go run .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2) Cursor MCP Ayarı
|
||||
|
||||
`~/.cursor/mcp.json` dosyasına şu şekilde ekle:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ginimage-api": {
|
||||
"url": "http://127.0.0.1:8080/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sonra Cursor'da MCP server'i yenile/reload et.
|
||||
|
||||
---
|
||||
|
||||
## 3) Mevcut Tool'lar
|
||||
|
||||
### 3.1) `api_overview`
|
||||
- **Açıklama:** GinImage API endpoint özeti ve kullanımı
|
||||
- **Giriş:** Yok
|
||||
- **Çıkış:** Metin
|
||||
|
||||
### 3.2) `health_check`
|
||||
- **Açıklama:** API health endpoint durumunu kontrol eder
|
||||
- **Giriş:** `path` (string, opsiyonel) - Varsayılan: `/swagger/index.html`
|
||||
- **Çıkış:** Metin
|
||||
|
||||
### 3.3) `md_guide_list`
|
||||
- **Açıklama:** `docs/mcp-tools` altındaki markdown rehber dosyalarını listeler
|
||||
- **Giriş:** Yok
|
||||
- **Çıkış:** Metin
|
||||
|
||||
### 3.4) `md_guide_get`
|
||||
- **Açıklama:** Seçilen markdown rehber dosyasının içeriğini döndürür
|
||||
- **Giriş:** `guide` (string, zorunlu) - Rehber dosya adı (örn: `codebase_map.md`)
|
||||
- **Çıkış:** Metin (dosya içeriği)
|
||||
|
||||
### 3.5) `codebase_map`
|
||||
- **Açıklama:** Proje klasör ve kritik dosya yapısını özetler
|
||||
- **Giriş:**
|
||||
- `focus` (string, opsiyonel) - Odak klasörü
|
||||
- `depth` (number, opsiyonel) - Tarama derinliği (varsayılan: 2, maksimum: 5)
|
||||
- **Çıkış:** Metin (proje yapısı)
|
||||
|
||||
### 3.6) `tool_stats`
|
||||
- **Açıklama:** MCP tool kullanım istatistiklerini veritabanından özetler
|
||||
- **Giriş:** `limit` (number, opsiyonel) - Kayıt limiti (varsayılan: 10, maksimum: 50)
|
||||
- **Çıkış:** Metin (istatistikler)
|
||||
|
||||
---
|
||||
|
||||
## 3.7) Markdown Rehberi Yükleme Endpoint'i
|
||||
|
||||
**POST `/api/v1/mcp/guides/upload`**
|
||||
- `docs/mcp-tools` altına `.md` dosyası yükler
|
||||
- `multipart/form-data` bekler
|
||||
- Zorunlu alan: `file` (`.md` uzantılı)
|
||||
- Opsiyonel alan: `overwrite` (`true/false`, varsayılan: `false`)
|
||||
- Güvenlik: Bearer Token gerekli
|
||||
|
||||
---
|
||||
|
||||
## 4) Örnek MCP Çağrıları (JSON-RPC 2.0)
|
||||
|
||||
### Tool Listesini Almak
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":1,
|
||||
"method":"tools/list"
|
||||
}'
|
||||
```
|
||||
|
||||
### `api_overview` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":2,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"api_overview",
|
||||
"arguments":{}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### `health_check` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":3,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"health_check",
|
||||
"arguments":{"path":"/swagger/index.html"}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### `md_guide_list` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":4,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"md_guide_list",
|
||||
"arguments":{}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### `md_guide_get` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":5,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"md_guide_get",
|
||||
"arguments":{"guide":"codebase_map.md"}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### `codebase_map` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":6,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"codebase_map",
|
||||
"arguments":{"focus":"app/images","depth":2}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### `tool_stats` Çağrısı
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc":"2.0",
|
||||
"id":7,
|
||||
"method":"tools/call",
|
||||
"params":{
|
||||
"name":"tool_stats",
|
||||
"arguments":{"limit":10}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Markdown Rehberi Yükleme
|
||||
|
||||
```bash
|
||||
curl -X POST "http://127.0.0.1:8080/api/v1/mcp/guides/upload" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@./docs/mcp-tools/ornek-rehber.md" \
|
||||
-F "overwrite=false"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Ortam Değişkenleri
|
||||
|
||||
- **`PORT`** (opsiyonel)
|
||||
- Gin API ve MCP endpoint'inin dinleyeceği port (varsayılan: 8080)
|
||||
|
||||
- **`GINIMAGE_API_BASE_URL`** (opsiyonel)
|
||||
- `health_check` tool'unun kontrol için kullanacağı base URL
|
||||
- Tanımlanmamışsa gelen isteğin host bilgisinden otomatik üretilir
|
||||
|
||||
---
|
||||
|
||||
## 6) Mimari
|
||||
|
||||
```
|
||||
app/mcp/
|
||||
├── server.go # HTTP handlers, DELETE handler, helper functions
|
||||
├── server_mcpgo.go # mcp-go tool registration, logging wrapper
|
||||
├── models/
|
||||
│ ├── tool_run.go # ToolRun DB modeli
|
||||
│ └── ...
|
||||
└── README.md (this file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Yeni Tool Ekleme Rehberi
|
||||
|
||||
### Adım 1: Tool'u Kayıt Et
|
||||
|
||||
`server_mcpgo.go` içinde `newMCPGoServer()` içine ekle:
|
||||
|
||||
```go
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"my_tool",
|
||||
mcpgo.WithDescription("Tool açıklaması."),
|
||||
mcpgo.WithString("param1", mcpgo.Description("Parametre 1"), mcpgo.Required()),
|
||||
mcpgo.WithNumber("param2", mcpgo.Description("Parametre 2")),
|
||||
),
|
||||
withToolRunLog("my_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
// Parametreleri ayrıştır
|
||||
param1, err := req.RequireString("param1")
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
param2 := req.GetInt("param2", 0)
|
||||
|
||||
// İşlem yap
|
||||
result := "Sonuç"
|
||||
|
||||
// Sonuç dön
|
||||
return mcpgo.NewToolResultText(result), nil
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
### Adım 2: Parametre Yardımcıları
|
||||
|
||||
`CallToolRequest` yöntemleri:
|
||||
- `GetString(key, defaultValue) string`
|
||||
- `RequireString(key) (string, error)`
|
||||
- `GetInt(key, defaultValue) int`
|
||||
- `RequireInt(key) (int, error)`
|
||||
- `GetFloat(key, defaultValue) float64`
|
||||
- `RequireFloat(key) (float64, error)`
|
||||
- `GetBool(key, defaultValue) bool`
|
||||
- `RequireBool(key) (bool, error)`
|
||||
- `GetArguments() map[string]any`
|
||||
- `BindArguments(target any) error` (strongly-typed)
|
||||
|
||||
### Adım 3: Sonuç Türleri
|
||||
|
||||
- **Metin:** `mcpgo.NewToolResultText(text string)`
|
||||
- **JSON:** `mcpgo.NewToolResultJSON(data any)`
|
||||
- **Yapılandırılmış:** `mcpgo.NewToolResultStructured(structured any, fallbackText string)`
|
||||
- **Hata:** `mcpgo.NewToolResultError(text string)`
|
||||
- **Resim:** `mcpgo.NewToolResultImage(text, imageData, mimeType string)`
|
||||
- **Ses:** `mcpgo.NewToolResultAudio(text, audioData, mimeType string)`
|
||||
|
||||
### Adım 4: DB Loglama (Otomatik)
|
||||
|
||||
`withToolRunLog` wrapper'ı otomatik olarak:
|
||||
- Tool çağrı zamanını ölçer
|
||||
- Başarı/hata durumunu kaydeder
|
||||
- Argümanları (4096 byte'a kadar) kayıt eder
|
||||
- `mcp_tool_runs` tablosuna yazar
|
||||
|
||||
---
|
||||
|
||||
## 8) Sık Karşılaşılan Sorunlar
|
||||
|
||||
| Hata | Çözüm |
|
||||
|------|-------|
|
||||
| `connection refused` | Backend çalışmıyor. `go run .` ile başlat |
|
||||
| MCP server Cursor'da görünmüyor | `~/.cursor/mcp.json` dosya formatını kontrol et, Cursor MCP reload yap |
|
||||
| 404 dönüyor | URL doğru mu? `/mcp` route'u kullanılmalı |
|
||||
| `health_check` beklenmedik hosta gidiyor | `GINIMAGE_API_BASE_URL` değerini açıkça ver |
|
||||
| `md_guide_get` "guide not found" dönüyor | Dosya `docs/mcp-tools` altında mı? `.md` uzantısı mı? |
|
||||
|
||||
---
|
||||
|
||||
## 9) Referanslar
|
||||
|
||||
- [MCP Specification](https://modelcontextprotocol.io/)
|
||||
- [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go)
|
||||
- GinImage API Overview: `apiOverviewText()` fonksiyonu bak
|
||||
|
||||
---
|
||||
|
||||
**Sürüm:** 0.1.0 (mcp-go migration)
|
||||
**Tarih:** 2026-04-16
|
||||
**Durum:** ✅ Production Ready
|
||||
61
app/mcp/http_helpers.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// doAPIRequest genel amaçlı HTTP istek yardımcısı
|
||||
func doAPIRequest(ctx context.Context, method, path, bearer string, body interface{}) (int, string, error) {
|
||||
baseURL := resolveBaseURLFromContext(ctx)
|
||||
url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path)
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("request body marshal error: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("request creation error: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("request error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", fmt.Errorf("response read error: %v", err)
|
||||
}
|
||||
|
||||
// Pretty-print JSON yanıt
|
||||
var prettyBuf bytes.Buffer
|
||||
if json.Indent(&prettyBuf, respBytes, "", " ") == nil {
|
||||
return resp.StatusCode, prettyBuf.String(), nil
|
||||
}
|
||||
return resp.StatusCode, string(respBytes), nil
|
||||
}
|
||||
|
||||
// apiResult tool sonucu formatlar
|
||||
func apiResult(status int, body string) string {
|
||||
return fmt.Sprintf("HTTP %d\n%s", status, body)
|
||||
}
|
||||
17
app/mcp/models/tool_run.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ToolRun struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ToolName string `gorm:"size:128;index;not null" json:"tool_name"`
|
||||
Status string `gorm:"size:16;index;not null" json:"status"`
|
||||
DurationMs int64 `gorm:"index" json:"duration_ms"`
|
||||
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
|
||||
ArgumentsRaw string `gorm:"type:longtext" json:"arguments_raw,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"`
|
||||
}
|
||||
|
||||
func (ToolRun) TableName() string {
|
||||
return "mcp_tool_runs"
|
||||
}
|
||||
583
app/mcp/server.go
Normal file
@@ -0,0 +1,583 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mcpModels "ginimageApi/app/mcp/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type HTTPRequest struct {
|
||||
JSONRPC string `json:"jsonrpc" example:"2.0"`
|
||||
ID interface{} `json:"id,omitempty" swaggertype:"object"`
|
||||
Method string `json:"method" example:"tools/list"`
|
||||
Params map[string]interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type HTTPResponse struct {
|
||||
JSONRPC string `json:"jsonrpc" example:"2.0"`
|
||||
ID interface{} `json:"id,omitempty" swaggertype:"object"`
|
||||
Result map[string]interface{} `json:"result,omitempty"`
|
||||
Error map[string]interface{} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type UploadGuideResponse struct {
|
||||
Message string `json:"message" example:"markdown guide uploaded"`
|
||||
Guide string `json:"guide" example:"my-guide.md"`
|
||||
Path string `json:"path" example:"docs/mcp-tools/my-guide.md"`
|
||||
}
|
||||
|
||||
type UploadGuideErrorResponse struct {
|
||||
Error string `json:"error" example:"file must be a markdown (.md) file"`
|
||||
}
|
||||
|
||||
const (
|
||||
mdGuidesDir = "docs/mcp-tools"
|
||||
maxGuideSize = 64 * 1024
|
||||
defaultDepth = 2
|
||||
maxDepth = 5
|
||||
)
|
||||
|
||||
// HTTPHandler godoc
|
||||
// @Summary MCP JSON-RPC endpoint
|
||||
// @Description MCP isteklerini JSON-RPC 2.0 formatinda kabul eder.
|
||||
// @Tags mcp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body HTTPRequest true "MCP JSON-RPC request"
|
||||
// @Success 200 {object} HTTPResponse
|
||||
// @Failure 400 {object} HTTPResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/mcp [post]
|
||||
func HTTPHandler() gin.HandlerFunc {
|
||||
return gin.WrapH(getMCPGoHTTPHandler())
|
||||
}
|
||||
|
||||
// StreamableHTTPGETHandler implements MCP Streamable HTTP GET.
|
||||
func StreamableHTTPGETHandler() gin.HandlerFunc {
|
||||
return gin.WrapH(getMCPGoHTTPHandler())
|
||||
}
|
||||
|
||||
// StreamableHTTPDELETEHandler godoc
|
||||
// @Summary MCP streamable DELETE endpoint
|
||||
// @Description Stateless MCP server icin session teardown desteklenmez, 405 doner.
|
||||
// @Tags mcp
|
||||
// @Produce json
|
||||
// @Success 405 {string} string "Method Not Allowed"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/mcp [delete]
|
||||
func StreamableHTTPDELETEHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Allow", "POST, GET")
|
||||
c.Status(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// UploadGuideHandler godoc
|
||||
// @Summary MCP markdown rehberi yukler
|
||||
// @Description `.md` dosyasini `docs/mcp-tools` altina kaydeder ve MCP tool'lari tarafindan okunabilir hale getirir.
|
||||
// @Tags mcp
|
||||
// @Accept mpfd
|
||||
// @Produce json
|
||||
// @Param file formData file true "Yuklenecek markdown dosyasi"
|
||||
// @Param overwrite formData boolean false "Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)"
|
||||
// @Success 200 {object} UploadGuideResponse
|
||||
// @Failure 400 {object} UploadGuideErrorResponse
|
||||
// @Failure 409 {object} UploadGuideErrorResponse
|
||||
// @Failure 500 {object} UploadGuideErrorResponse
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/mcp/guides/upload [post]
|
||||
func UploadGuideHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"})
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(file.Filename)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya adi bos olamaz"})
|
||||
return
|
||||
}
|
||||
|
||||
cleanName := filepath.Base(name)
|
||||
if cleanName != name || strings.Contains(cleanName, "/") || strings.Contains(cleanName, "\\") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz dosya adi"})
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(cleanName), ".md") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "yalnizca .md dosyasi yuklenebilir"})
|
||||
return
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
data, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "bos dosya yuklenemez"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(data) > maxGuideSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("dosya boyutu %d byte sinirini asamaz", maxGuideSize)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(mdGuidesDir, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber klasoru olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(mdGuidesDir, cleanName)
|
||||
overwrite := strings.EqualFold(strings.TrimSpace(c.PostForm("overwrite")), "true") ||
|
||||
strings.TrimSpace(c.PostForm("overwrite")) == "1"
|
||||
if !overwrite {
|
||||
if _, statErr := os.Stat(targetPath); statErr == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "ayni isimde rehber zaten var, overwrite=true gonder"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber kaydedilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UploadGuideResponse{
|
||||
Message: "markdown guide uploaded",
|
||||
Guide: cleanName,
|
||||
Path: toSlashPath(targetPath),
|
||||
})
|
||||
|
||||
// Yeni guide icin MCP tool'larini yeniden yukle
|
||||
go reloadMCPGoServer()
|
||||
}
|
||||
}
|
||||
|
||||
func getToolStats(limit int) (string, error) {
|
||||
if configs.DB == nil {
|
||||
return "", fmt.Errorf("database is not available")
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
type statRow struct {
|
||||
ToolName string
|
||||
TotalRuns int64
|
||||
SuccessRuns int64
|
||||
ErrorRuns int64
|
||||
AvgDurationMs float64
|
||||
}
|
||||
|
||||
rows := make([]statRow, 0)
|
||||
err := configs.DB.Model(&mcpModels.ToolRun{}).
|
||||
Select(`tool_name,
|
||||
COUNT(*) as total_runs,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_runs,
|
||||
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_runs,
|
||||
AVG(duration_ms) as avg_duration_ms`).
|
||||
Group("tool_name").
|
||||
Order("total_runs DESC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to query tool stats")
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return "No tool run records yet.", nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("MCP tool stats\n")
|
||||
b.WriteString(fmt.Sprintf("Limit: %d\n\n", limit))
|
||||
for _, row := range rows {
|
||||
b.WriteString(fmt.Sprintf("- %s: total=%d success=%d error=%d avg_ms=%.1f\n",
|
||||
row.ToolName,
|
||||
row.TotalRuns,
|
||||
row.SuccessRuns,
|
||||
row.ErrorRuns,
|
||||
row.AvgDurationMs,
|
||||
))
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func listMDGuides() ([]string, error) {
|
||||
entries, err := os.ReadDir(mdGuidesDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
guides := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||
guides = append(guides, name)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(guides)
|
||||
return guides, nil
|
||||
}
|
||||
|
||||
func readMDGuide(guide string) (string, error) {
|
||||
name := strings.TrimSpace(guide)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("guide is required")
|
||||
}
|
||||
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return "", fmt.Errorf("invalid guide name")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||
return "", fmt.Errorf("guide must end with .md")
|
||||
}
|
||||
|
||||
cleanName := filepath.Base(name)
|
||||
if cleanName != name {
|
||||
return "", fmt.Errorf("invalid guide name")
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(mdGuidesDir, cleanName)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("guide not found")
|
||||
}
|
||||
return "", fmt.Errorf("unable to read guide")
|
||||
}
|
||||
|
||||
if len(data) > maxGuideSize {
|
||||
return "", fmt.Errorf("guide is too large")
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func buildCodebaseMap(focus string, depth int) (string, error) {
|
||||
cleanFocus, err := sanitizeFocus(focus)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if depth <= 0 {
|
||||
depth = defaultDepth
|
||||
}
|
||||
if depth > maxDepth {
|
||||
depth = maxDepth
|
||||
}
|
||||
|
||||
basePath := "."
|
||||
headerFocus := "./"
|
||||
if cleanFocus != "" {
|
||||
basePath = cleanFocus
|
||||
headerFocus = cleanFocus
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(basePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("focus not found")
|
||||
}
|
||||
return "", fmt.Errorf("unable to scan focus")
|
||||
}
|
||||
|
||||
dirs := make([]string, 0)
|
||||
files := make([]string, 0)
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
fullPath := filepath.Join(basePath, name)
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, toSlashPath(fullPath))
|
||||
continue
|
||||
}
|
||||
files = append(files, toSlashPath(fullPath))
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
sort.Strings(files)
|
||||
|
||||
allFiles, err := collectFiles(basePath, depth)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to map files")
|
||||
}
|
||||
|
||||
keyFiles := pickKeyFiles(allFiles)
|
||||
moduleHints := buildModuleHints(allFiles)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Codebase map\n")
|
||||
b.WriteString(fmt.Sprintf("Focus: %s\n", headerFocus))
|
||||
b.WriteString(fmt.Sprintf("Depth: %d\n\n", depth))
|
||||
|
||||
b.WriteString("Top directories:\n")
|
||||
if len(dirs) == 0 {
|
||||
b.WriteString("- (none)\n")
|
||||
} else {
|
||||
for _, d := range dirs {
|
||||
b.WriteString("- " + d + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nTop files:\n")
|
||||
if len(files) == 0 {
|
||||
b.WriteString("- (none)\n")
|
||||
} else {
|
||||
limit := min(8, len(files))
|
||||
for _, f := range files[:limit] {
|
||||
b.WriteString("- " + f + "\n")
|
||||
}
|
||||
if len(files) > limit {
|
||||
b.WriteString(fmt.Sprintf("- ... (%d more)\n", len(files)-limit))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nKey files:\n")
|
||||
if len(keyFiles) == 0 {
|
||||
b.WriteString("- (none)\n")
|
||||
} else {
|
||||
for _, f := range keyFiles {
|
||||
b.WriteString("- " + f + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nModule hints:\n")
|
||||
if len(moduleHints) == 0 {
|
||||
b.WriteString("- (no module hints found)\n")
|
||||
} else {
|
||||
for _, hint := range moduleHints {
|
||||
b.WriteString("- " + hint + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func sanitizeFocus(focus string) (string, error) {
|
||||
f := strings.TrimSpace(strings.ReplaceAll(focus, "\\", "/"))
|
||||
if f == "" || f == "." || f == "./" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(f, "/") || strings.Contains(f, "..") {
|
||||
return "", fmt.Errorf("invalid focus")
|
||||
}
|
||||
clean := filepath.Clean(f)
|
||||
if clean == "." || clean == "" {
|
||||
return "", nil
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func collectFiles(basePath string, depth int) ([]string, error) {
|
||||
files := make([]string, 0)
|
||||
baseDepth := pathDepth(basePath)
|
||||
|
||||
err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if path == basePath {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := d.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
currentDepth := pathDepth(path) - baseDepth
|
||||
if currentDepth > depth {
|
||||
if d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !d.IsDir() {
|
||||
files = append(files, toSlashPath(path))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func pickKeyFiles(allFiles []string) []string {
|
||||
priority := []string{
|
||||
"go.mod",
|
||||
"main.go",
|
||||
"routers/router.go",
|
||||
"app/mcp/server.go",
|
||||
"configs/db.go",
|
||||
"configs/redis.go",
|
||||
}
|
||||
|
||||
chosen := make([]string, 0, 10)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range priority {
|
||||
for _, f := range allFiles {
|
||||
if f == p || strings.HasSuffix(f, "/"+p) {
|
||||
if !seen[f] {
|
||||
chosen = append(chosen, f)
|
||||
seen[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range allFiles {
|
||||
if strings.Contains(f, "/handlers/") && strings.HasSuffix(f, ".go") {
|
||||
if !seen[f] {
|
||||
chosen = append(chosen, f)
|
||||
seen[f] = true
|
||||
}
|
||||
}
|
||||
if len(chosen) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(chosen) > 10 {
|
||||
return chosen[:10]
|
||||
}
|
||||
return chosen
|
||||
}
|
||||
|
||||
func buildModuleHints(allFiles []string) []string {
|
||||
modules := make(map[string]bool)
|
||||
for _, f := range allFiles {
|
||||
parts := strings.Split(f, "/")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
if parts[0] == "app" && len(parts) >= 2 {
|
||||
modules[parts[0]+"/"+parts[1]] = true
|
||||
}
|
||||
if parts[0] == "pkg" && len(parts) >= 2 {
|
||||
modules[parts[0]+"/"+parts[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(modules) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(modules))
|
||||
for k := range modules {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func pathDepth(path string) int {
|
||||
clean := strings.Trim(toSlashPath(path), "/")
|
||||
if clean == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(clean, "/") + 1
|
||||
}
|
||||
|
||||
func toSlashPath(path string) string {
|
||||
return strings.TrimPrefix(filepath.ToSlash(path), "./")
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func apiOverviewText() string {
|
||||
return strings.TrimSpace(`
|
||||
GinImage API (base: /api/v1)
|
||||
|
||||
Public auth:
|
||||
- POST /auth/register
|
||||
- POST /auth/login
|
||||
- POST /auth/refresh
|
||||
|
||||
Public blog:
|
||||
- GET /blogs
|
||||
- GET /blogs/categories
|
||||
- GET /blogs/categories/:slug
|
||||
- GET /blogs/tags
|
||||
- GET /blogs/tags/:slug
|
||||
- GET /blogs/:slug
|
||||
|
||||
Protected (Bearer token gerekli):
|
||||
- GET /me
|
||||
- POST /images/process
|
||||
- GET /images
|
||||
- GET /images/:id
|
||||
|
||||
Admin:
|
||||
- POST /users/:id/admin
|
||||
- POST /blogs
|
||||
- PUT /blogs/:id
|
||||
- DELETE /blogs/:id
|
||||
- POST /blogs/categories
|
||||
- PUT /blogs/categories/:id
|
||||
- DELETE /blogs/categories/:id
|
||||
- POST /blogs/tags
|
||||
- PUT /blogs/tags/:id
|
||||
- DELETE /blogs/tags/:id
|
||||
`)
|
||||
}
|
||||
|
||||
func ensurePathPrefix(path string) string {
|
||||
if strings.HasPrefix(path, "/") {
|
||||
return path
|
||||
}
|
||||
return "/" + path
|
||||
}
|
||||
|
||||
func RunFromEnv() error {
|
||||
return runMCPGoFromEnv()
|
||||
}
|
||||
303
app/mcp/server_mcpgo.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mcpModels "ginimageApi/app/mcp/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
||||
mcpserver "github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
type mcpBaseURLKey struct{}
|
||||
|
||||
var (
|
||||
mcpGoOnce sync.Once
|
||||
mcpGoServer *mcpserver.MCPServer
|
||||
mcpGoHTTPHandler http.Handler
|
||||
mcpGoMu sync.RWMutex
|
||||
)
|
||||
|
||||
func getMCPGoHTTPHandler() http.Handler {
|
||||
mcpGoOnce.Do(func() {
|
||||
mcpGoServer = newMCPGoServer()
|
||||
mcpGoHTTPHandler = mcpserver.NewStreamableHTTPServer(
|
||||
mcpGoServer,
|
||||
mcpserver.WithStateLess(true),
|
||||
mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
|
||||
return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r))
|
||||
}),
|
||||
)
|
||||
})
|
||||
mcpGoMu.RLock()
|
||||
defer mcpGoMu.RUnlock()
|
||||
return mcpGoHTTPHandler
|
||||
}
|
||||
|
||||
// reloadMCPGoServer yeni bir MD rehber eklendikten sonra MCP server'i yeniden olusturur.
|
||||
func reloadMCPGoServer() {
|
||||
mcpGoMu.Lock()
|
||||
defer mcpGoMu.Unlock()
|
||||
mcpGoOnce = sync.Once{} // sıfırla
|
||||
newServer := newMCPGoServer()
|
||||
newHandler := mcpserver.NewStreamableHTTPServer(
|
||||
newServer,
|
||||
mcpserver.WithStateLess(true),
|
||||
mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context {
|
||||
return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r))
|
||||
}),
|
||||
)
|
||||
mcpGoServer = newServer
|
||||
mcpGoHTTPHandler = newHandler
|
||||
}
|
||||
|
||||
func newMCPGoServer() *mcpserver.MCPServer {
|
||||
s := mcpserver.NewMCPServer("ginimage-api-mcp", "0.1.0")
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"api_overview",
|
||||
mcpgo.WithDescription("GinImage API endpoint ozeti ve kullanimi."),
|
||||
),
|
||||
withToolRunLog("api_overview", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
return mcpgo.NewToolResultText(apiOverviewText()), nil
|
||||
}),
|
||||
)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"health_check",
|
||||
mcpgo.WithDescription("API health endpoint durumunu kontrol eder."),
|
||||
mcpgo.WithString("path", mcpgo.Description("Kontrol edilecek path. Varsayilan: /swagger/index.html")),
|
||||
),
|
||||
withToolRunLog("health_check", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
path := req.GetString("path", "/swagger/index.html")
|
||||
baseURL := resolveBaseURLFromContext(ctx)
|
||||
url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path)
|
||||
|
||||
resp, err := http.Get(url) //nolint:gosec
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultText(fmt.Sprintf("Health check failed: %v", err)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return mcpgo.NewToolResultText(fmt.Sprintf("Health check: %s -> HTTP %d", url, resp.StatusCode)), nil
|
||||
}),
|
||||
)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"md_guide_list",
|
||||
mcpgo.WithDescription("docs/mcp-tools altindaki markdown rehber dosyalarini listeler."),
|
||||
),
|
||||
withToolRunLog("md_guide_list", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
guides, err := listMDGuides()
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError("failed to list guides"), nil
|
||||
}
|
||||
if len(guides) == 0 {
|
||||
return mcpgo.NewToolResultText("No markdown guides found under docs/mcp-tools"), nil
|
||||
}
|
||||
return mcpgo.NewToolResultText("Available guides:\n- " + strings.Join(guides, "\n- ")), nil
|
||||
}),
|
||||
)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"md_guide_get",
|
||||
mcpgo.WithDescription("Secilen markdown rehber dosyasinin icerigini dondurur."),
|
||||
mcpgo.WithString(
|
||||
"guide",
|
||||
mcpgo.Description("Rehber dosya adi. Ornek: codebase_map.md"),
|
||||
mcpgo.Required(),
|
||||
),
|
||||
),
|
||||
withToolRunLog("md_guide_get", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
guide, err := req.RequireString("guide")
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError("invalid params"), nil
|
||||
}
|
||||
content, err := readMDGuide(guide)
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcpgo.NewToolResultText(content), nil
|
||||
}),
|
||||
)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"codebase_map",
|
||||
mcpgo.WithDescription("Proje klasor ve kritik dosya yapisini ozetler."),
|
||||
mcpgo.WithString("focus", mcpgo.Description("Opsiyonel odak klasoru. Ornek: app/images")),
|
||||
mcpgo.WithNumber("depth", mcpgo.Description("Opsiyonel tarama derinligi. Varsayilan 2, maksimum 5")),
|
||||
),
|
||||
withToolRunLog("codebase_map", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
focus := req.GetString("focus", "")
|
||||
depth := req.GetInt("depth", 0)
|
||||
text, err := buildCodebaseMap(focus, depth)
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcpgo.NewToolResultText(text), nil
|
||||
}),
|
||||
)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(
|
||||
"tool_stats",
|
||||
mcpgo.WithDescription("MCP tool kullanim istatistiklerini veritabanindan ozetler."),
|
||||
mcpgo.WithNumber("limit", mcpgo.Description("Opsiyonel kayit limiti. Varsayilan 10, maksimum 50")),
|
||||
),
|
||||
withToolRunLog("tool_stats", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
limit := req.GetInt("limit", 10)
|
||||
statsText, err := getToolStats(limit)
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcpgo.NewToolResultText(statsText), nil
|
||||
}),
|
||||
)
|
||||
|
||||
registerMDGuideTools(s)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// registerMDGuideTools docs/mcp-tools/ altindaki her .md dosyasini ayri bir tool olarak kaydeder.
|
||||
func registerMDGuideTools(s *mcpserver.MCPServer) {
|
||||
guides, err := listMDGuides()
|
||||
if err != nil || len(guides) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, guide := range guides {
|
||||
guideName := guide // closure icin kopyala
|
||||
toolName := mdGuideToolName(guideName)
|
||||
description := fmt.Sprintf("Rehber: %s — MCP guide dokumani.", guideName)
|
||||
|
||||
s.AddTool(
|
||||
mcpgo.NewTool(toolName, mcpgo.WithDescription(description)),
|
||||
withToolRunLog(toolName, func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
content, err := readMDGuide(guideName)
|
||||
if err != nil {
|
||||
return mcpgo.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcpgo.NewToolResultText(content), nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// mdGuideToolName dosya adini gecerli bir tool adina donusturur.
|
||||
// Ornek: "codebase_map.md" -> "guide_codebase_map"
|
||||
func mdGuideToolName(filename string) string {
|
||||
name := strings.TrimSuffix(filename, ".md")
|
||||
// Alfanumerik ve alt cizgi disindaki karakterleri _ ile degistir
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return "guide_" + b.String()
|
||||
}
|
||||
|
||||
func withToolRunLog(toolName string, next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
started := time.Now()
|
||||
result, err := next(ctx, req)
|
||||
duration := time.Since(started)
|
||||
|
||||
status := "success"
|
||||
errMessage := ""
|
||||
if err != nil {
|
||||
status = "error"
|
||||
errMessage = err.Error()
|
||||
} else if result != nil && result.IsError {
|
||||
status = "error"
|
||||
errMessage = extractToolResultText(result)
|
||||
}
|
||||
|
||||
argsText := ""
|
||||
if raw, marshalErr := json.Marshal(req.GetArguments()); marshalErr == nil {
|
||||
argsText = string(raw)
|
||||
}
|
||||
if len(argsText) > 4096 {
|
||||
argsText = argsText[:4096]
|
||||
}
|
||||
|
||||
if configs.DB != nil && strings.TrimSpace(toolName) != "" {
|
||||
run := mcpModels.ToolRun{
|
||||
ToolName: toolName,
|
||||
Status: status,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
ErrorMessage: errMessage,
|
||||
ArgumentsRaw: argsText,
|
||||
}
|
||||
_ = configs.DB.Create(&run).Error
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
func extractToolResultText(result *mcpgo.CallToolResult) string {
|
||||
if result == nil || len(result.Content) == 0 {
|
||||
return "tool error"
|
||||
}
|
||||
for _, content := range result.Content {
|
||||
if textContent, ok := content.(mcpgo.TextContent); ok {
|
||||
if strings.TrimSpace(textContent.Text) != "" {
|
||||
return textContent.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return "tool error"
|
||||
}
|
||||
|
||||
func resolveBaseURLFromContext(ctx context.Context) string {
|
||||
if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
if fromCtx, ok := ctx.Value(mcpBaseURLKey{}).(string); ok && strings.TrimSpace(fromCtx) != "" {
|
||||
return fromCtx
|
||||
}
|
||||
port := strings.TrimSpace(os.Getenv("PORT"))
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
return "http://127.0.0.1:" + port
|
||||
}
|
||||
|
||||
func resolveBaseURLFromRequest(r *http.Request) string {
|
||||
if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" {
|
||||
return envURL
|
||||
}
|
||||
if r == nil {
|
||||
return resolveBaseURLFromContext(context.Background())
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, r.Host)
|
||||
}
|
||||
|
||||
func runMCPGoFromEnv() error {
|
||||
if mcpGoServer == nil {
|
||||
_ = getMCPGoHTTPHandler()
|
||||
}
|
||||
return mcpserver.ServeStdio(mcpGoServer)
|
||||
}
|
||||
128
app/mcp/server_mcpgo_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// Test server creation
|
||||
func TestNewMCPGoServer(t *testing.T) {
|
||||
server := newMCPGoServer()
|
||||
if server == nil {
|
||||
t.Fatal("expected server to be created, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test withToolRunLog wrapper succeeds
|
||||
func TestWithToolRunLogWrapper(t *testing.T) {
|
||||
called := false
|
||||
handler := withToolRunLog("test_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
called = true
|
||||
return mcpgo.NewToolResultText("test result"), nil
|
||||
})
|
||||
result, err := handler(context.Background(), mcpgo.CallToolRequest{
|
||||
Params: mcpgo.CallToolParams{
|
||||
Name: "test_tool",
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("expected handler to be called")
|
||||
}
|
||||
if result == nil {
|
||||
t.Error("expected result to be non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Test withToolRunLog wrapper with error result
|
||||
func TestWithToolRunLogWrapperErrorResult(t *testing.T) {
|
||||
handler := withToolRunLog("error_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
||||
return mcpgo.NewToolResultError("test error"), nil
|
||||
})
|
||||
result, err := handler(context.Background(), mcpgo.CallToolRequest{
|
||||
Params: mcpgo.CallToolParams{
|
||||
Name: "error_tool",
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Error("expected result to be non-nil")
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Error("expected IsError flag to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// Test extractToolResultText with nil result
|
||||
func TestExtractToolResultTextNil(t *testing.T) {
|
||||
result := extractToolResultText(nil)
|
||||
if result != "tool error" {
|
||||
t.Errorf("expected 'tool error', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Test extractToolResultText with empty content
|
||||
func TestExtractToolResultTextEmpty(t *testing.T) {
|
||||
toolResult := &mcpgo.CallToolResult{
|
||||
Content: []mcpgo.Content{},
|
||||
}
|
||||
result := extractToolResultText(toolResult)
|
||||
if result != "tool error" {
|
||||
t.Errorf("expected 'tool error', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Test getMCPGoHTTPHandler initializes once
|
||||
func TestGetMCPGoHTTPHandlerOnce(t *testing.T) {
|
||||
handler1 := getMCPGoHTTPHandler()
|
||||
handler2 := getMCPGoHTTPHandler()
|
||||
if handler1 == nil {
|
||||
t.Error("expected handler1 to be non-nil")
|
||||
}
|
||||
if handler2 == nil {
|
||||
t.Error("expected handler2 to be non-nil")
|
||||
}
|
||||
// Both should be the same instance (sync.Once ensures this)
|
||||
if handler1 != handler2 {
|
||||
t.Error("expected handlers to be the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
// Test resolveBaseURLFromContext with env var
|
||||
func TestResolveBaseURLFromContextEnv(t *testing.T) {
|
||||
t.Setenv("GINIMAGE_API_BASE_URL", "http://api.example.com")
|
||||
url := resolveBaseURLFromContext(context.Background())
|
||||
expected := "http://api.example.com"
|
||||
if url != expected {
|
||||
t.Errorf("expected %q, got %q", expected, url)
|
||||
}
|
||||
}
|
||||
|
||||
// Test resolveBaseURLFromContext without env var
|
||||
func TestResolveBaseURLFromContextDefault(t *testing.T) {
|
||||
t.Setenv("GINIMAGE_API_BASE_URL", "")
|
||||
t.Setenv("PORT", "")
|
||||
url := resolveBaseURLFromContext(context.Background())
|
||||
if url != "http://127.0.0.1:8080" {
|
||||
t.Errorf("expected default URL, got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// Test resolveBaseURLFromContext with custom port
|
||||
func TestResolveBaseURLFromContextCustomPort(t *testing.T) {
|
||||
t.Setenv("GINIMAGE_API_BASE_URL", "")
|
||||
t.Setenv("PORT", "9090")
|
||||
url := resolveBaseURLFromContext(context.Background())
|
||||
expected := "http://127.0.0.1:9090"
|
||||
if url != expected {
|
||||
t.Errorf("expected %q, got %q", expected, url)
|
||||
}
|
||||
}
|
||||
102
app/mcp/server_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHTTPHandlerToolsList tests POST /mcp tools/list request
|
||||
func TestHTTPHandlerToolsList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/mcp", HTTPHandler())
|
||||
payload := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPHandlerAPIOverviewTool tests tools/call api_overview
|
||||
func TestHTTPHandlerAPIOverviewTool(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/mcp", HTTPHandler())
|
||||
payload := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/call",
|
||||
"params": map[string]interface{}{
|
||||
"name": "api_overview",
|
||||
"arguments": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPHandlerInvalidJSON tests invalid JSON request
|
||||
func TestHTTPHandlerInvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/mcp", HTTPHandler())
|
||||
req := httptest.NewRequest("POST", "/mcp", strings.NewReader("invalid json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableHTTPDELETEHandler tests DELETE response
|
||||
func TestStreamableHTTPDELETEHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.DELETE("/api/v1/mcp", StreamableHTTPDELETEHandler())
|
||||
req := httptest.NewRequest("DELETE", "/api/v1/mcp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected status 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPInitialize tests initialize method
|
||||
func TestMCPInitialize(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.POST("/mcp", HTTPHandler())
|
||||
payload := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
265
app/middleware/auth.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ginimageApi/app/accounts/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type accessTokenPayload struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type accessTokenClaims struct {
|
||||
TokenType string `json:"token_type"`
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type refreshTokenClaims struct {
|
||||
TokenType string `json:"token_type"`
|
||||
UserID string `json:"user_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func jwtIssuer() string {
|
||||
issuer := os.Getenv("JWT_ISSUER")
|
||||
if issuer == "" {
|
||||
issuer = "ginimageApi"
|
||||
}
|
||||
return issuer
|
||||
}
|
||||
|
||||
func jwtAudience() string {
|
||||
audience := os.Getenv("JWT_AUDIENCE")
|
||||
if audience == "" {
|
||||
audience = "ginimageApi-client"
|
||||
}
|
||||
return audience
|
||||
}
|
||||
|
||||
func jwtSecret() string {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "dev-secret-change-me"
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func GenerateAccessToken(userID uint, email, username string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := accessTokenClaims{
|
||||
TokenType: "access",
|
||||
UserID: strconv.FormatUint(uint64(userID), 10),
|
||||
Email: email,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: tokenID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(jwtSecret()))
|
||||
}
|
||||
|
||||
func GenerateRefreshToken(userID uint, ttl time.Duration) (string, string, error) {
|
||||
now := time.Now()
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
claims := refreshTokenClaims{
|
||||
TokenType: "refresh",
|
||||
UserID: strconv.FormatUint(uint64(userID), 10),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: tokenID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(jwtSecret()))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return signed, tokenID, nil
|
||||
}
|
||||
|
||||
func parseAccessToken(token string) (accessTokenPayload, error) {
|
||||
parsed, err := jwt.ParseWithClaims(
|
||||
token,
|
||||
&accessTokenClaims{},
|
||||
func(t *jwt.Token) (any, error) {
|
||||
return []byte(jwtSecret()), nil
|
||||
},
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithExpirationRequired(),
|
||||
)
|
||||
if err != nil {
|
||||
return accessTokenPayload{}, errors.New("token gecersiz")
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(*accessTokenClaims)
|
||||
if !ok || !parsed.Valid {
|
||||
return accessTokenPayload{}, errors.New("token gecersiz")
|
||||
}
|
||||
if claims.TokenType != "access" {
|
||||
return accessTokenPayload{}, errors.New("token type gecersiz")
|
||||
}
|
||||
|
||||
uid64, err := strconv.ParseUint(claims.UserID, 10, 64)
|
||||
if err != nil {
|
||||
return accessTokenPayload{}, errors.New("user_id claim gecersiz")
|
||||
}
|
||||
|
||||
exp := int64(0)
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time.Unix()
|
||||
}
|
||||
|
||||
return accessTokenPayload{
|
||||
UserID: uint(uid64),
|
||||
Email: claims.Email,
|
||||
Username: claims.Username,
|
||||
Exp: exp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) (string, error) {
|
||||
header := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if header == "" {
|
||||
return "", errors.New("authorization basligi yok")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", errors.New("authorization formati gecersiz")
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(parts[1])
|
||||
if token == "" {
|
||||
return "", errors.New("authorization formati gecersiz")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// AuthRequired access token dogrular ve kullanici bilgisini context'e yazar.
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token, err := bearerToken(c)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
payload, err := parseAccessToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", payload.UserID)
|
||||
c.Set("email", payload.Email)
|
||||
c.Set("username", payload.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminRequired mutating endpointlerde kullanicinin admin oldugunu dogrular.
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userIDAny, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
var userID uint
|
||||
switch v := userIDAny.(type) {
|
||||
case uint:
|
||||
userID = v
|
||||
case int:
|
||||
if v < 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"})
|
||||
return
|
||||
}
|
||||
userID = uint(v)
|
||||
case string:
|
||||
parsed, err := strconv.ParseUint(v, 10, 64)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"})
|
||||
return
|
||||
}
|
||||
userID = uint(parsed)
|
||||
default:
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := configs.DB.First(&user, userID).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.IsAdmin == nil || !*user.IsAdmin {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAccessTokenForUser(user models.User) (string, error) {
|
||||
return GenerateAccessToken(user.ID, user.Email, user.UserName, 15*time.Minute)
|
||||
}
|
||||
|
||||
func RefreshTokenExpiry() time.Duration {
|
||||
return 7 * 24 * time.Hour
|
||||
}
|
||||
|
||||
func AccessTokenTTL() time.Duration {
|
||||
return 15 * time.Minute
|
||||
}
|
||||
|
||||
func TokenPayloadDebug(token string) string {
|
||||
payload, err := parseAccessToken(token)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return fmt.Sprintf("uid=%d email=%s username=%s exp=%d", payload.UserID, payload.Email, payload.Username, payload.Exp)
|
||||
}
|
||||
231
app/middleware/auth_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ginimageApi/app/accounts/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupMiddlewareTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
prev := configs.DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.User{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
configs.DB = db
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
configs.DB = prev
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateAndParseAccessToken(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
token, err := GenerateAccessToken(99, "u@example.com", "u1", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken failed: %v", err)
|
||||
}
|
||||
if got := len(strings.Split(token, ".")); got != 3 {
|
||||
t.Fatalf("expected standard JWT with 3 segments, got %d", got)
|
||||
}
|
||||
|
||||
payload, err := parseAccessToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
if payload.UserID != 99 || payload.Email != "u@example.com" || payload.Username != "u1" {
|
||||
t.Fatalf("unexpected payload: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessTokenExpired(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
token, err := GenerateAccessToken(1, "a@a.com", "a", -time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := parseAccessToken(token); err == nil {
|
||||
t.Fatalf("expected parse error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessTokenRejectsRefreshToken(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
token, _, err := GenerateRefreshToken(1, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := parseAccessToken(token); err == nil {
|
||||
t.Fatalf("expected parse error for refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAccessTokenRequiresUserID(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
claims := accessTokenClaims{
|
||||
TokenType: "access",
|
||||
Email: "a@a.com",
|
||||
Username: "a",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign token: %v", err)
|
||||
}
|
||||
|
||||
if _, err := parseAccessToken(token); err == nil {
|
||||
t.Fatalf("expected parse error for missing user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequired(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
token, err := GenerateAccessToken(7, "mail@example.com", "user7", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("token generate failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/me", AuthRequired(), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": c.GetUint("user_id"),
|
||||
"email": c.GetString("email"),
|
||||
"username": c.GetString("username"),
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if body["email"] != "mail@example.com" {
|
||||
t.Fatalf("expected email in context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredRejectsInvalidToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/me", AuthRequired(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredRejectsRawAuthorizationToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
|
||||
token, err := GenerateAccessToken(11, "raw@example.com", "rawuser", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("token generate failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/me", AuthRequired(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for raw token without Bearer, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequired(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupMiddlewareTestDB(t)
|
||||
|
||||
isAdmin := true
|
||||
isUser := false
|
||||
admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin}
|
||||
user := models.User{UserName: "user", Email: "user@example.com", Password: "x", IsAdmin: &isUser}
|
||||
if err := configs.DB.Create(&admin).Error; err != nil {
|
||||
t.Fatalf("admin create failed: %v", err)
|
||||
}
|
||||
if err := configs.DB.Create(&user).Error; err != nil {
|
||||
t.Fatalf("user create failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/admin", func(c *gin.Context) {
|
||||
c.Set("user_id", user.ID)
|
||||
c.Next()
|
||||
}, AdminRequired(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/admin", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d", w.Code)
|
||||
}
|
||||
|
||||
r2 := gin.New()
|
||||
r2.POST("/admin", func(c *gin.Context) {
|
||||
c.Set("user_id", admin.ID)
|
||||
c.Next()
|
||||
}, AdminRequired(), func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
r2.ServeHTTP(w2, httptest.NewRequest(http.MethodPost, "/admin", nil))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for admin, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
79
app/middleware/security.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DynamicCORS CORS davranisini ortama gore dinamik ayarlar.
|
||||
func DynamicCORS() gin.HandlerFunc {
|
||||
allowOrigin := os.Getenv("CORS_ALLOW_ORIGIN")
|
||||
if allowOrigin == "" {
|
||||
allowOrigin = "*"
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", allowOrigin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type clientWindow struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
// DynamicRateLimit IP bazli basit bir dakika penceresi limiti uygular.
|
||||
func DynamicRateLimit() gin.HandlerFunc {
|
||||
limit := 120
|
||||
if v := os.Getenv("RATE_LIMIT_RPM"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
clients := map[string]*clientWindow{}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
now := time.Now()
|
||||
|
||||
mu.Lock()
|
||||
entry, ok := clients[ip]
|
||||
if !ok || now.After(entry.windowEnds) {
|
||||
entry = &clientWindow{count: 0, windowEnds: now.Add(time.Minute)}
|
||||
clients[ip] = entry
|
||||
}
|
||||
|
||||
entry.count++
|
||||
remaining := limit - entry.count
|
||||
resetIn := int(time.Until(entry.windowEnds).Seconds())
|
||||
mu.Unlock()
|
||||
|
||||
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
c.Header("X-RateLimit-Reset", strconv.Itoa(resetIn))
|
||||
|
||||
if entry.count > limit {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit asildi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
75
app/middleware/security_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDynamicCORS(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "http://example.com")
|
||||
|
||||
r := gin.New()
|
||||
r.Use(DynamicCORS())
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://example.com" {
|
||||
t.Fatalf("unexpected allow origin: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicCORSOptions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("CORS_ALLOW_ORIGIN", "*")
|
||||
|
||||
r := gin.New()
|
||||
r.Use(DynamicCORS())
|
||||
r.OPTIONS("/ping", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/ping", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicRateLimit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("RATE_LIMIT_RPM", "2")
|
||||
|
||||
r := gin.New()
|
||||
r.Use(DynamicRateLimit())
|
||||
r.GET("/limited", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/limited", nil)
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if i < 3 && w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d expected 200, got %d", i, w.Code)
|
||||
}
|
||||
if i == 3 && w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request %d expected 429, got %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||