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

1282 lines
34 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 (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"ginimageApi/app/accounts/models"
"ginimageApi/app/middleware"
"ginimageApi/configs"
imageProcessor "ginimageApi/pkg/images"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type registerRequest struct {
Username string `json:"username" binding:"required,min=3"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"first_name" binding:"required,min=2"`
LastName string `json:"last_name" binding:"required,min=2"`
Password string `json:"password" binding:"required,min=6"`
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type verifyEmailRequest struct {
Token string `form:"token" binding:"required"`
}
type socialLoginRequest struct {
AccessToken string `json:"access_token" binding:"required"`
}
type profileUpdateRequest struct {
FirstName string `form:"first_name" binding:"omitempty,min=2"`
LastName string `form:"last_name" binding:"omitempty,min=2"`
}
type adminRequest struct {
IsAdmin bool `json:"is_admin"`
}
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"password"`
ConfirmPassword string `json:"confirm_password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type VerifyEmailRequest struct {
Token string `json:"token"`
}
type SocialLoginRequest struct {
AccessToken string `json:"access_token"`
}
type ProfileUpdateRequest struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarURL string `json:"avatar_url"`
}
type TokenResponse struct {
AccessToken string `json:"access"` // JWT (HS256) access token
RefreshToken string `json:"refresh"` // JWT (HS256) refresh token
}
type RegisterResponse struct {
Message string `json:"message"`
VerificationURL string `json:"verification_url"`
VerificationToken string `json:"verification_token"`
}
type SocialTokenResponse struct {
Message string `json:"message"`
Provider string `json:"provider"`
NewUser bool `json:"new_user"`
AccessToken string `json:"access"`
RefreshToken string `json:"refresh"`
}
type MessageResponse struct {
Message string `json:"message"`
}
type MeResponse struct {
UserID any `json:"user_id"`
Email string `json:"email"`
Username string `json:"username"`
}
type ProfileResponse struct {
UserID uint64 `json:"user_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
AvatarURL string `json:"avatar_url"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
func boolPtr(v bool) *bool {
return &v
}
func currentUserID(c *gin.Context) (uint64, error) {
userIDAny, ok := c.Get("user_id")
if !ok {
return 0, errors.New("kullanici bulunamadi")
}
switch v := userIDAny.(type) {
case uint:
return uint64(v), nil
case uint64:
return v, nil
case int:
if v < 0 {
return 0, errors.New("gecersiz kullanici")
}
return uint64(v), nil
case string:
parsed, err := strconv.ParseUint(v, 10, 64)
if err != nil {
return 0, errors.New("gecersiz kullanici")
}
return parsed, nil
default:
return 0, errors.New("gecersiz kullanici")
}
}
func getOrCreateProfileForUser(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
}
func saveAvatarFromMultipart(c *gin.Context, formField string) (string, bool, error) {
// Content-Type multipart/form-data değilse (örn. JSON isteği) avatar alanı yok, hata değil
if !strings.Contains(c.ContentType(), "multipart/form-data") {
return "", false, nil
}
file, err := c.FormFile(formField)
if err != nil {
// Alan eksikse veya dosya seçilmediyse hata değil
if errors.Is(err, http.ErrMissingFile) ||
strings.Contains(err.Error(), "no such file") ||
strings.Contains(err.Error(), "missing") {
return "", false, nil
}
return "", false, err
}
cfg := loadAvatarProcessingConfig()
maxBytes := int64(cfg.MaxSizeMB) * 1024 * 1024
if file.Size > maxBytes {
return "", false, fmt.Errorf("avatar boyutu %dMB limitini asiyor", cfg.MaxSizeMB)
}
f, err := file.Open()
if err != nil {
return "", false, err
}
defer func() { _ = f.Close() }()
sourceBuffer, err := io.ReadAll(f)
if err != nil {
return "", false, err
}
if int64(len(sourceBuffer)) > maxBytes {
return "", false, fmt.Errorf("avatar boyutu %dMB limitini asiyor", cfg.MaxSizeMB)
}
processedBuffer, err := imageProcessor.ProcessImage(sourceBuffer, imageProcessor.ProcessOptions{
Width: cfg.Width,
Height: cfg.Height,
Quality: cfg.Quality,
Format: cfg.Format,
Cover: true,
})
if err != nil {
return "", false, err
}
if err := os.MkdirAll("uploads/avatars", 0o755); err != nil {
return "", false, err
}
ext := avatarFormatToExt(cfg.Format)
randomPart, err := randomTokenHex(8)
if err != nil {
return "", false, err
}
fileName := fmt.Sprintf("avatar_%d_%s%s", time.Now().Unix(), randomPart, ext)
dst := filepath.Join("uploads", "avatars", fileName)
if err := os.WriteFile(dst, processedBuffer, 0o644); err != nil {
return "", false, err
}
return "/uploads/avatars/" + fileName, true, nil
}
type avatarProcessingConfig struct {
Width int
Height int
Quality int
MaxSizeMB int
Format string
}
func loadAvatarProcessingConfig() avatarProcessingConfig {
width := envIntOrDefault("AVATAR_WIDTH", 256)
height := envIntOrDefault("AVATAR_HEIGHT", 256)
quality := envIntOrDefault("AVATAR_QUALITY", 80)
maxSizeMB := envIntOrDefault("AVATAR_MAX_SIZE_MB", 5)
format := pickAllowedAvatarFormat(os.Getenv("AVATAR_FORMATS"))
if width <= 0 {
width = 256
}
if height <= 0 {
height = 256
}
if quality < 1 || quality > 100 {
quality = 80
}
if maxSizeMB <= 0 {
maxSizeMB = 5
}
return avatarProcessingConfig{
Width: width,
Height: height,
Quality: quality,
MaxSizeMB: maxSizeMB,
Format: format,
}
}
func envIntOrDefault(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
func pickAllowedAvatarFormat(raw string) string {
allowed := map[string]bool{
"webp": true,
"avif": true,
"png": true,
"jpeg": true,
"jpg": true,
}
for _, part := range strings.Split(raw, ",") {
candidate := strings.ToLower(strings.TrimSpace(part))
if candidate == "jpg" {
candidate = "jpeg"
}
if allowed[candidate] {
return candidate
}
}
return "avif"
}
func avatarFormatToExt(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "jpeg", "jpg":
return ".jpg"
case "png":
return ".png"
case "webp":
return ".webp"
default:
return ".avif"
}
}
func avatarURLToLocalPath(avatarURL string) (string, bool) {
cleanURL := filepath.Clean(strings.TrimSpace(avatarURL))
if !strings.HasPrefix(cleanURL, "/uploads/avatars/") {
return "", false
}
localPath := filepath.Clean(strings.TrimPrefix(cleanURL, "/"))
base := filepath.Clean(filepath.Join("uploads", "avatars"))
if !strings.HasPrefix(localPath, base+string(os.PathSeparator)) {
return "", false
}
return localPath, true
}
func deleteLocalAvatarByURL(avatarURL string) error {
localPath, ok := avatarURLToLocalPath(avatarURL)
if !ok {
return nil
}
err := os.Remove(localPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
const (
providerGoogle = "google"
providerGitHub = "github"
)
var (
googleUserInfoURL = "https://openidconnect.googleapis.com/v1/userinfo"
githubUserURL = "https://api.github.com/user"
githubEmailsURL = "https://api.github.com/user/emails"
socialHTTPClient = &http.Client{Timeout: 10 * time.Second}
)
type socialIdentity struct {
Provider string
ProviderID string
Email string
Username string
FirstName string
LastName string
AvatarURL string
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func splitName(full string) (string, string) {
full = strings.TrimSpace(full)
if full == "" {
return "", ""
}
parts := strings.Fields(full)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
func normalizeUsernameCandidate(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return "user"
}
var b strings.Builder
for _, r := range raw {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '_' || r == '.' || r == '-':
b.WriteRune(r)
}
}
result := b.String()
if result == "" {
return "user"
}
if len(result) < 3 {
return result + "_01"
}
return result
}
func uniqueUsername(tx *gorm.DB, base string) (string, error) {
candidate := normalizeUsernameCandidate(base)
for i := 0; i < 100; i++ {
attempt := candidate
if i > 0 {
attempt = fmt.Sprintf("%s_%d", candidate, i)
}
var count int64
if err := tx.Model(&models.User{}).Where("user_name = ?", attempt).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return attempt, nil
}
}
return "", errors.New("benzersiz username uretilemedi")
}
func ensureProfile(tx *gorm.DB, userID uint64, firstName, lastName, avatarURL string) error {
var profile models.Profile
err := tx.Where("user_id = ?", userID).First(&profile).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
profile = models.Profile{
UserID: userID,
FirstName: firstName,
LastName: lastName,
AvatarURL: avatarURL,
}
return tx.Create(&profile).Error
}
if err != nil {
return err
}
changed := false
if profile.FirstName == "" && firstName != "" {
profile.FirstName = firstName
changed = true
}
if profile.LastName == "" && lastName != "" {
profile.LastName = lastName
changed = true
}
if avatarURL != "" && profile.AvatarURL != avatarURL {
profile.AvatarURL = avatarURL
changed = true
}
if changed {
return tx.Save(&profile).Error
}
return nil
}
func socialRequest(token, endpoint string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "ginimageApi/1.0")
return req, nil
}
func decodeSocialBody(resp *http.Response, out any) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("provider response status=%d", resp.StatusCode)
}
if err := json.Unmarshal(body, out); err != nil {
return err
}
return nil
}
func fetchGoogleIdentity(accessToken string) (socialIdentity, error) {
req, err := socialRequest(accessToken, googleUserInfoURL)
if err != nil {
return socialIdentity{}, err
}
resp, err := socialHTTPClient.Do(req)
if err != nil {
return socialIdentity{}, err
}
defer func() { _ = resp.Body.Close() }()
var payload struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := decodeSocialBody(resp, &payload); err != nil {
return socialIdentity{}, err
}
if payload.Sub == "" || payload.Email == "" {
return socialIdentity{}, errors.New("google kimlik bilgisi eksik")
}
if !payload.EmailVerified {
return socialIdentity{}, errors.New("google e-posta dogrulanmamis")
}
firstName := payload.GivenName
lastName := payload.FamilyName
if firstName == "" && lastName == "" {
firstName, lastName = splitName(payload.Name)
}
username := strings.Split(payload.Email, "@")[0]
return socialIdentity{
Provider: providerGoogle,
ProviderID: payload.Sub,
Email: strings.ToLower(strings.TrimSpace(payload.Email)),
Username: username,
FirstName: firstName,
LastName: lastName,
AvatarURL: payload.Picture,
}, nil
}
func fetchGitHubPrimaryEmail(accessToken string) (string, error) {
req, err := socialRequest(accessToken, githubEmailsURL)
if err != nil {
return "", err
}
resp, err := socialHTTPClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
Visibility string `json:"visibility"`
}
if err := decodeSocialBody(resp, &emails); err != nil {
return "", err
}
for _, e := range emails {
if e.Primary && e.Verified && e.Email != "" {
return strings.ToLower(strings.TrimSpace(e.Email)), nil
}
}
for _, e := range emails {
if e.Verified && e.Email != "" {
return strings.ToLower(strings.TrimSpace(e.Email)), nil
}
}
return "", errors.New("github verified email bulunamadi")
}
func fetchGitHubIdentity(accessToken string) (socialIdentity, error) {
req, err := socialRequest(accessToken, githubUserURL)
if err != nil {
return socialIdentity{}, err
}
resp, err := socialHTTPClient.Do(req)
if err != nil {
return socialIdentity{}, err
}
defer func() { _ = resp.Body.Close() }()
var payload struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
if err := decodeSocialBody(resp, &payload); err != nil {
return socialIdentity{}, err
}
if payload.ID == 0 {
return socialIdentity{}, errors.New("github kimlik bilgisi eksik")
}
email := strings.ToLower(strings.TrimSpace(payload.Email))
if email == "" {
email, err = fetchGitHubPrimaryEmail(accessToken)
if err != nil {
return socialIdentity{}, err
}
}
firstName, lastName := splitName(payload.Name)
username := payload.Login
if username == "" {
username = strings.Split(email, "@")[0]
}
return socialIdentity{
Provider: providerGitHub,
ProviderID: strconv.FormatInt(payload.ID, 10),
Email: email,
Username: username,
FirstName: firstName,
LastName: lastName,
AvatarURL: payload.AvatarURL,
}, nil
}
func upsertSocialUser(identity socialIdentity) (models.User, bool, error) {
var resultUser models.User
isNewUser := false
err := configs.DB.Transaction(func(tx *gorm.DB) error {
var social models.SocialAccount
err := tx.Where("provider = ? AND provider_id = ?", identity.Provider, identity.ProviderID).First(&social).Error
if err == nil {
if err := tx.First(&resultUser, social.UserID).Error; err != nil {
return err
}
social.Email = identity.Email
social.Name = strings.TrimSpace(identity.FirstName + " " + identity.LastName)
social.AvatarURL = identity.AvatarURL
if err := tx.Save(&social).Error; err != nil {
return err
}
if resultUser.EmailVerified == nil || !*resultUser.EmailVerified || resultUser.IsActive == nil || !*resultUser.IsActive {
now := time.Now()
resultUser.EmailVerified = boolPtr(true)
resultUser.IsActive = boolPtr(true)
resultUser.EmailVerifiedAt = &now
resultUser.EmailVerifyToken = ""
if err := tx.Save(&resultUser).Error; err != nil {
return err
}
}
return ensureProfile(tx, uint64(resultUser.ID), identity.FirstName, identity.LastName, identity.AvatarURL)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("email = ?", identity.Email).First(&resultUser).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
username, err := uniqueUsername(tx, identity.Username)
if err != nil {
return err
}
now := time.Now()
resultUser = models.User{
UserName: username,
Email: identity.Email,
EmailVerified: boolPtr(true),
EmailVerifiedAt: &now,
IsActive: boolPtr(true),
IsAdmin: boolPtr(false),
EmailVerifyToken: "",
}
if err := tx.Create(&resultUser).Error; err != nil {
return err
}
isNewUser = true
} else {
now := time.Now()
resultUser.EmailVerified = boolPtr(true)
resultUser.IsActive = boolPtr(true)
resultUser.EmailVerifiedAt = &now
resultUser.EmailVerifyToken = ""
if err := tx.Save(&resultUser).Error; err != nil {
return err
}
}
social = models.SocialAccount{
UserID: uint64(resultUser.ID),
Provider: identity.Provider,
ProviderID: identity.ProviderID,
Email: identity.Email,
Name: strings.TrimSpace(identity.FirstName + " " + identity.LastName),
AvatarURL: identity.AvatarURL,
}
if err := tx.Where("provider = ? AND provider_id = ?", identity.Provider, identity.ProviderID).FirstOrCreate(&social).Error; err != nil {
return err
}
return ensureProfile(tx, uint64(resultUser.ID), identity.FirstName, identity.LastName, identity.AvatarURL)
})
if err != nil {
return models.User{}, false, err
}
return resultUser, isNewUser, nil
}
func tokenFingerprint(token string) string {
if len(token) <= 10 {
return token
}
return token[:6] + "..." + token[len(token)-4:]
}
func randomTokenHex(size int) (string, error) {
b := make([]byte, size)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func issueTokens(user models.User, userAgent, ip string) (string, string, string, error) {
return issueTokensWithSessionTTL(user, userAgent, ip, 0)
}
func issueTokensWithSessionTTL(user models.User, userAgent, ip string, sessionTTL time.Duration) (string, string, string, error) {
accessTTL := middleware.AccessTokenTTL()
refreshTTL := middleware.RefreshTokenExpiry()
var sessionExpiresAt *time.Time
if sessionTTL > 0 {
exp := time.Now().Add(sessionTTL)
sessionExpiresAt = &exp
if sessionTTL < accessTTL {
accessTTL = sessionTTL
}
if sessionTTL < refreshTTL {
refreshTTL = sessionTTL
}
}
accessToken, err := middleware.GenerateAccessToken(user.ID, user.Email, user.UserName, accessTTL)
if err != nil {
return "", "", "", err
}
refreshToken, tokenID, err := middleware.GenerateRefreshToken(user.ID, refreshTTL)
if err != nil {
return "", "", "", err
}
refreshRecord := models.RefreshToken{
UserID: uint64(user.ID),
TokenID: tokenID,
TokenHash: hashToken(refreshToken),
TokenFingerprint: tokenFingerprint(refreshToken),
ExpiresAt: time.Now().Add(refreshTTL),
SessionExpiresAt: sessionExpiresAt,
Revoked: false,
UserAgent: userAgent,
IP: ip,
}
if err := configs.DB.Create(&refreshRecord).Error; err != nil {
return "", "", "", err
}
return accessToken, refreshToken, tokenID, nil
}
// Register godoc
// @Summary Kullanici kaydi olusturur
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Kayit verisi"
// @Success 201 {object} RegisterResponse
// @Failure 400 {object} ErrorResponse
// @Failure 409 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/register [post]
func Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var exists models.User
err := configs.DB.Where("email = ?", req.Email).First(&exists).Error
if err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "email zaten kayitli"})
return
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"})
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"})
return
}
user := models.User{
UserName: req.Username,
Email: req.Email,
Password: string(hashedPassword),
EmailVerified: boolPtr(false),
IsActive: boolPtr(false),
IsAdmin: boolPtr(false),
}
verificationToken, err := randomTokenHex(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "dogrulama token olusturulamadi"})
return
}
user.EmailVerifyToken = hashToken(verificationToken)
err = configs.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
return ensureProfile(tx, uint64(user.ID), req.FirstName, req.LastName, "")
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici olusturulamadi"})
return
}
verifyURL := fmt.Sprintf("/api/v1/auth/verify-email?token=%s", url.QueryEscape(verificationToken))
log.Printf("email verify link: email=%s link=%s", user.Email, verifyURL)
c.JSON(http.StatusCreated, gin.H{
"message": "kayit basarili, hesabi aktiflestirmek icin email dogrulamasi gerekli",
"verification_url": verifyURL,
"verification_token": verificationToken,
})
}
// VerifyEmail godoc
// @Summary E-posta dogrulama tokeni ile hesabi aktif eder
// @Tags auth
// @Produce json
// @Param token query string true "Dogrulama tokeni"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/verify-email [get]
func VerifyEmail(c *gin.Context) {
var req verifyEmailRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenHash := hashToken(req.Token)
var user models.User
if err := configs.DB.Where("email_verify_token = ?", tokenHash).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "dogrulama token gecersiz"})
return
}
now := time.Now()
user.EmailVerified = boolPtr(true)
user.IsActive = boolPtr(true)
user.EmailVerifiedAt = &now
user.EmailVerifyToken = ""
if err := configs.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hesap aktif edilemedi"})
return
}
accessToken, refreshToken, _, err := issueTokens(user, c.Request.UserAgent(), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"})
return
}
c.JSON(http.StatusOK, TokenResponse{AccessToken: accessToken, RefreshToken: refreshToken})
}
// Login godoc
// @Summary Kullanici girisi yapar
// @Tags auth
// @Accept json
// @Produce json
// @Param request body LoginRequest true "Giris verisi"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/login [post]
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := configs.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "gecersiz eposta veya sifre"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "gecersiz eposta veya sifre"})
return
}
if user.IsActive == nil || !*user.IsActive || user.EmailVerified == nil || !*user.EmailVerified {
c.JSON(http.StatusForbidden, gin.H{"error": "hesap aktif degil, e-posta dogrulamasi gerekli"})
return
}
accessToken, refreshToken, _, err := issueTokens(user, c.Request.UserAgent(), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"})
return
}
c.JSON(http.StatusOK, TokenResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
})
}
// Refresh godoc
// @Summary Refresh token ile yeni token uretir
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RefreshRequest true "Refresh token"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/refresh [post]
func Refresh(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash := hashToken(req.RefreshToken)
var current models.RefreshToken
if err := configs.DB.Where("token_hash = ?", hash).First(&current).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token gecersiz"})
return
}
if current.Revoked || time.Now().After(current.ExpiresAt) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token suresi dolmus veya iptal edilmis"})
return
}
if current.SessionExpiresAt != nil && time.Now().After(*current.SessionExpiresAt) {
current.Revoked = true
_ = configs.DB.Save(&current).Error
c.JSON(http.StatusUnauthorized, gin.H{"error": "oturum suresi doldu, yeniden giris gerekli"})
return
}
var user models.User
if err := configs.DB.First(&user, current.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"})
return
}
var sessionTTL time.Duration
if current.SessionExpiresAt != nil {
sessionTTL = time.Until(*current.SessionExpiresAt)
if sessionTTL <= 0 {
current.Revoked = true
_ = configs.DB.Save(&current).Error
c.JSON(http.StatusUnauthorized, gin.H{"error": "oturum suresi doldu, yeniden giris gerekli"})
return
}
}
newAccessToken, newRefreshToken, newTokenID, err := issueTokensWithSessionTTL(user, c.Request.UserAgent(), c.ClientIP(), sessionTTL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token yenilenemedi"})
return
}
current.Revoked = true
current.ReplacedByTokenID = newTokenID
if err := configs.DB.Save(&current).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "eski token iptal edilemedi"})
return
}
c.JSON(http.StatusOK, TokenResponse{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
})
}
func socialLogin(c *gin.Context, provider string) {
var req socialLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var (
identity socialIdentity
err error
)
switch provider {
case providerGoogle:
identity, err = fetchGoogleIdentity(req.AccessToken)
case providerGitHub:
identity, err = fetchGitHubIdentity(req.AccessToken)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "desteklenmeyen provider"})
return
}
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "provider token gecersiz: " + err.Error()})
return
}
user, newUser, err := upsertSocialUser(identity)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "sosyal giris tamamlanamadi"})
return
}
accessToken, refreshToken, _, err := issueTokens(user, c.Request.UserAgent(), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"})
return
}
c.JSON(http.StatusOK, SocialTokenResponse{
Message: "giris basarili",
Provider: provider,
NewUser: newUser,
AccessToken: accessToken,
RefreshToken: refreshToken,
})
}
// GoogleLogin godoc
// @Summary Google access token ile giris veya kayit yapar
// @Tags auth
// @Accept json
// @Produce json
// @Param request body SocialLoginRequest true "Google access token"
// @Success 200 {object} SocialTokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/social/google [post]
func GoogleLogin(c *gin.Context) {
socialLogin(c, providerGoogle)
}
// GitHubLogin godoc
// @Summary GitHub access token ile giris veya kayit yapar
// @Tags auth
// @Accept json
// @Produce json
// @Param request body SocialLoginRequest true "GitHub access token"
// @Success 200 {object} SocialTokenResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/auth/social/github [post]
func GitHubLogin(c *gin.Context) {
socialLogin(c, providerGitHub)
}
// Me godoc
// @Summary Giris yapan kullanicinin bilgilerini doner
// @Tags users
// @Produce json
// @Security BearerAuth
// @Success 200 {object} MeResponse
// @Failure 401 {object} ErrorResponse
// @Router /api/v1/me [get]
func Me(c *gin.Context) {
userID, _ := c.Get("user_id")
email, _ := c.Get("email")
username, _ := c.Get("username")
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"email": email,
"username": username,
})
}
// GetMyProfile godoc
// @Summary Giris yapan kullanicinin profilini getirir
// @Tags users
// @Produce json
// @Security BearerAuth
// @Success 200 {object} ProfileResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/me/profile [get]
func GetMyProfile(c *gin.Context) {
userID, err := currentUserID(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
profile, err := getOrCreateProfileForUser(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil okunamadi"})
return
}
c.JSON(http.StatusOK, ProfileResponse{
UserID: profile.UserID,
FirstName: profile.FirstName,
LastName: profile.LastName,
AvatarURL: profile.AvatarURL,
})
}
// UpdateMyProfile godoc
// @Summary Giris yapan kullanicinin profilini gunceller
// @Tags users
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param first_name formData string false "Ad"
// @Param last_name formData string false "Soyad"
// @Param avatar formData file false "Avatar dosyasi"
// @Success 200 {object} ProfileResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /api/v1/me/profile [put]
func UpdateMyProfile(c *gin.Context) {
startedAt := time.Now()
userID, err := currentUserID(c)
if err != nil {
log.Printf("[PROFILE-UPDATE] result=unauthorized error=%v", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
var req profileUpdateRequest
// Content-Type ne olursa olsun form alanlarını oku (multipart veya url-encoded)
// JSON body gelse dahi form tag'leri üzerinden bağlanır, eksik alan hata değil.
_ = c.ShouldBind(&req)
profile, err := getOrCreateProfileForUser(userID)
if err != nil {
log.Printf("[PROFILE-UPDATE] user_id=%d result=failed stage=load_profile error=%v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil okunamadi"})
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 {
log.Printf("[PROFILE-UPDATE] user_id=%d result=failed stage=avatar_process error=%v", userID, err)
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)
}
log.Printf("[PROFILE-UPDATE] user_id=%d result=failed stage=save_profile error=%v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "profil guncellenemedi"})
return
}
if hasAvatar && oldAvatarURL != "" && oldAvatarURL != profile.AvatarURL {
if err := deleteLocalAvatarByURL(oldAvatarURL); err != nil {
log.Printf("[PROFILE-UPDATE] user_id=%d result=warn stage=delete_old_avatar error=%v", userID, err)
}
}
log.Printf(
"[PROFILE-UPDATE] user_id=%d result=success has_first_name=%t has_last_name=%t has_avatar=%t duration_ms=%d",
userID,
req.FirstName != "",
req.LastName != "",
hasAvatar,
time.Since(startedAt).Milliseconds(),
)
c.JSON(http.StatusOK, ProfileResponse{
UserID: profile.UserID,
FirstName: profile.FirstName,
LastName: profile.LastName,
AvatarURL: profile.AvatarURL,
})
}
// MakeAdmin godoc
// @Summary Kullanicinin admin yetkisini gunceller
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Kullanici ID"
// @Param request body adminRequest true "Admin durumu"
// @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/users/{id}/admin [post]
func MakeAdmin(c *gin.Context) {
var req adminRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
var user models.User
if err := configs.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"})
return
}
user.IsAdmin = boolPtr(req.IsAdmin)
if err := configs.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici guncellenemedi"})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("kullanici admin=%v olarak guncellendi", req.IsAdmin)})
}