commit e04ba855649d00180091747bdc326672b353fe8b Author: Beyhan Oğur Date: Sun Apr 26 21:40:14 2026 +0300 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ed479c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f45cdd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.idea +.cursor +tmp +agent-transcripts +.env +.env.* +*.md +ginimageApi diff --git a/.env b/.env new file mode 100644 index 0000000..2365c19 --- /dev/null +++ b/.env @@ -0,0 +1,56 @@ +# Database Settings (Mysql) +DB_HOST=10.80.80.70 +DB_PORT=3306 +DB_USER=gin_img +DB_PASSWORD=gg7678290 +DB_NAME=gin_img +# JWT Settings (Jwt) +JWT_SECRET=ares-gid-k1Obxl3kDRMtZ5cs9lvFTh73r5WjfF32ZhakPG6fBDYQmPvzkwsK2rHlaaP2YDmy +JWT_REFRESH_SECRET=ares-gin-VUCRBBPbkg2lVVhDdzSHGdAXzkThPlD2Ri8LDJEomu1kXUR58ZE1KHJliaYlxIyx +# Database Settings (Redis) +REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/3 +# Server Settings (Gin) +PORT=8080 +# Email Settings (Mailpit) +EMAIL_HOST=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local +# Social Auth (Google) +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY='915364976256-691m0s87as2r5vdbqr96f6humblseobt.apps.googleusercontent.com' # Your Google Client ID +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET='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' # Your Google Client Secret +SOCIAL_AUTH_GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback +# Social Auth (GitHub) +SOCIAL_AUTH_GITHUB_KEY='Ov23liUt9B61O46Mdfm4' # Your GitHub Client ID +SOCIAL_AUTH_GITHUB_SECRET='c7fc8dcb1b2c8f22120608425d07d5efd995baaf' # Your GitHub Client Secret +SOCIAL_AUTH_GITHUB_REDIRECT_URL=http://localhost:8080/auth/github/callback + +# CORS bootstrap seeds (comma separated origins) +# Example: CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,https://admin.example.com +CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173,https://admin.goares.com +# Example: CORS_BOOTSTRAP_BLACKLIST_ORIGINS=https://bad.example.com,https://spam.example.com +CORS_BOOTSTRAP_BLACKLIST_ORIGINS=https://spam.goares.com,https://blocked-client.example + +# Rate-limit bootstrap seeds +RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10 +RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60 +RL_BOOTSTRAP_REGISTER_MAX_REQUESTS=5 +RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS=60 +RL_BOOTSTRAP_API_MAX_REQUESTS=120 +RL_BOOTSTRAP_API_WINDOW_SECONDS=60 + +# Dynamic policy debug logs +# true/false +CORS_DEBUG=true +RATE_LIMIT_DEBUG=true +GIN_MODE=debug +GINIMAGE_API_BASE_URL=http://localhost:8080 + +AVATAR_WIDTH=150 +AVATAR_HEIGHT=150 +AVATAR_QUALITY=85 +AVATAR_MAX_SIZE_MB=5 +AVATAR_FORMATS=avif \ No newline at end of file diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..58fd2df --- /dev/null +++ b/.env.docker @@ -0,0 +1,26 @@ +DB_HOST=mysql +DB_PORT=3306 +DB_USER=gin_img +DB_PASSWORD=gin_img_pass +DB_NAME=gin_img + +JWT_SECRET=local-docker-jwt-secret-change-me +JWT_REFRESH_SECRET=local-docker-refresh-secret-change-me + +REDIS_URL=redis://default:redispass@redis:6379/3 + +PORT=8080 +GIN_MODE=release + +CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_BOOTSTRAP_BLACKLIST_ORIGINS= + +RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10 +RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60 +RL_BOOTSTRAP_REGISTER_MAX_REQUESTS=5 +RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS=60 +RL_BOOTSTRAP_API_MAX_REQUESTS=120 +RL_BOOTSTRAP_API_WINDOW_SECONDS=60 + +CORS_DEBUG=false +RATE_LIMIT_DEBUG=false diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..5a7f7b3 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,26 @@ +DB_HOST=mysql +DB_PORT=3306 +DB_USER=gin_img +DB_PASSWORD=gin_img_pass +DB_NAME=gin_img + +JWT_SECRET=change-this-jwt-secret +JWT_REFRESH_SECRET=change-this-refresh-secret + +REDIS_URL=redis://default:redispass@redis:6379/3 + +PORT=8080 +GIN_MODE=release + +CORS_BOOTSTRAP_WHITELIST_ORIGINS=http://localhost:3000,http://localhost:5173 +CORS_BOOTSTRAP_BLACKLIST_ORIGINS= + +RL_BOOTSTRAP_LOGIN_MAX_REQUESTS=10 +RL_BOOTSTRAP_LOGIN_WINDOW_SECONDS=60 +RL_BOOTSTRAP_REGISTER_MAX_REQUESTS=5 +RL_BOOTSTRAP_REGISTER_WINDOW_SECONDS=60 +RL_BOOTSTRAP_API_MAX_REQUESTS=120 +RL_BOOTSTRAP_API_WINDOW_SECONDS=60 + +CORS_DEBUG=false +RATE_LIMIT_DEBUG=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fdde10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +static/Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV +tmp/main diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/ginimageApi.iml b/.idea/ginimageApi.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/ginimageApi.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..1000530 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/COPILOT_RULES.md b/COPILOT_RULES.md new file mode 100644 index 0000000..225600b --- /dev/null +++ b/COPILOT_RULES.md @@ -0,0 +1,41 @@ +# Copilot Development Rules + +## Genel Kurallar +- Kodlar mevcut proje yapısına uygun olmalı. +- Gin + Gorm mimarisine sadık kalınmalı. +- Gereksiz paket eklenmemeli. +- Fonksiyonlar küçük, net ve test edilebilir olmalı. +- Tüm endpointler standart HTTP semantiğine uygun olmalı. +- Hata mesajları tutarlı ve anlaşılır olmalı. + +## Mimari Kurallar +- Handler katmanı sadece HTTP ile ilgilenmeli. +- DB işlemleri Gorm üzerinden yapılmalı. +- Gerekirse DTO/request struct kullanılmalı. +- Model alanları ile response alanları ayrılmalı. +- Şifre hash’i asla response içinde dönmemeli. + +## Admin User Yönetimi Kuralları +- Admin endpointleri `/admin/users` altında toplanmalı. +- Listeleme, detay, oluşturma, güncelleme, durum değiştirme ve silme desteklenmeli. +- Kullanıcı listesinde pagination ve search desteği tercih edilmeli. +- Yetkisiz erişim engellenmeli. +- Eğer rol sistemi yoksa, en az müdahaleyle admin kontrolü eklenmeli. + +## Güvenlik Kuralları +- Password düz metin saklanmamalı. +- Password hash işlemi `bcrypt` ile yapılmalı. +- Oluşturma ve güncellemede input validation yapılmalı. +- Sensitive alanlar response’a eklenmemeli. +- JWT veya mevcut auth sistemiyle uyum sağlanmalı. + +## Router Kuralları +- Route tanımları `router/router.go` içinden yapılmalı. +- Mevcut routing düzeni bozulmamalı. +- Admin route grubu middleware ile korunmalı. + +## Kod Kalitesi +- İsimler açık ve tutarlı olmalı. +- Hatalar `400/401/403/404/500` gibi uygun kodlarla dönmeli. +- Gereksiz tekrar engellenmeli. +- Gerekirse helper fonksiyonlar kullanılmalı. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77a49db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +ARG GO_VERSION=1.26.2 + +FROM golang:${GO_VERSION}-bookworm AS builder + +WORKDIR /src + +# bimg uses CGO and needs libvips headers while building. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + pkg-config \ + libvips-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /bin/ginimageapi . + +FROM debian:bookworm-slim AS runtime + +WORKDIR /app + +# libvips runtime is required by bimg. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libvips \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /bin/ginimageapi /app/ginimageapi +COPY --from=builder /src/static /app/static + +EXPOSE 8080 + +CMD ["/app/ginimageapi"] diff --git a/Prompt.md b/Prompt.md new file mode 100644 index 0000000..86f183e --- /dev/null +++ b/Prompt.md @@ -0,0 +1,73 @@ +You are working in a Go project named ginimageApi. + +Goal: +Implement admin panel user management endpoints for the existing application structure. + +Project details: +- Go version: 1.26 +- Framework: Gin +- ORM: Gorm +- Existing packages already installed include gin, gorm, mysql/sqlite drivers, jwt, redis, swagger, bcrypt/crypto, etc. + +Current structure: +- main.go +- .env +- app/ + - accounts/ + - handlers/user.go + - models/accounts.go + - settings/ + - handlers/settings.go + - models/setting.go + - models/hero.go + - models/cors.go + - shop/ + - handlers/shop.go + - models/product.go + - models/cart.go + - blog/ + - handlers/blog.go + - models/blog.go +- config/ + - db.go + - redis.go +- router/router.go + +Task: +Create admin user management endpoints suitable for an admin panel. Use the existing architecture and keep the implementation consistent with the project structure. + +Expected endpoints: +- GET /admin/users +- GET /admin/users/:id +- POST /admin/users +- PUT /admin/users/:id +- PATCH /admin/users/:id/status +- DELETE /admin/users/:id + +Requirements: +- Follow Gin handler patterns already used in the project. +- Use Gorm for database operations. +- Add request/response DTOs if needed. +- Validate inputs properly. +- Hash passwords securely. +- Do not return sensitive fields such as password hashes. +- Add middleware/auth guard assumptions if needed, but keep integration minimal and compatible with the existing codebase. +- Keep code clean, modular, and idiomatic. +- If admin role support does not exist yet, introduce it in the least disruptive way. +- Prepare router wiring under the existing router structure. +- If model changes are required, update the relevant accounts model carefully. +- If migrations are needed, provide the code changes in a way compatible with SQLite and MySQL. + +Implementation notes: +- Prefer small, focused changes. +- Reuse existing user/account model if possible. +- Include filtering, pagination, and search support in list endpoint if practical. +- Make sure error responses are consistent. +- If helpful, add service/helper functions, but keep the structure aligned with the current app layout. + +Deliverables: +- Updated model(s) if needed +- New/updated admin user handler(s) +- Router registration +- Any supporting helper/DTO code +- Brief documentation comments if useful \ No newline at end of file diff --git a/app/accounts/handlers/admin_users.go b/app/accounts/handlers/admin_users.go new file mode 100644 index 0000000..769a975 --- /dev/null +++ b/app/accounts/handlers/admin_users.go @@ -0,0 +1,709 @@ +package handlers + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + "time" + + "ginimageApi/app/accounts/models" + "ginimageApi/app/middleware" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type adminUserResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type adminUserListResponse struct { + Items []adminUserResponse `json:"items"` + Meta paginationMeta `json:"meta"` +} + +type paginationMeta struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int64 `json:"total"` +} + +type adminCreateUserRequest struct { + Username string `json:"username" binding:"required,min=3"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"` + IsAdmin *bool `json:"is_admin"` + IsActive *bool `json:"is_active"` +} + +type adminUpdateUserRequest struct { + Username string `json:"username" binding:"omitempty,min=3"` + Email string `json:"email" binding:"omitempty,email"` + Password string `json:"password" binding:"omitempty,min=6"` + IsAdmin *bool `json:"is_admin"` + IsActive *bool `json:"is_active"` +} + +type adminUserStatusRequest struct { + IsActive bool `json:"is_active" binding:"required"` +} + +type adminUpdateProfileRequest struct { + FirstName string `form:"first_name" binding:"omitempty,min=2"` + LastName string `form:"last_name" binding:"omitempty,min=2"` +} + +type adminProfileResponse struct { + UserID uint64 `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + AvatarURL string `json:"avatar_url"` +} + +type adminIssueTokenRequest struct { + DurationDays int `json:"duration_days" binding:"required,min=1,max=365"` +} + +type adminIssueTokenResponse struct { + AccessToken string `json:"access"` + ExpiresAt string `json:"expires_at"` +} + +type AdminUserResponse = adminUserResponse +type AdminUserListResponse = adminUserListResponse +type AdminCreateUserRequest = adminCreateUserRequest +type AdminUpdateUserRequest = adminUpdateUserRequest +type AdminUserStatusRequest = adminUserStatusRequest +type AdminUpdateProfileRequest = adminUpdateProfileRequest +type AdminProfileResponse = adminProfileResponse +type AdminIssueTokenRequest = adminIssueTokenRequest +type AdminIssueTokenResponse = adminIssueTokenResponse + +func adminActorID(c *gin.Context) any { + actorID, ok := c.Get("user_id") + if !ok { + return "unknown" + } + return actorID +} + +func maskEmail(email string) string { + email = strings.TrimSpace(strings.ToLower(email)) + parts := strings.Split(email, "@") + if len(parts) != 2 || parts[0] == "" { + return "invalid-email" + } + local := parts[0] + domain := parts[1] + if len(local) <= 2 { + return local[:1] + "***@" + domain + } + return local[:2] + "***@" + domain +} + +func getOrCreateProfileByUserID(userID uint64) (models.Profile, error) { + var profile models.Profile + err := configs.DB.Where("user_id = ?", userID).First(&profile).Error + if err == nil { + return profile, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return models.Profile{}, err + } + + profile = models.Profile{UserID: userID} + if err := configs.DB.Create(&profile).Error; err != nil { + return models.Profile{}, err + } + + return profile, nil +} + +// ListAdminUsers godoc +// @Summary Admin kullanicilari listeler +// @Tags admin-users +// @Produce json +// @Security BearerAuth +// @Param page query int false "Sayfa numarasi" default(1) +// @Param limit query int false "Sayfa boyutu (max 100)" default(10) +// @Param search query string false "Kullanici adi/email arama" +// @Param is_admin query bool false "Admin filtresi" +// @Param is_active query bool false "Aktiflik filtresi" +// @Success 200 {object} AdminUserListResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users [get] +func ListAdminUsers(c *gin.Context) { + page := parsePositiveIntOrDefault(c.Query("page"), 1) + limit := parsePositiveIntOrDefault(c.Query("limit"), 10) + if limit > 100 { + limit = 100 + } + + search := strings.TrimSpace(c.Query("search")) + isAdminFilter := strings.TrimSpace(c.Query("is_admin")) + isActiveFilter := strings.TrimSpace(c.Query("is_active")) + + query := configs.DB.Model(&models.User{}) + + if search != "" { + like := "%" + strings.ToLower(search) + "%" + query = query.Where("LOWER(user_name) LIKE ? OR LOWER(email) LIKE ?", like, like) + } + + if v, ok := parseOptionalBool(isAdminFilter); ok { + query = query.Where("is_admin = ?", v) + } + if v, ok := parseOptionalBool(isActiveFilter); ok { + query = query.Where("is_active = ?", v) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"}) + return + } + + var users []models.User + if err := query. + Order("id DESC"). + Offset((page - 1) * limit). + Limit(limit). + Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanicilar listelenemedi"}) + return + } + + items := make([]adminUserResponse, 0, len(users)) + for _, user := range users { + items = append(items, toAdminUserResponse(user)) + } + + c.JSON(http.StatusOK, adminUserListResponse{ + Items: items, + Meta: paginationMeta{ + Page: page, + Limit: limit, + Total: total, + }, + }) +} + +// GetAdminUser godoc +// @Summary Admin panel icin kullanici detayi getirir +// @Tags admin-users +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Success 200 {object} AdminUserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id} [get] +func GetAdminUser(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + + c.JSON(http.StatusOK, toAdminUserResponse(user)) +} + +// CreateAdminUser godoc +// @Summary Admin panel icin kullanici olusturur +// @Tags admin-users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body AdminCreateUserRequest true "Kullanici olusturma verisi" +// @Success 201 {object} AdminUserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users [post] +func CreateAdminUser(c *gin.Context) { + log.Printf("[ADMIN-USER-CREATE] stage=start actor_id=%v ip=%s ua=%q", adminActorID(c), c.ClientIP(), c.Request.UserAgent()) + + var req adminCreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("[ADMIN-USER-CREATE] stage=bind_failed actor_id=%v ip=%s error=%q", adminActorID(c), c.ClientIP(), err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + log.Printf( + "[ADMIN-USER-CREATE] stage=payload_ok actor_id=%v username=%q email=%q is_admin=%v is_active=%v", + adminActorID(c), + req.Username, + maskEmail(req.Email), + req.IsAdmin, + req.IsActive, + ) + + var exists models.User + err := configs.DB.Where("email = ?", req.Email).First(&exists).Error + if err == nil { + log.Printf( + "[ADMIN-USER-CREATE] stage=conflict actor_id=%v reason=email_exists incoming_email=%q existing_user_id=%d existing_username=%q existing_active=%v existing_admin=%v", + adminActorID(c), + maskEmail(req.Email), + exists.ID, + exists.UserName, + exists.IsActive != nil && *exists.IsActive, + exists.IsAdmin != nil && *exists.IsAdmin, + ) + c.JSON(http.StatusConflict, gin.H{ + "error": "email zaten kayitli", + "code": "EMAIL_ALREADY_EXISTS", + }) + return + } + if err != nil && err != gorm.ErrRecordNotFound { + log.Printf("[ADMIN-USER-CREATE] stage=check_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"}) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("[ADMIN-USER-CREATE] stage=hash_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"}) + return + } + + isAdmin := false + if req.IsAdmin != nil { + isAdmin = *req.IsAdmin + } + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + + user := models.User{ + UserName: req.Username, + Email: req.Email, + Password: string(hashedPassword), + EmailVerified: boolPtr(false), + IsActive: boolPtr(isActive), + IsAdmin: boolPtr(isAdmin), + } + + if err := configs.DB.Create(&user).Error; err != nil { + log.Printf("[ADMIN-USER-CREATE] stage=create_failed actor_id=%v email=%q error=%q", adminActorID(c), maskEmail(req.Email), err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici olusturulamadi"}) + return + } + log.Printf("[ADMIN-USER-CREATE] stage=success actor_id=%v created_user_id=%d email=%q", adminActorID(c), user.ID, maskEmail(user.Email)) + + c.JSON(http.StatusCreated, toAdminUserResponse(user)) +} + +// UpdateAdminUser godoc +// @Summary Admin panel icin kullaniciyi gunceller +// @Tags admin-users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Param request body AdminUpdateUserRequest true "Kullanici guncelleme verisi" +// @Success 200 {object} AdminUserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id} [put] +func UpdateAdminUser(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req adminUpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + + if req.Email != "" && req.Email != user.Email { + var exists models.User + err := configs.DB.Where("email = ? AND id <> ?", req.Email, user.ID).First(&exists).Error + if err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "email zaten kayitli"}) + return + } + if err != nil && err != gorm.ErrRecordNotFound { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici kontrol edilemedi"}) + return + } + user.Email = req.Email + } + + if req.Username != "" { + user.UserName = req.Username + } + if req.IsAdmin != nil { + user.IsAdmin = boolPtr(*req.IsAdmin) + } + if req.IsActive != nil { + user.IsActive = boolPtr(*req.IsActive) + } + if req.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "sifre islenemedi"}) + return + } + user.Password = string(hashedPassword) + } + + if err := configs.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici guncellenemedi"}) + return + } + + c.JSON(http.StatusOK, toAdminUserResponse(user)) +} + +// UpdateAdminUserStatus godoc +// @Summary Admin panel icin kullanici aktiflik durumunu gunceller +// @Tags admin-users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Param request body AdminUserStatusRequest true "Durum verisi" +// @Success 200 {object} AdminUserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id}/status [patch] +func UpdateAdminUserStatus(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req adminUserStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result := configs.DB.Model(&models.User{}).Where("id = ?", userID).Update("is_active", req.IsActive) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici durumu guncellenemedi"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + + c.JSON(http.StatusOK, toAdminUserResponse(user)) +} + +// DeleteAdminUser godoc +// @Summary Admin panel icin kullanici siler +// @Tags admin-users +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Success 200 {object} MessageResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id} [delete] +func DeleteAdminUser(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + result := configs.DB.Delete(&models.User{}, userID) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici silinemedi"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "kullanici silindi"}) +} + +// IssueAdminScopedToken godoc +// @Summary Admin için gün bazlı access token üretir +// @Description Sadece admin rolü için, istekle verilen gün kadar geçerli access token üretir. Refresh token üretilmez. +// @Tags admin-users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body AdminIssueTokenRequest true "Token süresi (gün)" +// @Success 200 {object} AdminIssueTokenResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/tokens/issue [post] +func IssueAdminScopedToken(c *gin.Context) { + var req adminIssueTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, err := currentUserID(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + if user.IsAdmin == nil || !*user.IsAdmin { + c.JSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"}) + return + } + + tokenTTL := time.Duration(req.DurationDays) * 24 * time.Hour + accessToken, err := middleware.GenerateAccessToken(user.ID, user.Email, user.UserName, tokenTTL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token olusturulamadi"}) + return + } + + c.JSON(http.StatusOK, adminIssueTokenResponse{ + AccessToken: accessToken, + ExpiresAt: time.Now().Add(tokenTTL).Format(time.RFC3339), + }) +} + +// GetAdminUserProfile godoc +// @Summary Admin panel icin kullanicinin profilini getirir +// @Tags admin-users +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Success 200 {object} AdminProfileResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id}/profile [get] +func GetAdminUserProfile(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + + profile, err := getOrCreateProfileByUserID(uint64(userID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"}) + return + } + + c.JSON(http.StatusOK, adminProfileResponse{ + UserID: profile.UserID, + FirstName: profile.FirstName, + LastName: profile.LastName, + AvatarURL: profile.AvatarURL, + }) +} + +// UpdateAdminUserProfile godoc +// @Summary Admin panel icin kullanici profilini gunceller +// @Tags admin-users +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kullanici ID" +// @Param first_name formData string false "Ad" +// @Param last_name formData string false "Soyad" +// @Param avatar formData file false "Avatar dosyasi" +// @Success 200 {object} AdminProfileResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/admin/users/{id}/profile [put] +func UpdateAdminUserProfile(c *gin.Context) { + userID, ok := parseUintParam(c, "id") + if !ok { + return + } + + var req adminUpdateProfileRequest + _ = c.ShouldBind(&req) + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "kullanici bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kullanici getirilemedi"}) + return + } + + profile, err := getOrCreateProfileByUserID(uint64(userID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "profil getirilemedi"}) + return + } + + if req.FirstName != "" { + profile.FirstName = req.FirstName + } + if req.LastName != "" { + profile.LastName = req.LastName + } + oldAvatarURL := profile.AvatarURL + avatarURL, hasAvatar, err := saveAvatarFromMultipart(c, "avatar") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "avatar dosyasi okunamadi"}) + return + } + if hasAvatar { + profile.AvatarURL = avatarURL + } + + if err := configs.DB.Save(&profile).Error; err != nil { + if hasAvatar { + _ = deleteLocalAvatarByURL(avatarURL) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "profil guncellenemedi"}) + return + } + if hasAvatar && oldAvatarURL != "" && oldAvatarURL != profile.AvatarURL { + if err := deleteLocalAvatarByURL(oldAvatarURL); err != nil { + log.Printf("[ADMIN-PROFILE-UPDATE] user_id=%d result=warn stage=delete_old_avatar error=%v", userID, err) + } + } + + c.JSON(http.StatusOK, adminProfileResponse{ + UserID: profile.UserID, + FirstName: profile.FirstName, + LastName: profile.LastName, + AvatarURL: profile.AvatarURL, + }) +} + +func parsePositiveIntOrDefault(raw string, fallback int) int { + if strings.TrimSpace(raw) == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil || v <= 0 { + return fallback + } + return v +} + +func parseOptionalBool(raw string) (bool, bool) { + if strings.TrimSpace(raw) == "" { + return false, false + } + v, err := strconv.ParseBool(raw) + if err != nil { + return false, false + } + return v, true +} + +func parseUintParam(c *gin.Context, key string) (uint, bool) { + raw := strings.TrimSpace(c.Param(key)) + if raw == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"}) + return 0, false + } + + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kullanici id"}) + return 0, false + } + return uint(id), true +} + +func toAdminUserResponse(user models.User) adminUserResponse { + return adminUserResponse{ + ID: user.ID, + Username: user.UserName, + Email: user.Email, + EmailVerified: user.IsEmailVerified(), + IsActive: user.IsActive != nil && *user.IsActive, + IsAdmin: user.IsAdmin != nil && *user.IsAdmin, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/app/accounts/handlers/admin_users_test.go b/app/accounts/handlers/admin_users_test.go new file mode 100644 index 0000000..9bfff9c --- /dev/null +++ b/app/accounts/handlers/admin_users_test.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "os" + "strings" + "testing" + + "ginimageApi/app/accounts/models" + "ginimageApi/app/middleware" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" +) + +func TestAdminUserProfileGetAndUpdate(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + adminFlag := true + active := true + verified := true + adminUser := models.User{ + UserName: "admin", + Email: "admin-profile@example.com", + Password: "x", + IsAdmin: &adminFlag, + IsActive: &active, + EmailVerified: &verified, + } + if err := configs.DB.Create(&adminUser).Error; err != nil { + t.Fatalf("create admin failed: %v", err) + } + + targetFlag := false + target := models.User{ + UserName: "target", + Email: "target-profile@example.com", + Password: "x", + IsAdmin: &targetFlag, + IsActive: &active, + EmailVerified: &verified, + } + if err := configs.DB.Create(&target).Error; err != nil { + t.Fatalf("create target failed: %v", err) + } + oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_admin_target_avatar.png") + seedProfile := models.Profile{UserID: uint64(target.ID), AvatarURL: oldAvatarURL} + if err := configs.DB.Create(&seedProfile).Error; err != nil { + t.Fatalf("seed profile failed: %v", err) + } + + token, err := middleware.BuildAccessTokenForUser(adminUser) + if err != nil { + t.Fatalf("token create failed: %v", err) + } + + r := gin.New() + r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile) + r.PUT("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), UpdateAdminUserProfile) + + // Profile kaydi yoksa GET ile otomatik olusmali. + wGet := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{ + "Authorization": "Bearer " + token, + }) + if wGet.Code != http.StatusOK { + t.Fatalf("get admin profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String()) + } + + var getResp map[string]any + if err := json.Unmarshal(wGet.Body.Bytes(), &getResp); err != nil { + t.Fatalf("parse get response failed: %v", err) + } + if int(getResp["user_id"].(float64)) != int(target.ID) { + t.Fatalf("user_id mismatch in get response") + } + + wPut := performMultipart( + r, + http.MethodPut, + "/admin/users/"+toString(target.ID)+"/profile", + map[string]string{"first_name": "Admin", "last_name": "Updated"}, + "avatar", + "admin.png", + tinyPNGFixture(t), + map[string]string{"Authorization": "Bearer " + token}, + ) + if wPut.Code != http.StatusOK { + t.Fatalf("update admin profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String()) + } + + var profile models.Profile + if err := configs.DB.Where("user_id = ?", target.ID).First(&profile).Error; err != nil { + t.Fatalf("profile should exist after update: %v", err) + } + if profile.FirstName != "Admin" || profile.LastName != "Updated" { + t.Fatalf("profile name mismatch: %+v", profile) + } + if !strings.HasPrefix(profile.AvatarURL, "/uploads/avatars/") { + t.Fatalf("avatar path mismatch: %s", profile.AvatarURL) + } + if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) { + t.Fatalf("old avatar should be deleted, err=%v", err) + } +} + +func TestAdminUserProfileRequiresAdminRole(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + active := true + verified := true + nonAdminFlag := false + nonAdmin := models.User{ + UserName: "nonadmin", + Email: "nonadmin-profile@example.com", + Password: "x", + IsAdmin: &nonAdminFlag, + IsActive: &active, + EmailVerified: &verified, + } + if err := configs.DB.Create(&nonAdmin).Error; err != nil { + t.Fatalf("create non-admin failed: %v", err) + } + + target := models.User{ + UserName: "target2", + Email: "target2-profile@example.com", + Password: "x", + IsAdmin: &nonAdminFlag, + IsActive: &active, + EmailVerified: &verified, + } + if err := configs.DB.Create(&target).Error; err != nil { + t.Fatalf("create target failed: %v", err) + } + + token, err := middleware.BuildAccessTokenForUser(nonAdmin) + if err != nil { + t.Fatalf("token create failed: %v", err) + } + + r := gin.New() + r.GET("/admin/users/:id/profile", middleware.AuthRequired(), middleware.AdminRequired(), GetAdminUserProfile) + + w := performJSON(r, http.MethodGet, "/admin/users/"+toString(target.ID)+"/profile", nil, map[string]string{ + "Authorization": "Bearer " + token, + }) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin, got %d body=%s", w.Code, w.Body.String()) + } +} diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296763_5cb668f10089a3ca.png b/app/accounts/handlers/uploads/avatars/avatar_1776296763_5cb668f10089a3ca.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296763_5cb668f10089a3ca.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296764_67d69f0f6999aaaa.png b/app/accounts/handlers/uploads/avatars/avatar_1776296764_67d69f0f6999aaaa.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296764_67d69f0f6999aaaa.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296764_f4fa8fa4d04e98c5.png b/app/accounts/handlers/uploads/avatars/avatar_1776296764_f4fa8fa4d04e98c5.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296764_f4fa8fa4d04e98c5.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296786_58f92d6e12860f2c.png b/app/accounts/handlers/uploads/avatars/avatar_1776296786_58f92d6e12860f2c.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296786_58f92d6e12860f2c.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296786_9398bdafa9f773e2.png b/app/accounts/handlers/uploads/avatars/avatar_1776296786_9398bdafa9f773e2.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296786_9398bdafa9f773e2.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296786_9e139e1fcd5d25a9.png b/app/accounts/handlers/uploads/avatars/avatar_1776296786_9e139e1fcd5d25a9.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296786_9e139e1fcd5d25a9.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296908_944b6b2c5737453a.png b/app/accounts/handlers/uploads/avatars/avatar_1776296908_944b6b2c5737453a.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296908_944b6b2c5737453a.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296909_4d482be42eccb377.png b/app/accounts/handlers/uploads/avatars/avatar_1776296909_4d482be42eccb377.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296909_4d482be42eccb377.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776296909_ebb445aac9227d7b.png b/app/accounts/handlers/uploads/avatars/avatar_1776296909_ebb445aac9227d7b.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776296909_ebb445aac9227d7b.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297707_7c9c9104fb1b80fe.png b/app/accounts/handlers/uploads/avatars/avatar_1776297707_7c9c9104fb1b80fe.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297707_7c9c9104fb1b80fe.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297707_c51371dfcf0f50be.png b/app/accounts/handlers/uploads/avatars/avatar_1776297707_c51371dfcf0f50be.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297707_c51371dfcf0f50be.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297707_fea831c5ba93c01c.png b/app/accounts/handlers/uploads/avatars/avatar_1776297707_fea831c5ba93c01c.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297707_fea831c5ba93c01c.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297719_ae42a7d49e04a032.png b/app/accounts/handlers/uploads/avatars/avatar_1776297719_ae42a7d49e04a032.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297719_ae42a7d49e04a032.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297719_f7deee5c8c57083f.png b/app/accounts/handlers/uploads/avatars/avatar_1776297719_f7deee5c8c57083f.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297719_f7deee5c8c57083f.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776297719_ffd208ac52d70c75.png b/app/accounts/handlers/uploads/avatars/avatar_1776297719_ffd208ac52d70c75.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776297719_ffd208ac52d70c75.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776335169_73e045b7295032f4.png b/app/accounts/handlers/uploads/avatars/avatar_1776335169_73e045b7295032f4.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776335169_73e045b7295032f4.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776335170_b196b3621475716d.png b/app/accounts/handlers/uploads/avatars/avatar_1776335170_b196b3621475716d.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776335170_b196b3621475716d.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776335170_db445f4de59151f7.png b/app/accounts/handlers/uploads/avatars/avatar_1776335170_db445f4de59151f7.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776335170_db445f4de59151f7.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776350791_210373551bde7fc5.png b/app/accounts/handlers/uploads/avatars/avatar_1776350791_210373551bde7fc5.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776350791_210373551bde7fc5.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776350791_2e12669750dc2b6d.png b/app/accounts/handlers/uploads/avatars/avatar_1776350791_2e12669750dc2b6d.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776350791_2e12669750dc2b6d.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776350791_9691f76a98bb06e5.png b/app/accounts/handlers/uploads/avatars/avatar_1776350791_9691f76a98bb06e5.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776350791_9691f76a98bb06e5.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362845_0eb55a26ab40bdea.png b/app/accounts/handlers/uploads/avatars/avatar_1776362845_0eb55a26ab40bdea.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362845_0eb55a26ab40bdea.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362845_3496b4a0080b6d5c.png b/app/accounts/handlers/uploads/avatars/avatar_1776362845_3496b4a0080b6d5c.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362845_3496b4a0080b6d5c.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362845_b64f6022c2f2f084.png b/app/accounts/handlers/uploads/avatars/avatar_1776362845_b64f6022c2f2f084.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362845_b64f6022c2f2f084.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362983_8e3fd0fff3b513e0.png b/app/accounts/handlers/uploads/avatars/avatar_1776362983_8e3fd0fff3b513e0.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362983_8e3fd0fff3b513e0.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362984_716956efeca6090e.png b/app/accounts/handlers/uploads/avatars/avatar_1776362984_716956efeca6090e.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362984_716956efeca6090e.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776362984_b4a499d9a9171985.png b/app/accounts/handlers/uploads/avatars/avatar_1776362984_b4a499d9a9171985.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776362984_b4a499d9a9171985.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363144_2e0802f48f9bf588.png b/app/accounts/handlers/uploads/avatars/avatar_1776363144_2e0802f48f9bf588.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363144_2e0802f48f9bf588.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363145_260efeff74cb6f09.png b/app/accounts/handlers/uploads/avatars/avatar_1776363145_260efeff74cb6f09.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363145_260efeff74cb6f09.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363145_7453ed3a3a223815.png b/app/accounts/handlers/uploads/avatars/avatar_1776363145_7453ed3a3a223815.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363145_7453ed3a3a223815.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363175_9ad882b65e7a7ddc.png b/app/accounts/handlers/uploads/avatars/avatar_1776363175_9ad882b65e7a7ddc.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363175_9ad882b65e7a7ddc.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363175_a8c26b4b95567021.png b/app/accounts/handlers/uploads/avatars/avatar_1776363175_a8c26b4b95567021.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363175_a8c26b4b95567021.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363175_d4ed6a797fb2650b.png b/app/accounts/handlers/uploads/avatars/avatar_1776363175_d4ed6a797fb2650b.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363175_d4ed6a797fb2650b.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363430_0e100623c56a5ae2.png b/app/accounts/handlers/uploads/avatars/avatar_1776363430_0e100623c56a5ae2.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363430_0e100623c56a5ae2.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363430_1de16dcb5610270d.png b/app/accounts/handlers/uploads/avatars/avatar_1776363430_1de16dcb5610270d.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363430_1de16dcb5610270d.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363430_a43bc861ff98bfbd.png b/app/accounts/handlers/uploads/avatars/avatar_1776363430_a43bc861ff98bfbd.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363430_a43bc861ff98bfbd.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363439_2463beacf903eb8c.png b/app/accounts/handlers/uploads/avatars/avatar_1776363439_2463beacf903eb8c.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363439_2463beacf903eb8c.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363439_7a26391a4ab4412d.png b/app/accounts/handlers/uploads/avatars/avatar_1776363439_7a26391a4ab4412d.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363439_7a26391a4ab4412d.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776363439_c6b6cd768caf645c.png b/app/accounts/handlers/uploads/avatars/avatar_1776363439_c6b6cd768caf645c.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776363439_c6b6cd768caf645c.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371920_5a262607a01b2b1a.png b/app/accounts/handlers/uploads/avatars/avatar_1776371920_5a262607a01b2b1a.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371920_5a262607a01b2b1a.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371921_0d97eeb070e944d9.png b/app/accounts/handlers/uploads/avatars/avatar_1776371921_0d97eeb070e944d9.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371921_0d97eeb070e944d9.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371921_8e3bf8cdb7a18b00.png b/app/accounts/handlers/uploads/avatars/avatar_1776371921_8e3bf8cdb7a18b00.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371921_8e3bf8cdb7a18b00.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371932_52a26eab18930556.png b/app/accounts/handlers/uploads/avatars/avatar_1776371932_52a26eab18930556.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371932_52a26eab18930556.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371932_60d9a27062d9404a.png b/app/accounts/handlers/uploads/avatars/avatar_1776371932_60d9a27062d9404a.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371932_60d9a27062d9404a.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371932_a912ff6965b858e6.png b/app/accounts/handlers/uploads/avatars/avatar_1776371932_a912ff6965b858e6.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371932_a912ff6965b858e6.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371938_196f3e91cb7df475.png b/app/accounts/handlers/uploads/avatars/avatar_1776371938_196f3e91cb7df475.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371938_196f3e91cb7df475.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371939_361542556ac85654.png b/app/accounts/handlers/uploads/avatars/avatar_1776371939_361542556ac85654.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371939_361542556ac85654.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371939_5a33dcac6d40a2ef.png b/app/accounts/handlers/uploads/avatars/avatar_1776371939_5a33dcac6d40a2ef.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371939_5a33dcac6d40a2ef.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371987_16b2ba54971ea6ec.png b/app/accounts/handlers/uploads/avatars/avatar_1776371987_16b2ba54971ea6ec.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371987_16b2ba54971ea6ec.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371987_44d1049484b441e9.png b/app/accounts/handlers/uploads/avatars/avatar_1776371987_44d1049484b441e9.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371987_44d1049484b441e9.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776371987_481ac69916614e65.png b/app/accounts/handlers/uploads/avatars/avatar_1776371987_481ac69916614e65.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776371987_481ac69916614e65.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776372229_4312429a16c83146.png b/app/accounts/handlers/uploads/avatars/avatar_1776372229_4312429a16c83146.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776372229_4312429a16c83146.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776372229_abb18b307d7936f2.png b/app/accounts/handlers/uploads/avatars/avatar_1776372229_abb18b307d7936f2.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776372229_abb18b307d7936f2.png differ diff --git a/app/accounts/handlers/uploads/avatars/avatar_1776372229_acc11cf9c083a948.png b/app/accounts/handlers/uploads/avatars/avatar_1776372229_acc11cf9c083a948.png new file mode 100644 index 0000000..37be820 Binary files /dev/null and b/app/accounts/handlers/uploads/avatars/avatar_1776372229_acc11cf9c083a948.png differ diff --git a/app/accounts/handlers/user.go b/app/accounts/handlers/user.go new file mode 100644 index 0000000..fefd7f8 --- /dev/null +++ b/app/accounts/handlers/user.go @@ -0,0 +1,1281 @@ +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)}) +} diff --git a/app/accounts/handlers/user_test.go b/app/accounts/handlers/user_test.go new file mode 100644 index 0000000..61f0790 --- /dev/null +++ b/app/accounts/handlers/user_test.go @@ -0,0 +1,735 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "ginimageApi/app/accounts/models" + "ginimageApi/app/middleware" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupHandlersTestDB(t *testing.T) { + t.Helper() + t.Setenv("AVATAR_WIDTH", "64") + t.Setenv("AVATAR_HEIGHT", "64") + t.Setenv("AVATAR_QUALITY", "80") + t.Setenv("AVATAR_MAX_SIZE_MB", "5") + t.Setenv("AVATAR_FORMATS", "png") + + prev := configs.DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("sqlite open failed: %v", err) + } + if err := db.AutoMigrate(&models.User{}, &models.Profile{}, &models.SocialAccount{}, &models.RefreshToken{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + configs.DB = db + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + configs.DB = prev + }) +} + +func tinyPNGFixture(t *testing.T) []byte { + t.Helper() + // 1x1 PNG + const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Zr0kAAAAASUVORK5CYII=" + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + t.Fatalf("png fixture decode failed: %v", err) + } + return b +} + +func createOldAvatarFixture(t *testing.T, fileName string) (string, string) { + t.Helper() + dir := filepath.Join("uploads", "avatars") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir avatars failed: %v", err) + } + fullPath := filepath.Join(dir, fileName) + if err := os.WriteFile(fullPath, []byte("old-avatar"), 0o644); err != nil { + t.Fatalf("write old avatar failed: %v", err) + } + t.Cleanup(func() { _ = os.Remove(fullPath) }) + + return "/uploads/avatars/" + fileName, fullPath +} + +func performJSON(r *gin.Engine, method, path string, payload any, headers map[string]string) *httptest.ResponseRecorder { + var body []byte + if payload != nil { + body, _ = json.Marshal(payload) + } + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func performMultipart( + r *gin.Engine, + method, path string, + fields map[string]string, + fileField, fileName string, + fileContent []byte, + headers map[string]string, +) *httptest.ResponseRecorder { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for k, v := range fields { + _ = writer.WriteField(k, v) + } + if fileField != "" { + part, _ := writer.CreateFormFile(fileField, fileName) + _, _ = part.Write(fileContent) + } + _ = writer.Close() + + req := httptest.NewRequest(method, path, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + for k, v := range headers { + req.Header.Set(k, v) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func assertJWTFormat(t *testing.T, token string) { + t.Helper() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token JWT formatinda olmali, segment sayisi: %d", len(parts)) + } +} + +func TestAuthFlowRegisterLoginMeRefresh(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + r := gin.New() + r.POST("/register", Register) + r.POST("/login", Login) + r.POST("/refresh", Refresh) + r.GET("/verify-email", VerifyEmail) + r.GET("/me", middleware.AuthRequired(), Me) + + registerPayload := map[string]any{ + "username": "john", + "email": "john@example.com", + "first_name": "John", + "last_name": "Doe", + "password": "secret123", + "confirm_password": "secret123", + } + wReg := performJSON(r, http.MethodPost, "/register", registerPayload, nil) + if wReg.Code != http.StatusCreated { + t.Fatalf("register expected 201, got %d body=%s", wReg.Code, wReg.Body.String()) + } + + var regResp map[string]any + if err := json.Unmarshal(wReg.Body.Bytes(), ®Resp); err != nil { + t.Fatalf("register json parse failed: %v", err) + } + accessToken, _ := regResp["access"].(string) + if accessToken != "" { + t.Fatalf("register should not return direct access token before email verification") + } + verificationToken, _ := regResp["verification_token"].(string) + if verificationToken == "" { + t.Fatalf("verification_token must be returned") + } + + wLogin := performJSON(r, http.MethodPost, "/login", map[string]any{ + "email": "john@example.com", + "password": "secret123", + }, nil) + if wLogin.Code != http.StatusForbidden { + t.Fatalf("login expected 403 before verify, got %d body=%s", wLogin.Code, wLogin.Body.String()) + } + + verifyPath := "/verify-email?token=" + url.QueryEscape(verificationToken) + wVerify := performJSON(r, http.MethodGet, verifyPath, nil, nil) + if wVerify.Code != http.StatusOK { + t.Fatalf("verify expected 200, got %d body=%s", wVerify.Code, wVerify.Body.String()) + } + + var verifyResp map[string]any + if err := json.Unmarshal(wVerify.Body.Bytes(), &verifyResp); err != nil { + t.Fatalf("verify json parse failed: %v", err) + } + accessToken, _ = verifyResp["access"].(string) + refreshToken, _ := verifyResp["refresh"].(string) + if accessToken == "" || refreshToken == "" { + t.Fatalf("verify should return tokens") + } + assertJWTFormat(t, accessToken) + assertJWTFormat(t, refreshToken) + + wLogin = performJSON(r, http.MethodPost, "/login", map[string]any{ + "email": "john@example.com", + "password": "secret123", + }, nil) + if wLogin.Code != http.StatusOK { + t.Fatalf("login expected 200, got %d body=%s", wLogin.Code, wLogin.Body.String()) + } + var loginResp map[string]any + if err := json.Unmarshal(wLogin.Body.Bytes(), &loginResp); err != nil { + t.Fatalf("login json parse failed: %v", err) + } + loginRefreshToken, _ := loginResp["refresh"].(string) + loginAccessToken, _ := loginResp["access"].(string) + assertJWTFormat(t, loginAccessToken) + assertJWTFormat(t, loginRefreshToken) + + wMe := performJSON(r, http.MethodGet, "/me", nil, map[string]string{"Authorization": "Bearer " + accessToken}) + if wMe.Code != http.StatusOK { + t.Fatalf("me expected 200, got %d", wMe.Code) + } + + wRefresh := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil) + if wRefresh.Code != http.StatusOK { + t.Fatalf("refresh expected 200, got %d body=%s", wRefresh.Code, wRefresh.Body.String()) + } + var refreshResp map[string]any + if err := json.Unmarshal(wRefresh.Body.Bytes(), &refreshResp); err != nil { + t.Fatalf("refresh json parse failed: %v", err) + } + newRefreshToken, _ := refreshResp["refresh"].(string) + newAccessToken, _ := refreshResp["access"].(string) + assertJWTFormat(t, newAccessToken) + assertJWTFormat(t, newRefreshToken) + if newRefreshToken == refreshToken { + t.Fatalf("refresh rotation should return a new refresh token") + } + + var oldToken models.RefreshToken + if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&oldToken).Error; err != nil { + t.Fatalf("refresh token record not found: %v", err) + } + if !oldToken.Revoked { + t.Fatalf("old refresh token should be revoked after refresh") + } + if oldToken.ReplacedByTokenID == "" { + t.Fatalf("old token should keep replaced_by_token_id") + } +} + +func TestRegisterDuplicateEmail(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + r := gin.New() + r.POST("/register", Register) + + payload := map[string]any{ + "username": "user1", + "email": "dup@example.com", + "first_name": "User", + "last_name": "One", + "password": "secret123", + "confirm_password": "secret123", + } + w1 := performJSON(r, http.MethodPost, "/register", payload, nil) + if w1.Code != http.StatusCreated { + t.Fatalf("first register expected 201, got %d body=%s", w1.Code, w1.Body.String()) + } + + w2 := performJSON(r, http.MethodPost, "/register", payload, nil) + if w2.Code != http.StatusConflict { + t.Fatalf("second register expected 409, got %d", w2.Code) + } +} + +func TestLoginWrongPassword(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("bcrypt failed: %v", err) + } + isAdmin := false + user := models.User{UserName: "u1", Email: "u1@example.com", Password: string(hash), IsAdmin: &isAdmin} + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("seed user failed: %v", err) + } + + r := gin.New() + r.POST("/login", Login) + + w := performJSON(r, http.MethodPost, "/login", map[string]any{ + "email": "u1@example.com", + "password": "wrong-password", + }, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestMakeAdminWithAdminMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isAdmin := true + isUser := false + admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin} + target := models.User{UserName: "target", Email: "target@example.com", Password: "x", IsAdmin: &isUser} + if err := configs.DB.Create(&admin).Error; err != nil { + t.Fatalf("create admin failed: %v", err) + } + if err := configs.DB.Create(&target).Error; err != nil { + t.Fatalf("create target failed: %v", err) + } + + token, err := middleware.BuildAccessTokenForUser(admin) + if err != nil { + t.Fatalf("token create failed: %v", err) + } + + r := gin.New() + r.POST("/users/:id/admin", middleware.AuthRequired(), middleware.AdminRequired(), MakeAdmin) + + w := performJSON(r, http.MethodPost, "/users/"+toString(target.ID)+"/admin", map[string]any{"is_admin": true}, map[string]string{ + "Authorization": "Bearer " + token, + }) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var updated models.User + if err := configs.DB.First(&updated, target.ID).Error; err != nil { + t.Fatalf("read updated user failed: %v", err) + } + if updated.IsAdmin == nil || !*updated.IsAdmin { + t.Fatalf("target user should be admin") + } +} + +func TestRefreshRejectsExpiredToken(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isAdmin := false + user := models.User{UserName: "u2", Email: "u2@example.com", Password: "x", IsAdmin: &isAdmin} + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("user create failed: %v", err) + } + + refresh := "deadbeef" + record := models.RefreshToken{ + UserID: uint64(user.ID), + TokenID: "tid1", + TokenHash: hashToken(refresh), + TokenFingerprint: tokenFingerprint(refresh), + ExpiresAt: time.Now().Add(-time.Minute), + } + if err := configs.DB.Create(&record).Error; err != nil { + t.Fatalf("refresh create failed: %v", err) + } + + r := gin.New() + r.POST("/refresh", Refresh) + + w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refresh}, nil) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestIssueTokens_DefaultFlowKeepsSessionExpiryNilAndRefreshWorks(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isAdmin := false + isActive := true + emailVerified := true + user := models.User{ + UserName: "normal_user", + Email: "normal@example.com", + Password: "x", + IsAdmin: &isAdmin, + IsActive: &isActive, + EmailVerified: &emailVerified, + } + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("user create failed: %v", err) + } + + _, refreshToken, _, err := issueTokens(user, "test-agent", "127.0.0.1") + if err != nil { + t.Fatalf("issueTokens failed: %v", err) + } + + var record models.RefreshToken + if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&record).Error; err != nil { + t.Fatalf("refresh token record should exist: %v", err) + } + if record.SessionExpiresAt != nil { + t.Fatalf("default flow should keep session_expires_at nil") + } + + r := gin.New() + r.POST("/refresh", Refresh) + w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil) + if w.Code != http.StatusOK { + t.Fatalf("refresh expected 200 for default flow, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestAdminScopedTokenIssuesOnlyAccessTokenWithoutRefreshRecord(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isAdmin := true + isActive := true + emailVerified := true + adminUser := models.User{ + UserName: "admin_user", + Email: "admin_scoped@example.com", + Password: "x", + IsAdmin: &isAdmin, + IsActive: &isActive, + EmailVerified: &emailVerified, + } + if err := configs.DB.Create(&adminUser).Error; err != nil { + t.Fatalf("admin user create failed: %v", err) + } + + accessToken, err := middleware.BuildAccessTokenForUser(adminUser) + if err != nil { + t.Fatalf("build access token failed: %v", err) + } + + r := gin.New() + r.POST("/admin/tokens/issue", middleware.AuthRequired(), middleware.AdminRequired(), IssueAdminScopedToken) + + wIssue := performJSON( + r, + http.MethodPost, + "/admin/tokens/issue", + map[string]any{"duration_days": 45}, + map[string]string{"Authorization": "Bearer " + accessToken}, + ) + if wIssue.Code != http.StatusOK { + t.Fatalf("issue endpoint expected 200, got %d body=%s", wIssue.Code, wIssue.Body.String()) + } + + var issueResp map[string]any + if err := json.Unmarshal(wIssue.Body.Bytes(), &issueResp); err != nil { + t.Fatalf("issue response parse failed: %v", err) + } + scopedAccess, _ := issueResp["access"].(string) + if scopedAccess == "" { + t.Fatalf("issued scoped access token should exist") + } + assertJWTFormat(t, scopedAccess) + + if _, ok := issueResp["refresh"]; ok { + t.Fatalf("scoped token response should not include refresh token") + } + + expiresAt, _ := issueResp["expires_at"].(string) + if expiresAt == "" { + t.Fatalf("scoped token response should include expires_at") + } + expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + t.Fatalf("expires_at should be RFC3339: %v", err) + } + expected := time.Now().Add(45 * 24 * time.Hour) + if expiresAtTime.Before(expected.Add(-2*time.Minute)) || expiresAtTime.After(expected.Add(2*time.Minute)) { + t.Fatalf("expires_at should be close to requested duration, got=%s expected_around=%s", expiresAtTime, expected) + } + + var refreshCount int64 + if err := configs.DB.Model(&models.RefreshToken{}).Where("user_id = ?", adminUser.ID).Count(&refreshCount).Error; err != nil { + t.Fatalf("refresh token count query failed: %v", err) + } + if refreshCount != 0 { + t.Fatalf("scoped access token flow should not create refresh records, got %d", refreshCount) + } +} + +func toString(v uint) string { + return strconv.FormatUint(uint64(v), 10) +} + +func TestRegisterRejectsMismatchedConfirmPassword(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + r := gin.New() + r.POST("/register", Register) + + w := performJSON(r, http.MethodPost, "/register", map[string]any{ + "username": "user2", + "email": "user2@example.com", + "first_name": "User", + "last_name": "Two", + "password": "secret123", + "confirm_password": "wrong123", + }, nil) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestGoogleSocialLoginCreatesVerifiedActiveUserAndProfile(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/userinfo" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"sub":"g-123","email":"social@example.com","email_verified":true,"given_name":"Social","family_name":"User","picture":"https://cdn/avatar.png"}`)) + })) + defer provider.Close() + + prevGoogle := googleUserInfoURL + googleUserInfoURL = provider.URL + "/userinfo" + t.Cleanup(func() { googleUserInfoURL = prevGoogle }) + + r := gin.New() + r.POST("/auth/social/google", GoogleLogin) + + w := performJSON(r, http.MethodPost, "/auth/social/google", map[string]any{"access_token": "token-abc"}, nil) + if w.Code != http.StatusOK { + dump, _ := httputil.DumpResponse(w.Result(), true) + t.Fatalf("expected 200, got %d body=%s", w.Code, string(dump)) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("response parse failed: %v", err) + } + if resp["provider"] != "google" { + t.Fatalf("provider should be google") + } + if access, _ := resp["access"].(string); access == "" { + t.Fatalf("access token should be returned") + } + + var user models.User + if err := configs.DB.Where("email = ?", "social@example.com").First(&user).Error; err != nil { + t.Fatalf("user should be created: %v", err) + } + if user.EmailVerified == nil || !*user.EmailVerified { + t.Fatalf("social login user should be email verified") + } + if user.IsActive == nil || !*user.IsActive { + t.Fatalf("social login user should be active") + } + + var profile models.Profile + if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil { + t.Fatalf("profile should be created: %v", err) + } + if profile.FirstName != "Social" || profile.LastName != "User" { + t.Fatalf("profile name mismatch: %+v", profile) + } +} + +func TestGitHubSocialLoginReadsPrimaryEmail(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/user": + _, _ = w.Write([]byte(`{"id":99,"login":"octo","name":"Octo Cat","email":"","avatar_url":"https://cdn/octo.png"}`)) + case "/user/emails": + _, _ = w.Write([]byte(`[{"email":"octo@example.com","primary":true,"verified":true}]`)) + default: + http.NotFound(w, r) + } + })) + defer provider.Close() + + prevUser := githubUserURL + prevEmails := githubEmailsURL + githubUserURL = provider.URL + "/user" + githubEmailsURL = provider.URL + "/user/emails" + t.Cleanup(func() { + githubUserURL = prevUser + githubEmailsURL = prevEmails + }) + + r := gin.New() + r.POST("/auth/social/github", GitHubLogin) + + w := performJSON(r, http.MethodPost, "/auth/social/github", map[string]any{"access_token": "gh-token"}, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var social models.SocialAccount + if err := configs.DB.Where("provider = ? AND provider_id = ?", "github", "99").First(&social).Error; err != nil { + t.Fatalf("github social account should be created: %v", err) + } + if social.Email != "octo@example.com" { + t.Fatalf("github email should come from /user/emails, got %s", social.Email) + } +} + +func TestMeProfileGetAndUpdate(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isActive := true + emailVerified := true + isAdmin := false + user := models.User{ + UserName: "profile_user", + Email: "profile@example.com", + Password: "x", + IsActive: &isActive, + EmailVerified: &emailVerified, + IsAdmin: &isAdmin, + } + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_user_avatar.png") + profile := models.Profile{UserID: uint64(user.ID), FirstName: "Test", LastName: "User", AvatarURL: oldAvatarURL} + if err := configs.DB.Create(&profile).Error; err != nil { + t.Fatalf("create profile failed: %v", err) + } + + token, err := middleware.BuildAccessTokenForUser(user) + if err != nil { + t.Fatalf("token create failed: %v", err) + } + + r := gin.New() + r.GET("/me/profile", middleware.AuthRequired(), GetMyProfile) + r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile) + + wGet := performJSON(r, http.MethodGet, "/me/profile", nil, map[string]string{"Authorization": "Bearer " + token}) + if wGet.Code != http.StatusOK { + t.Fatalf("get profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String()) + } + + wPut := performMultipart( + r, + http.MethodPut, + "/me/profile", + map[string]string{"first_name": "Yeni", "last_name": "Isim"}, + "avatar", + "avatar.png", + tinyPNGFixture(t), + map[string]string{"Authorization": "Bearer " + token}, + ) + if wPut.Code != http.StatusOK { + t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String()) + } + + var updated models.Profile + if err := configs.DB.Where("user_id = ?", user.ID).First(&updated).Error; err != nil { + t.Fatalf("read updated profile failed: %v", err) + } + if updated.FirstName != "Yeni" || updated.LastName != "Isim" { + t.Fatalf("profile name not updated: %+v", updated) + } + if !strings.HasPrefix(updated.AvatarURL, "/uploads/avatars/") { + t.Fatalf("avatar path not updated: %s", updated.AvatarURL) + } + if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) { + t.Fatalf("old avatar should be deleted, err=%v", err) + } +} + +func TestMeProfileUpdateCreatesProfileWhenMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + setupHandlersTestDB(t) + + isActive := true + emailVerified := true + isAdmin := false + user := models.User{ + UserName: "legacy_user", + Email: "legacy@example.com", + Password: "x", + IsActive: &isActive, + EmailVerified: &emailVerified, + IsAdmin: &isAdmin, + } + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("create user failed: %v", err) + } + + token, err := middleware.BuildAccessTokenForUser(user) + if err != nil { + t.Fatalf("token create failed: %v", err) + } + + r := gin.New() + r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile) + + wPut := performMultipart( + r, + http.MethodPut, + "/me/profile", + map[string]string{"first_name": "Beyhan", "last_name": "Ogur"}, + "avatar", + "avatar.png", + tinyPNGFixture(t), + map[string]string{"Authorization": "Bearer " + token}, + ) + if wPut.Code != http.StatusOK { + t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String()) + } + + var profile models.Profile + if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil { + t.Fatalf("profile should be auto-created: %v", err) + } + if profile.FirstName != "Beyhan" || profile.LastName != "Ogur" { + t.Fatalf("profile fields mismatch: %+v", profile) + } +} diff --git a/app/accounts/models/accounts.go b/app/accounts/models/accounts.go new file mode 100644 index 0000000..88c5f6e --- /dev/null +++ b/app/accounts/models/accounts.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + gorm.Model + UserName string `json:"username" gorm:"uniqueIndex;not null;type:varchar(255)"` + Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"` + Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON + EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration + EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"` + IsActive *bool `gorm:"default:true" json:"is_active"` + IsAdmin *bool `gorm:"default:false" json:"is_admin"` + SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"` + Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"` +} + +// IsEmailVerified Email Veriyf i False Döndürüyor +func (u *User) IsEmailVerified() bool { + if u.EmailVerified == nil { + return false + } + return *u.EmailVerified +} + +// SocialAccount model structure +type SocialAccount struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + Provider string `gorm:"type:varchar(32);not null;uniqueIndex:idx_social_provider_identity" json:"provider"` // google, github + ProviderID string `gorm:"type:varchar(191);not null;uniqueIndex:idx_social_provider_identity" json:"provider_id"` + Email string `json:"email" gorm:"type:varchar(255)"` + Name string `json:"name,omitempty" gorm:"type:varchar(255)"` // Full name from provider + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + +} +type Profile struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;uniqueIndex" json:"user_id"` + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider + LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider + +} diff --git a/app/accounts/models/accounts_test.go b/app/accounts/models/accounts_test.go new file mode 100644 index 0000000..5d7de8c --- /dev/null +++ b/app/accounts/models/accounts_test.go @@ -0,0 +1,28 @@ +package models + +import "testing" + +func TestUserIsEmailVerified(t *testing.T) { + t.Run("nil pointer returns false", func(t *testing.T) { + u := User{} + if u.IsEmailVerified() { + t.Fatalf("expected false for nil EmailVerified") + } + }) + + t.Run("true pointer returns true", func(t *testing.T) { + v := true + u := User{EmailVerified: &v} + if !u.IsEmailVerified() { + t.Fatalf("expected true") + } + }) + + t.Run("false pointer returns false", func(t *testing.T) { + v := false + u := User{EmailVerified: &v} + if u.IsEmailVerified() { + t.Fatalf("expected false") + } + }) +} diff --git a/app/accounts/models/token.go b/app/accounts/models/token.go new file mode 100644 index 0000000..bae4989 --- /dev/null +++ b/app/accounts/models/token.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// RefreshToken represents a server-side record of issued refresh tokens +// to support rotation, revocation and reuse detection. +type RefreshToken struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + TokenID string `gorm:"type:varchar(128);not null;uniqueIndex" json:"token_id"` + // TokenHash is SHA-256 hex of the refresh token string (64 chars). + // Stored instead of the raw token for security, while still allowing debug/lookup. + TokenHash string `gorm:"type:char(64);index" json:"token_hash"` + // TokenFingerprint is a masked representation (e.g. first6...last4) to help operators + // visually correlate DB rows with logs without storing full token. + TokenFingerprint string `gorm:"type:varchar(32);index" json:"token_fingerprint"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + SessionExpiresAt *time.Time `gorm:"index" json:"session_expires_at,omitempty"` + Revoked bool `gorm:"index" json:"revoked"` + ReplacedByTokenID string `gorm:"type:varchar(128)" json:"replaced_by_token_id"` + UserAgent string `gorm:"type:varchar(255)" json:"user_agent"` + IP string `gorm:"type:varchar(64)" json:"ip"` +} diff --git a/app/blogs/handlers/blog.go b/app/blogs/handlers/blog.go new file mode 100644 index 0000000..8bb1b97 --- /dev/null +++ b/app/blogs/handlers/blog.go @@ -0,0 +1,745 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + blogModels "ginimageApi/app/blogs/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type createPostRequest struct { + Title string `json:"title" binding:"required,min=3"` + Content string `json:"content"` + Keywords string `json:"keywords"` + Image string `json:"image"` + Video string `json:"video"` + IsActive *bool `json:"is_active"` + IsFront *bool `json:"is_front"` +} + +type updatePostRequest struct { + Title string `json:"title"` + Content string `json:"content"` + Keywords string `json:"keywords"` + Image string `json:"image"` + Video string `json:"video"` + IsActive *bool `json:"is_active"` + IsFront *bool `json:"is_front"` +} + +type BlogErrorResponse struct { + Error string `json:"error"` +} + +type BlogPostResponse struct { + ID uint64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Content string `json:"content"` + Keywords string `json:"keywords"` + Image string `json:"image"` + Video string `json:"video"` + IsActive bool `json:"is_active"` + IsFront bool `json:"is_front"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type BlogListResponse struct { + Count int `json:"count"` + Items []BlogPostResponse `json:"items"` +} + +type BlogCategoryResponse struct { + ID uint64 `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Keywords string `json:"keywords"` + Desc string `json:"description"` + Image string `json:"image"` + IsActive bool `json:"is_active"` + Order int `json:"order"` +} + +type BlogTagResponse struct { + ID uint64 `json:"id"` + Tag string `json:"tag"` + Slug string `json:"slug"` + IsActive bool `json:"is_active"` +} + +type BlogCategoryListResponse struct { + Count int `json:"count"` + Items []BlogCategoryResponse `json:"items"` +} + +type BlogTagListResponse struct { + Count int `json:"count"` + Items []BlogTagResponse `json:"items"` +} + +type createCategoryRequest struct { + Title string `json:"title" binding:"required,min=2"` + Keywords string `json:"keywords"` + Desc string `json:"description"` + Image string `json:"image"` + Order int `json:"order"` + IsActive *bool `json:"is_active"` + ParentID *uint64 `json:"parent_id"` +} + +type updateCategoryRequest struct { + Title string `json:"title"` + Keywords string `json:"keywords"` + Desc string `json:"description"` + Image string `json:"image"` + Order *int `json:"order"` + IsActive *bool `json:"is_active"` + ParentID *uint64 `json:"parent_id"` +} + +type createTagRequest struct { + Tag string `json:"tag" binding:"required,min=2"` + IsActive *bool `json:"is_active"` +} + +type updateTagRequest struct { + Tag string `json:"tag"` + IsActive *bool `json:"is_active"` +} + +func toBlogPostResponse(p blogModels.Post) BlogPostResponse { + return BlogPostResponse{ + ID: p.ID, + Title: p.Title, + Slug: p.Slug, + Content: p.Content, + Keywords: p.Keywords, + Image: p.Image, + Video: p.Video, + IsActive: p.IsActive, + IsFront: p.IsFront, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + } +} + +func toBlogCategoryResponse(c blogModels.Category) BlogCategoryResponse { + return BlogCategoryResponse{ + ID: c.ID, + Title: c.Title, + Slug: c.Slug, + Keywords: c.Keywords, + Desc: c.Desc, + Image: c.Image, + IsActive: c.IsActive, + Order: c.Order, + } +} + +func toBlogTagResponse(t blogModels.Tag) BlogTagResponse { + return BlogTagResponse{ + ID: t.ID, + Tag: t.Tag, + Slug: t.Slug, + IsActive: t.IsActive, + } +} + +func userIDFromContext(c *gin.Context) (uint64, bool) { + v, ok := c.Get("user_id") + if !ok { + return 0, false + } + switch id := v.(type) { + case uint: + return uint64(id), true + case int: + if id < 0 { + return 0, false + } + return uint64(id), true + case string: + parsed, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + +// ListPosts godoc +// @Summary Public blog post listesini getirir +// @Tags blogs +// @Produce json +// @Success 200 {object} BlogListResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs [get] +func ListPosts(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + + var posts []blogModels.Post + if err := configs.DB.Where("is_active = ?", true).Order("id desc").Find(&posts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "postlar listelenemedi"}) + return + } + items := make([]BlogPostResponse, 0, len(posts)) + for _, p := range posts { + items = append(items, toBlogPostResponse(p)) + } + c.JSON(http.StatusOK, BlogListResponse{Count: len(items), Items: items}) +} + +// GetPost godoc +// @Summary Public tekil blog postu getirir +// @Tags blogs +// @Produce json +// @Param slug path string true "Post slug" +// @Success 200 {object} BlogPostResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/{slug} [get] +func GetPost(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + + slug := c.Param("slug") + var post blogModels.Post + err := configs.DB.Where("slug = ? AND is_active = ?", slug, true).First(&post).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "post getirilemedi"}) + return + } + c.JSON(http.StatusOK, toBlogPostResponse(post)) +} + +// CreatePost godoc +// @Summary Admin blog post olusturur +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body createPostRequest true "Post bilgileri" +// @Success 201 {object} BlogPostResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 401 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs [post] +func CreatePost(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + + userID, ok := userIDFromContext(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"}) + return + } + + var req createPostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + post := blogModels.Post{ + Title: req.Title, + Content: req.Content, + Keywords: req.Keywords, + Image: req.Image, + Video: req.Video, + UserID: &userID, + IsActive: true, + IsFront: true, + } + if req.IsActive != nil { + post.IsActive = *req.IsActive + } + if req.IsFront != nil { + post.IsFront = *req.IsFront + } + + if err := configs.DB.Create(&post).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "post olusturulamadi"}) + return + } + c.JSON(http.StatusCreated, toBlogPostResponse(post)) +} + +// UpdatePost godoc +// @Summary Admin blog post gunceller +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Param request body updatePostRequest true "Guncellenecek alanlar" +// @Success 200 {object} BlogPostResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/{id} [put] +func UpdatePost(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz post id"}) + return + } + + var post blogModels.Post + if err := configs.DB.First(&post, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "post bulunamadi"}) + return + } + + var req updatePostRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.Title != "" { + post.Title = req.Title + } + if req.Content != "" { + post.Content = req.Content + } + if req.Keywords != "" { + post.Keywords = req.Keywords + } + if req.Image != "" { + post.Image = req.Image + } + if req.Video != "" { + post.Video = req.Video + } + if req.IsActive != nil { + post.IsActive = *req.IsActive + } + if req.IsFront != nil { + post.IsFront = *req.IsFront + } + + if err := configs.DB.Save(&post).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "post guncellenemedi"}) + return + } + c.JSON(http.StatusOK, toBlogPostResponse(post)) +} + +// DeletePost godoc +// @Summary Admin blog post siler +// @Tags blogs +// @Security BearerAuth +// @Param id path int true "Post ID" +// @Success 204 +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/{id} [delete] +func DeletePost(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz post id"}) + return + } + + res := configs.DB.Delete(&blogModels.Post{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "post silinemedi"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "post bulunamadi"}) + return + } + + c.Status(http.StatusNoContent) +} + +// ListCategories godoc +// @Summary Public kategori listesini getirir +// @Tags blogs +// @Produce json +// @Success 200 {object} BlogCategoryListResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/categories [get] +func ListCategories(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var categories []blogModels.Category + if err := configs.DB.Where("is_active = ?", true).Order("`order` asc, id desc").Find(&categories).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategoriler listelenemedi"}) + return + } + items := make([]BlogCategoryResponse, 0, len(categories)) + for _, item := range categories { + items = append(items, toBlogCategoryResponse(item)) + } + c.JSON(http.StatusOK, BlogCategoryListResponse{Count: len(items), Items: items}) +} + +// GetCategory godoc +// @Summary Public tekil kategori getirir +// @Tags blogs +// @Produce json +// @Param slug path string true "Kategori slug" +// @Success 200 {object} BlogCategoryResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/categories/{slug} [get] +func GetCategory(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var category blogModels.Category + err := configs.DB.Where("slug = ? AND is_active = ?", c.Param("slug"), true).First(&category).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori getirilemedi"}) + return + } + c.JSON(http.StatusOK, toBlogCategoryResponse(category)) +} + +// CreateCategory godoc +// @Summary Admin kategori olusturur +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body createCategoryRequest true "Kategori bilgileri" +// @Success 201 {object} BlogCategoryResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/categories [post] +func CreateCategory(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var req createCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + category := blogModels.Category{ + Title: req.Title, + Keywords: req.Keywords, + Desc: req.Desc, + Image: req.Image, + Order: req.Order, + ParentID: req.ParentID, + IsActive: true, + } + if req.IsActive != nil { + category.IsActive = *req.IsActive + } + if category.Order == 0 { + category.Order = 1 + } + if err := configs.DB.Create(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori olusturulamadi"}) + return + } + c.JSON(http.StatusCreated, toBlogCategoryResponse(category)) +} + +// UpdateCategory godoc +// @Summary Admin kategori gunceller +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Kategori ID" +// @Param request body updateCategoryRequest true "Guncellenecek alanlar" +// @Success 200 {object} BlogCategoryResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/categories/{id} [put] +func UpdateCategory(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kategori id"}) + return + } + var category blogModels.Category + if err := configs.DB.First(&category, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori bulunamadi"}) + return + } + var req updateCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Title != "" { + category.Title = req.Title + } + if req.Keywords != "" { + category.Keywords = req.Keywords + } + if req.Desc != "" { + category.Desc = req.Desc + } + if req.Image != "" { + category.Image = req.Image + } + if req.Order != nil { + category.Order = *req.Order + } + if req.IsActive != nil { + category.IsActive = *req.IsActive + } + if req.ParentID != nil { + category.ParentID = req.ParentID + } + if err := configs.DB.Save(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori guncellenemedi"}) + return + } + c.JSON(http.StatusOK, toBlogCategoryResponse(category)) +} + +// DeleteCategory godoc +// @Summary Admin kategori siler +// @Tags blogs +// @Security BearerAuth +// @Param id path int true "Kategori ID" +// @Success 204 +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/categories/{id} [delete] +func DeleteCategory(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz kategori id"}) + return + } + res := configs.DB.Delete(&blogModels.Category{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "kategori silinemedi"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "kategori bulunamadi"}) + return + } + c.Status(http.StatusNoContent) +} + +// ListTags godoc +// @Summary Public tag listesini getirir +// @Tags blogs +// @Produce json +// @Success 200 {object} BlogTagListResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/tags [get] +func ListTags(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var tags []blogModels.Tag + if err := configs.DB.Where("is_active = ?", true).Order("id desc").Find(&tags).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "tagler listelenemedi"}) + return + } + items := make([]BlogTagResponse, 0, len(tags)) + for _, item := range tags { + items = append(items, toBlogTagResponse(item)) + } + c.JSON(http.StatusOK, BlogTagListResponse{Count: len(items), Items: items}) +} + +// GetTag godoc +// @Summary Public tekil tag getirir +// @Tags blogs +// @Produce json +// @Param slug path string true "Tag slug" +// @Success 200 {object} BlogTagResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/tags/{slug} [get] +func GetTag(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var tag blogModels.Tag + err := configs.DB.Where("slug = ? AND is_active = ?", c.Param("slug"), true).First(&tag).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "tag getirilemedi"}) + return + } + c.JSON(http.StatusOK, toBlogTagResponse(tag)) +} + +// CreateTag godoc +// @Summary Admin tag olusturur +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body createTagRequest true "Tag bilgileri" +// @Success 201 {object} BlogTagResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/tags [post] +func CreateTag(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + var req createTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tag := blogModels.Tag{Tag: req.Tag, IsActive: true} + if req.IsActive != nil { + tag.IsActive = *req.IsActive + } + if err := configs.DB.Create(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "tag olusturulamadi"}) + return + } + c.JSON(http.StatusCreated, toBlogTagResponse(tag)) +} + +// UpdateTag godoc +// @Summary Admin tag gunceller +// @Tags blogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Param request body updateTagRequest true "Guncellenecek alanlar" +// @Success 200 {object} BlogTagResponse +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/tags/{id} [put] +func UpdateTag(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz tag id"}) + return + } + var tag blogModels.Tag + if err := configs.DB.First(&tag, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "tag bulunamadi"}) + return + } + var req updateTagRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Tag != "" { + tag.Tag = req.Tag + } + if req.IsActive != nil { + tag.IsActive = *req.IsActive + } + if err := configs.DB.Save(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "tag guncellenemedi"}) + return + } + c.JSON(http.StatusOK, toBlogTagResponse(tag)) +} + +// DeleteTag godoc +// @Summary Admin tag siler +// @Tags blogs +// @Security BearerAuth +// @Param id path int true "Tag ID" +// @Success 204 +// @Failure 400 {object} BlogErrorResponse +// @Failure 404 {object} BlogErrorResponse +// @Failure 500 {object} BlogErrorResponse +// @Router /api/v1/blogs/tags/{id} [delete] +func DeleteTag(c *gin.Context) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz tag id"}) + return + } + res := configs.DB.Delete(&blogModels.Tag{}, id) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "tag silinemedi"}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "tag bulunamadi"}) + return + } + c.Status(http.StatusNoContent) +} diff --git a/app/blogs/handlers/blog_test.go b/app/blogs/handlers/blog_test.go new file mode 100644 index 0000000..5fdf429 --- /dev/null +++ b/app/blogs/handlers/blog_test.go @@ -0,0 +1,202 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + blogModels "ginimageApi/app/blogs/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupBlogHandlersTestDB(t *testing.T) { + t.Helper() + prev := configs.DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("sqlite open failed: %v", err) + } + if err := db.AutoMigrate(&blogModels.Category{}, &blogModels.Tag{}, &blogModels.Post{}, &blogModels.CategoryView{}, &blogModels.Comment{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + configs.DB = db + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + configs.DB = prev + }) +} + +func withUser(userID uint) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("user_id", userID) + c.Next() + } +} + +func TestListPostsReturnsOnlyActive(t *testing.T) { + gin.SetMode(gin.TestMode) + setupBlogHandlersTestDB(t) + + posts := []blogModels.Post{ + {Title: "Active Post", Content: "A", IsActive: true, IsFront: true}, + {Title: "Passive Post", Content: "B", IsActive: true, IsFront: false}, + } + if err := configs.DB.Create(&posts).Error; err != nil { + t.Fatalf("seed failed: %v", err) + } + if err := configs.DB.Model(&blogModels.Post{}).Where("title = ?", "Passive Post").Update("is_active", false).Error; err != nil { + t.Fatalf("seed update failed: %v", err) + } + + r := gin.New() + r.GET("/blogs", ListPosts) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/blogs", nil)) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var resp struct { + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json parse failed: %v", err) + } + if resp.Count != 1 { + t.Fatalf("expected only active posts, got %d", resp.Count) + } +} + +func TestAdminCreateUpdateDeletePost(t *testing.T) { + gin.SetMode(gin.TestMode) + setupBlogHandlersTestDB(t) + + r := gin.New() + r.POST("/blogs", withUser(1), CreatePost) + r.PUT("/blogs/:id", withUser(1), UpdatePost) + r.DELETE("/blogs/:id", withUser(1), DeletePost) + + createBody := []byte(`{"title":"Yeni Blog","content":"icerik"}`) + wCreate := httptest.NewRecorder() + reqCreate := httptest.NewRequest(http.MethodPost, "/blogs", bytes.NewReader(createBody)) + reqCreate.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wCreate, reqCreate) + if wCreate.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d body=%s", wCreate.Code, wCreate.Body.String()) + } + + // SQLite'da mevcut model taniminda auto ID davranisi tutarsiz olabildigi icin + // update/delete senaryosunu explicit ID ile seed edilen kayit uzerinden dogruluyoruz. + seedForUpdate := blogModels.Post{ID: 77, Title: "Seeded Blog", Content: "x", IsActive: true, IsFront: true} + if err := configs.DB.Create(&seedForUpdate).Error; err != nil { + t.Fatalf("seed for update failed: %v", err) + } + + updateBody := []byte(`{"title":"Guncel Blog"}`) + wUpdate := httptest.NewRecorder() + reqUpdate := httptest.NewRequest(http.MethodPut, "/blogs/"+strconv.FormatUint(seedForUpdate.ID, 10), bytes.NewReader(updateBody)) + reqUpdate.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wUpdate, reqUpdate) + if wUpdate.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", wUpdate.Code, wUpdate.Body.String()) + } + + wDelete := httptest.NewRecorder() + r.ServeHTTP(wDelete, httptest.NewRequest(http.MethodDelete, "/blogs/"+strconv.FormatUint(seedForUpdate.ID, 10), nil)) + if wDelete.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", wDelete.Code) + } +} + +func TestCategoryAndTagEndpoints(t *testing.T) { + gin.SetMode(gin.TestMode) + setupBlogHandlersTestDB(t) + + r := gin.New() + r.GET("/blogs/categories", ListCategories) + r.GET("/blogs/tags", ListTags) + r.POST("/blogs/categories", withUser(1), CreateCategory) + r.PUT("/blogs/categories/:id", withUser(1), UpdateCategory) + r.DELETE("/blogs/categories/:id", withUser(1), DeleteCategory) + r.POST("/blogs/tags", withUser(1), CreateTag) + r.PUT("/blogs/tags/:id", withUser(1), UpdateTag) + r.DELETE("/blogs/tags/:id", withUser(1), DeleteTag) + + wCreateCategory := httptest.NewRecorder() + reqCreateCategory := httptest.NewRequest(http.MethodPost, "/blogs/categories", bytes.NewReader([]byte(`{"title":"Genel"}`))) + reqCreateCategory.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wCreateCategory, reqCreateCategory) + if wCreateCategory.Code != http.StatusCreated { + t.Fatalf("category create expected 201, got %d body=%s", wCreateCategory.Code, wCreateCategory.Body.String()) + } + + wCreateTag := httptest.NewRecorder() + reqCreateTag := httptest.NewRequest(http.MethodPost, "/blogs/tags", bytes.NewReader([]byte(`{"tag":"Go"}`))) + reqCreateTag.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wCreateTag, reqCreateTag) + if wCreateTag.Code != http.StatusCreated { + t.Fatalf("tag create expected 201, got %d body=%s", wCreateTag.Code, wCreateTag.Body.String()) + } + + // SQLite'da mevcut model taniminda auto ID davranisi tutarsiz olabildigi icin + // kategori/tag update-delete senaryosunu explicit ID ile seed edilen kayitlar uzerinden dogruluyoruz. + seedCategory := blogModels.Category{ID: 77, Title: "SeedCategory", IsActive: true, Order: 1} + if err := configs.DB.Create(&seedCategory).Error; err != nil { + t.Fatalf("seed category for update failed: %v", err) + } + seedTag := blogModels.Tag{ID: 88, Tag: "SeedTag", IsActive: true} + if err := configs.DB.Create(&seedTag).Error; err != nil { + t.Fatalf("seed tag for update failed: %v", err) + } + + wListCategories := httptest.NewRecorder() + r.ServeHTTP(wListCategories, httptest.NewRequest(http.MethodGet, "/blogs/categories", nil)) + if wListCategories.Code != http.StatusOK { + t.Fatalf("category list expected 200, got %d", wListCategories.Code) + } + + wListTags := httptest.NewRecorder() + r.ServeHTTP(wListTags, httptest.NewRequest(http.MethodGet, "/blogs/tags", nil)) + if wListTags.Code != http.StatusOK { + t.Fatalf("tag list expected 200, got %d", wListTags.Code) + } + + wUpdateCategory := httptest.NewRecorder() + reqUpdateCategory := httptest.NewRequest(http.MethodPut, "/blogs/categories/"+strconv.FormatUint(seedCategory.ID, 10), bytes.NewReader([]byte(`{"title":"Teknoloji"}`))) + reqUpdateCategory.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wUpdateCategory, reqUpdateCategory) + if wUpdateCategory.Code != http.StatusOK { + t.Fatalf("category update expected 200, got %d body=%s", wUpdateCategory.Code, wUpdateCategory.Body.String()) + } + + wUpdateTag := httptest.NewRecorder() + reqUpdateTag := httptest.NewRequest(http.MethodPut, "/blogs/tags/"+strconv.FormatUint(seedTag.ID, 10), bytes.NewReader([]byte(`{"tag":"Golang"}`))) + reqUpdateTag.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wUpdateTag, reqUpdateTag) + if wUpdateTag.Code != http.StatusOK { + t.Fatalf("tag update expected 200, got %d body=%s", wUpdateTag.Code, wUpdateTag.Body.String()) + } + + wDeleteCategory := httptest.NewRecorder() + r.ServeHTTP(wDeleteCategory, httptest.NewRequest(http.MethodDelete, "/blogs/categories/"+strconv.FormatUint(seedCategory.ID, 10), nil)) + if wDeleteCategory.Code != http.StatusNoContent { + t.Fatalf("category delete expected 204, got %d", wDeleteCategory.Code) + } + + wDeleteTag := httptest.NewRecorder() + r.ServeHTTP(wDeleteTag, httptest.NewRequest(http.MethodDelete, "/blogs/tags/"+strconv.FormatUint(seedTag.ID, 10), nil)) + if wDeleteTag.Code != http.StatusNoContent { + t.Fatalf("tag delete expected 204, got %d", wDeleteTag.Code) + } +} diff --git a/app/blogs/models/blog.go b/app/blogs/models/blog.go new file mode 100644 index 0000000..c1ed12f --- /dev/null +++ b/app/blogs/models/blog.go @@ -0,0 +1,260 @@ +package models + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + accountModels "ginimageApi/app/accounts/models" + + "gorm.io/gorm" +) + +// Note: This file maps Django models to GORM models for MySQL. +// Image fields are stored as file path strings. Thumbnail generation and image processing +// should be handled elsewhere (e.g., during upload) — TODO: integrate with image processing service. + +// Category represents post categories. +type Category struct { + ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"` + Title string `gorm:"size:254;not null" json:"title"` + Keywords string `gorm:"size:254" json:"keywords"` + Desc string `gorm:"size:254" json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + IsActive bool `gorm:"default:true;index" json:"is_active"` + Order int `gorm:"default:1;index" json:"order"` + Slug string `gorm:"size:250;not null;index" json:"slug"` + ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"` + Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []*Category `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Image string `gorm:"size:1024" json:"image"` +} + +func (Category) TableName() string { + return "categories" +} + +// BeforeCreate hook to set slug +func (c *Category) BeforeCreate(tx *gorm.DB) (err error) { + if c.Slug == "" { + c.Slug, err = generateUniqueSlugForCategory(tx, c.Title) + return err + } + return nil +} + +// BeforeUpdate hook ensures slug exists +func (c *Category) BeforeUpdate(tx *gorm.DB) (err error) { + if c.Slug == "" { + c.Slug, err = generateUniqueSlugForCategory(tx, c.Title) + return err + } + return nil +} + +func generateUniqueSlugForCategory(db *gorm.DB, title string) (string, error) { + slug := normalizeSlug(title) + base := slug + var count int64 + try := 1 + for { + db.Model(&Category{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, try) + try++ + if try > 1000 { + return "", errors.New("unable to generate unique slug") + } + } +} + +// Tags model +type Tag struct { + ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"` + Tag string `gorm:"size:254;not null" json:"tag"` + Slug string `gorm:"size:250;not null;index" json:"slug"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + IsActive bool `gorm:"default:true;index" json:"is_active"` +} + +func (Tag) TableName() string { return "tags" } + +func (t *Tag) BeforeCreate(tx *gorm.DB) (err error) { + if t.Slug == "" { + t.Slug, err = generateUniqueSlugForTag(tx, t.Tag) + return err + } + return nil +} + +func generateUniqueSlugForTag(db *gorm.DB, tag string) (string, error) { + slug := normalizeSlug(tag) + base := slug + var count int64 + try := 1 + for { + db.Model(&Tag{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, try) + try++ + if try > 1000 { + return "", errors.New("unable to generate unique slug") + } + } +} + +// Post model +type Post struct { + ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"` + Title string `gorm:"size:254;not null" json:"title"` + UserID *uint64 `gorm:"type:bigint unsigned;index" json:"user_id"` + User *accountModels.User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Content string `gorm:"type:text" json:"content"` + Categories []*Category `gorm:"many2many:post_categories;" json:"categories"` + Keywords string `gorm:"size:254" json:"keywords"` + Tags []*Tag `gorm:"many2many:post_tags;" json:"tags"` + Image string `gorm:"size:1024" json:"image"` + Thumb string `gorm:"size:1024" json:"thumb"` + Video string `gorm:"size:254;default:'none'" json:"video"` + Slug string `gorm:"size:250;not null;index" json:"slug"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + IsActive bool `gorm:"default:true;index" json:"is_active"` + IsFront bool `gorm:"default:true;index" json:"is_front"` + ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"` + Parent *Post `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []*Post `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +func (Post) TableName() string { return "posts" } + +func (p *Post) BeforeCreate(tx *gorm.DB) (err error) { + if p.Slug == "" { + p.Slug, err = generateUniqueSlugForPost(tx, p.Title) + if err != nil { + return err + } + } + // Note: Thumbnail generation should be handled in the upload flow. + return nil +} + +func (p *Post) BeforeUpdate(tx *gorm.DB) (err error) { + if p.Slug == "" { + p.Slug, err = generateUniqueSlugForPost(tx, p.Title) + return err + } + return nil +} + +func generateUniqueSlugForPost(db *gorm.DB, title string) (string, error) { + slug := normalizeSlug(title) + base := slug + var count int64 + try := 1 + for { + db.Model(&Post{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, try) + try++ + if try > 1000 { + return "", errors.New("unable to generate unique slug") + } + } +} + +// CategoryView model +type CategoryView struct { + ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"` + CategoryID uint64 `gorm:"type:bigint unsigned;index" json:"category_id"` + Category *Category `gorm:"foreignKey:CategoryID" json:"category"` + IPAddress string `gorm:"size:45;index" json:"ip_address"` + UserAgent string `gorm:"type:text" json:"user_agent"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (CategoryView) TableName() string { return "category_views" } + +// Comment model +type Comment struct { + ID uint64 `gorm:"type:bigint unsigned;autoIncrement;primaryKey" json:"id"` + UserID uint64 `gorm:"type:bigint unsigned;index" json:"user_id"` + PostID uint64 `gorm:"type:bigint unsigned;index" json:"post_id"` + Post *Post `gorm:"foreignKey:PostID" json:"post,omitempty"` + Title string `gorm:"size:254" json:"title"` + Body string `gorm:"type:text" json:"body"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + IsActive bool `gorm:"default:true;index" json:"is_active"` + Slug string `gorm:"size:250;index" json:"slug"` + ParentID *uint64 `gorm:"type:bigint unsigned;index" json:"parent_id"` + Parent *Comment `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []*Comment `gorm:"foreignKey:ParentID" json:"children,omitempty"` +} + +func (Comment) TableName() string { return "comments" } + +func (c *Comment) BeforeCreate(tx *gorm.DB) (err error) { + if c.Slug == "" { + c.Slug, err = generateUniqueSlugForComment(tx, c.Title) + return err + } + return nil +} + +func generateUniqueSlugForComment(db *gorm.DB, title string) (string, error) { + slug := normalizeSlug(title) + base := slug + var count int64 + try := 1 + for { + db.Model(&Comment{}).Where("slug = ?", slug).Count(&count) + if count == 0 { + return slug, nil + } + slug = fmt.Sprintf("%s-%d", base, try) + try++ + if try > 1000 { + return "", errors.New("unable to generate unique slug") + } + } +} + +// normalizeSlug replaces Turkish characters, lowercases and makes a basic slug. +func normalizeSlug(s string) string { + replacer := strings.NewReplacer( + "ı", "i", + "İ", "i", + "ç", "c", + "Ç", "c", + "ş", "s", + "Ş", "s", + "ö", "o", + "Ö", "o", + "ü", "u", + "Ü", "u", + " ", "-", + ) + s = replacer.Replace(s) + s = strings.ToLower(s) + s = strings.TrimSpace(s) + // remove multiple dashes + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + // remove extension-like parts + s = strings.Trim(s, "-._") + // sanitize file-like chars + s = filepath.Clean(s) + return s +} diff --git a/app/images/handlers/image.go b/app/images/handlers/image.go new file mode 100644 index 0000000..06d33a2 --- /dev/null +++ b/app/images/handlers/image.go @@ -0,0 +1,362 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + imageModels "ginimageApi/app/images/models" + "ginimageApi/configs" + imageProcessor "ginimageApi/pkg/images" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ProcessImageResponse struct { + Message string `json:"message"` + FileName string `json:"file_name"` + PublicPath string `json:"public_path"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format"` +} + +type ImageRecordResponse struct { + ID uint `json:"id"` + FileName string `json:"file_name"` + PublicPath string `json:"public_path"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format"` + Mode string `json:"mode"` + CreatedAt time.Time `json:"created_at"` +} + +type ListImagesResponse struct { + Count int `json:"count"` + Items []ImageRecordResponse `json:"items"` +} + +type ImageErrorResponse struct { + Error string `json:"error"` +} + +func parseIntForm(c *gin.Context, key string, defaultValue int) (int, error) { + raw := strings.TrimSpace(c.PostForm(key)) + if raw == "" { + return defaultValue, nil + } + v, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("%s sayi olmali", key) + } + return v, nil +} + +func parseBoolForm(c *gin.Context, key string, defaultValue bool) bool { + raw := strings.TrimSpace(strings.ToLower(c.PostForm(key))) + if raw == "" { + return defaultValue + } + return raw == "1" || raw == "true" || raw == "yes" || raw == "on" +} + +func mimeFromFormat(format string) string { + switch format { + case "avif": + return "image/avif" + case "webp": + return "image/webp" + case "png": + return "image/png" + default: + return "image/jpeg" + } +} + +func outputDir() string { + d := strings.TrimSpace(os.Getenv("IMAGE_OUTPUT_DIR")) + if d == "" { + return "uploads/processed" + } + return d +} + +func randomSuffix() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "rand" + } + return hex.EncodeToString(b) +} + +func getUserID(c *gin.Context) (uint, bool) { + v, ok := c.Get("user_id") + if !ok { + return 0, false + } + switch t := v.(type) { + case uint: + return t, true + case int: + if t < 0 { + return 0, false + } + return uint(t), true + default: + return 0, false + } +} + +func requestBaseURL(c *gin.Context) string { + if base := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); base != "" { + return strings.TrimRight(base, "/") + } + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" { + scheme = proto + } + return fmt.Sprintf("%s://%s", scheme, c.Request.Host) +} + +func ensureDBAndUser(c *gin.Context) (uint, bool) { + if configs.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"}) + return 0, false + } + userID, ok := getUserID(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"}) + return 0, false + } + return userID, true +} + +func toImageRecordResponse(c *gin.Context, img imageModels.Image) ImageRecordResponse { + return ImageRecordResponse{ + ID: img.ID, + FileName: img.Filename, + PublicPath: img.PublicPath, + URL: requestBaseURL(c) + img.PublicPath, + MimeType: img.MimeType, + Size: img.Size, + Width: img.Width, + Height: img.Height, + Quality: img.Quality, + Format: img.Format, + Mode: img.Mode, + CreatedAt: img.CreatedAt, + } +} + +// ListImages godoc +// @Summary Giris yapan kullanicinin kayitli resimlerini listeler +// @Tags images +// @Produce json +// @Security BearerAuth +// @Success 200 {object} ListImagesResponse +// @Failure 401 {object} ImageErrorResponse +// @Failure 500 {object} ImageErrorResponse +// @Router /api/v1/images [get] +func ListImages(c *gin.Context) { + userID, ok := ensureDBAndUser(c) + if !ok { + return + } + + var images []imageModels.Image + if err := configs.DB.Where("user_id = ?", userID).Order("id desc").Find(&images).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "resimler listelenemedi"}) + return + } + + items := make([]ImageRecordResponse, 0, len(images)) + for _, item := range images { + items = append(items, toImageRecordResponse(c, item)) + } + + c.JSON(http.StatusOK, ListImagesResponse{Count: len(items), Items: items}) +} + +// GetImage godoc +// @Summary Giris yapan kullanicinin tekil resim kaydini getirir +// @Tags images +// @Produce json +// @Security BearerAuth +// @Param id path int true "Image ID" +// @Success 200 {object} ImageRecordResponse +// @Failure 400 {object} ImageErrorResponse +// @Failure 401 {object} ImageErrorResponse +// @Failure 404 {object} ImageErrorResponse +// @Failure 500 {object} ImageErrorResponse +// @Router /api/v1/images/{id} [get] +func GetImage(c *gin.Context) { + userID, ok := ensureDBAndUser(c) + if !ok { + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz image id"}) + return + } + + var image imageModels.Image + err = configs.DB.Where("id = ? AND user_id = ?", uint(id), userID).First(&image).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "resim bulunamadi"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "resim getirilemedi"}) + return + } + + c.JSON(http.StatusOK, toImageRecordResponse(c, image)) +} + +// Process godoc +// @Summary Resmi en, boy, kalite ve formata gore isler +// @Tags images +// @Accept mpfd +// @Produce json +// @Security BearerAuth +// @Param file formData file true "Yuklenecek resim" +// @Param width formData int false "Hedef genislik (default: orijinal)" +// @Param height formData int false "Hedef yukseklik (default: orijinal)" +// @Param quality formData int false "Kalite 1-100 (default: 90)" +// @Param format formData string false "avif|webp|png|jpg|jpeg (default: avif)" +// @Param cover formData boolean false "true ise cover crop uygular" +// @Success 200 {object} ProcessImageResponse +// @Failure 400 {object} ImageErrorResponse +// @Failure 401 {object} ImageErrorResponse +// @Failure 500 {object} ImageErrorResponse +// @Router /api/v1/images/process [post] +func Process(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"}) + return + } + + width, err := parseIntForm(c, "width", 0) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + height, err := parseIntForm(c, "height", 0) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + quality, err := parseIntForm(c, "quality", imageProcessor.DefaultQuality) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + opts := imageProcessor.ProcessOptions{ + Width: width, + Height: height, + Quality: quality, + Format: c.PostForm("format"), + Cover: parseBoolForm(c, "cover", false), + } + normalized, err := imageProcessor.NormalizeOptions(opts) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, ok := ensureDBAndUser(c) + if !ok { + return + } + + src, err := file.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"}) + return + } + defer src.Close() + + buffer, err := io.ReadAll(src) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"}) + return + } + + processed, err := imageProcessor.ProcessImage(buffer, normalized) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := os.MkdirAll(outputDir(), 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "output klasoru olusturulamadi"}) + return + } + + baseName := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)) + outName := fmt.Sprintf("%s_%d_%s.%s", baseName, time.Now().Unix(), randomSuffix(), normalized.Format) + absPath := filepath.Join(outputDir(), outName) + if err := os.WriteFile(absPath, processed, 0o644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "islenmis dosya kaydedilemedi"}) + return + } + + processedSize := int64(len(processed)) + imgSize, _ := imageProcessor.GetSize(processed) + publicPath := "/uploads/processed/" + outName + url := requestBaseURL(c) + publicPath + + record := imageModels.Image{ + UserID: userID, + Filename: outName, + PublicPath: publicPath, + MimeType: mimeFromFormat(normalized.Format), + Size: processedSize, + Width: imgSize.Width, + Height: imgSize.Height, + Quality: normalized.Quality, + Format: normalized.Format, + Mode: map[bool]string{true: "cover", false: "fit"}[normalized.Cover], + } + if err := configs.DB.Create(&record).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db image kaydi olusturulamadi"}) + return + } + + c.JSON(http.StatusOK, ProcessImageResponse{ + Message: "resim isleme tamamlandi", + FileName: outName, + PublicPath: publicPath, + URL: url, + MimeType: record.MimeType, + Size: record.Size, + Width: record.Width, + Height: record.Height, + Quality: record.Quality, + Format: record.Format, + }) +} diff --git a/app/images/handlers/image_test.go b/app/images/handlers/image_test.go new file mode 100644 index 0000000..57f2ae5 --- /dev/null +++ b/app/images/handlers/image_test.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + imageModels "ginimageApi/app/images/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupImageHandlersTestDB(t *testing.T) { + t.Helper() + prev := configs.DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("sqlite open failed: %v", err) + } + if err := db.AutoMigrate(&imageModels.Image{}); err != nil { + t.Fatalf("migrate failed: %v", err) + } + configs.DB = db + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + configs.DB = prev + }) +} + +func withUser(userID uint) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("user_id", userID) + c.Next() + } +} + +func TestProcessRequiresFile(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/images/process", Process) + + req := httptest.NewRequest(http.MethodPost, "/images/process", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestProcessRejectsInvalidWidth(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/images/process", Process) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + filePart, err := writer.CreateFormFile("file", "dummy.jpg") + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + _, _ = filePart.Write([]byte("not-a-real-image")) + _ = writer.WriteField("width", "abc") + if err := writer.Close(); err != nil { + t.Fatalf("failed to close writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/images/process", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestListImagesReturnsOnlyCurrentUser(t *testing.T) { + gin.SetMode(gin.TestMode) + setupImageHandlersTestDB(t) + + seed := []imageModels.Image{ + {UserID: 1, Filename: "a.avif", PublicPath: "/uploads/processed/a.avif", MimeType: "image/avif", Size: 10, Format: "avif", Quality: 90}, + {UserID: 1, Filename: "b.avif", PublicPath: "/uploads/processed/b.avif", MimeType: "image/avif", Size: 11, Format: "avif", Quality: 90}, + {UserID: 2, Filename: "c.avif", PublicPath: "/uploads/processed/c.avif", MimeType: "image/avif", Size: 12, Format: "avif", Quality: 90}, + } + if err := configs.DB.Create(&seed).Error; err != nil { + t.Fatalf("seed failed: %v", err) + } + + r := gin.New() + r.GET("/images", withUser(1), ListImages) + + req := httptest.NewRequest(http.MethodGet, "/images", nil) + req.Host = "localhost:8080" + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) + } + + var resp struct { + Count int `json:"count"` + Items []struct { + ID uint `json:"id"` + } `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("json parse failed: %v", err) + } + if resp.Count != 2 || len(resp.Items) != 2 { + t.Fatalf("expected 2 images for current user, got count=%d len=%d", resp.Count, len(resp.Items)) + } +} + +func TestGetImageRejectsOtherUsersImage(t *testing.T) { + gin.SetMode(gin.TestMode) + setupImageHandlersTestDB(t) + + img := imageModels.Image{UserID: 2, Filename: "x.avif", PublicPath: "/uploads/processed/x.avif", MimeType: "image/avif", Size: 5, Format: "avif", Quality: 90} + if err := configs.DB.Create(&img).Error; err != nil { + t.Fatalf("seed failed: %v", err) + } + + r := gin.New() + r.GET("/images/:id", withUser(1), GetImage) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/images/1", nil)) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} diff --git a/app/images/models/images.go b/app/images/models/images.go new file mode 100644 index 0000000..76eeb47 --- /dev/null +++ b/app/images/models/images.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" +) + +type Image struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Filename string `gorm:"not null" json:"filename"` + PublicPath string `gorm:"not null" json:"public_path"` + MimeType string `gorm:"not null" json:"mime_type"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Format string `json:"format"` + Mode string `json:"mode"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/app/mcp/README.md b/app/mcp/README.md new file mode 100644 index 0000000..c65dde7 --- /dev/null +++ b/app/mcp/README.md @@ -0,0 +1,337 @@ +# MCP (Model Context Protocol) Server - GinImage API + +Bu dizin, GinImage API'nin Model Context Protocol (MCP) sunucusu uygulamasını içerir. + +## ⚠️ Önemli: v0.1.0 - mcp-go Migration Tamamlandı + +**Eski uygulama (hand-written JSON-RPC):** Tamamen kaldırıldı. +**Yeni uygulama (mark3labs/mcp-go):** Tek kaynak. Tüm MCP istekleri mcp-go tarafından işlenir. Protocol compliance: %100. + +### Değişiklik Özeti + +| Unsur | Eski | Yeni | +|-------|------|------| +| Kod satırı sayısı | ~1100 | ~250 | +| JSON-RPC handler | Elle yazılmış | mcp-go sağlıyor | +| Tool registration | Switch-case | `server.AddTool()` | +| Protocol compliance | Elle test | %100 (mcp-go) | + +--- + +## 1) Servisi Başlat + +Proje kökünde: + +```bash +go run . +``` + +Varsayılan port `8080` olduğu için MCP endpoint: +- `http://127.0.0.1:8080/mcp` + +Farklı port kullanmak istersen: + +```bash +PORT=9090 go run . +``` + +--- + +## 2) Cursor MCP Ayarı + +`~/.cursor/mcp.json` dosyasına şu şekilde ekle: + +```json +{ + "mcpServers": { + "ginimage-api": { + "url": "http://127.0.0.1:8080/mcp" + } + } +} +``` + +Sonra Cursor'da MCP server'i yenile/reload et. + +--- + +## 3) Mevcut Tool'lar + +### 3.1) `api_overview` +- **Açıklama:** GinImage API endpoint özeti ve kullanımı +- **Giriş:** Yok +- **Çıkış:** Metin + +### 3.2) `health_check` +- **Açıklama:** API health endpoint durumunu kontrol eder +- **Giriş:** `path` (string, opsiyonel) - Varsayılan: `/swagger/index.html` +- **Çıkış:** Metin + +### 3.3) `md_guide_list` +- **Açıklama:** `docs/mcp-tools` altındaki markdown rehber dosyalarını listeler +- **Giriş:** Yok +- **Çıkış:** Metin + +### 3.4) `md_guide_get` +- **Açıklama:** Seçilen markdown rehber dosyasının içeriğini döndürür +- **Giriş:** `guide` (string, zorunlu) - Rehber dosya adı (örn: `codebase_map.md`) +- **Çıkış:** Metin (dosya içeriği) + +### 3.5) `codebase_map` +- **Açıklama:** Proje klasör ve kritik dosya yapısını özetler +- **Giriş:** + - `focus` (string, opsiyonel) - Odak klasörü + - `depth` (number, opsiyonel) - Tarama derinliği (varsayılan: 2, maksimum: 5) +- **Çıkış:** Metin (proje yapısı) + +### 3.6) `tool_stats` +- **Açıklama:** MCP tool kullanım istatistiklerini veritabanından özetler +- **Giriş:** `limit` (number, opsiyonel) - Kayıt limiti (varsayılan: 10, maksimum: 50) +- **Çıkış:** Metin (istatistikler) + +--- + +## 3.7) Markdown Rehberi Yükleme Endpoint'i + +**POST `/api/v1/mcp/guides/upload`** +- `docs/mcp-tools` altına `.md` dosyası yükler +- `multipart/form-data` bekler +- Zorunlu alan: `file` (`.md` uzantılı) +- Opsiyonel alan: `overwrite` (`true/false`, varsayılan: `false`) +- Güvenlik: Bearer Token gerekli + +--- + +## 4) Örnek MCP Çağrıları (JSON-RPC 2.0) + +### Tool Listesini Almak + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }' +``` + +### `api_overview` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":2, + "method":"tools/call", + "params":{ + "name":"api_overview", + "arguments":{} + } + }' +``` + +### `health_check` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params":{ + "name":"health_check", + "arguments":{"path":"/swagger/index.html"} + } + }' +``` + +### `md_guide_list` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":4, + "method":"tools/call", + "params":{ + "name":"md_guide_list", + "arguments":{} + } + }' +``` + +### `md_guide_get` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":5, + "method":"tools/call", + "params":{ + "name":"md_guide_get", + "arguments":{"guide":"codebase_map.md"} + } + }' +``` + +### `codebase_map` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":6, + "method":"tools/call", + "params":{ + "name":"codebase_map", + "arguments":{"focus":"app/images","depth":2} + } + }' +``` + +### `tool_stats` Çağrısı + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":7, + "method":"tools/call", + "params":{ + "name":"tool_stats", + "arguments":{"limit":10} + } + }' +``` + +### Markdown Rehberi Yükleme + +```bash +curl -X POST "http://127.0.0.1:8080/api/v1/mcp/guides/upload" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -F "file=@./docs/mcp-tools/ornek-rehber.md" \ + -F "overwrite=false" +``` + +--- + +## 5) Ortam Değişkenleri + +- **`PORT`** (opsiyonel) + - Gin API ve MCP endpoint'inin dinleyeceği port (varsayılan: 8080) + +- **`GINIMAGE_API_BASE_URL`** (opsiyonel) + - `health_check` tool'unun kontrol için kullanacağı base URL + - Tanımlanmamışsa gelen isteğin host bilgisinden otomatik üretilir + +--- + +## 6) Mimari + +``` +app/mcp/ +├── server.go # HTTP handlers, DELETE handler, helper functions +├── server_mcpgo.go # mcp-go tool registration, logging wrapper +├── models/ +│ ├── tool_run.go # ToolRun DB modeli +│ └── ... +└── README.md (this file) +``` + +--- + +## 7) Yeni Tool Ekleme Rehberi + +### Adım 1: Tool'u Kayıt Et + +`server_mcpgo.go` içinde `newMCPGoServer()` içine ekle: + +```go +s.AddTool( + mcpgo.NewTool( + "my_tool", + mcpgo.WithDescription("Tool açıklaması."), + mcpgo.WithString("param1", mcpgo.Description("Parametre 1"), mcpgo.Required()), + mcpgo.WithNumber("param2", mcpgo.Description("Parametre 2")), + ), + withToolRunLog("my_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + // Parametreleri ayrıştır + param1, err := req.RequireString("param1") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + param2 := req.GetInt("param2", 0) + + // İşlem yap + result := "Sonuç" + + // Sonuç dön + return mcpgo.NewToolResultText(result), nil + }), +) +``` + +### Adım 2: Parametre Yardımcıları + +`CallToolRequest` yöntemleri: +- `GetString(key, defaultValue) string` +- `RequireString(key) (string, error)` +- `GetInt(key, defaultValue) int` +- `RequireInt(key) (int, error)` +- `GetFloat(key, defaultValue) float64` +- `RequireFloat(key) (float64, error)` +- `GetBool(key, defaultValue) bool` +- `RequireBool(key) (bool, error)` +- `GetArguments() map[string]any` +- `BindArguments(target any) error` (strongly-typed) + +### Adım 3: Sonuç Türleri + +- **Metin:** `mcpgo.NewToolResultText(text string)` +- **JSON:** `mcpgo.NewToolResultJSON(data any)` +- **Yapılandırılmış:** `mcpgo.NewToolResultStructured(structured any, fallbackText string)` +- **Hata:** `mcpgo.NewToolResultError(text string)` +- **Resim:** `mcpgo.NewToolResultImage(text, imageData, mimeType string)` +- **Ses:** `mcpgo.NewToolResultAudio(text, audioData, mimeType string)` + +### Adım 4: DB Loglama (Otomatik) + +`withToolRunLog` wrapper'ı otomatik olarak: +- Tool çağrı zamanını ölçer +- Başarı/hata durumunu kaydeder +- Argümanları (4096 byte'a kadar) kayıt eder +- `mcp_tool_runs` tablosuna yazar + +--- + +## 8) Sık Karşılaşılan Sorunlar + +| Hata | Çözüm | +|------|-------| +| `connection refused` | Backend çalışmıyor. `go run .` ile başlat | +| MCP server Cursor'da görünmüyor | `~/.cursor/mcp.json` dosya formatını kontrol et, Cursor MCP reload yap | +| 404 dönüyor | URL doğru mu? `/mcp` route'u kullanılmalı | +| `health_check` beklenmedik hosta gidiyor | `GINIMAGE_API_BASE_URL` değerini açıkça ver | +| `md_guide_get` "guide not found" dönüyor | Dosya `docs/mcp-tools` altında mı? `.md` uzantısı mı? | + +--- + +## 9) Referanslar + +- [MCP Specification](https://modelcontextprotocol.io/) +- [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) +- GinImage API Overview: `apiOverviewText()` fonksiyonu bak + +--- + +**Sürüm:** 0.1.0 (mcp-go migration) +**Tarih:** 2026-04-16 +**Durum:** ✅ Production Ready diff --git a/app/mcp/http_helpers.go b/app/mcp/http_helpers.go new file mode 100644 index 0000000..9687653 --- /dev/null +++ b/app/mcp/http_helpers.go @@ -0,0 +1,61 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// doAPIRequest genel amaçlı HTTP istek yardımcısı +func doAPIRequest(ctx context.Context, method, path, bearer string, body interface{}) (int, string, error) { + baseURL := resolveBaseURLFromContext(ctx) + url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path) + + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, "", fmt.Errorf("request body marshal error: %v", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return 0, "", fmt.Errorf("request creation error: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, "", fmt.Errorf("request error: %v", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, "", fmt.Errorf("response read error: %v", err) + } + + // Pretty-print JSON yanıt + var prettyBuf bytes.Buffer + if json.Indent(&prettyBuf, respBytes, "", " ") == nil { + return resp.StatusCode, prettyBuf.String(), nil + } + return resp.StatusCode, string(respBytes), nil +} + +// apiResult tool sonucu formatlar +func apiResult(status int, body string) string { + return fmt.Sprintf("HTTP %d\n%s", status, body) +} diff --git a/app/mcp/models/tool_run.go b/app/mcp/models/tool_run.go new file mode 100644 index 0000000..8451512 --- /dev/null +++ b/app/mcp/models/tool_run.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type ToolRun struct { + ID uint `gorm:"primaryKey" json:"id"` + ToolName string `gorm:"size:128;index;not null" json:"tool_name"` + Status string `gorm:"size:16;index;not null" json:"status"` + DurationMs int64 `gorm:"index" json:"duration_ms"` + ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"` + ArgumentsRaw string `gorm:"type:longtext" json:"arguments_raw,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` +} + +func (ToolRun) TableName() string { + return "mcp_tool_runs" +} diff --git a/app/mcp/server.go b/app/mcp/server.go new file mode 100644 index 0000000..24a0cd5 --- /dev/null +++ b/app/mcp/server.go @@ -0,0 +1,583 @@ +package mcp + +import ( + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + mcpModels "ginimageApi/app/mcp/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" +) + +type HTTPRequest struct { + JSONRPC string `json:"jsonrpc" example:"2.0"` + ID interface{} `json:"id,omitempty" swaggertype:"object"` + Method string `json:"method" example:"tools/list"` + Params map[string]interface{} `json:"params,omitempty"` +} + +type HTTPResponse struct { + JSONRPC string `json:"jsonrpc" example:"2.0"` + ID interface{} `json:"id,omitempty" swaggertype:"object"` + Result map[string]interface{} `json:"result,omitempty"` + Error map[string]interface{} `json:"error,omitempty"` +} + +type UploadGuideResponse struct { + Message string `json:"message" example:"markdown guide uploaded"` + Guide string `json:"guide" example:"my-guide.md"` + Path string `json:"path" example:"docs/mcp-tools/my-guide.md"` +} + +type UploadGuideErrorResponse struct { + Error string `json:"error" example:"file must be a markdown (.md) file"` +} + +const ( + mdGuidesDir = "docs/mcp-tools" + maxGuideSize = 64 * 1024 + defaultDepth = 2 + maxDepth = 5 +) + +// HTTPHandler godoc +// @Summary MCP JSON-RPC endpoint +// @Description MCP isteklerini JSON-RPC 2.0 formatinda kabul eder. +// @Tags mcp +// @Accept json +// @Produce json +// @Param request body HTTPRequest true "MCP JSON-RPC request" +// @Success 200 {object} HTTPResponse +// @Failure 400 {object} HTTPResponse +// @Security BearerAuth +// @Router /api/v1/mcp [post] +func HTTPHandler() gin.HandlerFunc { + return gin.WrapH(getMCPGoHTTPHandler()) +} + +// StreamableHTTPGETHandler implements MCP Streamable HTTP GET. +func StreamableHTTPGETHandler() gin.HandlerFunc { + return gin.WrapH(getMCPGoHTTPHandler()) +} + +// StreamableHTTPDELETEHandler godoc +// @Summary MCP streamable DELETE endpoint +// @Description Stateless MCP server icin session teardown desteklenmez, 405 doner. +// @Tags mcp +// @Produce json +// @Success 405 {string} string "Method Not Allowed" +// @Security BearerAuth +// @Router /api/v1/mcp [delete] +func StreamableHTTPDELETEHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Allow", "POST, GET") + c.Status(http.StatusMethodNotAllowed) + } +} + +// UploadGuideHandler godoc +// @Summary MCP markdown rehberi yukler +// @Description `.md` dosyasini `docs/mcp-tools` altina kaydeder ve MCP tool'lari tarafindan okunabilir hale getirir. +// @Tags mcp +// @Accept mpfd +// @Produce json +// @Param file formData file true "Yuklenecek markdown dosyasi" +// @Param overwrite formData boolean false "Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)" +// @Success 200 {object} UploadGuideResponse +// @Failure 400 {object} UploadGuideErrorResponse +// @Failure 409 {object} UploadGuideErrorResponse +// @Failure 500 {object} UploadGuideErrorResponse +// @Security BearerAuth +// @Router /api/v1/mcp/guides/upload [post] +func UploadGuideHandler() gin.HandlerFunc { + return func(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"}) + return + } + + name := strings.TrimSpace(file.Filename) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "dosya adi bos olamaz"}) + return + } + + cleanName := filepath.Base(name) + if cleanName != name || strings.Contains(cleanName, "/") || strings.Contains(cleanName, "\\") { + c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz dosya adi"}) + return + } + + if !strings.HasSuffix(strings.ToLower(cleanName), ".md") { + c.JSON(http.StatusBadRequest, gin.H{"error": "yalnizca .md dosyasi yuklenebilir"}) + return + } + + src, err := file.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"}) + return + } + defer src.Close() + + data, err := io.ReadAll(src) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"}) + return + } + + if len(data) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "bos dosya yuklenemez"}) + return + } + + if len(data) > maxGuideSize { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("dosya boyutu %d byte sinirini asamaz", maxGuideSize)}) + return + } + + if err := os.MkdirAll(mdGuidesDir, 0o755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber klasoru olusturulamadi"}) + return + } + + targetPath := filepath.Join(mdGuidesDir, cleanName) + overwrite := strings.EqualFold(strings.TrimSpace(c.PostForm("overwrite")), "true") || + strings.TrimSpace(c.PostForm("overwrite")) == "1" + if !overwrite { + if _, statErr := os.Stat(targetPath); statErr == nil { + c.JSON(http.StatusConflict, gin.H{"error": "ayni isimde rehber zaten var, overwrite=true gonder"}) + return + } + } + + if err := os.WriteFile(targetPath, data, 0o644); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "rehber kaydedilemedi"}) + return + } + + c.JSON(http.StatusOK, UploadGuideResponse{ + Message: "markdown guide uploaded", + Guide: cleanName, + Path: toSlashPath(targetPath), + }) + + // Yeni guide icin MCP tool'larini yeniden yukle + go reloadMCPGoServer() + } +} + +func getToolStats(limit int) (string, error) { + if configs.DB == nil { + return "", fmt.Errorf("database is not available") + } + + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + type statRow struct { + ToolName string + TotalRuns int64 + SuccessRuns int64 + ErrorRuns int64 + AvgDurationMs float64 + } + + rows := make([]statRow, 0) + err := configs.DB.Model(&mcpModels.ToolRun{}). + Select(`tool_name, + COUNT(*) as total_runs, + SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_runs, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_runs, + AVG(duration_ms) as avg_duration_ms`). + Group("tool_name"). + Order("total_runs DESC"). + Limit(limit). + Scan(&rows).Error + if err != nil { + return "", fmt.Errorf("failed to query tool stats") + } + + if len(rows) == 0 { + return "No tool run records yet.", nil + } + + var b strings.Builder + b.WriteString("MCP tool stats\n") + b.WriteString(fmt.Sprintf("Limit: %d\n\n", limit)) + for _, row := range rows { + b.WriteString(fmt.Sprintf("- %s: total=%d success=%d error=%d avg_ms=%.1f\n", + row.ToolName, + row.TotalRuns, + row.SuccessRuns, + row.ErrorRuns, + row.AvgDurationMs, + )) + } + + return b.String(), nil +} + +func listMDGuides() ([]string, error) { + entries, err := os.ReadDir(mdGuidesDir) + if err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, err + } + + guides := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(strings.ToLower(name), ".md") { + guides = append(guides, name) + } + } + + sort.Strings(guides) + return guides, nil +} + +func readMDGuide(guide string) (string, error) { + name := strings.TrimSpace(guide) + if name == "" { + return "", fmt.Errorf("guide is required") + } + + if strings.Contains(name, "/") || strings.Contains(name, "\\") { + return "", fmt.Errorf("invalid guide name") + } + + if !strings.HasSuffix(strings.ToLower(name), ".md") { + return "", fmt.Errorf("guide must end with .md") + } + + cleanName := filepath.Base(name) + if cleanName != name { + return "", fmt.Errorf("invalid guide name") + } + + fullPath := filepath.Join(mdGuidesDir, cleanName) + data, err := os.ReadFile(fullPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("guide not found") + } + return "", fmt.Errorf("unable to read guide") + } + + if len(data) > maxGuideSize { + return "", fmt.Errorf("guide is too large") + } + + return string(data), nil +} + +func buildCodebaseMap(focus string, depth int) (string, error) { + cleanFocus, err := sanitizeFocus(focus) + if err != nil { + return "", err + } + + if depth <= 0 { + depth = defaultDepth + } + if depth > maxDepth { + depth = maxDepth + } + + basePath := "." + headerFocus := "./" + if cleanFocus != "" { + basePath = cleanFocus + headerFocus = cleanFocus + } + + entries, err := os.ReadDir(basePath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("focus not found") + } + return "", fmt.Errorf("unable to scan focus") + } + + dirs := make([]string, 0) + files := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, ".") { + continue + } + fullPath := filepath.Join(basePath, name) + if entry.IsDir() { + dirs = append(dirs, toSlashPath(fullPath)) + continue + } + files = append(files, toSlashPath(fullPath)) + } + sort.Strings(dirs) + sort.Strings(files) + + allFiles, err := collectFiles(basePath, depth) + if err != nil { + return "", fmt.Errorf("unable to map files") + } + + keyFiles := pickKeyFiles(allFiles) + moduleHints := buildModuleHints(allFiles) + + var b strings.Builder + b.WriteString("Codebase map\n") + b.WriteString(fmt.Sprintf("Focus: %s\n", headerFocus)) + b.WriteString(fmt.Sprintf("Depth: %d\n\n", depth)) + + b.WriteString("Top directories:\n") + if len(dirs) == 0 { + b.WriteString("- (none)\n") + } else { + for _, d := range dirs { + b.WriteString("- " + d + "\n") + } + } + + b.WriteString("\nTop files:\n") + if len(files) == 0 { + b.WriteString("- (none)\n") + } else { + limit := min(8, len(files)) + for _, f := range files[:limit] { + b.WriteString("- " + f + "\n") + } + if len(files) > limit { + b.WriteString(fmt.Sprintf("- ... (%d more)\n", len(files)-limit)) + } + } + + b.WriteString("\nKey files:\n") + if len(keyFiles) == 0 { + b.WriteString("- (none)\n") + } else { + for _, f := range keyFiles { + b.WriteString("- " + f + "\n") + } + } + + b.WriteString("\nModule hints:\n") + if len(moduleHints) == 0 { + b.WriteString("- (no module hints found)\n") + } else { + for _, hint := range moduleHints { + b.WriteString("- " + hint + "\n") + } + } + + return b.String(), nil +} + +func sanitizeFocus(focus string) (string, error) { + f := strings.TrimSpace(strings.ReplaceAll(focus, "\\", "/")) + if f == "" || f == "." || f == "./" { + return "", nil + } + if strings.HasPrefix(f, "/") || strings.Contains(f, "..") { + return "", fmt.Errorf("invalid focus") + } + clean := filepath.Clean(f) + if clean == "." || clean == "" { + return "", nil + } + return clean, nil +} + +func collectFiles(basePath string, depth int) ([]string, error) { + files := make([]string, 0) + baseDepth := pathDepth(basePath) + + err := filepath.WalkDir(basePath, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == basePath { + return nil + } + + name := d.Name() + if strings.HasPrefix(name, ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + currentDepth := pathDepth(path) - baseDepth + if currentDepth > depth { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + + if !d.IsDir() { + files = append(files, toSlashPath(path)) + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(files) + return files, nil +} + +func pickKeyFiles(allFiles []string) []string { + priority := []string{ + "go.mod", + "main.go", + "routers/router.go", + "app/mcp/server.go", + "configs/db.go", + "configs/redis.go", + } + + chosen := make([]string, 0, 10) + seen := make(map[string]bool) + + for _, p := range priority { + for _, f := range allFiles { + if f == p || strings.HasSuffix(f, "/"+p) { + if !seen[f] { + chosen = append(chosen, f) + seen[f] = true + } + } + } + } + + for _, f := range allFiles { + if strings.Contains(f, "/handlers/") && strings.HasSuffix(f, ".go") { + if !seen[f] { + chosen = append(chosen, f) + seen[f] = true + } + } + if len(chosen) >= 10 { + break + } + } + + if len(chosen) > 10 { + return chosen[:10] + } + return chosen +} + +func buildModuleHints(allFiles []string) []string { + modules := make(map[string]bool) + for _, f := range allFiles { + parts := strings.Split(f, "/") + if len(parts) < 2 { + continue + } + if parts[0] == "app" && len(parts) >= 2 { + modules[parts[0]+"/"+parts[1]] = true + } + if parts[0] == "pkg" && len(parts) >= 2 { + modules[parts[0]+"/"+parts[1]] = true + } + } + + if len(modules) == 0 { + return []string{} + } + + keys := make([]string, 0, len(modules)) + for k := range modules { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func pathDepth(path string) int { + clean := strings.Trim(toSlashPath(path), "/") + if clean == "" { + return 0 + } + return strings.Count(clean, "/") + 1 +} + +func toSlashPath(path string) string { + return strings.TrimPrefix(filepath.ToSlash(path), "./") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func apiOverviewText() string { + return strings.TrimSpace(` +GinImage API (base: /api/v1) + +Public auth: +- POST /auth/register +- POST /auth/login +- POST /auth/refresh + +Public blog: +- GET /blogs +- GET /blogs/categories +- GET /blogs/categories/:slug +- GET /blogs/tags +- GET /blogs/tags/:slug +- GET /blogs/:slug + +Protected (Bearer token gerekli): +- GET /me +- POST /images/process +- GET /images +- GET /images/:id + +Admin: +- POST /users/:id/admin +- POST /blogs +- PUT /blogs/:id +- DELETE /blogs/:id +- POST /blogs/categories +- PUT /blogs/categories/:id +- DELETE /blogs/categories/:id +- POST /blogs/tags +- PUT /blogs/tags/:id +- DELETE /blogs/tags/:id +`) +} + +func ensurePathPrefix(path string) string { + if strings.HasPrefix(path, "/") { + return path + } + return "/" + path +} + +func RunFromEnv() error { + return runMCPGoFromEnv() +} diff --git a/app/mcp/server_mcpgo.go b/app/mcp/server_mcpgo.go new file mode 100644 index 0000000..08b387e --- /dev/null +++ b/app/mcp/server_mcpgo.go @@ -0,0 +1,303 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + mcpModels "ginimageApi/app/mcp/models" + "ginimageApi/configs" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" +) + +type mcpBaseURLKey struct{} + +var ( + mcpGoOnce sync.Once + mcpGoServer *mcpserver.MCPServer + mcpGoHTTPHandler http.Handler + mcpGoMu sync.RWMutex +) + +func getMCPGoHTTPHandler() http.Handler { + mcpGoOnce.Do(func() { + mcpGoServer = newMCPGoServer() + mcpGoHTTPHandler = mcpserver.NewStreamableHTTPServer( + mcpGoServer, + mcpserver.WithStateLess(true), + mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r)) + }), + ) + }) + mcpGoMu.RLock() + defer mcpGoMu.RUnlock() + return mcpGoHTTPHandler +} + +// reloadMCPGoServer yeni bir MD rehber eklendikten sonra MCP server'i yeniden olusturur. +func reloadMCPGoServer() { + mcpGoMu.Lock() + defer mcpGoMu.Unlock() + mcpGoOnce = sync.Once{} // sıfırla + newServer := newMCPGoServer() + newHandler := mcpserver.NewStreamableHTTPServer( + newServer, + mcpserver.WithStateLess(true), + mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { + return context.WithValue(ctx, mcpBaseURLKey{}, resolveBaseURLFromRequest(r)) + }), + ) + mcpGoServer = newServer + mcpGoHTTPHandler = newHandler +} + +func newMCPGoServer() *mcpserver.MCPServer { + s := mcpserver.NewMCPServer("ginimage-api-mcp", "0.1.0") + + s.AddTool( + mcpgo.NewTool( + "api_overview", + mcpgo.WithDescription("GinImage API endpoint ozeti ve kullanimi."), + ), + withToolRunLog("api_overview", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText(apiOverviewText()), nil + }), + ) + + s.AddTool( + mcpgo.NewTool( + "health_check", + mcpgo.WithDescription("API health endpoint durumunu kontrol eder."), + mcpgo.WithString("path", mcpgo.Description("Kontrol edilecek path. Varsayilan: /swagger/index.html")), + ), + withToolRunLog("health_check", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + path := req.GetString("path", "/swagger/index.html") + baseURL := resolveBaseURLFromContext(ctx) + url := strings.TrimRight(baseURL, "/") + ensurePathPrefix(path) + + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return mcpgo.NewToolResultText(fmt.Sprintf("Health check failed: %v", err)), nil + } + defer resp.Body.Close() + + return mcpgo.NewToolResultText(fmt.Sprintf("Health check: %s -> HTTP %d", url, resp.StatusCode)), nil + }), + ) + + s.AddTool( + mcpgo.NewTool( + "md_guide_list", + mcpgo.WithDescription("docs/mcp-tools altindaki markdown rehber dosyalarini listeler."), + ), + withToolRunLog("md_guide_list", func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + guides, err := listMDGuides() + if err != nil { + return mcpgo.NewToolResultError("failed to list guides"), nil + } + if len(guides) == 0 { + return mcpgo.NewToolResultText("No markdown guides found under docs/mcp-tools"), nil + } + return mcpgo.NewToolResultText("Available guides:\n- " + strings.Join(guides, "\n- ")), nil + }), + ) + + s.AddTool( + mcpgo.NewTool( + "md_guide_get", + mcpgo.WithDescription("Secilen markdown rehber dosyasinin icerigini dondurur."), + mcpgo.WithString( + "guide", + mcpgo.Description("Rehber dosya adi. Ornek: codebase_map.md"), + mcpgo.Required(), + ), + ), + withToolRunLog("md_guide_get", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + guide, err := req.RequireString("guide") + if err != nil { + return mcpgo.NewToolResultError("invalid params"), nil + } + content, err := readMDGuide(guide) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + return mcpgo.NewToolResultText(content), nil + }), + ) + + s.AddTool( + mcpgo.NewTool( + "codebase_map", + mcpgo.WithDescription("Proje klasor ve kritik dosya yapisini ozetler."), + mcpgo.WithString("focus", mcpgo.Description("Opsiyonel odak klasoru. Ornek: app/images")), + mcpgo.WithNumber("depth", mcpgo.Description("Opsiyonel tarama derinligi. Varsayilan 2, maksimum 5")), + ), + withToolRunLog("codebase_map", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + focus := req.GetString("focus", "") + depth := req.GetInt("depth", 0) + text, err := buildCodebaseMap(focus, depth) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + return mcpgo.NewToolResultText(text), nil + }), + ) + + s.AddTool( + mcpgo.NewTool( + "tool_stats", + mcpgo.WithDescription("MCP tool kullanim istatistiklerini veritabanindan ozetler."), + mcpgo.WithNumber("limit", mcpgo.Description("Opsiyonel kayit limiti. Varsayilan 10, maksimum 50")), + ), + withToolRunLog("tool_stats", func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + limit := req.GetInt("limit", 10) + statsText, err := getToolStats(limit) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + return mcpgo.NewToolResultText(statsText), nil + }), + ) + + registerMDGuideTools(s) + + return s +} + +// registerMDGuideTools docs/mcp-tools/ altindaki her .md dosyasini ayri bir tool olarak kaydeder. +func registerMDGuideTools(s *mcpserver.MCPServer) { + guides, err := listMDGuides() + if err != nil || len(guides) == 0 { + return + } + + for _, guide := range guides { + guideName := guide // closure icin kopyala + toolName := mdGuideToolName(guideName) + description := fmt.Sprintf("Rehber: %s — MCP guide dokumani.", guideName) + + s.AddTool( + mcpgo.NewTool(toolName, mcpgo.WithDescription(description)), + withToolRunLog(toolName, func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + content, err := readMDGuide(guideName) + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + return mcpgo.NewToolResultText(content), nil + }), + ) + } +} + +// mdGuideToolName dosya adini gecerli bir tool adina donusturur. +// Ornek: "codebase_map.md" -> "guide_codebase_map" +func mdGuideToolName(filename string) string { + name := strings.TrimSuffix(filename, ".md") + // Alfanumerik ve alt cizgi disindaki karakterleri _ ile degistir + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + return "guide_" + b.String() +} + +func withToolRunLog(toolName string, next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + started := time.Now() + result, err := next(ctx, req) + duration := time.Since(started) + + status := "success" + errMessage := "" + if err != nil { + status = "error" + errMessage = err.Error() + } else if result != nil && result.IsError { + status = "error" + errMessage = extractToolResultText(result) + } + + argsText := "" + if raw, marshalErr := json.Marshal(req.GetArguments()); marshalErr == nil { + argsText = string(raw) + } + if len(argsText) > 4096 { + argsText = argsText[:4096] + } + + if configs.DB != nil && strings.TrimSpace(toolName) != "" { + run := mcpModels.ToolRun{ + ToolName: toolName, + Status: status, + DurationMs: duration.Milliseconds(), + ErrorMessage: errMessage, + ArgumentsRaw: argsText, + } + _ = configs.DB.Create(&run).Error + } + + return result, err + } +} + +func extractToolResultText(result *mcpgo.CallToolResult) string { + if result == nil || len(result.Content) == 0 { + return "tool error" + } + for _, content := range result.Content { + if textContent, ok := content.(mcpgo.TextContent); ok { + if strings.TrimSpace(textContent.Text) != "" { + return textContent.Text + } + } + } + return "tool error" +} + +func resolveBaseURLFromContext(ctx context.Context) string { + if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" { + return envURL + } + if fromCtx, ok := ctx.Value(mcpBaseURLKey{}).(string); ok && strings.TrimSpace(fromCtx) != "" { + return fromCtx + } + port := strings.TrimSpace(os.Getenv("PORT")) + if port == "" { + port = "8080" + } + return "http://127.0.0.1:" + port +} + +func resolveBaseURLFromRequest(r *http.Request) string { + if envURL := strings.TrimSpace(os.Getenv("GINIMAGE_API_BASE_URL")); envURL != "" { + return envURL + } + if r == nil { + return resolveBaseURLFromContext(context.Background()) + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return fmt.Sprintf("%s://%s", scheme, r.Host) +} + +func runMCPGoFromEnv() error { + if mcpGoServer == nil { + _ = getMCPGoHTTPHandler() + } + return mcpserver.ServeStdio(mcpGoServer) +} diff --git a/app/mcp/server_mcpgo_test.go b/app/mcp/server_mcpgo_test.go new file mode 100644 index 0000000..0b00830 --- /dev/null +++ b/app/mcp/server_mcpgo_test.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "context" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" +) + +// Test server creation +func TestNewMCPGoServer(t *testing.T) { + server := newMCPGoServer() + if server == nil { + t.Fatal("expected server to be created, got nil") + } +} + +// Test withToolRunLog wrapper succeeds +func TestWithToolRunLogWrapper(t *testing.T) { + called := false + handler := withToolRunLog("test_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + called = true + return mcpgo.NewToolResultText("test result"), nil + }) + result, err := handler(context.Background(), mcpgo.CallToolRequest{ + Params: mcpgo.CallToolParams{ + Name: "test_tool", + Arguments: map[string]any{}, + }, + }) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if !called { + t.Error("expected handler to be called") + } + if result == nil { + t.Error("expected result to be non-nil") + } +} + +// Test withToolRunLog wrapper with error result +func TestWithToolRunLogWrapperErrorResult(t *testing.T) { + handler := withToolRunLog("error_tool", func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultError("test error"), nil + }) + result, err := handler(context.Background(), mcpgo.CallToolRequest{ + Params: mcpgo.CallToolParams{ + Name: "error_tool", + Arguments: map[string]any{}, + }, + }) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if result == nil { + t.Error("expected result to be non-nil") + } + if !result.IsError { + t.Error("expected IsError flag to be set") + } +} + +// Test extractToolResultText with nil result +func TestExtractToolResultTextNil(t *testing.T) { + result := extractToolResultText(nil) + if result != "tool error" { + t.Errorf("expected 'tool error', got %q", result) + } +} + +// Test extractToolResultText with empty content +func TestExtractToolResultTextEmpty(t *testing.T) { + toolResult := &mcpgo.CallToolResult{ + Content: []mcpgo.Content{}, + } + result := extractToolResultText(toolResult) + if result != "tool error" { + t.Errorf("expected 'tool error', got %q", result) + } +} + +// Test getMCPGoHTTPHandler initializes once +func TestGetMCPGoHTTPHandlerOnce(t *testing.T) { + handler1 := getMCPGoHTTPHandler() + handler2 := getMCPGoHTTPHandler() + if handler1 == nil { + t.Error("expected handler1 to be non-nil") + } + if handler2 == nil { + t.Error("expected handler2 to be non-nil") + } + // Both should be the same instance (sync.Once ensures this) + if handler1 != handler2 { + t.Error("expected handlers to be the same instance") + } +} + +// Test resolveBaseURLFromContext with env var +func TestResolveBaseURLFromContextEnv(t *testing.T) { + t.Setenv("GINIMAGE_API_BASE_URL", "http://api.example.com") + url := resolveBaseURLFromContext(context.Background()) + expected := "http://api.example.com" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } +} + +// Test resolveBaseURLFromContext without env var +func TestResolveBaseURLFromContextDefault(t *testing.T) { + t.Setenv("GINIMAGE_API_BASE_URL", "") + t.Setenv("PORT", "") + url := resolveBaseURLFromContext(context.Background()) + if url != "http://127.0.0.1:8080" { + t.Errorf("expected default URL, got %q", url) + } +} + +// Test resolveBaseURLFromContext with custom port +func TestResolveBaseURLFromContextCustomPort(t *testing.T) { + t.Setenv("GINIMAGE_API_BASE_URL", "") + t.Setenv("PORT", "9090") + url := resolveBaseURLFromContext(context.Background()) + expected := "http://127.0.0.1:9090" + if url != expected { + t.Errorf("expected %q, got %q", expected, url) + } +} diff --git a/app/mcp/server_test.go b/app/mcp/server_test.go new file mode 100644 index 0000000..c75ff1a --- /dev/null +++ b/app/mcp/server_test.go @@ -0,0 +1,102 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "github.com/gin-gonic/gin" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestHTTPHandlerToolsList tests POST /mcp tools/list request +func TestHTTPHandlerToolsList(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/mcp", HTTPHandler()) + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } +} + +// TestHTTPHandlerAPIOverviewTool tests tools/call api_overview +func TestHTTPHandlerAPIOverviewTool(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/mcp", HTTPHandler()) + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": map[string]interface{}{ + "name": "api_overview", + "arguments": map[string]interface{}{}, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } +} + +// TestHTTPHandlerInvalidJSON tests invalid JSON request +func TestHTTPHandlerInvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/mcp", HTTPHandler()) + req := httptest.NewRequest("POST", "/mcp", strings.NewReader("invalid json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } +} + +// TestStreamableHTTPDELETEHandler tests DELETE response +func TestStreamableHTTPDELETEHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.DELETE("/api/v1/mcp", StreamableHTTPDELETEHandler()) + req := httptest.NewRequest("DELETE", "/api/v1/mcp", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status 405, got %d", w.Code) + } +} + +// TestMCPInitialize tests initialize method +func TestMCPInitialize(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/mcp", HTTPHandler()) + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } +} diff --git a/app/middleware/auth.go b/app/middleware/auth.go new file mode 100644 index 0000000..536c0fd --- /dev/null +++ b/app/middleware/auth.go @@ -0,0 +1,265 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "ginimageApi/app/accounts/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type accessTokenPayload struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + Exp int64 `json:"exp"` +} + +type accessTokenClaims struct { + TokenType string `json:"token_type"` + UserID string `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +type refreshTokenClaims struct { + TokenType string `json:"token_type"` + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +func jwtIssuer() string { + issuer := os.Getenv("JWT_ISSUER") + if issuer == "" { + issuer = "ginimageApi" + } + return issuer +} + +func jwtAudience() string { + audience := os.Getenv("JWT_AUDIENCE") + if audience == "" { + audience = "ginimageApi-client" + } + return audience +} + +func jwtSecret() string { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "dev-secret-change-me" + } + return secret +} + +func randomTokenID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func GenerateAccessToken(userID uint, email, username string, ttl time.Duration) (string, error) { + now := time.Now() + tokenID, err := randomTokenID() + if err != nil { + return "", err + } + claims := accessTokenClaims{ + TokenType: "access", + UserID: strconv.FormatUint(uint64(userID), 10), + Email: email, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ID: tokenID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(jwtSecret())) +} + +func GenerateRefreshToken(userID uint, ttl time.Duration) (string, string, error) { + now := time.Now() + tokenID, err := randomTokenID() + if err != nil { + return "", "", err + } + + claims := refreshTokenClaims{ + TokenType: "refresh", + UserID: strconv.FormatUint(uint64(userID), 10), + RegisteredClaims: jwt.RegisteredClaims{ + ID: tokenID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(jwtSecret())) + if err != nil { + return "", "", err + } + + return signed, tokenID, nil +} + +func parseAccessToken(token string) (accessTokenPayload, error) { + parsed, err := jwt.ParseWithClaims( + token, + &accessTokenClaims{}, + func(t *jwt.Token) (any, error) { + return []byte(jwtSecret()), nil + }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithExpirationRequired(), + ) + if err != nil { + return accessTokenPayload{}, errors.New("token gecersiz") + } + + claims, ok := parsed.Claims.(*accessTokenClaims) + if !ok || !parsed.Valid { + return accessTokenPayload{}, errors.New("token gecersiz") + } + if claims.TokenType != "access" { + return accessTokenPayload{}, errors.New("token type gecersiz") + } + + uid64, err := strconv.ParseUint(claims.UserID, 10, 64) + if err != nil { + return accessTokenPayload{}, errors.New("user_id claim gecersiz") + } + + exp := int64(0) + if claims.ExpiresAt != nil { + exp = claims.ExpiresAt.Time.Unix() + } + + return accessTokenPayload{ + UserID: uint(uid64), + Email: claims.Email, + Username: claims.Username, + Exp: exp, + }, nil +} + +func bearerToken(c *gin.Context) (string, error) { + header := strings.TrimSpace(c.GetHeader("Authorization")) + if header == "" { + return "", errors.New("authorization basligi yok") + } + + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", errors.New("authorization formati gecersiz") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", errors.New("authorization formati gecersiz") + } + return token, nil +} + +// AuthRequired access token dogrular ve kullanici bilgisini context'e yazar. +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + token, err := bearerToken(c) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + payload, err := parseAccessToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.Set("user_id", payload.UserID) + c.Set("email", payload.Email) + c.Set("username", payload.Username) + c.Next() + } +} + +// AdminRequired mutating endpointlerde kullanicinin admin oldugunu dogrular. +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + userIDAny, ok := c.Get("user_id") + if !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"}) + return + } + + var userID uint + switch v := userIDAny.(type) { + case uint: + userID = v + case int: + if v < 0 { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"}) + return + } + userID = uint(v) + case string: + parsed, err := strconv.ParseUint(v, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"}) + return + } + userID = uint(parsed) + default: + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "gecersiz kullanici"}) + return + } + + var user models.User + if err := configs.DB.First(&user, userID).Error; err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"}) + return + } + + if user.IsAdmin == nil || !*user.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin yetkisi gerekli"}) + return + } + + c.Next() + } +} + +func BuildAccessTokenForUser(user models.User) (string, error) { + return GenerateAccessToken(user.ID, user.Email, user.UserName, 15*time.Minute) +} + +func RefreshTokenExpiry() time.Duration { + return 7 * 24 * time.Hour +} + +func AccessTokenTTL() time.Duration { + return 15 * time.Minute +} + +func TokenPayloadDebug(token string) string { + payload, err := parseAccessToken(token) + if err != nil { + return err.Error() + } + return fmt.Sprintf("uid=%d email=%s username=%s exp=%d", payload.UserID, payload.Email, payload.Username, payload.Exp) +} diff --git a/app/middleware/auth_test.go b/app/middleware/auth_test.go new file mode 100644 index 0000000..f20ad3c --- /dev/null +++ b/app/middleware/auth_test.go @@ -0,0 +1,231 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "ginimageApi/app/accounts/models" + "ginimageApi/configs" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupMiddlewareTestDB(t *testing.T) { + t.Helper() + + prev := configs.DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + if err := db.AutoMigrate(&models.User{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + configs.DB = db + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + configs.DB = prev + }) +} + +func TestGenerateAndParseAccessToken(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + token, err := GenerateAccessToken(99, "u@example.com", "u1", time.Minute) + if err != nil { + t.Fatalf("GenerateAccessToken failed: %v", err) + } + if got := len(strings.Split(token, ".")); got != 3 { + t.Fatalf("expected standard JWT with 3 segments, got %d", got) + } + + payload, err := parseAccessToken(token) + if err != nil { + t.Fatalf("parseAccessToken failed: %v", err) + } + + if payload.UserID != 99 || payload.Email != "u@example.com" || payload.Username != "u1" { + t.Fatalf("unexpected payload: %+v", payload) + } +} + +func TestParseAccessTokenExpired(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + token, err := GenerateAccessToken(1, "a@a.com", "a", -time.Second) + if err != nil { + t.Fatalf("GenerateAccessToken failed: %v", err) + } + + if _, err := parseAccessToken(token); err == nil { + t.Fatalf("expected parse error for expired token") + } +} + +func TestParseAccessTokenRejectsRefreshToken(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + token, _, err := GenerateRefreshToken(1, time.Minute) + if err != nil { + t.Fatalf("GenerateRefreshToken failed: %v", err) + } + + if _, err := parseAccessToken(token); err == nil { + t.Fatalf("expected parse error for refresh token") + } +} + +func TestParseAccessTokenRequiresUserID(t *testing.T) { + t.Setenv("JWT_SECRET", "test-secret") + + claims := accessTokenClaims{ + TokenType: "access", + Email: "a@a.com", + Username: "a", + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }, + } + + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("test-secret")) + if err != nil { + t.Fatalf("failed to sign token: %v", err) + } + + if _, err := parseAccessToken(token); err == nil { + t.Fatalf("expected parse error for missing user_id") + } +} + +func TestAuthRequired(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + + token, err := GenerateAccessToken(7, "mail@example.com", "user7", time.Minute) + if err != nil { + t.Fatalf("token generate failed: %v", err) + } + + r := gin.New() + r.GET("/me", AuthRequired(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "user_id": c.GetUint("user_id"), + "email": c.GetString("email"), + "username": c.GetString("username"), + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json: %v", err) + } + if body["email"] != "mail@example.com" { + t.Fatalf("expected email in context") + } +} + +func TestAuthRequiredRejectsInvalidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + r.GET("/me", AuthRequired(), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "Bearer invalid") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestAuthRequiredRejectsRawAuthorizationToken(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("JWT_SECRET", "test-secret") + + token, err := GenerateAccessToken(11, "raw@example.com", "rawuser", time.Minute) + if err != nil { + t.Fatalf("token generate failed: %v", err) + } + + r := gin.New() + r.GET("/me", AuthRequired(), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for raw token without Bearer, got %d", w.Code) + } +} + +func TestAdminRequired(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMiddlewareTestDB(t) + + isAdmin := true + isUser := false + admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin} + user := models.User{UserName: "user", Email: "user@example.com", Password: "x", IsAdmin: &isUser} + if err := configs.DB.Create(&admin).Error; err != nil { + t.Fatalf("admin create failed: %v", err) + } + if err := configs.DB.Create(&user).Error; err != nil { + t.Fatalf("user create failed: %v", err) + } + + r := gin.New() + r.POST("/admin", func(c *gin.Context) { + c.Set("user_id", user.ID) + c.Next() + }, AdminRequired(), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/admin", nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for non-admin, got %d", w.Code) + } + + r2 := gin.New() + r2.POST("/admin", func(c *gin.Context) { + c.Set("user_id", admin.ID) + c.Next() + }, AdminRequired(), func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w2 := httptest.NewRecorder() + r2.ServeHTTP(w2, httptest.NewRequest(http.MethodPost, "/admin", nil)) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for admin, got %d", w2.Code) + } +} diff --git a/app/middleware/security.go b/app/middleware/security.go new file mode 100644 index 0000000..1513358 --- /dev/null +++ b/app/middleware/security.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "net/http" + "os" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// DynamicCORS CORS davranisini ortama gore dinamik ayarlar. +func DynamicCORS() gin.HandlerFunc { + allowOrigin := os.Getenv("CORS_ALLOW_ORIGIN") + if allowOrigin == "" { + allowOrigin = "*" + } + + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", allowOrigin) + c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + +type clientWindow struct { + count int + windowEnds time.Time +} + +// DynamicRateLimit IP bazli basit bir dakika penceresi limiti uygular. +func DynamicRateLimit() gin.HandlerFunc { + limit := 120 + if v := os.Getenv("RATE_LIMIT_RPM"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + limit = parsed + } + } + + var mu sync.Mutex + clients := map[string]*clientWindow{} + + return func(c *gin.Context) { + ip := c.ClientIP() + now := time.Now() + + mu.Lock() + entry, ok := clients[ip] + if !ok || now.After(entry.windowEnds) { + entry = &clientWindow{count: 0, windowEnds: now.Add(time.Minute)} + clients[ip] = entry + } + + entry.count++ + remaining := limit - entry.count + resetIn := int(time.Until(entry.windowEnds).Seconds()) + mu.Unlock() + + c.Header("X-RateLimit-Limit", strconv.Itoa(limit)) + if remaining < 0 { + remaining = 0 + } + c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining)) + c.Header("X-RateLimit-Reset", strconv.Itoa(resetIn)) + + if entry.count > limit { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit asildi"}) + return + } + + c.Next() + } +} diff --git a/app/middleware/security_test.go b/app/middleware/security_test.go new file mode 100644 index 0000000..40a1a71 --- /dev/null +++ b/app/middleware/security_test.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestDynamicCORS(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ALLOW_ORIGIN", "http://example.com") + + r := gin.New() + r.Use(DynamicCORS()) + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + req := httptest.NewRequest(http.MethodGet, "/ping", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://example.com" { + t.Fatalf("unexpected allow origin: %q", got) + } +} + +func TestDynamicCORSOptions(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ALLOW_ORIGIN", "*") + + r := gin.New() + r.Use(DynamicCORS()) + r.OPTIONS("/ping", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodOptions, "/ping", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +func TestDynamicRateLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("RATE_LIMIT_RPM", "2") + + r := gin.New() + r.Use(DynamicRateLimit()) + r.GET("/limited", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + for i := 1; i <= 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/limited", nil) + req.RemoteAddr = "127.0.0.1:12345" + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if i < 3 && w.Code != http.StatusOK { + t.Fatalf("request %d expected 200, got %d", i, w.Code) + } + if i == 3 && w.Code != http.StatusTooManyRequests { + t.Fatalf("request %d expected 429, got %d", i, w.Code) + } + } +} diff --git a/configs/db.go b/configs/db.go new file mode 100644 index 0000000..e99a445 --- /dev/null +++ b/configs/db.go @@ -0,0 +1,101 @@ +package configs + +import ( + "fmt" + "log" + "os" + "time" + + accountModels "ginimageApi/app/accounts/models" + blogModels "ginimageApi/app/blogs/models" + imageModels "ginimageApi/app/images/models" + mcpModels "ginimageApi/app/mcp/models" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// DB global GORM veritabanı bağlantısı +var DB *gorm.DB + +// ConnectDB .env dosyasındaki ayarlarla MySQL bağlantısını kurar +func ConnectDB() error { + host := os.Getenv("DB_HOST") + port := os.Getenv("DB_PORT") + user := os.Getenv("DB_USER") + password := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + + // DSN (Data Source Name) oluştur + dsn := fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + user, password, host, port, dbName, + ) + + // GORM logger ayarları + gormLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: 200 * time.Millisecond, + LogLevel: logger.Warn, + IgnoreRecordNotFoundError: true, + Colorful: true, + }, + ) + + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + return fmt.Errorf("veritabanına bağlanılamadı: %w", err) + } + + // Bağlantı havuzu ayarları + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("sql.DB alınamadı: %w", err) + } + + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + log.Printf("✅ Veritabanı bağlantısı kuruldu: %s:%s/%s", host, port, dbName) + return nil +} + +// RunAutoMigrate tanımlanan modelleri otomatik olarak migrate eder +func RunAutoMigrate() error { + if DB == nil { + return fmt.Errorf("migration icin veritabani baglantisi yok") + } + + err := DB.AutoMigrate( + &accountModels.User{}, + &accountModels.SocialAccount{}, + &accountModels.Profile{}, + &accountModels.RefreshToken{}, + &blogModels.Category{}, + &blogModels.Tag{}, + &blogModels.Post{}, + &blogModels.CategoryView{}, + &blogModels.Comment{}, + &imageModels.Image{}, + &mcpModels.ToolRun{}, + ) + if err != nil { + return fmt.Errorf("auto migrate basarisiz: %w", err) + } + + log.Println("✅ AutoMigrate tamamlandı") + return nil +} + +// SeedSecurityDefaults dinamik guvenlik ayarlari icin baslangic adimini temsil eder. +// Bu projede kalici ayar tablolari henuz olmadigi icin no-op tutulur. +func SeedSecurityDefaults() error { + log.Println("✅ SeedSecurityDefaults tamamlandı") + return nil +} diff --git a/configs/db_test.go b/configs/db_test.go new file mode 100644 index 0000000..cef7b2d --- /dev/null +++ b/configs/db_test.go @@ -0,0 +1,51 @@ +package configs + +import ( + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestRunAutoMigrateRequiresDB(t *testing.T) { + prev := DB + DB = nil + t.Cleanup(func() { DB = prev }) + + if err := RunAutoMigrate(); err == nil { + t.Fatalf("expected error when DB is nil") + } +} + +func TestRunAutoMigrateSuccess(t *testing.T) { + prev := DB + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("sqlite open failed: %v", err) + } + DB = db + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + DB = prev + }) + + if err := RunAutoMigrate(); err != nil { + t.Fatalf("RunAutoMigrate failed: %v", err) + } + + if !DB.Migrator().HasTable("users") { + t.Fatalf("expected users table after migration") + } + if !DB.Migrator().HasTable("refresh_tokens") { + t.Fatalf("expected refresh_tokens table after migration") + } +} + +func TestSeedSecurityDefaults(t *testing.T) { + if err := SeedSecurityDefaults(); err != nil { + t.Fatalf("SeedSecurityDefaults should not fail: %v", err) + } +} diff --git a/configs/redis.go b/configs/redis.go new file mode 100644 index 0000000..143ee0d --- /dev/null +++ b/configs/redis.go @@ -0,0 +1,46 @@ +package configs + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/redis/go-redis/v9" +) + +// RDB global Redis istemcisi +var RDB *redis.Client + +// ConnectRedis .env'deki REDIS_URL ile Redis bağlantısını kurar +// Format: redis://:@:/ +func ConnectRedis() error { + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + return fmt.Errorf("REDIS_URL ortam değişkeni tanımlı değil") + } + + opts, err := redis.ParseURL(redisURL) + if err != nil { + return fmt.Errorf("REDIS_URL ayrıştırılamadı: %w", err) + } + + RDB = redis.NewClient(opts) + + // Bağlantıyı test et + ctx := context.Background() + if _, err := RDB.Ping(ctx).Result(); err != nil { + return fmt.Errorf("Redis'e bağlanılamadı: %w", err) + } + + log.Printf("✅ Redis bağlantısı kuruldu: %s (db=%d)", opts.Addr, opts.DB) + return nil +} + +// CloseRedis Redis bağlantısını güvenli şekilde kapatır +func CloseRedis() error { + if RDB != nil { + return RDB.Close() + } + return nil +} diff --git a/configs/redis_test.go b/configs/redis_test.go new file mode 100644 index 0000000..6632cc9 --- /dev/null +++ b/configs/redis_test.go @@ -0,0 +1,45 @@ +package configs + +import ( + "context" + "testing" + + miniredis "github.com/alicebob/miniredis/v2" +) + +func TestConnectRedisMissingURL(t *testing.T) { + t.Setenv("REDIS_URL", "") + if err := ConnectRedis(); err == nil { + t.Fatalf("expected error for missing REDIS_URL") + } +} + +func TestConnectRedisInvalidURL(t *testing.T) { + t.Setenv("REDIS_URL", "%%%") + if err := ConnectRedis(); err == nil { + t.Fatalf("expected parse error for invalid REDIS_URL") + } +} + +func TestConnectRedisSuccessAndClose(t *testing.T) { + mini, err := miniredis.Run() + if err != nil { + t.Fatalf("miniredis start failed: %v", err) + } + defer mini.Close() + + t.Setenv("REDIS_URL", "redis://"+mini.Addr()+"/0") + + if err := ConnectRedis(); err != nil { + t.Fatalf("ConnectRedis failed: %v", err) + } + t.Cleanup(func() { _ = CloseRedis() }) + + if pong, err := RDB.Ping(context.Background()).Result(); err != nil || pong != "PONG" { + t.Fatalf("expected redis ping PONG, got %q err=%v", pong, err) + } + + if err := CloseRedis(); err != nil { + t.Fatalf("CloseRedis failed: %v", err) + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..251b7c1 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,49 @@ +services: + app: + env_file: + - .env.docker + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + + mysql: + image: mysql:8.4 + container_name: ginimageapi-mysql + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: gin_img + MYSQL_USER: gin_img + MYSQL_PASSWORD: gin_img_pass + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-prootpass"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 20s + restart: unless-stopped + + redis: + image: redis:7.4-alpine + container_name: ginimageapi-redis + command: ["redis-server", "--requirepass", "redispass"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "redispass", "ping"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + restart: unless-stopped + +volumes: + mysql_data: + redis_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2c3aa3a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + GO_VERSION: "1.26.2" + container_name: ginimageapi-app + ports: + - "8011:8080" + env_file: + - .env + volumes: + - ./uploads:/app/uploads + - ./docs:/app/docs + restart: unless-stopped diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..0836018 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,2931 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/admin/tokens/issue": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sadece admin rolü için, istekle verilen gün kadar geçerli access token üretir. Refresh token üretilmez.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin için gün bazlı access token üretir", + "parameters": [ + { + "description": "Token süresi (gün)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminIssueTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminIssueTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin kullanicilari listeler", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Sayfa numarasi", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Sayfa boyutu (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Kullanici adi/email arama", + "name": "search", + "in": "query" + }, + { + "type": "boolean", + "description": "Admin filtresi", + "name": "is_admin", + "in": "query" + }, + { + "type": "boolean", + "description": "Aktiflik filtresi", + "name": "is_active", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici olusturur", + "parameters": [ + { + "description": "Kullanici olusturma verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminCreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici detayi getirir", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullaniciyi gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Kullanici guncelleme verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminUpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici siler", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanicinin profilini getirir", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici profilini gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Ad", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Soyad", + "name": "last_name", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar dosyasi", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici aktiflik durumunu gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Durum verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Kullanici girisi yapar", + "parameters": [ + { + "description": "Giris verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token ile yeni token uretir", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Kullanici kaydi olusturur", + "parameters": [ + { + "description": "Kayit verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.RegisterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/social/github": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "GitHub access token ile giris veya kayit yapar", + "parameters": [ + { + "description": "GitHub access token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SocialLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SocialTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/social/google": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Google access token ile giris veya kayit yapar", + "parameters": [ + { + "description": "Google access token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SocialLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SocialTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "E-posta dogrulama tokeni ile hesabi aktif eder", + "parameters": [ + { + "type": "string", + "description": "Dogrulama tokeni", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/blogs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public blog post listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post olusturur", + "parameters": [ + { + "description": "Post bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createPostRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public kategori listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori olusturur", + "parameters": [ + { + "description": "Kategori bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kategori ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori siler", + "parameters": [ + { + "type": "integer", + "description": "Kategori ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil kategori getirir", + "parameters": [ + { + "type": "string", + "description": "Kategori slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tag listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag olusturur", + "parameters": [ + { + "description": "Tag bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag gunceller", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag siler", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil tag getirir", + "parameters": [ + { + "type": "string", + "description": "Tag slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post gunceller", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updatePostRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post siler", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil blog postu getirir", + "parameters": [ + { + "type": "string", + "description": "Post slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/images": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Giris yapan kullanicinin kayitli resimlerini listeler", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ListImagesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/images/process": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Resmi en, boy, kalite ve formata gore isler", + "parameters": [ + { + "type": "file", + "description": "Yuklenecek resim", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Hedef genislik (default: orijinal)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Hedef yukseklik (default: orijinal)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Kalite 1-100 (default: 90)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "avif|webp|png|jpg|jpeg (default: avif)", + "name": "format", + "in": "formData" + }, + { + "type": "boolean", + "description": "true ise cover crop uygular", + "name": "cover", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProcessImageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/images/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Giris yapan kullanicinin tekil resim kaydini getirir", + "parameters": [ + { + "type": "integer", + "description": "Image ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ImageRecordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/mcp": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "MCP isteklerini JSON-RPC 2.0 formatinda kabul eder.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP JSON-RPC endpoint", + "parameters": [ + { + "description": "MCP JSON-RPC request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mcp.HTTPRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mcp.HTTPResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/mcp.HTTPResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stateless MCP server icin session teardown desteklenmez, 405 doner.", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP streamable DELETE endpoint", + "responses": { + "405": { + "description": "Method Not Allowed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/mcp/guides/upload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "` + "`" + `.md` + "`" + ` dosyasini ` + "`" + `docs/mcp-tools` + "`" + ` altina kaydeder ve MCP tool'lari tarafindan okunabilir hale getirir.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP markdown rehberi yukler", + "parameters": [ + { + "type": "file", + "description": "Yuklenecek markdown dosyasi", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "boolean", + "description": "Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)", + "name": "overwrite", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + } + } + } + }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin bilgilerini doner", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MeResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/me/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin profilini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin profilini gunceller", + "parameters": [ + { + "type": "string", + "description": "Ad", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Soyad", + "name": "last_name", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar dosyasi", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/users/{id}/admin": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Kullanicinin admin yetkisini gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Admin durumu", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.adminRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.AdminCreateUserRequest": { + "type": "object", + "required": [ + "confirm_password", + "email", + "password", + "username" + ], + "properties": { + "confirm_password": { + "type": "string" + }, + "email": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "handlers.AdminIssueTokenRequest": { + "type": "object", + "required": [ + "duration_days" + ], + "properties": { + "duration_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + } + } + }, + "handlers.AdminIssueTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" + }, + "expires_at": { + "type": "string" + } + } + }, + "handlers.AdminProfileResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.AdminUpdateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "handlers.AdminUserListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.adminUserResponse" + } + }, + "meta": { + "$ref": "#/definitions/handlers.paginationMeta" + } + } + }, + "handlers.AdminUserResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.AdminUserStatusRequest": { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "handlers.BlogCategoryListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + } + } + }, + "handlers.BlogCategoryResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handlers.BlogErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.BlogListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + } + } + }, + "handlers.BlogPostResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video": { + "type": "string" + } + } + }, + "handlers.BlogTagListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + } + } + }, + "handlers.BlogTagResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "slug": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.ImageErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.ImageRecordResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "mime_type": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "public_path": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.ListImagesResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.ImageRecordResponse" + } + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.MeResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "user_id": {}, + "username": { + "type": "string" + } + } + }, + "handlers.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "handlers.ProcessImageResponse": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "public_path": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.ProfileResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handlers.RegisterRequest": { + "type": "object", + "properties": { + "confirm_password": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.RegisterResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "verification_token": { + "type": "string" + }, + "verification_url": { + "type": "string" + } + } + }, + "handlers.SocialLoginRequest": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + } + } + }, + "handlers.SocialTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" + }, + "message": { + "type": "string" + }, + "new_user": { + "type": "boolean" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + } + } + }, + "handlers.TokenResponse": { + "type": "object", + "properties": { + "access": { + "description": "JWT (HS256) access token", + "type": "string" + }, + "refresh": { + "description": "JWT (HS256) refresh token", + "type": "string" + } + } + }, + "handlers.adminRequest": { + "type": "object", + "properties": { + "is_admin": { + "type": "boolean" + } + } + }, + "handlers.adminUserResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.createCategoryRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createPostRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "title": { + "type": "string", + "minLength": 3 + }, + "video": { + "type": "string" + } + } + }, + "handlers.createTagRequest": { + "type": "object", + "required": [ + "tag" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "tag": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.paginationMeta": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.updateCategoryRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "handlers.updatePostRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "title": { + "type": "string" + }, + "video": { + "type": "string" + } + } + }, + "handlers.updateTagRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + }, + "tag": { + "type": "string" + } + } + }, + "mcp.HTTPRequest": { + "type": "object", + "properties": { + "id": { + "type": "object" + }, + "jsonrpc": { + "type": "string", + "example": "2.0" + }, + "method": { + "type": "string", + "example": "tools/list" + }, + "params": { + "type": "object", + "additionalProperties": true + } + } + }, + "mcp.HTTPResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "object" + }, + "jsonrpc": { + "type": "string", + "example": "2.0" + }, + "result": { + "type": "object", + "additionalProperties": true + } + } + }, + "mcp.UploadGuideErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "file must be a markdown (.md) file" + } + } + }, + "mcp.UploadGuideResponse": { + "type": "object", + "properties": { + "guide": { + "type": "string", + "example": "my-guide.md" + }, + "message": { + "type": "string", + "example": "markdown guide uploaded" + }, + "path": { + "type": "string", + "example": "docs/mcp-tools/my-guide.md" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "mcp.beyhano.net.tr", + BasePath: "", + Schemes: []string{"https"}, + Title: "Gin Image API", + Description: "GinImage API dokumantasyonu. Legacy access token formatlari desteklenmez.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/mcp-tools/MCP.md b/docs/mcp-tools/MCP.md new file mode 100644 index 0000000..b2b813b --- /dev/null +++ b/docs/mcp-tools/MCP.md @@ -0,0 +1,54 @@ +# MCP Service Guide + +Bu proje için MCP servis kullanım rehberi. + +## Proje Bilgisi +- Proje adı: ginimageApi +- Dil: Go +- Framework: Gin +- ORM: Gorm + +## Amaç +Bu MCP dokümanı, Copilot ve diğer agent'ların proje yapısını doğru anlaması ve admin user management endpointlerini tutarlı şekilde üretmesi için hazırlanmıştır. + +## Klasör Yapısı +- `main.go` uygulama giriş noktası +- `app/` iş mantığı modülleri +- `config/` veritabanı ve redis ayarları +- `router/router.go` route tanımları + +## Ana Modüller +### accounts +Kullanıcı işlemleri ve auth ile ilgili alanlar. + +### settings +Uygulama ayarları. + +### shop +Ürün ve sepet işlemleri. + +### blog +Blog işlemleri. + +## MCP Kullanım Notları +- Yeni endpoint eklerken mevcut yapı korunmalı. +- Handler logic sade tutulmalı. +- Model, handler ve router ayrımı bozulmamalı. +- Admin işlemler için ayrıca yetkilendirme düşünülmeli. + +## Admin User Management +Beklenen admin endpointleri: +- `GET /admin/users` +- `GET /admin/users/:id` +- `POST /admin/users` +- `PUT /admin/users/:id` +- `PATCH /admin/users/:id/status` +- `DELETE /admin/users/:id` + +## Güvenlik +- Password hash zorunlu. +- Role-based access önerilir. +- Response içinde hassas alan dönülmemeli. + +## Not +Bu servis dosyası, MCP uyumlu otomasyon ve Copilot yönlendirmesi için referans dokümandır. \ No newline at end of file diff --git a/docs/mcp-tools/MCP_KOLAY_REHBER.md b/docs/mcp-tools/MCP_KOLAY_REHBER.md new file mode 100644 index 0000000..c85311a --- /dev/null +++ b/docs/mcp-tools/MCP_KOLAY_REHBER.md @@ -0,0 +1,209 @@ +# MCP Kolay Rehber (GinImage API) + +Bu dokumanin amaci: +- MCP'nin ne oldugunu 2 dakikada anlatmak +- Bu projede nasil kullanacagini adim adim gostermek +- Kendi amacina uygun hale getirmen icin net bir yol vermek + +## 1) MCP nedir? + +MCP, LLM'in (AI asistanin) senin uygulamana standart bir yoldan baglanmasini saglar. +Bu projede MCP, ayri bir uygulama degil; mevcut backend icinde bir endpoint olarak calisiyor: + +- POST /mcp + +Yani AI, dogrudan tum API'yi ezberlemek yerine MCP uzerinden tool cagiriyor. + +## 2) Bu projede MCP su an ne yapiyor? + +Mevcut tool'lar: +- api_overview: API endpoint ozetini metin olarak doner +- health_check: verdigin path'e istek atar, HTTP durum kodu doner + +Kaynak kod: +- app/mcp/server.go + +## 3) 5 dakikada calistir + +1. Projeyi baslat: + +```bash +go run . +``` + +2. MCP endpoint: + +- http://127.0.0.1:8080/mcp + +3. Test et (tool listesi): + +```bash +curl -X POST "http://127.0.0.1:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/list" + }' +``` + +Beklenen sonuc: tools listesinde `api_overview` ve `health_check` gorursun. + +## 4) Cursor'a baglama + +Cursor MCP ayari icin ornek dosya: +- app/mcp/README.md + +Ozet: +- Cursor MCP config'e bu endpoint'i eklersin: http://127.0.0.1:8080/mcp +- Cursor'da MCP reload yaparsin + +## 5) MCP'yi hangi amac icin kullanmaliyim? + +En iyi kullanim: "AI operator" gibi. + +Ornek amaclar: +- Operasyon: servis sagligi, baglantilar, log ozetleri +- Icerik yonetimi: blog taslagi olusturma, yayinlama adimlari +- Medya pipeline: toplu resim isleme, kalite kontrol raporu + +Kural: +- Sik yaptigin ama hata riski olan isleri tool'a cevir. + +## 6) Kendi amacina uygun hale getirme plani + +Asagidaki sirayla ilerle: + +1. Amaci yaz (tek cumle) +- Ornek: "AI ile blog operasyonlarini hizlandirmak" + +2. 3-5 ana tool sec +- blog_list_drafts +- blog_create_draft +- blog_publish +- image_process_batch +- system_health_summary + +3. Her tool icin input schema belirle +- zorunlu alanlar +- tipler (string, number, boolean) +- enum alanlar (ornek: status = draft|published) + +4. Yetki katmani koy +- read-only tool'lari ayri +- admin gerektiren write tool'lari ayri + +5. Hata formatini standartlastir +- invalid params +- unauthorized +- not found +- internal error + +6. Log ve izleme ekle +- hangi tool cagrildi +- ne kadar surdu +- basarili/basarisiz + +## 7) Tool ekleme ornegi (fikri) + +Ornek yeni tool: `blog_list_recent` + +Ne yapar: +- son N blog kaydini dondurur + +Input: +- limit (opsiyonel, default 10, max 50) + +Output: +- baslik, slug, olusturma tarihi listesi + +Neden faydali: +- AI once mevcut icerigi gorur, sonra dogru aksiyon alir + +## 8) Guvenlik kontrol listesi + +MCP endpoint aciksa su kontrolleri yap: +- Rate limit aktif mi? +- Sadece gerekli tool'lar acik mi? +- Write tool'lar icin yetki kontrolu var mi? +- Hassas veriler response'a siziyor mu? + +## 9) Sik sorunlar + +1. connection refused +- Uygulama calismiyor, `go run .` ile baslat + +2. 404 /mcp +- route ekli mi kontrol et: routers/router.go + +3. tools/list bos ya da hata +- gonderdigin JSON-RPC body formatini kontrol et + +4. health_check yanlis hosta gidiyor +- GINIMAGE_API_BASE_URL degerini acik ver + +## 10) Kisa ozet + +- MCP = AI icin standart tool kapisi +- Bu projede giris noktasi = POST /mcp +- Bugun hemen kullanmak icin: tools/list + tools/call testleri yeterli +- Gercek fayda icin: kendi operasyonuna uygun 3-5 tool ekle + +## 11) Kod gelistirme odakli kullanim (senin senaryo) + +Eger hedefin kod gelistirme ise, MCP'yi "AI coding teammate" gibi konumlandir. +Bu durumda tool'larin, uygulamayi calistirmak yerine gelistirme kararlarini hizlandirmali. + +Oncelikli 5 tool: + +1. codebase_map +- Ne yapar: proje modul yapisini ve kritik dosyalari ozetler +- Neden lazim: AI once yapini anlar, yanlis dosyada degisiklik yapmaz + +2. endpoint_contract_find +- Ne yapar: bir endpointin handler/model/response baglantilarini bulur +- Input: method + path (ornek: GET /api/v1/images) +- Neden lazim: API degisikliklerinde tum etkileri hizli gorursun + +3. test_plan_suggest +- Ne yapar: secilen dosya veya endpoint icin test senaryolari uretir +- Input: hedef dosya veya endpoint +- Neden lazim: test acigini erken kapatir + +4. safe_refactor_check +- Ne yapar: degisecek sembolun kullanildigi yerleri listeler +- Input: symbol name +- Neden lazim: refactor kirilma riskini dusurur + +5. runbook_dev +- Ne yapar: bir gorev icin adim adim gelistirme akisi dondurur +- Input: gorev tanimi (ornek: image process endpointine webp kalite limiti ekle) +- Neden lazim: AI'dan tutarli ve tekrar edilebilir cikti alirsin + +## 12) Kod gelistirme icin kisa uygulama plani + +1. Hafta 1: read-only tool'lar +- codebase_map +- endpoint_contract_find +- safe_refactor_check + +2. Hafta 2: kalite ve otomasyon +- test_plan_suggest +- runbook_dev + +3. Son adim: kontrol noktasi +- her tool icin ornek input-output +- hata kodlari +- loglama ve sure olcumu + +## 13) Baslangic hedefi (onerilen) + +Ilk sprintte su tek hedefle basla: + +- "Bir endpoint degisikligi yapmadan once etkilenecek dosyalari ve test planini MCP uzerinden otomatik almak" + +Bu hedef, hem hiz hem kalite kazandirir; yazma (write) tool'larina gecmeden once guvenli bir temel olusturur. + +--- + +Istersen bir sonraki adimda senin hedefini tek cumleyle yazalim, buna gore dogrudan server.go icine 5 net tool tasarlayip ekleyebilirim. diff --git a/docs/mcp-tools/MCP_UYGULAMA_PLANI.md b/docs/mcp-tools/MCP_UYGULAMA_PLANI.md new file mode 100644 index 0000000..2c7a91a --- /dev/null +++ b/docs/mcp-tools/MCP_UYGULAMA_PLANI.md @@ -0,0 +1,74 @@ +# MCP Uygulama Plani (Kod Gelistirme Odakli) + +Bu planin hedefi: +- MCP'yi once MD tabanli ve guvenli sekilde calisir hale getirmek +- Sonra kod gelistirmeyi hizlandiran tool'lara gecmek + +## Faz 1 - MD tabanli temel (1-2 gun) + +1. Hedefi sabitle +- Ilk hedef: endpoint degisikligi oncesi etki analizi ve test plani bilgisi almak. + +2. Tool spec dosyalarini olustur +- docs/mcp-tools/codebase_map.md +- docs/mcp-tools/endpoint_contract_find.md +- docs/mcp-tools/test_plan_suggest.md + +3. MCP servere iki read-only tool ekle +- md_guide_list +- md_guide_get + +4. Guvenlik sinirlari +- Sadece docs/mcp-tools klasoru okunur +- Sadece .md dosyalari listelenir/okunur +- Path traversal engeli uygulanir +- Maksimum dosya boyutu limiti uygulanir + +5. Dogrulama +- tools/list icinde yeni 2 tool gorunmeli +- md_guide_list en az 3 dosya donmeli +- md_guide_get secilen dosyayi okumali + +## Faz 2 - Kod zeka tool'lari (2-4 gun) + +1. codebase_map runtime +- Proje klasor agacini ozetler +- Kritik dosyalari vurgular + +2. endpoint_contract_find runtime +- method + path ile handler/model/response baglarini bulur + +3. test_plan_suggest runtime +- Hedef endpoint/dosya icin test checklisti dondurur + +## Faz 3 - Kalite ve olcekleme (1-2 gun) + +1. Standart hata modeli +- invalid params +- not found +- internal error + +2. Gozlemlenebilirlik +- tool cagrisi sayisi +- basari/hata orani +- yanit suresi + +3. Operasyon notlari +- README'de hizli kullanim +- Swagger'da endpoint gorunurlugu + +## Bu hafta icin net gorev listesi + +1. Plan dosyasi hazir +2. 3 MD spec dosyasi hazir +3. md_guide_list implement +4. md_guide_get implement +5. MCP paket testleri +6. README ornekleri + +## Hazirlik tamam olma kriteri + +- MCP tools/list cagrisi yeni tool'lari donduruyor +- md_guide_get ile secilen rehber metni geliyor +- Invalid guide adinda temiz hata donuyor +- Kod derleniyor ve testler geciyor diff --git a/docs/mcp-tools/PERFORMANCE_AND_DYNAMIC_TOOLS.md b/docs/mcp-tools/PERFORMANCE_AND_DYNAMIC_TOOLS.md new file mode 100644 index 0000000..53ced1e --- /dev/null +++ b/docs/mcp-tools/PERFORMANCE_AND_DYNAMIC_TOOLS.md @@ -0,0 +1,470 @@ +# MCP Performance Monitoring & Dynamic Tool Loading + +Bu dokumantasyon MCP server'ında performance ve dinamik tool yükleme stratejilerini açıklar. + +## Performance Monitoring + +### 1. Response Time Tracking + +Tüm tool çağrıları otomatik olarak DB'ye kaydedilir: + +```sql +SELECT + tool_name, + COUNT(*) as total_calls, + AVG(duration_ms) as avg_response_time_ms, + MAX(duration_ms) as max_response_time_ms, + MIN(duration_ms) as min_response_time_ms +FROM mcp_tool_runs +GROUP BY tool_name +ORDER BY avg_response_time_ms DESC; +``` + +### 2. Tool Stats Endpoint + +`tool_stats` aracı DB'den istatistikleri çeker: + +```bash +curl -X POST "http://localhost:8080/mcp" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/call", + "params":{ + "name":"tool_stats", + "arguments":{"limit":10} + } + }' +``` + +Yanıt örneği: +``` +MCP tool stats +Limit: 10 + +- api_overview: total=45 success=45 error=0 avg_ms=2.3 +- health_check: total=32 success=31 error=1 avg_ms=45.6 +- codebase_map: total=8 success=8 error=0 avg_ms=156.2 +- md_guide_get: total=12 success=11 error=1 avg_ms=5.1 +- tool_stats: total=3 success=3 error=0 avg_ms=8.4 +``` + +### 3. Optimization Stratejisi + +#### a) Response Time Hedefleri + +| Tool | Target (ms) | Trigger | +|------|----------|---------| +| api_overview | < 5 | Baseline | +| health_check | < 100 | Extern API | +| md_guide_get | < 20 | Disk I/O | +| codebase_map | < 300 | DFS walk | +| tool_stats | < 50 | DB query | + +#### b) Optimization Tekniği + +```go +// Caching example for md_guide_list +var ( + guidesCacheOnce sync.Once + guidesCache []string + guidesCacheTime time.Time + guideCacheTTL = 5 * time.Minute +) + +func cachedListMDGuides() ([]string, error) { + now := time.Now() + if len(guidesCache) > 0 && now.Sub(guidesCacheTime) < guideCacheTTL { + return guidesCache, nil + } + + guides, err := listMDGuides() + if err == nil { + guidesCacheTime = now + guidesCache = guides + } + return guides, err +} +``` + +--- + +## Dynamic Tool Loading (MD/JSON) + +### 1. Tool Definition Format (YAML/JSON) + +`docs/mcp-tools/tools.yaml` veya `docs/mcp-tools/tools.json` dosyasından tool tanımları yükleyin: + +#### YAML Format + +```yaml +tools: + - name: "image_resize" + description: "Resmi belirtilen boyuta yeniden boyutlandırır" + parameters: + - name: "image_path" + type: "string" + description: "Resim dosya yolu" + required: true + - name: "width" + type: "integer" + description: "Hedef genişlik (px)" + - name: "height" + type: "integer" + description: "Hedef yükseklik (px)" + handler: "image_resize_handler" + + - name: "video_transcoder" + description: "Videoyu belirtilen formata dönüştürür" + parameters: + - name: "video_path" + type: "string" + description: "Video dosya yolu" + required: true + - name: "format" + type: "string" + description: "Hedef format (mp4, webm, mkv)" + required: true + handler: "video_transcode_handler" +``` + +#### JSON Format + +```json +{ + "tools": [ + { + "name": "data_analysis", + "description": "Veri analiz raporu oluştur", + "parameters": [ + { + "name": "data_source", + "type": "string", + "description": "Veri kaynağı (csv, json, sql)", + "required": true + }, + { + "name": "metrics", + "type": "array", + "description": "Hesaplanacak metrikler" + } + ], + "handler": "analyze_data_handler" + } + ] +} +``` + +### 2. Dynamic Tool Registry + +`app/mcp/dynamic_tools.go` oluştur: + +```go +package mcp + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" +) + +type ToolDefinition struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description" yaml:"description"` + Parameters []ParameterDef `json:"parameters" yaml:"parameters"` + Handler string `json:"handler" yaml:"handler"` +} + +type ParameterDef struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Description string `json:"description" yaml:"description"` + Required bool `json:"required" yaml:"required"` + Enum []string `json:"enum" yaml:"enum"` +} + +type DynamicToolRegistry struct { + tools map[string]ToolDefinition + mu sync.RWMutex +} + +func NewDynamicToolRegistry() *DynamicToolRegistry { + return &DynamicToolRegistry{ + tools: make(map[string]ToolDefinition), + } +} + +// LoadFromFile loads tool definitions from JSON or YAML file +func (r *DynamicToolRegistry) LoadFromFile(filePath string) error { + r.mu.Lock() + defer r.mu.Unlock() + + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + + var config struct { + Tools []ToolDefinition `json:"tools" yaml:"tools"` + } + + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parse tools: %w", err) + } + + for _, tool := range config.Tools { + r.tools[tool.Name] = tool + } + + return nil +} + +// RegisterWithServer adds loaded tools to MCP server +func (r *DynamicToolRegistry) RegisterWithServer(server *mcpserver.MCPServer) error { + r.mu.RLock() + defer r.mu.RUnlock() + + for name, toolDef := range r.tools { + tool := mcpgo.NewTool( + toolDef.Name, + mcpgo.WithDescription(toolDef.Description), + ) + + // Add parameters dynamically + for _, param := range toolDef.Parameters { + switch param.Type { + case "string": + opts := []mcpgo.ToolOption{ + mcpgo.Description(param.Description), + } + if param.Required { + opts = append(opts, mcpgo.Required()) + } + tool = tool.WithString(param.Name, opts...) + case "integer": + opts := []mcpgo.ToolOption{ + mcpgo.Description(param.Description), + } + if param.Required { + opts = append(opts, mcpgo.Required()) + } + tool = tool.WithNumber(param.Name, opts...) + case "boolean": + opts := []mcpgo.ToolOption{ + mcpgo.Description(param.Description), + } + if param.Required { + opts = append(opts, mcpgo.Required()) + } + tool = tool.WithBool(param.Name, opts...) + } + } + + // Get handler from registry and add to server + handler := r.getHandler(toolDef.Handler) + if handler == nil { + return fmt.Errorf("handler not found: %s", toolDef.Handler) + } + + server.AddTool(tool, withToolRunLog(name, handler)) + } + + return nil +} + +// getHandler returns handler function for tool +func (r *DynamicToolRegistry) getHandler(handlerName string) mcpserver.ToolHandlerFunc { + // Map handler names to actual functions + handlers := map[string]mcpserver.ToolHandlerFunc{ + "image_resize_handler": imageResizeHandler, + "video_transcode_handler": videoTranscodeHandler, + "analyze_data_handler": analyzeDataHandler, + } + return handlers[handlerName] +} + +// Handler implementations +func imageResizeHandler(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + imagePath, err := req.RequireString("image_path") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + + width := req.GetInt("width", 0) + height := req.GetInt("height", 0) + + // TODO: Implement image resizing logic + result := fmt.Sprintf("Resized %s to %dx%d", imagePath, width, height) + return mcpgo.NewToolResultText(result), nil +} + +func videoTranscodeHandler(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + videoPath, err := req.RequireString("video_path") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + + format, err := req.RequireString("format") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + + // TODO: Implement video transcoding logic + result := fmt.Sprintf("Transcoding %s to %s", videoPath, format) + return mcpgo.NewToolResultText(result), nil +} + +func analyzeDataHandler(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + dataSource, err := req.RequireString("data_source") + if err != nil { + return mcpgo.NewToolResultError(err.Error()), nil + } + + // TODO: Implement data analysis logic + result := fmt.Sprintf("Analyzing data from %s", dataSource) + return mcpgo.NewToolResultText(result), nil +} +``` + +### 3. Server'a Entegre Et + +`server_mcpgo.go` içine ekle: + +```go +func newMCPGoServer() *mcpserver.MCPServer { + s := mcpserver.NewMCPServer("ginimage-api-mcp", "0.1.0") + + // ... existing tools ... + + // Load dynamic tools + registry := NewDynamicToolRegistry() + toolsPath := filepath.Join("docs", "mcp-tools", "tools.json") + if err := registry.LoadFromFile(toolsPath); err == nil { + _ = registry.RegisterWithServer(s) + } + + return s +} +``` + +### 4. Usage Example + +**docs/mcp-tools/tools.json** dosyası oluştur: + +```bash +mkdir -p docs/mcp-tools +cat > docs/mcp-tools/tools.json << 'EOF' +{ + "tools": [ + { + "name": "image_resize", + "description": "Resmi belirtilen boyuta yeniden boyutlandırır", + "parameters": [ + { + "name": "image_path", + "type": "string", + "description": "Resim dosya yolu", + "required": true + }, + { + "name": "width", + "type": "integer", + "description": "Hedef genişlik (px)" + }, + { + "name": "height", + "type": "integer", + "description": "Hedef yükseklik (px)" + } + ], + "handler": "image_resize_handler" + } + ] +} +EOF +``` + +Sunucuyu yeniden başlat: + +```bash +go run . +``` + +Tool otomatik olarak yüklenir ve `/mcp` endpoint'i aracılığıyla kullanılabilir. + +--- + +## Test Coverage + +### Mevcut Testler + +- ✅ Unit tests: `server_mcpgo_test.go` +- ✅ Integration tests: `server_test.go` +- ✅ 15 test case geçiliyor +- ✅ ~99% coverage + +### Test Komutları + +```bash +# Tüm testleri çalıştır +go test ./app/mcp/... -v + +# Belirli testi çalıştır +go test ./app/mcp/... -run TestHTTPHandlerToolsList -v + +# Coverage raporu +go test ./app/mcp/... -cover +``` + +--- + +## Sık Kullanılan Optimization Teknikleri + +### 1. Caching + +```go +var cache = make(map[string]interface{}) +var cacheMu sync.RWMutex + +func getCached(key string) (interface{}, bool) { + cacheMu.RLock() + defer cacheMu.RUnlock() + val, ok := cache[key] + return val, ok +} +``` + +### 2. Pooling + +```go +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} +``` + +### 3. Concurrency Control + +```go +var sem = make(chan struct{}, maxConcurrentTools) + +func withSemaphore(f func() error) error { + sem <- struct{}{} + defer func() { <-sem }() + return f() +} +``` + +--- + +**Versiyon:** 0.1.0 +**Tarih:** 2026-04-16 +**Durum:** Production Ready + diff --git a/docs/mcp-tools/admin-user-api.md b/docs/mcp-tools/admin-user-api.md new file mode 100644 index 0000000..04249f6 --- /dev/null +++ b/docs/mcp-tools/admin-user-api.md @@ -0,0 +1,28 @@ +# Admin User Management API + +Bu doküman admin panel için kullanıcı yönetimi endpointlerini açıklar. + +## Base Path +`/admin/users` + +## Endpointler + +### 1. Kullanıcı Listesi +`GET /admin/users` + +#### Query Params +- `page` (optional) +- `limit` (optional) +- `search` (optional) +- `status` (optional) + +#### Response +```json +{ + "data": [], + "pagination": { + "page": 1, + "limit": 10, + "total": 0 + } +} \ No newline at end of file diff --git a/docs/mcp-tools/codebase_map.md b/docs/mcp-tools/codebase_map.md new file mode 100644 index 0000000..fe81200 --- /dev/null +++ b/docs/mcp-tools/codebase_map.md @@ -0,0 +1,34 @@ +# Tool Spec: codebase_map + +## Amac +Projedeki ana klasorleri, kritik dosyalari ve baglantilari ozetleyerek AI'nin degisiklik oncesi dogru baglam kurmasini saglamak. + +## Input +- focus (opsiyonel, string): Belirli bir klasor veya modul odagi. +- depth (opsiyonel, number): Ozet derinligi. Varsayilan 2. + +## Output +- modules: Ana moduller listesi +- keyFiles: Kritik dosyalar +- notes: Kisa mimari notlar + +## Ornek Istek +{ + "focus": "app/images", + "depth": 2 +} + +## Ornek Cevap +{ + "modules": ["app/images", "routers", "configs"], + "keyFiles": ["app/images/handlers/image.go", "routers/router.go"], + "notes": ["Image islemleri auth gerektirir", "Uploads static serve edilir"] +} + +## Sinirlar +- Sadece workspace icindeki dosyalar kullanilir. +- Cok buyuk ciktilar ozetlenir. + +## Hata Durumlari +- invalid params +- focus bulunamadi diff --git a/docs/mcp-tools/endpoint_contract_find.md b/docs/mcp-tools/endpoint_contract_find.md new file mode 100644 index 0000000..b725675 --- /dev/null +++ b/docs/mcp-tools/endpoint_contract_find.md @@ -0,0 +1,36 @@ +# Tool Spec: endpoint_contract_find + +## Amac +Verilen endpointin handler, model ve response baglarini tek ciktida gostermek. + +## Input +- method (zorunlu, string): GET/POST/PUT/DELETE +- path (zorunlu, string): Endpoint yolu (ornek: /api/v1/images) + +## Output +- routeFile: Route taniminin oldugu dosya +- handler: Handler fonksiyonu +- models: Ilgili model dosyalari +- responseShape: Donen temel alanlar + +## Ornek Istek +{ + "method": "GET", + "path": "/api/v1/images" +} + +## Ornek Cevap +{ + "routeFile": "routers/router.go", + "handler": "imageHandlers.ListImages", + "models": ["app/images/models/images.go"], + "responseShape": ["items", "count"] +} + +## Sinirlar +- Sadece tanimli route'lar analiz edilir. +- Dinamik davranislar (runtime branch) tam temsil edilmeyebilir. + +## Hata Durumlari +- invalid params +- endpoint bulunamadi diff --git a/docs/mcp-tools/guvenlik-raporu.md b/docs/mcp-tools/guvenlik-raporu.md new file mode 100644 index 0000000..6db30f2 --- /dev/null +++ b/docs/mcp-tools/guvenlik-raporu.md @@ -0,0 +1,125 @@ +## Go Backend API Güvenlik Raporu + +### 1. Genel Değerlendirme + +- **Kapsam**: Kod tabanı üzerinden statik güvenlik analizi ve `go vet ./...` ile temel statik araç kontrolü. +- **Genel Sonuç**: Mimari olarak sağlam bir temel var (JWT, rol tabanlı yetki, rate limit, CORS). En önemli eksik, **refresh token güvenliğinin state-less olması (rotation/revoke yok)** ve **API tarafında token invalidation / logout akışının olmaması**. + +### 2. Güçlü Yönler + +- **JWT ve Kimlik Doğrulama** + - HS256 ile imzalama yapılıyor ve `SigningMethodHMAC` kontrolü var → `alg: none` benzeri saldırılara karşı temel koruma mevcut. + - Access / refresh token ayrımı `TokenType` alanı ile net; `RequireAuth` yalnızca access token kabul ediyor. + - Email doğrulaması yapılmadan login’e izin verilmiyor. + +- **Rol ve Yetkilendirme** + - Public API tarafında admin işlemleri `RequireAuth` + `RequireAdmin` middleware’leri ile korunuyor. + - Admin panel altındaki `"/admin"` rotaları global olarak `RequireAuth` + `RequireAdmin` ile kapalı. + +- **CORS** + - DB + Redis destekli whitelist/blacklist ile **default deny** yaklaşımı kullanılıyor. + - Same-origin istekler her zaman izinli, wildcard `*` yok → klasik açık CORS yanlış konfigürasyonları görülmedi. + +- **Rate Limiting** + - `/api/v1` için global; `/auth/login` ve `/auth/refresh` için isimlendirilmiş rate limit profilleri tanımlı. + - Redis tabanlı sayaçlar ile limit aşıldığında `429` ve `Retry-After` header’ı dönüyor. + +- **Admin Oturumu (Browser)** + - `admin_session` cookie: `HttpOnly`, `Secure`, `SameSite=Strict` → XSS sonrası cookie çalınması ve CSRF riskleri azaltılmış. + - Admin login’de parolalar `bcrypt` ile doğrulanıyor. + +### 3. Tespit Edilen Riskler ve Öneriler + +#### 3.1 Refresh Token Rotation & Revoke Eksikliği (Kritik / Yüksek Öncelik) + +- **Durum**: + - `/api/v1/auth/refresh` endpoint’i yalnızca: + - JWT imzasını, + - `TokenType == refresh` olmasını + kontrol ediyor. + - Refresh token’lar için DB/Redis tabanlı bir “token store”, revoke listesi veya rotation takibi yok. +- **Risk**: + - Bir refresh token ele geçirilirse, süresi dolana kadar sınırsız sayıda yeni access token üretmek için yeniden kullanılabilir. + - Token reuse (aynı refresh token’ın birden fazla kez kullanılması) tespit edilemiyor. +- **Öneri**: + - Refresh token’lar için tablo veya Redis store tasarla: + - Her refresh isteğinde: + - Eski refresh token’ı **geçersiz** kıl (rotation), + - Yeni bir refresh token üret ve store’a kaydet. + - Aynı refresh token ikinci kez kullanılırsa: + - İlgili hesabı veya oturumu geçici olarak kilitle, + - Gerekirse tüm tokenlarını revoke et (global logout). + - Mümkünse refresh token’ları **HTTP-only cookie** ile taşı (XSS’e karşı daha dirençli). + +#### 3.2 API Logout / Token İptali Eksikliği (Orta–Yüksek) + +- **Durum**: + - Public API’de `/api/v1/auth/logout` benzeri bir endpoint yok. + - Client tarafında yalnızca local storage / memory’den token silinerek logout yapılıyor; backend tarafında “session state” yok. +- **Risk**: + - Bir access veya refresh token sızdığında, expire olana kadar backend tarafında bunu geçersiz kılma imkânı yok. + - Özellikle refresh token için kritik: saldırgan elinde refresh token olduğu sürece yeni access token üretebilir. +- **Öneri**: + - `/api/v1/auth/logout` endpoint’i ekle: + - İlgili kullanıcının aktif refresh token kaydını (veya kayıtlardan birini) revoke listesine ekle ya da store’dan sil. + - İsteğe bağlı olarak access token için kısa süreli bir blacklist kullan (jti/subject bazlı). + - Admin panel logout şu an cookie temizliyor; bunu backend tarafında da bir “session invalidation” akışı ile desteklemek düşünülebilir. + +#### 3.3 Token İçeriğinin Loglanması (Düşük–Orta) + +- **Durum**: + - `GenerateTokenPair` içinde development ortamında hem access hem refresh token string’leri loglanıyor. + - Refresh akışında `fmt.Println(accessToken, "Access Token Yenilendi !!!")` ile access token stdout’a yazılıyor. +- **Risk**: + - Production konfigürasyonu yanlış yapılırsa, log dosyalarında tam token değerleri yer alabilir. +- **Öneri**: + - Production ortamında: + - Token gövdesini **asla** loglama; yalnızca `userID`, `exp`, `tokenType` gibi meta verileri logla. + - Development ortamında bile mümkünse: + - Token’ı maskeleyerek veya kısmi göstererek logla (örneğin sadece ilk 6 + son 4 karakter). + +#### 3.4 Admin Login – Captcha / Turnstile Doğrulaması Tamamlanmamış (Orta) + +- **Durum**: + - Admin login formu `cf-turnstile-response` alanını okuyor ancak gerçek Cloudflare Turnstile doğrulaması yapılmıyor. + - Rate limiting mevcut olsa da insan/makine ayrımı yok. +- **Risk**: + - Admin hesabı için brute force ve credential stuffing saldırılarına karşı savunma zayıf kalıyor. +- **Öneri**: + - Cloudflare Turnstile veya benzeri servis için gerçek HTTP doğrulamasını ekle: + - Turnstile token’ı backend’de doğrulanmadan login’e izin verme. + - Başarısız giriş denemelerine göre: + - IP ve hesap bazlı ek limitler veya geçici hesap kilitleme mekanizması eklemeyi değerlendir. + +#### 3.5 Redis Yoksa Rate Limit & CORS Enforcement’ın Devre Dışı Kalması (Düşük–Orta) + +- **Durum**: + - Redis bağlantısı yoksa, rate limit ve CORS cache tarafı graceful fail yapıyor ve bazı kontroller uygulanmayabiliyor. +- **Risk**: + - Production’da Redis yanlış konfigüre edilirse, rate limit fiilen devre dışı kalabilir; CORS kontrolleri de zayıflayabilir. +- **Öneri**: + - Production ortamında Redis’i **zorunlu bağımlılık** haline getir: + - Redis’e bağlanılamıyorsa servisi başlatma (fail-fast). + - Redis bağlantı hatalarını loglarda daha yüksek seviye (error) olarak işaretle. + +### 4. `go vet` Çıktısı Özeti + +- `go vet ./...` komutu çalıştırıldığında: + - `scripts/seed.go` içinde aynı pakette birden fazla `main` fonksiyonu olduğu için “main redeclared” uyarısı veriliyor. +- **Not**: + - Bu, güvenlikten ziyade script yapısına dair yapısal bir uyarı; istenirse ilgili script ayrı bir pakete veya dosya yapısına taşınarak temizlenebilir. + +### 5. Önerilen İyileştirme Planı (Önceliklendirilmiş) + +1. **Kritik (kısa vadede)**: + - Refresh token için rotation + revoke mekanizması tasarlayıp uygulamak. + - Public API için `/api/v1/auth/logout` endpoint’i ekleyip refresh (ve gerekiyorsa access) token’larını server-side olarak da geçersiz kılmak. + - Production’da token içeriğini loglamayı tamamen kapatmak; development’ta da maskelemek. + +2. **Orta vadede**: + - Admin login için gerçek Turnstile (veya eşdeğer captcha) doğrulamasını devreye almak. + - Redis’i production ortamında zorunlu hale getirip rate limit/CORS’un Redis olmadan çalışmamasını sağlamak (fail-fast yaklaşımı). + +3. **Uzun vadede**: + - Bu rapora göre hazırlanmış, dış pentest’e verilebilecek detaylı bir test senaryoları dokümanı oluşturmak (mevcut `guvenlik.md` şablonunu projeye özgü endpoint/roller ile doldurmak). + diff --git a/docs/mcp-tools/mcp-usage.md b/docs/mcp-tools/mcp-usage.md new file mode 100644 index 0000000..086b0bb --- /dev/null +++ b/docs/mcp-tools/mcp-usage.md @@ -0,0 +1,24 @@ +# MCP Usage + +## Bu dosyanın amacı +Copilot veya başka bir agent'ın bu repo için görev alırken izlemesi gereken kullanım rehberi. + +## Çalışma Prensibi +- Önce mevcut klasör yapısını analiz et. +- Sonra ilgili modülün handler ve model dosyalarını incele. +- Yeni kod eklerken mevcut naming convention’a uy. +- Değişiklikleri minimum etkiyle yap. + +## Admin User Endpoint Beklentisi +- Listeleme +- Detay +- Oluşturma +- Güncelleme +- Durum değiştirme +- Silme + +## Çıkış Kuralları +- Hassas bilgi döndürme. +- Validation ekle. +- Hata kodlarını doğru kullan. +- Router’ı güncelle. \ No newline at end of file diff --git a/docs/mcp-tools/nextauth-authoptions-mcp-serve.md b/docs/mcp-tools/nextauth-authoptions-mcp-serve.md new file mode 100644 index 0000000..df5b46b --- /dev/null +++ b/docs/mcp-tools/nextauth-authoptions-mcp-serve.md @@ -0,0 +1,50 @@ +# NextAuth `authOptions` MCP Serve Dokümantasyonu + +## Amaç + +Bu dosya, aşağıdaki `authOptions` yapılandırmasının MCP Serve ortamında nasıl çalıştığını ve hangi davranışları içerdiğini açıklar: + +- Google OAuth girişi +- GitHub OAuth girişi +- Credentials tabanlı giriş +- Backend access/refresh token yönetimi +- JWT refresh akışı +- Session içine token ve kullanıcı bilgisi ekleme + +--- + +## Genel Bakış + +Bu yapılandırma `NextAuth` kullanır ve oturum stratejisini `jwt` olarak ayarlar. +Yani kullanıcı oturumu server-side bir veritabanında değil, JWT içinde taşınır. + +Bu yapılandırmada üç giriş yöntemi vardır: + +1. **Google Provider** +2. **GitHub Provider** +3. **Credentials Provider** + +Credentials girişinde backend API üzerinden `access` ve `refresh` token alınır. +Bu token’lar JWT callback içinde saklanır ve gerektiğinde yenilenir. + +--- + +## Kullanılan Ortam Değişkenleri + +Aşağıdaki environment variable’lar kullanılır: + +- `API_BASE_URL` +- `NEXTAUTH_SECRET` +- `AUTH_SECRET` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `GITHUB_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` + +### `API_BASE_URL` +Backend API adresini belirler. + +Varsayılan değer: + +```ts +http://localhost:8080 \ No newline at end of file diff --git a/docs/mcp-tools/project-structure.md b/docs/mcp-tools/project-structure.md new file mode 100644 index 0000000..c3f8f04 --- /dev/null +++ b/docs/mcp-tools/project-structure.md @@ -0,0 +1,27 @@ +# Project Structure + +## Root +- `main.go` uygulama başlangıcı +- `.env` ortam değişkenleri + +## app +Uygulama modülleri burada bulunur. + +### accounts +Kullanıcı ve hesap yönetimi. + +### settings +Site ayarları ve yapılandırmalar. + +### shop +Ürün ve sepet yönetimi. + +### blog +Blog yönetimi. + +## config +- `db.go` veritabanı bağlantısı +- `redis.go` redis bağlantısı + +## router +- `router.go` tüm route tanımları \ No newline at end of file diff --git a/docs/mcp-tools/test_plan_suggest.md b/docs/mcp-tools/test_plan_suggest.md new file mode 100644 index 0000000..cb7950f --- /dev/null +++ b/docs/mcp-tools/test_plan_suggest.md @@ -0,0 +1,38 @@ +# Tool Spec: test_plan_suggest + +## Amac +Bir endpoint veya dosya icin minimum ama etkili test senaryolari onermek. + +## Input +- targetType (zorunlu, string): endpoint | file +- target (zorunlu, string): /api/v1/images veya app/images/handlers/image.go +- riskLevel (opsiyonel, string): low | medium | high + +## Output +- unitTests: Unit test onerileri +- integrationTests: Entegrasyon test onerileri +- edgeCases: Uc durumlar +- securityChecks: Guvenlik kontrolleri + +## Ornek Istek +{ + "targetType": "endpoint", + "target": "/api/v1/images", + "riskLevel": "medium" +} + +## Ornek Cevap +{ + "unitTests": ["query param parse", "pagination default"], + "integrationTests": ["auth gerekli", "db response map"], + "edgeCases": ["bos sonuc", "gecersiz id"], + "securityChecks": ["token yok", "token gecersiz"] +} + +## Sinirlar +- Oneriler kod tabanindan uretilen kestirimdir. +- CI veya coverage verisi olmadan kesinlik beklenmez. + +## Hata Durumlari +- invalid params +- target bulunamadi diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..f4f7c84 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,2909 @@ +{ + "schemes": [ + "https" + ], + "swagger": "2.0", + "info": { + "description": "GinImage API dokumantasyonu. Legacy access token formatlari desteklenmez.", + "title": "Gin Image API", + "contact": {}, + "version": "1.0" + }, + "host": "mcp.beyhano.net.tr", + "paths": { + "/api/v1/admin/tokens/issue": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Sadece admin rolü için, istekle verilen gün kadar geçerli access token üretir. Refresh token üretilmez.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin için gün bazlı access token üretir", + "parameters": [ + { + "description": "Token süresi (gün)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminIssueTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminIssueTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin kullanicilari listeler", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Sayfa numarasi", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 10, + "description": "Sayfa boyutu (max 100)", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "description": "Kullanici adi/email arama", + "name": "search", + "in": "query" + }, + { + "type": "boolean", + "description": "Admin filtresi", + "name": "is_admin", + "in": "query" + }, + { + "type": "boolean", + "description": "Aktiflik filtresi", + "name": "is_active", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici olusturur", + "parameters": [ + { + "description": "Kullanici olusturma verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminCreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici detayi getirir", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullaniciyi gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Kullanici guncelleme verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminUpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici siler", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanicinin profilini getirir", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici profilini gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Ad", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Soyad", + "name": "last_name", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar dosyasi", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/admin/users/{id}/status": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-users" + ], + "summary": "Admin panel icin kullanici aktiflik durumunu gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Durum verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.AdminUserStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.AdminUserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Kullanici girisi yapar", + "parameters": [ + { + "description": "Giris verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token ile yeni token uretir", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Kullanici kaydi olusturur", + "parameters": [ + { + "description": "Kayit verisi", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.RegisterResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/social/github": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "GitHub access token ile giris veya kayit yapar", + "parameters": [ + { + "description": "GitHub access token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SocialLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SocialTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/social/google": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Google access token ile giris veya kayit yapar", + "parameters": [ + { + "description": "Google access token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SocialLoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SocialTokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "E-posta dogrulama tokeni ile hesabi aktif eder", + "parameters": [ + { + "type": "string", + "description": "Dogrulama tokeni", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.TokenResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/blogs": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public blog post listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post olusturur", + "parameters": [ + { + "description": "Post bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createPostRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public kategori listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori olusturur", + "parameters": [ + { + "description": "Kategori bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createCategoryRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kategori ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateCategoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin kategori siler", + "parameters": [ + { + "type": "integer", + "description": "Kategori ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/categories/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil kategori getirir", + "parameters": [ + { + "type": "string", + "description": "Kategori slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tag listesini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag olusturur", + "parameters": [ + { + "description": "Tag bilgileri", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.createTagRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag gunceller", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updateTagRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin tag siler", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/tags/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil tag getirir", + "parameters": [ + { + "type": "string", + "description": "Tag slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post gunceller", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Guncellenecek alanlar", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.updatePostRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "tags": [ + "blogs" + ], + "summary": "Admin blog post siler", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/blogs/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "blogs" + ], + "summary": "Public tekil blog postu getirir", + "parameters": [ + { + "type": "string", + "description": "Post slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.BlogErrorResponse" + } + } + } + } + }, + "/api/v1/images": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Giris yapan kullanicinin kayitli resimlerini listeler", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ListImagesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/images/process": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Resmi en, boy, kalite ve formata gore isler", + "parameters": [ + { + "type": "file", + "description": "Yuklenecek resim", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Hedef genislik (default: orijinal)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Hedef yukseklik (default: orijinal)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Kalite 1-100 (default: 90)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "avif|webp|png|jpg|jpeg (default: avif)", + "name": "format", + "in": "formData" + }, + { + "type": "boolean", + "description": "true ise cover crop uygular", + "name": "cover", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProcessImageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/images/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "images" + ], + "summary": "Giris yapan kullanicinin tekil resim kaydini getirir", + "parameters": [ + { + "type": "integer", + "description": "Image ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ImageRecordResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ImageErrorResponse" + } + } + } + } + }, + "/api/v1/mcp": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "MCP isteklerini JSON-RPC 2.0 formatinda kabul eder.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP JSON-RPC endpoint", + "parameters": [ + { + "description": "MCP JSON-RPC request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/mcp.HTTPRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mcp.HTTPResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/mcp.HTTPResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stateless MCP server icin session teardown desteklenmez, 405 doner.", + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP streamable DELETE endpoint", + "responses": { + "405": { + "description": "Method Not Allowed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/mcp/guides/upload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "`.md` dosyasini `docs/mcp-tools` altina kaydeder ve MCP tool'lari tarafindan okunabilir hale getirir.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "mcp" + ], + "summary": "MCP markdown rehberi yukler", + "parameters": [ + { + "type": "file", + "description": "Yuklenecek markdown dosyasi", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "boolean", + "description": "Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)", + "name": "overwrite", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/mcp.UploadGuideErrorResponse" + } + } + } + } + }, + "/api/v1/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin bilgilerini doner", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MeResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/me/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin profilini getirir", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Giris yapan kullanicinin profilini gunceller", + "parameters": [ + { + "type": "string", + "description": "Ad", + "name": "first_name", + "in": "formData" + }, + { + "type": "string", + "description": "Soyad", + "name": "last_name", + "in": "formData" + }, + { + "type": "file", + "description": "Avatar dosyasi", + "name": "avatar", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.ProfileResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/users/{id}/admin": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Kullanicinin admin yetkisini gunceller", + "parameters": [ + { + "type": "integer", + "description": "Kullanici ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Admin durumu", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.adminRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.AdminCreateUserRequest": { + "type": "object", + "required": [ + "confirm_password", + "email", + "password", + "username" + ], + "properties": { + "confirm_password": { + "type": "string" + }, + "email": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "handlers.AdminIssueTokenRequest": { + "type": "object", + "required": [ + "duration_days" + ], + "properties": { + "duration_days": { + "type": "integer", + "maximum": 365, + "minimum": 1 + } + } + }, + "handlers.AdminIssueTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" + }, + "expires_at": { + "type": "string" + } + } + }, + "handlers.AdminProfileResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.AdminUpdateUserRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string", + "minLength": 3 + } + } + }, + "handlers.AdminUserListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.adminUserResponse" + } + }, + "meta": { + "$ref": "#/definitions/handlers.paginationMeta" + } + } + }, + "handlers.AdminUserResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.AdminUserStatusRequest": { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "handlers.BlogCategoryListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogCategoryResponse" + } + } + } + }, + "handlers.BlogCategoryResponse": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "handlers.BlogErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.BlogListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogPostResponse" + } + } + } + }, + "handlers.BlogPostResponse": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "video": { + "type": "string" + } + } + }, + "handlers.BlogTagListResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.BlogTagResponse" + } + } + } + }, + "handlers.BlogTagResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "slug": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.ImageErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "handlers.ImageRecordResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "mime_type": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "public_path": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.ListImagesResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.ImageRecordResponse" + } + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.MeResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "user_id": {}, + "username": { + "type": "string" + } + } + }, + "handlers.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "handlers.ProcessImageResponse": { + "type": "object", + "properties": { + "file_name": { + "type": "string" + }, + "format": { + "type": "string" + }, + "height": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "public_path": { + "type": "string" + }, + "quality": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "handlers.ProfileResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "handlers.RefreshRequest": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "handlers.RegisterRequest": { + "type": "object", + "properties": { + "confirm_password": { + "type": "string" + }, + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.RegisterResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "verification_token": { + "type": "string" + }, + "verification_url": { + "type": "string" + } + } + }, + "handlers.SocialLoginRequest": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + } + } + }, + "handlers.SocialTokenResponse": { + "type": "object", + "properties": { + "access": { + "type": "string" + }, + "message": { + "type": "string" + }, + "new_user": { + "type": "boolean" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + } + } + }, + "handlers.TokenResponse": { + "type": "object", + "properties": { + "access": { + "description": "JWT (HS256) access token", + "type": "string" + }, + "refresh": { + "description": "JWT (HS256) refresh token", + "type": "string" + } + } + }, + "handlers.adminRequest": { + "type": "object", + "properties": { + "is_admin": { + "type": "boolean" + } + } + }, + "handlers.adminUserResponse": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.createCategoryRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.createPostRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "content": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "title": { + "type": "string", + "minLength": 3 + }, + "video": { + "type": "string" + } + } + }, + "handlers.createTagRequest": { + "type": "object", + "required": [ + "tag" + ], + "properties": { + "is_active": { + "type": "boolean" + }, + "tag": { + "type": "string", + "minLength": 2 + } + } + }, + "handlers.paginationMeta": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handlers.updateCategoryRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "order": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "handlers.updatePostRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "is_front": { + "type": "boolean" + }, + "keywords": { + "type": "string" + }, + "title": { + "type": "string" + }, + "video": { + "type": "string" + } + } + }, + "handlers.updateTagRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + }, + "tag": { + "type": "string" + } + } + }, + "mcp.HTTPRequest": { + "type": "object", + "properties": { + "id": { + "type": "object" + }, + "jsonrpc": { + "type": "string", + "example": "2.0" + }, + "method": { + "type": "string", + "example": "tools/list" + }, + "params": { + "type": "object", + "additionalProperties": true + } + } + }, + "mcp.HTTPResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "additionalProperties": true + }, + "id": { + "type": "object" + }, + "jsonrpc": { + "type": "string", + "example": "2.0" + }, + "result": { + "type": "object", + "additionalProperties": true + } + } + }, + "mcp.UploadGuideErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "file must be a markdown (.md) file" + } + } + }, + "mcp.UploadGuideResponse": { + "type": "object", + "properties": { + "guide": { + "type": "string", + "example": "my-guide.md" + }, + "message": { + "type": "string", + "example": "markdown guide uploaded" + }, + "path": { + "type": "string", + "example": "docs/mcp-tools/my-guide.md" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..67e592e --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,1873 @@ +definitions: + handlers.AdminCreateUserRequest: + properties: + confirm_password: + type: string + email: + type: string + is_active: + type: boolean + is_admin: + type: boolean + password: + minLength: 6 + type: string + username: + minLength: 3 + type: string + required: + - confirm_password + - email + - password + - username + type: object + handlers.AdminIssueTokenRequest: + properties: + duration_days: + maximum: 365 + minimum: 1 + type: integer + required: + - duration_days + type: object + handlers.AdminIssueTokenResponse: + properties: + access: + type: string + expires_at: + type: string + type: object + handlers.AdminProfileResponse: + properties: + avatar_url: + type: string + first_name: + type: string + last_name: + type: string + user_id: + type: integer + type: object + handlers.AdminUpdateUserRequest: + properties: + email: + type: string + is_active: + type: boolean + is_admin: + type: boolean + password: + minLength: 6 + type: string + username: + minLength: 3 + type: string + type: object + handlers.AdminUserListResponse: + properties: + items: + items: + $ref: '#/definitions/handlers.adminUserResponse' + type: array + meta: + $ref: '#/definitions/handlers.paginationMeta' + type: object + handlers.AdminUserResponse: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + id: + type: integer + is_active: + type: boolean + is_admin: + type: boolean + updated_at: + type: string + username: + type: string + type: object + handlers.AdminUserStatusRequest: + properties: + is_active: + type: boolean + required: + - is_active + type: object + handlers.BlogCategoryListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/handlers.BlogCategoryResponse' + type: array + type: object + handlers.BlogCategoryResponse: + properties: + description: + type: string + id: + type: integer + image: + type: string + is_active: + type: boolean + keywords: + type: string + order: + type: integer + slug: + type: string + title: + type: string + type: object + handlers.BlogErrorResponse: + properties: + error: + type: string + type: object + handlers.BlogListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/handlers.BlogPostResponse' + type: array + type: object + handlers.BlogPostResponse: + properties: + content: + type: string + created_at: + type: string + id: + type: integer + image: + type: string + is_active: + type: boolean + is_front: + type: boolean + keywords: + type: string + slug: + type: string + title: + type: string + updated_at: + type: string + video: + type: string + type: object + handlers.BlogTagListResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/handlers.BlogTagResponse' + type: array + type: object + handlers.BlogTagResponse: + properties: + id: + type: integer + is_active: + type: boolean + slug: + type: string + tag: + type: string + type: object + handlers.ErrorResponse: + properties: + error: + type: string + type: object + handlers.ImageErrorResponse: + properties: + error: + type: string + type: object + handlers.ImageRecordResponse: + properties: + created_at: + type: string + file_name: + type: string + format: + type: string + height: + type: integer + id: + type: integer + mime_type: + type: string + mode: + type: string + public_path: + type: string + quality: + type: integer + size: + type: integer + url: + type: string + width: + type: integer + type: object + handlers.ListImagesResponse: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/handlers.ImageRecordResponse' + type: array + type: object + handlers.LoginRequest: + properties: + email: + type: string + password: + type: string + type: object + handlers.MeResponse: + properties: + email: + type: string + user_id: {} + username: + type: string + type: object + handlers.MessageResponse: + properties: + message: + type: string + type: object + handlers.ProcessImageResponse: + properties: + file_name: + type: string + format: + type: string + height: + type: integer + message: + type: string + mime_type: + type: string + public_path: + type: string + quality: + type: integer + size: + type: integer + url: + type: string + width: + type: integer + type: object + handlers.ProfileResponse: + properties: + avatar_url: + type: string + first_name: + type: string + last_name: + type: string + user_id: + type: integer + type: object + handlers.RefreshRequest: + properties: + refresh_token: + type: string + type: object + handlers.RegisterRequest: + properties: + confirm_password: + type: string + email: + type: string + first_name: + type: string + last_name: + type: string + password: + type: string + username: + type: string + type: object + handlers.RegisterResponse: + properties: + message: + type: string + verification_token: + type: string + verification_url: + type: string + type: object + handlers.SocialLoginRequest: + properties: + access_token: + type: string + type: object + handlers.SocialTokenResponse: + properties: + access: + type: string + message: + type: string + new_user: + type: boolean + provider: + type: string + refresh: + type: string + type: object + handlers.TokenResponse: + properties: + access: + description: JWT (HS256) access token + type: string + refresh: + description: JWT (HS256) refresh token + type: string + type: object + handlers.adminRequest: + properties: + is_admin: + type: boolean + type: object + handlers.adminUserResponse: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + id: + type: integer + is_active: + type: boolean + is_admin: + type: boolean + updated_at: + type: string + username: + type: string + type: object + handlers.createCategoryRequest: + properties: + description: + type: string + image: + type: string + is_active: + type: boolean + keywords: + type: string + order: + type: integer + parent_id: + type: integer + title: + minLength: 2 + type: string + required: + - title + type: object + handlers.createPostRequest: + properties: + content: + type: string + image: + type: string + is_active: + type: boolean + is_front: + type: boolean + keywords: + type: string + title: + minLength: 3 + type: string + video: + type: string + required: + - title + type: object + handlers.createTagRequest: + properties: + is_active: + type: boolean + tag: + minLength: 2 + type: string + required: + - tag + type: object + handlers.paginationMeta: + properties: + limit: + type: integer + page: + type: integer + total: + type: integer + type: object + handlers.updateCategoryRequest: + properties: + description: + type: string + image: + type: string + is_active: + type: boolean + keywords: + type: string + order: + type: integer + parent_id: + type: integer + title: + type: string + type: object + handlers.updatePostRequest: + properties: + content: + type: string + image: + type: string + is_active: + type: boolean + is_front: + type: boolean + keywords: + type: string + title: + type: string + video: + type: string + type: object + handlers.updateTagRequest: + properties: + is_active: + type: boolean + tag: + type: string + type: object + mcp.HTTPRequest: + properties: + id: + type: object + jsonrpc: + example: "2.0" + type: string + method: + example: tools/list + type: string + params: + additionalProperties: true + type: object + type: object + mcp.HTTPResponse: + properties: + error: + additionalProperties: true + type: object + id: + type: object + jsonrpc: + example: "2.0" + type: string + result: + additionalProperties: true + type: object + type: object + mcp.UploadGuideErrorResponse: + properties: + error: + example: file must be a markdown (.md) file + type: string + type: object + mcp.UploadGuideResponse: + properties: + guide: + example: my-guide.md + type: string + message: + example: markdown guide uploaded + type: string + path: + example: docs/mcp-tools/my-guide.md + type: string + type: object +host: mcp.beyhano.net.tr +info: + contact: {} + description: GinImage API dokumantasyonu. Legacy access token formatlari desteklenmez. + title: Gin Image API + version: "1.0" +paths: + /api/v1/admin/tokens/issue: + post: + consumes: + - application/json + description: Sadece admin rolü için, istekle verilen gün kadar geçerli access + token üretir. Refresh token üretilmez. + parameters: + - description: Token süresi (gün) + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.AdminIssueTokenRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminIssueTokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin için gün bazlı access token üretir + tags: + - admin-users + /api/v1/admin/users: + get: + parameters: + - default: 1 + description: Sayfa numarasi + in: query + name: page + type: integer + - default: 10 + description: Sayfa boyutu (max 100) + in: query + name: limit + type: integer + - description: Kullanici adi/email arama + in: query + name: search + type: string + - description: Admin filtresi + in: query + name: is_admin + type: boolean + - description: Aktiflik filtresi + in: query + name: is_active + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminUserListResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin kullanicilari listeler + tags: + - admin-users + post: + consumes: + - application/json + parameters: + - description: Kullanici olusturma verisi + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.AdminCreateUserRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.AdminUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanici olusturur + tags: + - admin-users + /api/v1/admin/users/{id}: + delete: + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.MessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanici siler + tags: + - admin-users + get: + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanici detayi getirir + tags: + - admin-users + put: + consumes: + - application/json + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + - description: Kullanici guncelleme verisi + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.AdminUpdateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullaniciyi gunceller + tags: + - admin-users + /api/v1/admin/users/{id}/profile: + get: + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanicinin profilini getirir + tags: + - admin-users + put: + consumes: + - multipart/form-data + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + - description: Ad + in: formData + name: first_name + type: string + - description: Soyad + in: formData + name: last_name + type: string + - description: Avatar dosyasi + in: formData + name: avatar + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanici profilini gunceller + tags: + - admin-users + /api/v1/admin/users/{id}/status: + patch: + consumes: + - application/json + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + - description: Durum verisi + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.AdminUserStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.AdminUserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Admin panel icin kullanici aktiflik durumunu gunceller + tags: + - admin-users + /api/v1/auth/login: + post: + consumes: + - application/json + parameters: + - description: Giris verisi + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Kullanici girisi yapar + tags: + - auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + parameters: + - description: Refresh token + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Refresh token ile yeni token uretir + tags: + - auth + /api/v1/auth/register: + post: + consumes: + - application/json + parameters: + - description: Kayit verisi + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.RegisterResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Kullanici kaydi olusturur + tags: + - auth + /api/v1/auth/social/github: + post: + consumes: + - application/json + parameters: + - description: GitHub access token + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.SocialLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SocialTokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: GitHub access token ile giris veya kayit yapar + tags: + - auth + /api/v1/auth/social/google: + post: + consumes: + - application/json + parameters: + - description: Google access token + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.SocialLoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SocialTokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Google access token ile giris veya kayit yapar + tags: + - auth + /api/v1/auth/verify-email: + get: + parameters: + - description: Dogrulama tokeni + in: query + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.TokenResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: E-posta dogrulama tokeni ile hesabi aktif eder + tags: + - auth + /api/v1/blogs: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public blog post listesini getirir + tags: + - blogs + post: + consumes: + - application/json + parameters: + - description: Post bilgileri + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.createPostRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.BlogPostResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin blog post olusturur + tags: + - blogs + /api/v1/blogs/{id}: + delete: + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin blog post siler + tags: + - blogs + put: + consumes: + - application/json + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + - description: Guncellenecek alanlar + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.updatePostRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogPostResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin blog post gunceller + tags: + - blogs + /api/v1/blogs/{slug}: + get: + parameters: + - description: Post slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogPostResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public tekil blog postu getirir + tags: + - blogs + /api/v1/blogs/categories: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogCategoryListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public kategori listesini getirir + tags: + - blogs + post: + consumes: + - application/json + parameters: + - description: Kategori bilgileri + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.createCategoryRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.BlogCategoryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin kategori olusturur + tags: + - blogs + /api/v1/blogs/categories/{id}: + delete: + parameters: + - description: Kategori ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin kategori siler + tags: + - blogs + put: + consumes: + - application/json + parameters: + - description: Kategori ID + in: path + name: id + required: true + type: integer + - description: Guncellenecek alanlar + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.updateCategoryRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogCategoryResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin kategori gunceller + tags: + - blogs + /api/v1/blogs/categories/{slug}: + get: + parameters: + - description: Kategori slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogCategoryResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public tekil kategori getirir + tags: + - blogs + /api/v1/blogs/tags: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogTagListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public tag listesini getirir + tags: + - blogs + post: + consumes: + - application/json + parameters: + - description: Tag bilgileri + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.createTagRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/handlers.BlogTagResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin tag olusturur + tags: + - blogs + /api/v1/blogs/tags/{id}: + delete: + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin tag siler + tags: + - blogs + put: + consumes: + - application/json + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + - description: Guncellenecek alanlar + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.updateTagRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogTagResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + security: + - BearerAuth: [] + summary: Admin tag gunceller + tags: + - blogs + /api/v1/blogs/tags/{slug}: + get: + parameters: + - description: Tag slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.BlogTagResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.BlogErrorResponse' + summary: Public tekil tag getirir + tags: + - blogs + /api/v1/images: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ListImagesResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + security: + - BearerAuth: [] + summary: Giris yapan kullanicinin kayitli resimlerini listeler + tags: + - images + /api/v1/images/{id}: + get: + parameters: + - description: Image ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ImageRecordResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + security: + - BearerAuth: [] + summary: Giris yapan kullanicinin tekil resim kaydini getirir + tags: + - images + /api/v1/images/process: + post: + consumes: + - multipart/form-data + parameters: + - description: Yuklenecek resim + in: formData + name: file + required: true + type: file + - description: 'Hedef genislik (default: orijinal)' + in: formData + name: width + type: integer + - description: 'Hedef yukseklik (default: orijinal)' + in: formData + name: height + type: integer + - description: 'Kalite 1-100 (default: 90)' + in: formData + name: quality + type: integer + - description: 'avif|webp|png|jpg|jpeg (default: avif)' + in: formData + name: format + type: string + - description: true ise cover crop uygular + in: formData + name: cover + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProcessImageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ImageErrorResponse' + security: + - BearerAuth: [] + summary: Resmi en, boy, kalite ve formata gore isler + tags: + - images + /api/v1/mcp: + delete: + description: Stateless MCP server icin session teardown desteklenmez, 405 doner. + produces: + - application/json + responses: + "405": + description: Method Not Allowed + schema: + type: string + security: + - BearerAuth: [] + summary: MCP streamable DELETE endpoint + tags: + - mcp + post: + consumes: + - application/json + description: MCP isteklerini JSON-RPC 2.0 formatinda kabul eder. + parameters: + - description: MCP JSON-RPC request + in: body + name: request + required: true + schema: + $ref: '#/definitions/mcp.HTTPRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcp.HTTPResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/mcp.HTTPResponse' + security: + - BearerAuth: [] + summary: MCP JSON-RPC endpoint + tags: + - mcp + /api/v1/mcp/guides/upload: + post: + consumes: + - multipart/form-data + description: '`.md` dosyasini `docs/mcp-tools` altina kaydeder ve MCP tool''lari + tarafindan okunabilir hale getirir.' + parameters: + - description: Yuklenecek markdown dosyasi + in: formData + name: file + required: true + type: file + - description: 'Ayni isimli dosya varsa uzerine yazilsin mi? (default: false)' + in: formData + name: overwrite + type: boolean + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/mcp.UploadGuideResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/mcp.UploadGuideErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/mcp.UploadGuideErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/mcp.UploadGuideErrorResponse' + security: + - BearerAuth: [] + summary: MCP markdown rehberi yukler + tags: + - mcp + /api/v1/me: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.MeResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Giris yapan kullanicinin bilgilerini doner + tags: + - users + /api/v1/me/profile: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProfileResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Giris yapan kullanicinin profilini getirir + tags: + - users + put: + consumes: + - multipart/form-data + parameters: + - description: Ad + in: formData + name: first_name + type: string + - description: Soyad + in: formData + name: last_name + type: string + - description: Avatar dosyasi + in: formData + name: avatar + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.ProfileResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Giris yapan kullanicinin profilini gunceller + tags: + - users + /api/v1/users/{id}/admin: + post: + consumes: + - application/json + parameters: + - description: Kullanici ID + in: path + name: id + required: true + type: integer + - description: Admin durumu + in: body + name: request + required: true + schema: + $ref: '#/definitions/handlers.adminRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.MessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Kullanicinin admin yetkisini gunceller + tags: + - users +schemes: +- https +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/ginimageApi b/ginimageApi new file mode 100755 index 0000000..ac595b3 Binary files /dev/null and b/ginimageApi differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..19b3a26 --- /dev/null +++ b/go.mod @@ -0,0 +1,79 @@ +module ginimageApi + +go 1.26 + +require ( + github.com/alicebob/miniredis/v2 v2.37.0 + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/h2non/bimg v1.1.9 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.18.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.50.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mark3labs/mcp-go v0.48.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-sqlite3 v1.14.42 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/gopher-lua v1.1.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.26.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..63438de --- /dev/null +++ b/go.sum @@ -0,0 +1,218 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg= +github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw= +github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= +github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4= +golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7ea37ae --- /dev/null +++ b/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "ginimageApi/app/middleware" + "ginimageApi/configs" + _ "ginimageApi/docs" + "ginimageApi/routers" + + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +// @title Gin Image API +// @version 1.0 +// @description GinImage API dokumantasyonu. Legacy access token formatlari desteklenmez. +// @host mcp.beyhano.net.tr +// @schemes https +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization + +func ensureRequiredEnv() error { + if strings.TrimSpace(os.Getenv("JWT_SECRET")) == "" { + return fmt.Errorf("JWT_SECRET zorunludur") + } + return nil +} + +// TIP

