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(¤t).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(¤t).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(¤t).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(¤t).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)}) }