Files
ginimageApi/app/accounts/handlers/admin_users.go
Beyhan Oğur e04ba85564 first commit
2026-04-26 21:40:14 +03:00

710 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"),
}
}