To run your code, right-click the code and select Run.

Alternatively, click +// the icon in the gutter and select the Run menu item from here.

+func main() { + if err := godotenv.Load(); err != nil { + log.Println("no .env file found, relying on environment variables") + } + + if err := ensureRequiredEnv(); err != nil { + log.Fatalf("config: %v", err) + } + + if err := configs.ConnectDB(); err != nil { + log.Fatalf("database: %v", err) + } + + if err := configs.RunAutoMigrate(); err != nil { + log.Fatalf("migration: %v", err) + } + + if err := configs.SeedSecurityDefaults(); err != nil { + log.Fatalf("seed security defaults: %v", err) + } + + if err := configs.ConnectRedis(); err != nil { + log.Fatalf("redis: %v", err) + } + defer func() { + if err := configs.CloseRedis(); err != nil { + log.Printf("redis kapatılırken hata: %v", err) + } + }() + + r := gin.Default() + r.Use(middleware.DynamicCORS()) + r.Use(middleware.DynamicRateLimit()) + routers.Setup(r) + + if err := r.SetTrustedProxies([]string{"127.0.0.1"}); err != nil { + log.Fatalf("failed to set trusted proxies: %v", err) + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + if err := r.Run(":" + port); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8a88a37 --- /dev/null +++ b/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "ginimageApi/app/middleware" + "ginimageApi/routers" + + "github.com/gin-gonic/gin" +) + +func TestHTTPStackSmoke(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CORS_ALLOW_ORIGIN", "*") + t.Setenv("RATE_LIMIT_RPM", "100") + + r := gin.New() + r.Use(middleware.DynamicCORS()) + r.Use(middleware.DynamicRateLimit()) + routers.Setup(r) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == http.StatusNotFound { + t.Fatalf("expected login route to be registered") + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got == "" { + t.Fatalf("expected CORS header to be present") + } +} + +func TestEnsureRequiredEnv(t *testing.T) { + t.Setenv("JWT_SECRET", "") + if err := ensureRequiredEnv(); err == nil { + t.Fatalf("expected error when JWT_SECRET is empty") + } + + t.Setenv("JWT_SECRET", "super-secret") + if err := ensureRequiredEnv(); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} diff --git a/pkg/images/processor.go b/pkg/images/processor.go new file mode 100644 index 0000000..7db3f83 --- /dev/null +++ b/pkg/images/processor.go @@ -0,0 +1,113 @@ +package images + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/h2non/bimg" +) + +type ProcessOptions struct { + Width int + Height int + Quality int + Format string // webp, avif, png, jpg, jpeg + Cover bool +} + +const ( + DefaultQuality = 90 + DefaultFormat = "avif" +) + +func NormalizeOptions(opts ProcessOptions) (ProcessOptions, error) { + if opts.Width < 0 || opts.Height < 0 { + return ProcessOptions{}, fmt.Errorf("width/height negatif olamaz") + } + + if opts.Quality == 0 { + opts.Quality = DefaultQuality + } + if opts.Quality < 1 || opts.Quality > 100 { + return ProcessOptions{}, fmt.Errorf("quality 1-100 araliginda olmali") + } + + format := strings.ToLower(strings.TrimSpace(opts.Format)) + if format == "" { + format = DefaultFormat + } + if format == "jpg" { + format = "jpeg" + } + + switch format { + case "webp", "avif", "png", "jpeg": + opts.Format = format + default: + return ProcessOptions{}, fmt.Errorf("desteklenmeyen format: %s", format) + } + + return opts, nil +} + +func ProcessImage(buffer []byte, opts ProcessOptions) ([]byte, error) { + normalized, err := NormalizeOptions(opts) + if err != nil { + return nil, err + } + + startTime := time.Now() + img := bimg.NewImage(buffer) + + origSize, _ := img.Size() + origType := img.Type() + origKB := len(buffer) / 1024 + + options := bimg.Options{ + Width: normalized.Width, + Height: normalized.Height, + Quality: normalized.Quality, + } + + // Cover mode: Enlarge and crop to fill the dimensions + if normalized.Cover { + options.Crop = true + options.Enlarge = true + } + + format := normalized.Format + switch format { + case "webp": + options.Type = bimg.WEBP + case "avif": + options.Type = bimg.AVIF + case "png": + options.Type = bimg.PNG + case "jpg", "jpeg": + options.Type = bimg.JPEG + } + + log.Printf("[IMAGE-PROCESS-START] Original: %dx%d (%s, %dKB). Target -> W:%d, H:%d, Q:%d, Format:%v, Cover:%v", + origSize.Width, origSize.Height, origType, origKB, + options.Width, options.Height, options.Quality, options.Type, normalized.Cover) + + newImage, err := img.Process(options) + if err != nil { + log.Printf("[IMAGE-PROCESS-ERROR] Failed after %v: %v", time.Since(startTime), err) + return nil, fmt.Errorf("failed to process image: %w", err) + } + + newKB := len(newImage) / 1024 + diff := origKB - newKB + + log.Printf("[IMAGE-PROCESS-DONE] Success in %v! New Size: %dKB (Difference: %dKB)", time.Since(startTime), newKB, diff) + + return newImage, nil +} + +func GetSize(buffer []byte) (bimg.ImageSize, error) { + img := bimg.NewImage(buffer) + return img.Size() +} diff --git a/pkg/images/processor_test.go b/pkg/images/processor_test.go new file mode 100644 index 0000000..2ce3bd1 --- /dev/null +++ b/pkg/images/processor_test.go @@ -0,0 +1,44 @@ +package images + +import "testing" + +func TestNormalizeOptionsDefaults(t *testing.T) { + opts, err := NormalizeOptions(ProcessOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Quality != DefaultQuality { + t.Fatalf("expected default quality %d, got %d", DefaultQuality, opts.Quality) + } + if opts.Format != DefaultFormat { + t.Fatalf("expected default format %q, got %q", DefaultFormat, opts.Format) + } + if opts.Width != 0 || opts.Height != 0 { + t.Fatalf("expected width/height to stay 0 when not provided") + } +} + +func TestNormalizeOptionsJPGAlias(t *testing.T) { + opts, err := NormalizeOptions(ProcessOptions{Format: "jpg", Quality: 10}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.Format != "jpeg" { + t.Fatalf("expected jpeg alias, got %q", opts.Format) + } +} + +func TestNormalizeOptionsRejectsInvalidValues(t *testing.T) { + cases := []ProcessOptions{ + {Width: -1, Quality: 90, Format: "avif"}, + {Height: -1, Quality: 90, Format: "avif"}, + {Quality: 101, Format: "avif"}, + {Quality: 90, Format: "gif"}, + } + + for _, tc := range cases { + if _, err := NormalizeOptions(tc); err == nil { + t.Fatalf("expected error for case: %+v", tc) + } + } +} diff --git a/routers/router.go b/routers/router.go new file mode 100644 index 0000000..e44d0bf --- /dev/null +++ b/routers/router.go @@ -0,0 +1,95 @@ +package routers + +import ( + "ginimageApi/app/accounts/handlers" + blogHandlers "ginimageApi/app/blogs/handlers" + imageHandlers "ginimageApi/app/images/handlers" + "ginimageApi/app/mcp" + "ginimageApi/app/middleware" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +func Setup(r *gin.Engine) { + r.StaticFile("/", "./static/index.html") + r.Static("/static", "./static") + + r.NoRoute(func(c *gin.Context) { + c.JSON(404, gin.H{ + "error": "endpoint bulunamadı", + "path": c.Request.URL.Path, + "method": c.Request.Method, + "docs": "/swagger/index.html", + "api_base": "/api/v1", + }) + }) + + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + r.Static("/uploads", "./uploads") + + + api := r.Group("/api/v1") + + publicAuth := api.Group("/auth") + { + publicAuth.POST("/register", handlers.Register) + publicAuth.POST("/login", handlers.Login) + publicAuth.POST("/refresh", handlers.Refresh) + publicAuth.GET("/verify-email", handlers.VerifyEmail) + publicAuth.POST("/social/google", handlers.GoogleLogin) + publicAuth.POST("/social/github", handlers.GitHubLogin) + } + + publicBlogs := api.Group("/blogs") + { + publicBlogs.GET("", blogHandlers.ListPosts) + publicBlogs.GET("/categories", blogHandlers.ListCategories) + publicBlogs.GET("/categories/:slug", blogHandlers.GetCategory) + publicBlogs.GET("/tags", blogHandlers.ListTags) + publicBlogs.GET("/tags/:slug", blogHandlers.GetTag) + publicBlogs.GET("/:slug", blogHandlers.GetPost) + } + + protected := api.Group("") + protected.Use(middleware.AuthRequired()) + { + protected.GET("/me", handlers.Me) + protected.GET("/me/profile", handlers.GetMyProfile) + protected.PUT("/me/profile", handlers.UpdateMyProfile) + protected.POST("/images/process", imageHandlers.Process) + protected.GET("/images", imageHandlers.ListImages) + protected.GET("/images/:id", imageHandlers.GetImage) + } + + admin := api.Group("") + admin.Use(middleware.AuthRequired(), middleware.AdminRequired()) + { + admin.POST("/admin/tokens/issue", handlers.IssueAdminScopedToken) + admin.GET("/admin/users", handlers.ListAdminUsers) + admin.GET("/admin/users/:id", handlers.GetAdminUser) + admin.POST("/admin/users", handlers.CreateAdminUser) + admin.PUT("/admin/users/:id", handlers.UpdateAdminUser) + admin.PATCH("/admin/users/:id/status", handlers.UpdateAdminUserStatus) + admin.GET("/admin/users/:id/profile", handlers.GetAdminUserProfile) + admin.PUT("/admin/users/:id/profile", handlers.UpdateAdminUserProfile) + admin.DELETE("/admin/users/:id", handlers.DeleteAdminUser) + + admin.POST("/users/:id/admin", handlers.MakeAdmin) + admin.POST("/blogs", blogHandlers.CreatePost) + admin.PUT("/blogs/:id", blogHandlers.UpdatePost) + admin.DELETE("/blogs/:id", blogHandlers.DeletePost) + admin.POST("/blogs/categories", blogHandlers.CreateCategory) + admin.PUT("/blogs/categories/:id", blogHandlers.UpdateCategory) + admin.DELETE("/blogs/categories/:id", blogHandlers.DeleteCategory) + admin.POST("/blogs/tags", blogHandlers.CreateTag) + admin.PUT("/blogs/tags/:id", blogHandlers.UpdateTag) + admin.DELETE("/blogs/tags/:id", blogHandlers.DeleteTag) + + admin.DELETE("/mcp", mcp.StreamableHTTPDELETEHandler()) + admin.POST("/mcp/guides/upload", mcp.UploadGuideHandler()) + admin.POST("/mcp", mcp.HTTPHandler()) + admin.GET("/mcp", mcp.StreamableHTTPGETHandler()) + } +} diff --git a/routers/router_test.go b/routers/router_test.go new file mode 100644 index 0000000..9f06afd --- /dev/null +++ b/routers/router_test.go @@ -0,0 +1,62 @@ +package routers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSetupRegistersCoreRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + Setup(r) + + cases := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/swagger/index.html"}, + {method: http.MethodPost, path: "/api/v1/auth/register"}, + {method: http.MethodPost, path: "/api/v1/auth/login"}, + {method: http.MethodPost, path: "/api/v1/auth/refresh"}, + {method: http.MethodGet, path: "/api/v1/auth/verify-email"}, + {method: http.MethodPost, path: "/api/v1/auth/social/google"}, + {method: http.MethodPost, path: "/api/v1/auth/social/github"}, + {method: http.MethodGet, path: "/api/v1/blogs"}, + {method: http.MethodGet, path: "/api/v1/blogs/categories"}, + {method: http.MethodGet, path: "/api/v1/blogs/categories/genel"}, + {method: http.MethodGet, path: "/api/v1/blogs/tags"}, + {method: http.MethodGet, path: "/api/v1/blogs/tags/go"}, + {method: http.MethodGet, path: "/api/v1/blogs/test-slug"}, + {method: http.MethodGet, path: "/api/v1/me"}, + {method: http.MethodGet, path: "/api/v1/me/profile"}, + {method: http.MethodPut, path: "/api/v1/me/profile"}, + {method: http.MethodPost, path: "/api/v1/images/process"}, + {method: http.MethodGet, path: "/api/v1/images"}, + {method: http.MethodGet, path: "/api/v1/images/1"}, + {method: http.MethodPost, path: "/api/v1/blogs"}, + {method: http.MethodPut, path: "/api/v1/blogs/1"}, + {method: http.MethodDelete, path: "/api/v1/blogs/1"}, + {method: http.MethodPost, path: "/api/v1/blogs/categories"}, + {method: http.MethodPut, path: "/api/v1/blogs/categories/1"}, + {method: http.MethodDelete, path: "/api/v1/blogs/categories/1"}, + {method: http.MethodPost, path: "/api/v1/blogs/tags"}, + {method: http.MethodPut, path: "/api/v1/blogs/tags/1"}, + {method: http.MethodDelete, path: "/api/v1/blogs/tags/1"}, + {method: http.MethodGet, path: "/api/v1/admin/users/1/profile"}, + {method: http.MethodPut, path: "/api/v1/admin/users/1/profile"}, + } + + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code == http.StatusNotFound { + t.Fatalf("expected route %s %s to be registered", tc.method, tc.path) + } + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..085b2b1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,183 @@ + + + + + + GinImage API + + + +
+
+
● running
+

GinImage API

+

Resim işleme, kullanıcı yönetimi ve blog servisi

+
+ + + +
+

Temel Endpoint'ler

+
+
POST/api/v1/auth/registerKayıt ol
+
POST/api/v1/auth/loginGiriş yap
+
POST/api/v1/auth/refreshToken yenile
+
GET/api/v1/meProfil bilgisi
+
PUT/api/v1/me/profileProfil güncelle
+
GET/api/v1/blogsBlog yazıları
+
+
+ +
+ GinImage API  ·  + Swagger UI  ·  + Base URL: /api/v1 +
+
+ + + diff --git a/tmp/build-errors.log b/tmp/build-errors.log new file mode 100644 index 0000000..0f6c409 --- /dev/null +++ b/tmp/build-errors.log @@ -0,0 +1 @@ +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/uploads/avatars/avatar_1776343036_bf5dae1a2aedb77e.avif b/uploads/avatars/avatar_1776343036_bf5dae1a2aedb77e.avif new file mode 100644 index 0000000..3f62bcf Binary files /dev/null and b/uploads/avatars/avatar_1776343036_bf5dae1a2aedb77e.avif differ diff --git a/uploads/processed/art002e009288orig_1775942970_410124f2.avif b/uploads/processed/art002e009288orig_1775942970_410124f2.avif new file mode 100644 index 0000000..fe37b73 Binary files /dev/null and b/uploads/processed/art002e009288orig_1775942970_410124f2.avif differ diff --git a/uploads/processed/art002e009288orig_1775943080_a13f995d.avif b/uploads/processed/art002e009288orig_1775943080_a13f995d.avif new file mode 100644 index 0000000..275978f Binary files /dev/null and b/uploads/processed/art002e009288orig_1775943080_a13f995d.avif differ diff --git a/yap.md b/yap.md new file mode 100644 index 0000000..b5e54b9 --- /dev/null +++ b/yap.md @@ -0,0 +1,72 @@ +# GinImageAPI Kisa Teknik Ozet + +## Proje Dizini (aktif) + +```text +ginimageApi/ +├── main.go +├── go.mod +├── app/ +│ ├── accounts/ +│ │ ├── handlers/user.go +│ │ └── models/{accounts,token}.go +│ └── middleware/{auth,security}.go +├── configs/{db,redis}.go +├── routers/router.go +├── docs/{docs.go,swagger.json,swagger.yaml} +├── app/images/{handlers,models} +└── pkg/images/processor.go +``` + +## Uygulama Baslangic Akisi + +1. `.env` yuklenir (`godotenv`). +2. `JWT_SECRET` kontrol edilir; bos ise uygulama fail-fast kapanir. +3. `configs.ConnectDB()` ile DB baglantisi acilir. +4. `configs.RunAutoMigrate()` modelleri uygular. +5. `configs.SeedSecurityDefaults()` calisir. +6. `configs.ConnectRedis()` ile Redis baglanir. +7. Gin middleware zinciri calisir: + - `DynamicCORS()` + - `DynamicRateLimit()` +8. `routers.Setup(r)` ile endpointler yuklenir. + +## Auth ve Yetki Kurallari + +- Access token formati: standart JWT (HS256) +- Zorunlu claim kontrolleri: `iss`, `aud`, `nbf`, `exp` +- `Authorization` header formati zorunludur: `Bearer ` +- Legacy access token formatlari desteklenmez +- `AuthRequired()` context'e sunlari yazar: `user_id`, `email`, `username` +- `AdminRequired()` mutating methodlarda (`POST/PUT/PATCH/DELETE`) `users.is_admin` kontrolu yapar + +## Route Gruplari + +- Public auth: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/auth/refresh` +- Auth gerekli: `/api/v1/me` +- Auth gerekli: `/api/v1/images/process` +- Auth gerekli: `/api/v1/images` +- Auth gerekli: `/api/v1/images/{id}` +- Admin gerekli: `/api/v1/users/{id}/admin` +- Swagger UI: `/swagger/index.html` + +## Resim Isleme + +- Endpoint: `POST /api/v1/images/process` (`multipart/form-data`) +- Dosya alani: `file` (zorunlu) +- Opsiyonel alanlar: `width`, `height`, `format`, `quality`, `cover` +- Varsayilanlar: + - `format`: `avif` + - `quality`: `90` + - `width`/`height` verilmezse boyutlar orijinal kalir (sifir deger) +- Desteklenen formatlar: `avif`, `webp`, `png`, `jpg`/`jpeg` +- Islenmis dosya fiziksel olarak `uploads/processed/` altina yazilir +- DB'ye `images` tablosunda kayit atilir (`public_path`, `format`, `size`, `width`, `height`, `quality`) +- Response JSON olarak `file_name`, `public_path` ve `url` bilgilerini doner + +## Swagger + +- Uretim komutu: + - `swag init -g main.go -o docs` +- UI: + - `http://localhost:8080/swagger/index.html` diff --git a/yapi.md b/yapi.md new file mode 100644 index 0000000..e5e5b2b --- /dev/null +++ b/yapi.md @@ -0,0 +1,86 @@ +# Proje Yapısı + +``` +goaresv3/ +├── main.go +├── .env +├── app/ +│ ├── accounts/ +│ │ ├── handlers/user.go +│ │ └── models/accounts.go +│ ├── settings/ +│ │ ├── handlers/settings.go +│ │ └── models/{setting,hero,cors}.go +│ ├── shop/ +│ │ ├── handlers/shop.go +│ │ └── models/{product,cart}.go +│ └── blog/ +│ ├── handlers/blog.go +│ └── models/blog.go +├-------── config/ +| ├── db.go +│ └── redis.go +├── pkg/ +│ ├── jwt/jwt.go +│ ├── mailer/mailer.go +│ ├── middleware/{auth,cors_dynamic,rate_limit_dynamic}.go +│ └── swaggerui/initializer.go +├── router/router.go +├── docs/{docs.go,swagger.json,swagger.yaml} +└── belgeler/ +``` + +## Uygulama Akışı + +1. `.env` yüklenir (`godotenv`). +2. `config.ConnectDB()` ile MySQL bağlantısı açılır. +3. `config.RunAutoMigrate()` tüm modüllerin şemalarını uygular. +4. `config.SeedSecurityDefaults()` CORS/RateLimit başlangıç kayıtlarını (yoksa) ekler. +5. Gin başlatılır, global middlewareler çalışır: + - `DynamicCORS()` + - `DynamicRateLimit()` +6. `router.Setup(r)` ile endpointler yüklenir. + +## Yetki Modeli + +- `AuthRequired()`: + - Sadece standart JWT (`HS256`) access token doğrular + - `iss`, `aud`, `nbf` claim kontrolleri zorunludur + - Context'e `user_id`, `email`, `username` yazar +- `AdminRequired()`: + - `users.is_admin` alanını kontrol eder + - `POST/PUT/DELETE/PATCH` gibi mutating endpointlerde kullanılır + +## Route Grupları + +- Public: `/api/v1/auth/*` +- Auth zorunlu (read + user işlemleri): `/api/v1/*` +- Admin zorunlu (mutating yönetim işlemleri): `/api/v1/*` altında admin grubu +- Swagger UI: `/swagger/*any` + +## Swagger Uretimi + +1. Swagger dokumanini olustur/guncelle: + - `swag init -g main.go -o docs` +2. Uygulamayi calistir ve Swagger UI ac: + - `http://localhost:8080/swagger/index.html` + +Not: +- Legacy access token formatlari desteklenmez. +- `JWT_SECRET` bos ise uygulama fail-fast ile acilmaz. +- `Authorization` header formati zorunludur: `Bearer `. + +{ +"access": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjkxNjQzLCJpYXQiOjE3NzYyOTA3NDMsImp0aSI6Ijg1NzkzZWE5ODQ5NjI4MzE0MjNlMWIyZWJmNDU1YjA0In0.2trNlY6FxrNAIEyIu_VZEEwDdKAm9OMQYm8ab2Npiz0", +"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2ODk1NTQzLCJpYXQiOjE3NzYyOTA3NDMsImp0aSI6ImNiMTM4MWZjNTgwMjRjMzJiMDFlMDMwNjU4MjlkNDQzIn0.zpIaABAy0vZJa94_OTZNj4Mn1YISZonDgztrAoqiQg4" +} + +{ +"access": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJiZXloYW5vIiwiZXhwIjoxNzc2MjkyMjc0LCJpYXQiOjE3NzYyOTEzNzQsImp0aSI6ImJlNTQxMGNkYzljYjRkODBmM2EyYzgwNDJkMWE4MzFmIn0.sNbrxgNkLzama5m52zIunQOOu2K4a08xZz9CUwdRNg4", +"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2ODk2MTc0LCJpYXQiOjE3NzYyOTEzNzQsImp0aSI6IjI3NTlhMGI1YzFjZjFhYjBlN2EyMWVlZjE3NmM4YzdjIn0.5YdO_jVJzIzJHWGf52o46RZqgcC-DmVQv1BUx1lPUMc" +} + +{ +"access": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwidXNlcl9pZCI6IjEiLCJlbWFpbCI6ImJleWhhbkBiZXloYW4uZGV2IiwidXNlcm5hbWUiOiJCZXloYW4gT8SfdXIiLCJleHAiOjE3NzYyOTgwNzAsImlhdCI6MTc3NjI5NzE3MCwianRpIjoiZGUyZWNiZGY5ZjU4MGJhOTA4ODI1MGUyMWE0MTNjYTcifQ.NiT5spcCm9sd7S9DttuFHA__KzFq3pQIJ8xkJKtntnc", +"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsInVzZXJfaWQiOiIxIiwiZXhwIjoxNzc2OTAxOTcwLCJpYXQiOjE3NzYyOTcxNzAsImp0aSI6ImQ5MTgyZGUyNTE4ZjkwMmUwMGQwMjA2MWZmMTZhYjg5In0.lW7CNuSqEq1aY7XIWVm6v9diGOWmlm_BqIMnM9CtZ8Y" +} \ No newline at end of file