first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:40:14 +03:00
commit e04ba85564
129 changed files with 17541 additions and 0 deletions

58
.air.toml Normal file
View File

@@ -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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.idea
.cursor
tmp
agent-transcripts
.env
.env.*
*.md
ginimageApi

56
.env Normal file
View File

@@ -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

26
.env.docker Normal file
View File

@@ -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

26
.env.docker.example Normal file
View File

@@ -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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
static/Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV
tmp/main

10
.idea/.gitignore generated vendored Normal file
View File

@@ -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

9
.idea/ginimageApi.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

10
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ginimageApi.iml" filepath="$PROJECT_DIR$/.idea/ginimageApi.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

41
COPILOT_RULES.md Normal file
View File

@@ -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 hashi 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 responsea 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ı.

37
Dockerfile Normal file
View File

@@ -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"]

73
Prompt.md Normal file
View File

@@ -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

View File

@@ -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"),
}
}

View File

@@ -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())
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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(), &regResp); 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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
})
}

View File

@@ -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"`
}

745
app/blogs/handlers/blog.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

260
app/blogs/models/blog.go Normal file
View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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)
}
}

View File

@@ -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"`
}

337
app/mcp/README.md Normal file
View File

@@ -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

61
app/mcp/http_helpers.go Normal file
View File

@@ -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)
}

View File

@@ -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"
}

583
app/mcp/server.go Normal file
View File

@@ -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()
}

303
app/mcp/server_mcpgo.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}

102
app/mcp/server_test.go Normal file
View File

@@ -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)
}
}

265
app/middleware/auth.go Normal file
View File

@@ -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)
}

231
app/middleware/auth_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

101
configs/db.go Normal file
View File

@@ -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
}

51
configs/db_test.go Normal file
View File

@@ -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)
}
}

46
configs/redis.go Normal file
View File

@@ -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://<user>:<password>@<host>:<port>/<db>
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
}

45
configs/redis_test.go Normal file
View File

@@ -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)
}
}

49
docker-compose.dev.yml Normal file
View File

@@ -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:

16
docker-compose.yml Normal file
View File

@@ -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

2931
docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

54
docs/mcp-tools/MCP.md Normal file
View File

@@ -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.

Some files were not shown because too many files have changed in this diff Show More