1282 lines
34 KiB
Go
1282 lines
34 KiB
Go
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)})
|
||
}
|