first commit
58
.air.toml
Normal 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
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.cursor
|
||||||
|
tmp
|
||||||
|
agent-transcripts
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
ginimageApi
|
||||||
56
.env
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
static/Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXV
|
||||||
|
tmp/main
|
||||||
10
.idea/.gitignore
generated
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,41 @@
|
|||||||
|
# Copilot Development Rules
|
||||||
|
|
||||||
|
## Genel Kurallar
|
||||||
|
- Kodlar mevcut proje yapısına uygun olmalı.
|
||||||
|
- Gin + Gorm mimarisine sadık kalınmalı.
|
||||||
|
- Gereksiz paket eklenmemeli.
|
||||||
|
- Fonksiyonlar küçük, net ve test edilebilir olmalı.
|
||||||
|
- Tüm endpointler standart HTTP semantiğine uygun olmalı.
|
||||||
|
- Hata mesajları tutarlı ve anlaşılır olmalı.
|
||||||
|
|
||||||
|
## Mimari Kurallar
|
||||||
|
- Handler katmanı sadece HTTP ile ilgilenmeli.
|
||||||
|
- DB işlemleri Gorm üzerinden yapılmalı.
|
||||||
|
- Gerekirse DTO/request struct kullanılmalı.
|
||||||
|
- Model alanları ile response alanları ayrılmalı.
|
||||||
|
- Şifre hash’i asla response içinde dönmemeli.
|
||||||
|
|
||||||
|
## Admin User Yönetimi Kuralları
|
||||||
|
- Admin endpointleri `/admin/users` altında toplanmalı.
|
||||||
|
- Listeleme, detay, oluşturma, güncelleme, durum değiştirme ve silme desteklenmeli.
|
||||||
|
- Kullanıcı listesinde pagination ve search desteği tercih edilmeli.
|
||||||
|
- Yetkisiz erişim engellenmeli.
|
||||||
|
- Eğer rol sistemi yoksa, en az müdahaleyle admin kontrolü eklenmeli.
|
||||||
|
|
||||||
|
## Güvenlik Kuralları
|
||||||
|
- Password düz metin saklanmamalı.
|
||||||
|
- Password hash işlemi `bcrypt` ile yapılmalı.
|
||||||
|
- Oluşturma ve güncellemede input validation yapılmalı.
|
||||||
|
- Sensitive alanlar response’a eklenmemeli.
|
||||||
|
- JWT veya mevcut auth sistemiyle uyum sağlanmalı.
|
||||||
|
|
||||||
|
## Router Kuralları
|
||||||
|
- Route tanımları `router/router.go` içinden yapılmalı.
|
||||||
|
- Mevcut routing düzeni bozulmamalı.
|
||||||
|
- Admin route grubu middleware ile korunmalı.
|
||||||
|
|
||||||
|
## Kod Kalitesi
|
||||||
|
- İsimler açık ve tutarlı olmalı.
|
||||||
|
- Hatalar `400/401/403/404/500` gibi uygun kodlarla dönmeli.
|
||||||
|
- Gereksiz tekrar engellenmeli.
|
||||||
|
- Gerekirse helper fonksiyonlar kullanılmalı.
|
||||||
37
Dockerfile
Normal 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
@@ -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
|
||||||
709
app/accounts/handlers/admin_users.go
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/accounts/handlers/admin_users_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
1281
app/accounts/handlers/user.go
Normal file
735
app/accounts/handlers/user_test.go
Normal 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(), ®Resp); err != nil {
|
||||||
|
t.Fatalf("register json parse failed: %v", err)
|
||||||
|
}
|
||||||
|
accessToken, _ := regResp["access"].(string)
|
||||||
|
if accessToken != "" {
|
||||||
|
t.Fatalf("register should not return direct access token before email verification")
|
||||||
|
}
|
||||||
|
verificationToken, _ := regResp["verification_token"].(string)
|
||||||
|
if verificationToken == "" {
|
||||||
|
t.Fatalf("verification_token must be returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
wLogin := performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
}, nil)
|
||||||
|
if wLogin.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("login expected 403 before verify, got %d body=%s", wLogin.Code, wLogin.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPath := "/verify-email?token=" + url.QueryEscape(verificationToken)
|
||||||
|
wVerify := performJSON(r, http.MethodGet, verifyPath, nil, nil)
|
||||||
|
if wVerify.Code != http.StatusOK {
|
||||||
|
t.Fatalf("verify expected 200, got %d body=%s", wVerify.Code, wVerify.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyResp map[string]any
|
||||||
|
if err := json.Unmarshal(wVerify.Body.Bytes(), &verifyResp); err != nil {
|
||||||
|
t.Fatalf("verify json parse failed: %v", err)
|
||||||
|
}
|
||||||
|
accessToken, _ = verifyResp["access"].(string)
|
||||||
|
refreshToken, _ := verifyResp["refresh"].(string)
|
||||||
|
if accessToken == "" || refreshToken == "" {
|
||||||
|
t.Fatalf("verify should return tokens")
|
||||||
|
}
|
||||||
|
assertJWTFormat(t, accessToken)
|
||||||
|
assertJWTFormat(t, refreshToken)
|
||||||
|
|
||||||
|
wLogin = performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
}, nil)
|
||||||
|
if wLogin.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login expected 200, got %d body=%s", wLogin.Code, wLogin.Body.String())
|
||||||
|
}
|
||||||
|
var loginResp map[string]any
|
||||||
|
if err := json.Unmarshal(wLogin.Body.Bytes(), &loginResp); err != nil {
|
||||||
|
t.Fatalf("login json parse failed: %v", err)
|
||||||
|
}
|
||||||
|
loginRefreshToken, _ := loginResp["refresh"].(string)
|
||||||
|
loginAccessToken, _ := loginResp["access"].(string)
|
||||||
|
assertJWTFormat(t, loginAccessToken)
|
||||||
|
assertJWTFormat(t, loginRefreshToken)
|
||||||
|
|
||||||
|
wMe := performJSON(r, http.MethodGet, "/me", nil, map[string]string{"Authorization": "Bearer " + accessToken})
|
||||||
|
if wMe.Code != http.StatusOK {
|
||||||
|
t.Fatalf("me expected 200, got %d", wMe.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
wRefresh := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil)
|
||||||
|
if wRefresh.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh expected 200, got %d body=%s", wRefresh.Code, wRefresh.Body.String())
|
||||||
|
}
|
||||||
|
var refreshResp map[string]any
|
||||||
|
if err := json.Unmarshal(wRefresh.Body.Bytes(), &refreshResp); err != nil {
|
||||||
|
t.Fatalf("refresh json parse failed: %v", err)
|
||||||
|
}
|
||||||
|
newRefreshToken, _ := refreshResp["refresh"].(string)
|
||||||
|
newAccessToken, _ := refreshResp["access"].(string)
|
||||||
|
assertJWTFormat(t, newAccessToken)
|
||||||
|
assertJWTFormat(t, newRefreshToken)
|
||||||
|
if newRefreshToken == refreshToken {
|
||||||
|
t.Fatalf("refresh rotation should return a new refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldToken models.RefreshToken
|
||||||
|
if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&oldToken).Error; err != nil {
|
||||||
|
t.Fatalf("refresh token record not found: %v", err)
|
||||||
|
}
|
||||||
|
if !oldToken.Revoked {
|
||||||
|
t.Fatalf("old refresh token should be revoked after refresh")
|
||||||
|
}
|
||||||
|
if oldToken.ReplacedByTokenID == "" {
|
||||||
|
t.Fatalf("old token should keep replaced_by_token_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterDuplicateEmail(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/register", Register)
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"username": "user1",
|
||||||
|
"email": "dup@example.com",
|
||||||
|
"first_name": "User",
|
||||||
|
"last_name": "One",
|
||||||
|
"password": "secret123",
|
||||||
|
"confirm_password": "secret123",
|
||||||
|
}
|
||||||
|
w1 := performJSON(r, http.MethodPost, "/register", payload, nil)
|
||||||
|
if w1.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("first register expected 201, got %d body=%s", w1.Code, w1.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w2 := performJSON(r, http.MethodPost, "/register", payload, nil)
|
||||||
|
if w2.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second register expected 409, got %d", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWrongPassword(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bcrypt failed: %v", err)
|
||||||
|
}
|
||||||
|
isAdmin := false
|
||||||
|
user := models.User{UserName: "u1", Email: "u1@example.com", Password: string(hash), IsAdmin: &isAdmin}
|
||||||
|
if err := configs.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("seed user failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/login", Login)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/login", map[string]any{
|
||||||
|
"email": "u1@example.com",
|
||||||
|
"password": "wrong-password",
|
||||||
|
}, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeAdminWithAdminMiddleware(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isAdmin := true
|
||||||
|
isUser := false
|
||||||
|
admin := models.User{UserName: "admin", Email: "admin@example.com", Password: "x", IsAdmin: &isAdmin}
|
||||||
|
target := models.User{UserName: "target", Email: "target@example.com", Password: "x", IsAdmin: &isUser}
|
||||||
|
if err := configs.DB.Create(&admin).Error; err != nil {
|
||||||
|
t.Fatalf("create admin failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&target).Error; err != nil {
|
||||||
|
t.Fatalf("create target failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := middleware.BuildAccessTokenForUser(admin)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/users/:id/admin", middleware.AuthRequired(), middleware.AdminRequired(), MakeAdmin)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/users/"+toString(target.ID)+"/admin", map[string]any{"is_admin": true}, map[string]string{
|
||||||
|
"Authorization": "Bearer " + token,
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated models.User
|
||||||
|
if err := configs.DB.First(&updated, target.ID).Error; err != nil {
|
||||||
|
t.Fatalf("read updated user failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated.IsAdmin == nil || !*updated.IsAdmin {
|
||||||
|
t.Fatalf("target user should be admin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshRejectsExpiredToken(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isAdmin := false
|
||||||
|
user := models.User{UserName: "u2", Email: "u2@example.com", Password: "x", IsAdmin: &isAdmin}
|
||||||
|
if err := configs.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("user create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := "deadbeef"
|
||||||
|
record := models.RefreshToken{
|
||||||
|
UserID: uint64(user.ID),
|
||||||
|
TokenID: "tid1",
|
||||||
|
TokenHash: hashToken(refresh),
|
||||||
|
TokenFingerprint: tokenFingerprint(refresh),
|
||||||
|
ExpiresAt: time.Now().Add(-time.Minute),
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&record).Error; err != nil {
|
||||||
|
t.Fatalf("refresh create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/refresh", Refresh)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refresh}, nil)
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueTokens_DefaultFlowKeepsSessionExpiryNilAndRefreshWorks(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isAdmin := false
|
||||||
|
isActive := true
|
||||||
|
emailVerified := true
|
||||||
|
user := models.User{
|
||||||
|
UserName: "normal_user",
|
||||||
|
Email: "normal@example.com",
|
||||||
|
Password: "x",
|
||||||
|
IsAdmin: &isAdmin,
|
||||||
|
IsActive: &isActive,
|
||||||
|
EmailVerified: &emailVerified,
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("user create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, refreshToken, _, err := issueTokens(user, "test-agent", "127.0.0.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issueTokens failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var record models.RefreshToken
|
||||||
|
if err := configs.DB.Where("token_hash = ?", hashToken(refreshToken)).First(&record).Error; err != nil {
|
||||||
|
t.Fatalf("refresh token record should exist: %v", err)
|
||||||
|
}
|
||||||
|
if record.SessionExpiresAt != nil {
|
||||||
|
t.Fatalf("default flow should keep session_expires_at nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/refresh", Refresh)
|
||||||
|
w := performJSON(r, http.MethodPost, "/refresh", map[string]any{"refresh_token": refreshToken}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("refresh expected 200 for default flow, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminScopedTokenIssuesOnlyAccessTokenWithoutRefreshRecord(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isAdmin := true
|
||||||
|
isActive := true
|
||||||
|
emailVerified := true
|
||||||
|
adminUser := models.User{
|
||||||
|
UserName: "admin_user",
|
||||||
|
Email: "admin_scoped@example.com",
|
||||||
|
Password: "x",
|
||||||
|
IsAdmin: &isAdmin,
|
||||||
|
IsActive: &isActive,
|
||||||
|
EmailVerified: &emailVerified,
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&adminUser).Error; err != nil {
|
||||||
|
t.Fatalf("admin user create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := middleware.BuildAccessTokenForUser(adminUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build access token failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/admin/tokens/issue", middleware.AuthRequired(), middleware.AdminRequired(), IssueAdminScopedToken)
|
||||||
|
|
||||||
|
wIssue := performJSON(
|
||||||
|
r,
|
||||||
|
http.MethodPost,
|
||||||
|
"/admin/tokens/issue",
|
||||||
|
map[string]any{"duration_days": 45},
|
||||||
|
map[string]string{"Authorization": "Bearer " + accessToken},
|
||||||
|
)
|
||||||
|
if wIssue.Code != http.StatusOK {
|
||||||
|
t.Fatalf("issue endpoint expected 200, got %d body=%s", wIssue.Code, wIssue.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var issueResp map[string]any
|
||||||
|
if err := json.Unmarshal(wIssue.Body.Bytes(), &issueResp); err != nil {
|
||||||
|
t.Fatalf("issue response parse failed: %v", err)
|
||||||
|
}
|
||||||
|
scopedAccess, _ := issueResp["access"].(string)
|
||||||
|
if scopedAccess == "" {
|
||||||
|
t.Fatalf("issued scoped access token should exist")
|
||||||
|
}
|
||||||
|
assertJWTFormat(t, scopedAccess)
|
||||||
|
|
||||||
|
if _, ok := issueResp["refresh"]; ok {
|
||||||
|
t.Fatalf("scoped token response should not include refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt, _ := issueResp["expires_at"].(string)
|
||||||
|
if expiresAt == "" {
|
||||||
|
t.Fatalf("scoped token response should include expires_at")
|
||||||
|
}
|
||||||
|
expiresAtTime, err := time.Parse(time.RFC3339, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expires_at should be RFC3339: %v", err)
|
||||||
|
}
|
||||||
|
expected := time.Now().Add(45 * 24 * time.Hour)
|
||||||
|
if expiresAtTime.Before(expected.Add(-2*time.Minute)) || expiresAtTime.After(expected.Add(2*time.Minute)) {
|
||||||
|
t.Fatalf("expires_at should be close to requested duration, got=%s expected_around=%s", expiresAtTime, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshCount int64
|
||||||
|
if err := configs.DB.Model(&models.RefreshToken{}).Where("user_id = ?", adminUser.ID).Count(&refreshCount).Error; err != nil {
|
||||||
|
t.Fatalf("refresh token count query failed: %v", err)
|
||||||
|
}
|
||||||
|
if refreshCount != 0 {
|
||||||
|
t.Fatalf("scoped access token flow should not create refresh records, got %d", refreshCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toString(v uint) string {
|
||||||
|
return strconv.FormatUint(uint64(v), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsMismatchedConfirmPassword(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/register", Register)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/register", map[string]any{
|
||||||
|
"username": "user2",
|
||||||
|
"email": "user2@example.com",
|
||||||
|
"first_name": "User",
|
||||||
|
"last_name": "Two",
|
||||||
|
"password": "secret123",
|
||||||
|
"confirm_password": "wrong123",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoogleSocialLoginCreatesVerifiedActiveUserAndProfile(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/userinfo" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"sub":"g-123","email":"social@example.com","email_verified":true,"given_name":"Social","family_name":"User","picture":"https://cdn/avatar.png"}`))
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
|
||||||
|
prevGoogle := googleUserInfoURL
|
||||||
|
googleUserInfoURL = provider.URL + "/userinfo"
|
||||||
|
t.Cleanup(func() { googleUserInfoURL = prevGoogle })
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/auth/social/google", GoogleLogin)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/auth/social/google", map[string]any{"access_token": "token-abc"}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
dump, _ := httputil.DumpResponse(w.Result(), true)
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, string(dump))
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("response parse failed: %v", err)
|
||||||
|
}
|
||||||
|
if resp["provider"] != "google" {
|
||||||
|
t.Fatalf("provider should be google")
|
||||||
|
}
|
||||||
|
if access, _ := resp["access"].(string); access == "" {
|
||||||
|
t.Fatalf("access token should be returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := configs.DB.Where("email = ?", "social@example.com").First(&user).Error; err != nil {
|
||||||
|
t.Fatalf("user should be created: %v", err)
|
||||||
|
}
|
||||||
|
if user.EmailVerified == nil || !*user.EmailVerified {
|
||||||
|
t.Fatalf("social login user should be email verified")
|
||||||
|
}
|
||||||
|
if user.IsActive == nil || !*user.IsActive {
|
||||||
|
t.Fatalf("social login user should be active")
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.Profile
|
||||||
|
if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil {
|
||||||
|
t.Fatalf("profile should be created: %v", err)
|
||||||
|
}
|
||||||
|
if profile.FirstName != "Social" || profile.LastName != "User" {
|
||||||
|
t.Fatalf("profile name mismatch: %+v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubSocialLoginReadsPrimaryEmail(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/user":
|
||||||
|
_, _ = w.Write([]byte(`{"id":99,"login":"octo","name":"Octo Cat","email":"","avatar_url":"https://cdn/octo.png"}`))
|
||||||
|
case "/user/emails":
|
||||||
|
_, _ = w.Write([]byte(`[{"email":"octo@example.com","primary":true,"verified":true}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
|
||||||
|
prevUser := githubUserURL
|
||||||
|
prevEmails := githubEmailsURL
|
||||||
|
githubUserURL = provider.URL + "/user"
|
||||||
|
githubEmailsURL = provider.URL + "/user/emails"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
githubUserURL = prevUser
|
||||||
|
githubEmailsURL = prevEmails
|
||||||
|
})
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/auth/social/github", GitHubLogin)
|
||||||
|
|
||||||
|
w := performJSON(r, http.MethodPost, "/auth/social/github", map[string]any{"access_token": "gh-token"}, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var social models.SocialAccount
|
||||||
|
if err := configs.DB.Where("provider = ? AND provider_id = ?", "github", "99").First(&social).Error; err != nil {
|
||||||
|
t.Fatalf("github social account should be created: %v", err)
|
||||||
|
}
|
||||||
|
if social.Email != "octo@example.com" {
|
||||||
|
t.Fatalf("github email should come from /user/emails, got %s", social.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeProfileGetAndUpdate(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isActive := true
|
||||||
|
emailVerified := true
|
||||||
|
isAdmin := false
|
||||||
|
user := models.User{
|
||||||
|
UserName: "profile_user",
|
||||||
|
Email: "profile@example.com",
|
||||||
|
Password: "x",
|
||||||
|
IsActive: &isActive,
|
||||||
|
EmailVerified: &emailVerified,
|
||||||
|
IsAdmin: &isAdmin,
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("create user failed: %v", err)
|
||||||
|
}
|
||||||
|
oldAvatarURL, oldAvatarPath := createOldAvatarFixture(t, "old_user_avatar.png")
|
||||||
|
profile := models.Profile{UserID: uint64(user.ID), FirstName: "Test", LastName: "User", AvatarURL: oldAvatarURL}
|
||||||
|
if err := configs.DB.Create(&profile).Error; err != nil {
|
||||||
|
t.Fatalf("create profile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := middleware.BuildAccessTokenForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/me/profile", middleware.AuthRequired(), GetMyProfile)
|
||||||
|
r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile)
|
||||||
|
|
||||||
|
wGet := performJSON(r, http.MethodGet, "/me/profile", nil, map[string]string{"Authorization": "Bearer " + token})
|
||||||
|
if wGet.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get profile expected 200, got %d body=%s", wGet.Code, wGet.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
wPut := performMultipart(
|
||||||
|
r,
|
||||||
|
http.MethodPut,
|
||||||
|
"/me/profile",
|
||||||
|
map[string]string{"first_name": "Yeni", "last_name": "Isim"},
|
||||||
|
"avatar",
|
||||||
|
"avatar.png",
|
||||||
|
tinyPNGFixture(t),
|
||||||
|
map[string]string{"Authorization": "Bearer " + token},
|
||||||
|
)
|
||||||
|
if wPut.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated models.Profile
|
||||||
|
if err := configs.DB.Where("user_id = ?", user.ID).First(&updated).Error; err != nil {
|
||||||
|
t.Fatalf("read updated profile failed: %v", err)
|
||||||
|
}
|
||||||
|
if updated.FirstName != "Yeni" || updated.LastName != "Isim" {
|
||||||
|
t.Fatalf("profile name not updated: %+v", updated)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(updated.AvatarURL, "/uploads/avatars/") {
|
||||||
|
t.Fatalf("avatar path not updated: %s", updated.AvatarURL)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(oldAvatarPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("old avatar should be deleted, err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeProfileUpdateCreatesProfileWhenMissing(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
t.Setenv("JWT_SECRET", "test-secret")
|
||||||
|
setupHandlersTestDB(t)
|
||||||
|
|
||||||
|
isActive := true
|
||||||
|
emailVerified := true
|
||||||
|
isAdmin := false
|
||||||
|
user := models.User{
|
||||||
|
UserName: "legacy_user",
|
||||||
|
Email: "legacy@example.com",
|
||||||
|
Password: "x",
|
||||||
|
IsActive: &isActive,
|
||||||
|
EmailVerified: &emailVerified,
|
||||||
|
IsAdmin: &isAdmin,
|
||||||
|
}
|
||||||
|
if err := configs.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("create user failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := middleware.BuildAccessTokenForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.PUT("/me/profile", middleware.AuthRequired(), UpdateMyProfile)
|
||||||
|
|
||||||
|
wPut := performMultipart(
|
||||||
|
r,
|
||||||
|
http.MethodPut,
|
||||||
|
"/me/profile",
|
||||||
|
map[string]string{"first_name": "Beyhan", "last_name": "Ogur"},
|
||||||
|
"avatar",
|
||||||
|
"avatar.png",
|
||||||
|
tinyPNGFixture(t),
|
||||||
|
map[string]string{"Authorization": "Bearer " + token},
|
||||||
|
)
|
||||||
|
if wPut.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update profile expected 200, got %d body=%s", wPut.Code, wPut.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile models.Profile
|
||||||
|
if err := configs.DB.Where("user_id = ?", user.ID).First(&profile).Error; err != nil {
|
||||||
|
t.Fatalf("profile should be auto-created: %v", err)
|
||||||
|
}
|
||||||
|
if profile.FirstName != "Beyhan" || profile.LastName != "Ogur" {
|
||||||
|
t.Fatalf("profile fields mismatch: %+v", profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/accounts/models/accounts.go
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
28
app/accounts/models/accounts_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
27
app/accounts/models/token.go
Normal 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
@@ -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)
|
||||||
|
}
|
||||||
202
app/blogs/handlers/blog_test.go
Normal 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
@@ -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
|
||||||
|
}
|
||||||
362
app/images/handlers/image.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
144
app/images/handlers/image_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/images/models/images.go
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
17
app/mcp/models/tool_run.go
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
128
app/mcp/server_mcpgo_test.go
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/middleware/security.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/middleware/security_test.go
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
54
docs/mcp-tools/MCP.md
Normal 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.
|
||||||